mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
feat(runtime): add user-controlled turn recovery
This commit is contained in:
+132
-1
@@ -54,6 +54,16 @@ export interface RuntimeControls {
|
||||
canUseFullAccess: boolean
|
||||
}
|
||||
|
||||
export type RecoveryStatus = "resuming" | "awaiting_user" | "recovered" | "failed"
|
||||
|
||||
export interface RecoveryState {
|
||||
status: RecoveryStatus
|
||||
recovery_id: string
|
||||
reason?: string
|
||||
attempts?: number
|
||||
can_continue?: boolean
|
||||
}
|
||||
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| {
|
||||
@@ -61,6 +71,7 @@ export type InboundEvent =
|
||||
chat_id: string
|
||||
model_preset?: string | null
|
||||
usage?: TokenUsage
|
||||
recovery_state?: RecoveryState
|
||||
}
|
||||
| {
|
||||
event: "message_accepted"
|
||||
@@ -118,6 +129,7 @@ export type InboundEvent =
|
||||
turn_id?: string
|
||||
}
|
||||
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
|
||||
| ({ event: "recovery_state"; chat_id: string } & RecoveryState)
|
||||
| {
|
||||
event: "session_updated"
|
||||
chat_id: string
|
||||
@@ -139,6 +151,12 @@ type OutboundEvent =
|
||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||
| { type: "attach"; chat_id: string }
|
||||
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
||||
| {
|
||||
type: "webui_request"
|
||||
request_id: string
|
||||
action: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
| {
|
||||
type: "message"
|
||||
chat_id: string
|
||||
@@ -271,6 +289,7 @@ export interface SessionSummary {
|
||||
updatedAt: string | null
|
||||
runStartedAt: number | null
|
||||
modelPreset: string | null
|
||||
recoveryState?: RecoveryState | null
|
||||
workspaceScope?: WorkspaceScopePayload | null
|
||||
pinned: boolean
|
||||
archived: boolean
|
||||
@@ -297,6 +316,7 @@ const CHAT_EVENTS = new Set([
|
||||
"turn_end",
|
||||
"goal_status",
|
||||
"goal_state",
|
||||
"recovery_state",
|
||||
"session_updated",
|
||||
"turn_model_updated",
|
||||
"error",
|
||||
@@ -377,6 +397,34 @@ function isWorkspaceScope(value: unknown): value is WorkspaceScopePayload {
|
||||
&& optional(value.restrict_to_workspace, "boolean")
|
||||
}
|
||||
|
||||
function isRecoveryState(value: unknown): value is RecoveryState {
|
||||
return isRecord(value)
|
||||
&& ["resuming", "awaiting_user", "recovered", "failed"].includes(String(value.status))
|
||||
&& typeof value.recovery_id === "string"
|
||||
&& optional(value.reason, "string")
|
||||
&& optional(value.attempts, "number")
|
||||
&& optional(value.can_continue, "boolean")
|
||||
}
|
||||
|
||||
interface WebUIResponseEvent {
|
||||
event: "webui_response"
|
||||
request_id: string
|
||||
ok: boolean
|
||||
result?: unknown
|
||||
error?: { status: number; message: string }
|
||||
}
|
||||
|
||||
function decodeWebUIResponse(value: unknown): WebUIResponseEvent | null | undefined {
|
||||
if (!isRecord(value) || value.event !== "webui_response") return undefined
|
||||
if (typeof value.request_id !== "string" || typeof value.ok !== "boolean") return null
|
||||
if (value.ok) return value as unknown as WebUIResponseEvent
|
||||
return isRecord(value.error)
|
||||
&& typeof value.error.status === "number"
|
||||
&& typeof value.error.message === "string"
|
||||
? value as unknown as WebUIResponseEvent
|
||||
: null
|
||||
}
|
||||
|
||||
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
||||
if (!isRecord(value)) return null
|
||||
const record = value
|
||||
@@ -407,7 +455,8 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
||||
&& ((record.model_preset !== undefined
|
||||
&& record.model_preset !== null
|
||||
&& typeof record.model_preset !== "string")
|
||||
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
|
||||
|| (record.usage !== undefined && !isTokenUsage(record.usage))
|
||||
|| (record.recovery_state !== undefined && !isRecoveryState(record.recovery_state)))
|
||||
) return null
|
||||
if (
|
||||
["user_message", "message", "delta", "reasoning_delta"].includes(name)
|
||||
@@ -452,6 +501,7 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
||||
) return null
|
||||
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
|
||||
if (name === "goal_state" && !isRecord(record.goal_state)) return null
|
||||
if (name === "recovery_state" && !isRecoveryState(record)) return null
|
||||
if (
|
||||
name === "session_updated"
|
||||
&& (!optional(record.scope, "string")
|
||||
@@ -700,6 +750,9 @@ export async function fetchSessions(
|
||||
modelPreset: typeof value.model_preset === "string" && value.model_preset.trim()
|
||||
? value.model_preset.trim()
|
||||
: null,
|
||||
...(isRecoveryState(value.recovery_state)
|
||||
? { recoveryState: value.recovery_state }
|
||||
: {}),
|
||||
...(isWorkspaceScope(value.workspace_scope) ? { workspaceScope: value.workspace_scope } : {}),
|
||||
pinned: pinned.has(value.key),
|
||||
archived: archived.has(value.key),
|
||||
@@ -855,6 +908,11 @@ export class NanobotClient {
|
||||
private closedByClient = false
|
||||
private opening = false
|
||||
private connectedOnce = false
|
||||
private readonly pendingMutations = new Map<string, {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}>()
|
||||
|
||||
constructor(private readonly options: ClientOptions) {}
|
||||
|
||||
@@ -916,6 +974,7 @@ export class NanobotClient {
|
||||
socket.addEventListener("close", () => {
|
||||
if (this.socket !== socket) return
|
||||
this.socket = null
|
||||
this.rejectPendingMutations("gateway connection closed")
|
||||
if (this.closedByClient) {
|
||||
this.options.onStatus("closed")
|
||||
return
|
||||
@@ -931,6 +990,7 @@ export class NanobotClient {
|
||||
const socket = this.socket
|
||||
this.socket = null
|
||||
socket?.close()
|
||||
this.rejectPendingMutations("gateway connection closed")
|
||||
}
|
||||
|
||||
send(content: string, options: MessageOptions = {}): string {
|
||||
@@ -975,6 +1035,63 @@ export class NanobotClient {
|
||||
this.write({ type: "set_workspace_scope", chat_id: this.chatId, workspace_scope: scope })
|
||||
}
|
||||
|
||||
updateRecovery(
|
||||
action: "continue" | "dismiss",
|
||||
chatId: string,
|
||||
recoveryId: string,
|
||||
): Promise<RecoveryState> {
|
||||
return this.requestMutation<unknown>(`recovery.${action}`, {
|
||||
chat_id: chatId,
|
||||
recovery_id: recoveryId,
|
||||
}).then((result) => {
|
||||
if (!isRecoveryState(result)) throw new Error("gateway returned an invalid recovery state")
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
private requestMutation<T>(
|
||||
action: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
timeoutMs = 20_000,
|
||||
): Promise<T> {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
return Promise.reject(new Error("gateway connection is not open"))
|
||||
}
|
||||
const requestId = crypto.randomUUID()
|
||||
const frame = JSON.stringify({
|
||||
type: "webui_request",
|
||||
request_id: requestId,
|
||||
action,
|
||||
payload,
|
||||
} satisfies OutboundEvent)
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingMutations.delete(requestId)
|
||||
reject(new Error(`gateway request timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
this.pendingMutations.set(requestId, {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
timer,
|
||||
})
|
||||
try {
|
||||
this.socket?.send(frame)
|
||||
} catch {
|
||||
clearTimeout(timer)
|
||||
this.pendingMutations.delete(requestId)
|
||||
reject(new Error("could not send gateway request"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private rejectPendingMutations(message: string): void {
|
||||
for (const pending of this.pendingMutations.values()) {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject(new Error(message))
|
||||
}
|
||||
this.pendingMutations.clear()
|
||||
}
|
||||
|
||||
private handleMessage(raw: string): void {
|
||||
let value: unknown
|
||||
try {
|
||||
@@ -983,6 +1100,20 @@ export class NanobotClient {
|
||||
this.options.onStatus("error", "gateway sent invalid JSON")
|
||||
return
|
||||
}
|
||||
const response = decodeWebUIResponse(value)
|
||||
if (response === null) {
|
||||
this.options.onStatus("error", "gateway sent an invalid event")
|
||||
return
|
||||
}
|
||||
if (response) {
|
||||
const pending = this.pendingMutations.get(response.request_id)
|
||||
if (!pending) return
|
||||
clearTimeout(pending.timer)
|
||||
this.pendingMutations.delete(response.request_id)
|
||||
if (response.ok) pending.resolve(response.result)
|
||||
else pending.reject(new Error(response.error?.message || "gateway request failed"))
|
||||
return
|
||||
}
|
||||
const event = decodeInboundEvent(value)
|
||||
if (event === undefined) return
|
||||
if (event === null) {
|
||||
|
||||
Reference in New Issue
Block a user