|
| 1 | +/** |
| 2 | + * close_agent / resume_agent (CL-6943): the session-lifecycle half of |
| 3 | + * reusable worker sessions. spawn_agent/wait_agents (CL-6942) start and |
| 4 | + * collect workers; these two verbs let an orchestrator tear one down on |
| 5 | + * purpose (close_agent) or bring a retained one back for further input |
| 6 | + * (resume_agent), instead of every session dying the instant its turn ends. |
| 7 | + * |
| 8 | + * interrupt_agent and followup_task (the verbs that actually push a new |
| 9 | + * prompt into a resumed session) are a separate, later change — resume_agent |
| 10 | + * here only flips a retained session back to an addressable state; it takes |
| 11 | + * no prompt argument. |
| 12 | + */ |
| 13 | + |
| 14 | +import { tool } from "@intx/agent"; |
| 15 | +import type { AgentTool } from "@intx/agent"; |
| 16 | +import { type } from "arktype"; |
| 17 | +import type { ToolDefinition, ToolResult } from "@intx/types/runtime"; |
| 18 | + |
| 19 | +import { DEFAULT_CLOSE_DEADLINE_MS } from "./dispose.js"; |
| 20 | +import type { AgentLifecycleStatus, SubAgentSessionStore } from "./session-store.js"; |
| 21 | + |
| 22 | +function lifecycleResult(callId: string, content: string): ToolResult { |
| 23 | + const isError = content.startsWith("Error:"); |
| 24 | + return { callId, content, ...(isError ? { isError: true } : {}) }; |
| 25 | +} |
| 26 | + |
| 27 | +const CloseAgentArgs = type({ |
| 28 | + target: "string", |
| 29 | +}); |
| 30 | + |
| 31 | +export const closeAgentToolDefinition: ToolDefinition = { |
| 32 | + name: "close_agent", |
| 33 | + description: |
| 34 | + "Permanently close a worker session by agent_id, closing its descendants first. Bounded " + |
| 35 | + `by a ~${Math.round(DEFAULT_CLOSE_DEADLINE_MS / 1000)}s cleanup deadline per session so a wedged worker cannot hang ` + |
| 36 | + "this call — a session that misses the deadline is still marked shutdown; its teardown just " + |
| 37 | + "keeps running in the background. Closing is permanent: a closed session cannot be resumed.", |
| 38 | + inputSchema: { |
| 39 | + type: "object", |
| 40 | + properties: { |
| 41 | + target: { type: "string", description: "agent_id of the session to close." }, |
| 42 | + }, |
| 43 | + required: ["target"], |
| 44 | + }, |
| 45 | +}; |
| 46 | + |
| 47 | +const ResumeAgentArgs = type({ |
| 48 | + target: "string", |
| 49 | +}); |
| 50 | + |
| 51 | +export const resumeAgentToolDefinition: ToolDefinition = { |
| 52 | + name: "resume_agent", |
| 53 | + description: |
| 54 | + "Reopen a retained, completed worker session (one that finished a turn and was never closed) " + |
| 55 | + "so it is addressable again. Fails on a session that is still running, was never retained, was " + |
| 56 | + "interrupted, or was already closed via close_agent (closing is permanent).", |
| 57 | + inputSchema: { |
| 58 | + type: "object", |
| 59 | + properties: { |
| 60 | + target: { type: "string", description: "agent_id of the session to resume." }, |
| 61 | + }, |
| 62 | + required: ["target"], |
| 63 | + }, |
| 64 | +}; |
| 65 | + |
| 66 | +/** Every id in `target`'s subtree (nodes with target somewhere up their parentSessionId chain), deepest first, target last. */ |
| 67 | +function descendantsClosingOrder( |
| 68 | + nodes: readonly { id: string; parentSessionId?: string | undefined }[], |
| 69 | + target: string, |
| 70 | +): string[] { |
| 71 | + const children = new Map<string, string[]>(); |
| 72 | + for (const node of nodes) { |
| 73 | + if (node.parentSessionId === undefined) continue; |
| 74 | + const siblings = children.get(node.parentSessionId) ?? []; |
| 75 | + siblings.push(node.id); |
| 76 | + children.set(node.parentSessionId, siblings); |
| 77 | + } |
| 78 | + const order: string[] = []; |
| 79 | + const visit = (id: string): void => { |
| 80 | + for (const child of children.get(id) ?? []) visit(child); |
| 81 | + order.push(id); |
| 82 | + }; |
| 83 | + visit(target); |
| 84 | + return order; |
| 85 | +} |
| 86 | + |
| 87 | +export interface LifecycleToolDeps { |
| 88 | + sessions: SubAgentSessionStore; |
| 89 | +} |
| 90 | + |
| 91 | +export function createCloseAgentTool(deps: LifecycleToolDeps): AgentTool { |
| 92 | + return tool({ |
| 93 | + definition: closeAgentToolDefinition, |
| 94 | + handler: async (call, _signal): Promise<ToolResult> => { |
| 95 | + const parsed = CloseAgentArgs(call.arguments); |
| 96 | + if (parsed instanceof type.errors) { |
| 97 | + return lifecycleResult(call.id, `Error: close_agent arguments invalid: ${parsed.summary}`); |
| 98 | + } |
| 99 | + const target = parsed.target.trim(); |
| 100 | + if (deps.sessions.get(target) === undefined) { |
| 101 | + return lifecycleResult( |
| 102 | + call.id, |
| 103 | + JSON.stringify({ agent_id: target, status: "not_found" satisfies AgentLifecycleStatus }), |
| 104 | + ); |
| 105 | + } |
| 106 | + const nodes = deps.sessions |
| 107 | + .list() |
| 108 | + .map((s) => ({ id: s.id, parentSessionId: s.parentSessionId })); |
| 109 | + const order = descendantsClosingOrder(nodes, target); |
| 110 | + const closed: { agent_id: string; status: AgentLifecycleStatus }[] = []; |
| 111 | + for (const id of order) { |
| 112 | + const status = await deps.sessions.closeOne(id, DEFAULT_CLOSE_DEADLINE_MS); |
| 113 | + closed.push({ agent_id: id, status }); |
| 114 | + } |
| 115 | + const own = closed.find((c) => c.agent_id === target); |
| 116 | + return lifecycleResult( |
| 117 | + call.id, |
| 118 | + JSON.stringify({ |
| 119 | + agent_id: target, |
| 120 | + status: own?.status ?? "shutdown", |
| 121 | + closed, |
| 122 | + }), |
| 123 | + ); |
| 124 | + }, |
| 125 | + }); |
| 126 | +} |
| 127 | + |
| 128 | +export function createResumeAgentTool(deps: LifecycleToolDeps): AgentTool { |
| 129 | + return tool({ |
| 130 | + definition: resumeAgentToolDefinition, |
| 131 | + handler: async (call, _signal): Promise<ToolResult> => { |
| 132 | + const parsed = ResumeAgentArgs(call.arguments); |
| 133 | + if (parsed instanceof type.errors) { |
| 134 | + return lifecycleResult(call.id, `Error: resume_agent arguments invalid: ${parsed.summary}`); |
| 135 | + } |
| 136 | + const target = parsed.target.trim(); |
| 137 | + const outcome = deps.sessions.resumeOne(target); |
| 138 | + if (!outcome.ok) { |
| 139 | + return lifecycleResult( |
| 140 | + call.id, |
| 141 | + `Error: cannot resume "${target}" (status: ${outcome.status}).`, |
| 142 | + ); |
| 143 | + } |
| 144 | + return lifecycleResult(call.id, JSON.stringify({ agent_id: target, status: "running" })); |
| 145 | + }, |
| 146 | + }); |
| 147 | +} |
0 commit comments