diff --git a/changelog.d/awaited-call-routes.added.md b/changelog.d/awaited-call-routes.added.md new file mode 100644 index 000000000..a44ae6175 --- /dev/null +++ b/changelog.d/awaited-call-routes.added.md @@ -0,0 +1,3 @@ +- A host's `executeToolCall` reaches the framework's own tools that the + agent has, as the agent's own call does (see the Fixed entry on awaited + tool calls). diff --git a/changelog.d/awaited-call-routes.fixed.md b/changelog.d/awaited-call-routes.fixed.md new file mode 100644 index 000000000..c08a14cea --- /dev/null +++ b/changelog.d/awaited-call-routes.fixed.md @@ -0,0 +1,38 @@ +- **An awaited tool call now goes where the agent's own call of the tool + goes.** The admin puppet (`puppetToolCall`), a host's `executeToolCall` + and a module's `ModuleContext.callTool` take the routing the turn's + dispatch takes, in its order. Before, an awaited call of `agent_settings`, + `save_recent_image`, `read_image`, `fetch_reference`, `gate_status`, + `event_tags`, `wake_add_rule`, `wake_remove_rule`, `sleep`, `wake`, + `think`, `journal`, `skip_reply` or `prose_help` reached the module + registry and failed with "Invalid tool name format", and the puppet stored + that failure in the agent's window as a call the agent had made. Now, + for each of these tools the framework has (the gate tools only with an + event gate, and `think`, `journal` and `skip_reply` only with a channel + registry, which an MCPL server brings): + - a call of one with agent origin (the puppet's, and a host's) runs the + handler the agent's own call runs and gets its answer, except + `skip_reply`, which ends the turn it's called in, while an awaited + call isn't made in a turn: a host's call of it gets a refusal + and does nothing, and the puppet refuses it, by name or through a + `toolsets--act` that reaches it, before anything runs or is stored, as + it refuses a tool that isn't on the agent's surface. A `sleep` made off + a turn posts no announcement, as a script's sleep after its turn + already doesn't; + - a module's call of the agent's own tools, `think`, `journal`, + `skip_reply` and `drafts`, is refused with a reason that says so, and + does nothing, and a module's call of the others runs for the agent its + `callerAgentName` names; + - where the dispatcher would answer one of these calls with a refusal + (such as a conversation fork's `sleep`), the awaited call gets it + without `refused`, which `ToolResult.refused` says a programmatic call + never carries; + - the puppet's check that a tool is on the agent's surface reads the + tools the agent may call, so an explicit-prose agent's `prose_help` is + on it; + - a `fetch_reference` whose work throws before it answers, such as one + whose `ref_id` has no text form, now answers with the error, on the + turn's path too, where before no result came from it; + - a `save_recent_image` that isn't one of its round's own calls, such as + a module's from inside its own pending tool call, doesn't wait on that + round's other calls, which would include the call that made it. diff --git a/src/framework.ts b/src/framework.ts index 96c85ff0b..f06eca940 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -816,6 +816,37 @@ export const BUILTIN_TOOLSET_TOOLS: ReadonlySet = new Set([ 'workspace--status', ]); +type ToolRoute = + | { kind: 'presentation' } + | { kind: 'host-act'; host: HostActTarget } + | { kind: 'toolset-act' } + | { kind: 'mcpl'; serverId: string; prefix: string } + | { kind: 'channel' } + | { kind: 'agent-settings' } + | { kind: 'save-image' } + | { kind: 'read-image' } + | { kind: 'fetch-reference' } + | { kind: 'gate-status' } + | { kind: 'tune-out' } + | { kind: 'subconscious' } + | { kind: 'sleep' } + | { kind: 'heartbeat' } + | { kind: 'wake-rule' } + | { kind: 'event-tags' } + | { kind: 'agent-verb' } + | { kind: 'code-execution' } + | { kind: 'drafts' } + | { kind: 'prose-help' } + | { kind: 'module' }; + +function agentsOwnVerbRefusal(toolName: string): ToolResult { + return { + success: false, + error: `${toolName} is the agent's own, which a module's call can't make for it, so nothing was done.`, + isError: true, + }; +} + type SettingsReading = { core: Record; exts: Map }>; @@ -26895,7 +26926,11 @@ export class AgentFramework { /** * Execute a tool call and return the result. - * Routes to the appropriate handler (module registry or MCPL). + * Routes it where the agent's own call of the name goes: to the + * framework's own tools the agent has (agent_settings, the workspace and + * gate tools, think, journal, prose_help and the rest), the channel tools, + * MCPL or the module registry. skip_reply, which ends the turn it's called + * in, is refused, since this call isn't made in one. * Its one caller in the framework is the operator's puppet * (puppetToolCall), which puts the agent's inline cap on the call as * resultMaxChars; a host may call it as well. @@ -26942,8 +26977,11 @@ export class AgentFramework { * wire ordering. For its own duration the puppet HOLDS the turn-alive * marker (wakes requeue, cross-turn writers defer), so a wake landing * during the tool's execution cannot start a turn underneath the pair. - * - Refused for tools outside the agent's own surface (canUseTool): the - * stored pair must be an act the agent could genuinely have taken. + * - Refused for tools outside the agent's own surface (the tools it may + * call, availableToolsForPresentation), and for skip_reply, by name or + * through a toolsets--act that reaches it, which ends the turn it's + * called in: the stored pair must be an act the agent could genuinely + * have taken. * - The call executes FOR REAL through the shared dispatch (MCPL, * channel tools, utils, modules) with the agent's provenance — a * puppeted walk_to actually moves the avatar. @@ -26995,14 +27033,21 @@ export class AgentFramework { `injecting a turn under an active stream corrupts wire ordering)`, ); } - const onSurface = this.getToolsForAgent(agentName) - .some((t) => t.name === toolName && agent.canUseTool(t.name)); + const onSurface = this.availableToolsForPresentation(agentName).some((t) => t.name === toolName); if (!onSurface) { throw new Error( `puppet refused: tool ${toolName} is not on ${agentName}'s surface — ` + `the stored pair must be an act the agent could genuinely have taken`, ); } + const route = this.toolRoute({ id: '', name: toolName, input }, agentName); + const acting = route.kind === 'host-act' && route.host.kind === 'call' ? route.host.name : toolName; + if (acting === 'skip_reply') { + throw new Error( + "puppet refused: skip_reply ends the turn it's called in, and a puppeted call isn't made in one, " + + 'so nothing was run or stored', + ); + } // Anthropic-shaped id so the stored pair is indistinguishable from a // provider-issued call. @@ -27141,96 +27186,123 @@ export class AgentFramework { return { success: false, error: marked, isError: true }; } - if (isPresentationTool(call.name)) return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call); - // Client-side programmatic tool calling for callers that await a tool's - // result: the puppet and a host through executeToolCall, and a module - // through callTool. Keyed by callerAgentName, so each agent's scripts are - // its own. - if (call.name === CODE_EXECUTION_TOOL_NAME && this.codeExecution) { - return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call); - } - - // toolsets--act on a host toolset's tool (the built-in one, a server's - // tab) goes where a programmatic call of that tool goes, under its name. - if (call.name === META_TOOL_NAMES.act && call.callerAgentName !== undefined) { - const host = this.hostPresentation?.resolveAct(call.callerAgentName, call.input) ?? null; - if (host?.kind === 'refused') return failed({ success: false, error: host.error, isError: true }); - if (host?.kind === 'call') return this.executeToolCallFrom({ ...call, name: host.name, input: host.args }, origin); - } - - // RFC-014: toolsets--act, resolved to the dynamic tool it calls, goes - // where a programmatic call of that tool goes, with its instance. - if (call.name === META_TOOL_NAMES.act && this.toolsetHub && this.mcplServerRegistry) { - const target = this.resolveToolsetAct(call); - if (!target.ok) return failed({ success: false, error: target.error, isError: true }, this.everyServerRedactor()(target.error)); - if (origin.kind === 'agent') { - const failure = this.agentToolPermissionFailure(origin.agentName, target.call, false); - if (failure) return failed(failure, this.everyServerRedactor()(failure.error ?? '')); - } - return this.executeMcplToolCall(target.call, target.serverId, target.prefix); + const subject = origin.kind === 'agent' ? origin.agentName : call.callerAgentName ?? '__ephemeral__'; + const route = this.toolRoute(call, call.callerAgentName); + switch (route.kind) { + case 'presentation': + return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call); + + // toolsets--act on a host toolset's tool (the built-in one, a server's + // tab) goes where a programmatic call of that tool goes, under its name. + case 'host-act': + if (route.host.kind === 'refused') return failed({ success: false, error: route.host.error, isError: true }); + return this.executeToolCallFrom({ ...call, name: route.host.name, input: route.host.args }, origin); + + // RFC-014: toolsets--act, resolved to the dynamic tool it calls, goes + // where a programmatic call of that tool goes, with its instance. + case 'toolset-act': { + const target = this.resolveToolsetAct(call); + if (!target.ok) return failed({ success: false, error: target.error, isError: true }, this.everyServerRedactor()(target.error)); + if (origin.kind === 'agent') { + const failure = this.agentToolPermissionFailure(origin.agentName, target.call, false); + if (failure) return failed(failure, this.everyServerRedactor()(failure.error ?? '')); + } + return this.executeMcplToolCall(target.call, target.serverId, target.prefix); + } + + case 'mcpl': + return this.executeMcplToolCall(call, route.serverId, route.prefix); + + // Synthesized channel tools. This route was missing: when + // subscription-GC moved off the retired MCPL unsubscribe tool onto the + // generic channel_close (discord-mcpl b095a9f), module-originated + // closes started failing downstream as "Invalid tool name format" — + // the janitor had been silently broken since. `origin` is trusted + // dispatch context established at the call boundary above, not + // inferred from which method was used: only the ModuleRegistry closure + // carries module provenance (see handleToolClose). + case 'channel': { + const turn = call.name === 'channel_publish' && call.callerAgentName ? this.callingTurnOf(call.callerAgentName, call) : undefined; + const input = call.name === 'channel_publish' && call.callerAgentName + ? this.publishInputWithTurnLocus(call.input, call.callerAgentName, turn) + : call.input; + return this.channelRegistry!.handleChannelToolCall(call.name, input, origin.kind === 'agent' && turn ? { ...origin, inTurn: true } : origin); + } + + case 'agent-settings': + return this.awaitedToolAnswer((deliver) => this.dispatchAgentSettingsToolCall(subject, call, deliver)); + case 'save-image': + return this.awaitedToolAnswer((deliver) => this.dispatchSaveImageToolCall(subject, call, deliver)); + case 'read-image': + return this.awaitedToolAnswer((deliver) => this.dispatchReadImageToolCall(subject, call, deliver)); + case 'fetch-reference': + return this.awaitedToolAnswer((deliver) => this.dispatchFetchReferenceToolCall(subject, call, deliver)); + case 'gate-status': + return this.awaitedToolAnswer((deliver) => this.dispatchGateToolCall(subject, call, deliver)); + + // The tune-out tool, in process: the same body the dispatcher runs, its + // answer awaited here (it can wait for the tune-out's transport). + case 'tune-out': { + const agentName = call.callerAgentName ?? this.primaryAgentName ?? '__ephemeral__'; + const input = (call.input ?? {}) as Record; + // The calling turn's own channels, as the channel tools read them here (origin.own). + const own = origin.kind === 'agent' ? origin.own ?? [] : []; + const turn = this.callingTurnOf(agentName, call); + return call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn); + } + case 'subconscious': { + const routed = this.readerToolCall(subject, call); + return 'refused' in routed ? failed({ success: false, error: `${routed.refused}, so the call didn't run.`, isError: true }) : routed.result; + } + case 'sleep': + return this.awaitedToolAnswer((deliver) => this.dispatchSleepToolCall(subject, call, deliver)); + + // The heartbeat's tools, for callers that await a tool's result (an + // in-process call, a puppet, a module's callTool). A module is trusted; + // an agent is held to what dispatch holds it to. + case 'heartbeat': { + const refusal = origin.kind === 'module' ? null : this.heartbeatRefusal(call.callerAgentName ?? '__ephemeral__', call); + return refusal ? { success: false, error: refusal, isError: true } : this.runHeartbeatTool(call); + } + case 'wake-rule': + return this.awaitedToolAnswer((deliver) => this.dispatchWakeRuleToolCall(subject, call, deliver)); + case 'event-tags': + return this.awaitedToolAnswer((deliver) => this.dispatchEventTagsToolCall(subject, call, deliver)); + case 'agent-verb': + if (origin.kind === 'module') return failed(agentsOwnVerbRefusal(call.name)); + if (call.name === 'skip_reply') { + return failed({ + success: false, + error: "skip_reply ends the turn it's called in, and this call wasn't made in a turn, so nothing was done.", + isError: true, + }); + } + return this.channelRegistry!.handleChannelToolCall(call.name, call.input, origin); + + // Client-side programmatic tool calling for callers that await a tool's + // result: the puppet and a host through executeToolCall, and a module + // through callTool. Keyed by callerAgentName, so each agent's scripts are + // its own. + case 'code-execution': + return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call); + + // drafts: the calling resident's own held drafts. A puppeted call has + // agent origin and acts on that resident's drafts, as the model's own + // call does through dispatchToolCall, but it isn't made inside the + // resident's turn, so it takes none of the turn's channels, engages none, + // and isn't noted as the resident's speech (callingTurnOf). A module + // holds no drafts. + case 'drafts': + if (origin.kind === 'module') return failed(agentsOwnVerbRefusal(call.name)); + return this.draftsToolResult(origin.agentName, call.input); + case 'prose-help': + return { success: true, data: PROSE_ROUTING_HELP }; + case 'module': + // Module tools. The caller uses the result in process, so it gets the + // module's own result, as the module returned it, less a refusal it + // claims, which is the dispatcher's to say. + return this.moduleRegistry.handleToolCall(call); } - - // MCPL tools go where the model's own call of the name goes, by the same - // resolver and with the prefix it matched: to the server whose listing - // has the name, else the longest matching prefix. - if (this.mcplServerRegistry) { - const mcpl = this.resolveMcplTool(call.name); - if (mcpl) return this.executeMcplToolCall(call, mcpl[0], mcpl[1]); - } - - // Synthesized channel tools. This route was missing: when - // subscription-GC moved off the retired MCPL unsubscribe tool onto the - // generic channel_close (discord-mcpl b095a9f), module-originated - // closes started failing downstream as "Invalid tool name format" — - // the janitor had been silently broken since. `origin` is trusted - // dispatch context established at the call boundary above, not - // inferred from which method was used: only the ModuleRegistry closure - // carries module provenance (see handleToolClose). The agent-only - // synthesized verbs (think, skip_reply) are deliberately not routed - // here. - if (call.name.startsWith('channel_') && this.channelRegistry) { - const turn = call.name === 'channel_publish' && call.callerAgentName ? this.callingTurnOf(call.callerAgentName, call) : undefined; - const input = call.name === 'channel_publish' && call.callerAgentName - ? this.publishInputWithTurnLocus(call.input, call.callerAgentName, turn) - : call.input; - return this.channelRegistry.handleChannelToolCall(call.name, input, origin.kind === 'agent' && turn ? { ...origin, inTurn: true } : origin); - } - - // drafts: the calling resident's own held drafts. A puppeted call has - // agent origin and acts on that resident's drafts, as the model's own - // call does through dispatchToolCall, but it isn't made inside the - // resident's turn, so it takes none of the turn's channels, engages none, - // and isn't noted as the resident's speech (callingTurnOf). A module - // holds no drafts. - if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') { - return this.draftsToolResult(origin.agentName, call.input); - } - - // The heartbeat's tools, for callers that await a tool's result (an - // in-process call, a puppet, a module's callTool). A module is trusted; - // an agent is held to what dispatch holds it to. - if (this.heartbeat?.ownsTool(call.name)) { - const refusal = origin.kind === 'module' ? null : this.heartbeatRefusal(call.callerAgentName ?? '__ephemeral__', call); - return refusal ? { success: false, error: refusal, isError: true } : this.runHeartbeatTool(call); - } - // Module tools. The caller uses the result in process, so it gets the - // module's own result, as the module returned it, less a refusal it - // claims, which is the dispatcher's to say. - // The tune-out tool, in process: the same body the dispatcher runs, its - // answer awaited here (it can wait for the tune-out's transport). - if ((call.name === 'tune_out' || call.name === 'focus') && this.tuneOutCoordinator) { - const agentName = call.callerAgentName ?? this.primaryAgentName ?? '__ephemeral__'; - const input = (call.input ?? {}) as Record; - // The calling turn's own channels, as the channel tools read them here (origin.own). - const own = origin.kind === 'agent' ? origin.own ?? [] : []; - const turn = this.callingTurnOf(agentName, call); - return call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn); - } - if ((SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(call.name)) { - const routed = this.readerToolCall(origin.kind === 'agent' ? origin.agentName : call.callerAgentName ?? '__ephemeral__', call); - return 'refused' in routed ? failed({ success: false, error: `${routed.refused}, so the call didn't run.`, isError: true }) : routed.result; - } - return this.moduleRegistry.handleToolCall(call); } /** @@ -29077,6 +29149,53 @@ export class AgentFramework { return { name: call.name, cause: permitted || refused ? silenceCauseOf([call]) : null, refused, permitted }; } + private toolRoute(call: ToolCall, actor: string | undefined): ToolRoute { + const name = call.name; + if (isPresentationTool(name)) return { kind: 'presentation' }; + if (name === META_TOOL_NAMES.act) { + const host = actor === undefined ? null : this.hostPresentation?.resolveAct(actor, call.input) ?? null; + if (host) return { kind: 'host-act', host }; + if (this.toolsetHub && this.mcplServerRegistry) return { kind: 'toolset-act' }; + } + const mcpl = this.mcplServerRegistry ? this.resolveMcplTool(name) : null; + if (mcpl) return { kind: 'mcpl', serverId: mcpl[0], prefix: mcpl[1] }; + if (name.startsWith('channel_') && this.channelRegistry) return { kind: 'channel' }; + if (name === 'agent_settings') return { kind: 'agent-settings' }; + if (name === 'save_recent_image') return { kind: 'save-image' }; + if (name === 'read_image') return { kind: 'read-image' }; + if (name === 'fetch_reference') return { kind: 'fetch-reference' }; + if (name === 'gate_status' && this.eventGate) return { kind: 'gate-status' }; + if ((name === 'tune_out' || name === 'focus') && this.tuneOutCoordinator) return { kind: 'tune-out' }; + if ((SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(name)) return { kind: 'subconscious' }; + if ((name === 'sleep' || name === 'wake') && this.eventGate) return { kind: 'sleep' }; + if (this.heartbeat?.ownsTool(name)) return { kind: 'heartbeat' }; + if ((name === 'wake_add_rule' || name === 'wake_remove_rule') && this.eventGate) return { kind: 'wake-rule' }; + if (name === 'event_tags' && this.eventGate) return { kind: 'event-tags' }; + if ((name === 'think' || name === 'journal' || name === 'skip_reply') && this.channelRegistry) return { kind: 'agent-verb' }; + if (name === CODE_EXECUTION_TOOL_NAME && this.codeExecution) return { kind: 'code-execution' }; + if (name === 'drafts' && this.channelRegistry) return { kind: 'drafts' }; + if (name === PROSE_HELP_TOOL.name) return { kind: 'prose-help' }; + return { kind: 'module' }; + } + + private pushToolResult(agentName: string, call: ToolCall, moduleName: string): (result: ToolResult) => void { + return (result) => this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName, result }); + } + + private awaitedToolAnswer(run: (deliver: (result: ToolResult) => void) => void): Promise { + return new Promise((resolve) => { + try { + run((result) => { + const answer = { ...result }; + delete answer.refused; + resolve(answer); + }); + } catch (error) { + resolve({ success: false, error: thrownText(error), isError: true }); + } + }); + } + /** * Dispatch a call the agent made. `turnId` is the turn it was made in * (TurnRouting.turnId), as its caller knows it: a model's call, the turn @@ -29127,217 +29246,207 @@ export class AgentFramework { const enrichedCall: ToolCall = { ...call, callerAgentName: agentName }; if (turnId !== undefined) enrichedCall.callerTurnId = turnId; else delete enrichedCall.callerTurnId; - if (isPresentationTool(call.name)) { - const result = this.editToolPresentation(agentName, enrichedCall); - this.pushEvent({type:'tool-result',callId:call.id,toolName:call.name,agentName,moduleName:'tool-presentation',result}); - return; - } + const route = this.toolRoute(enrichedCall, agentName); + switch (route.kind) { + case 'presentation': { + const result = this.editToolPresentation(agentName, enrichedCall); + this.pushEvent({type:'tool-result',callId:call.id,toolName:call.name,agentName,moduleName:'tool-presentation',result}); + return; + } + // toolsets--act on a host toolset's tool (the built-in one, a server's + // tab): the call of that tool, under its own name, down the path a + // direct call of it takes, permissions included. + case 'host-act': + this.dispatchHostAct(agentName, call, route.host, turnId); + return; - // toolsets--act on a host toolset's tool (the built-in one, a server's - // tab): the call of that tool, under its own name, down the path a - // direct call of it takes, permissions included. - if (enrichedCall.name === META_TOOL_NAMES.act) { - const host = this.hostPresentation?.resolveAct(agentName, enrichedCall.input) ?? null; - if (host) { - this.dispatchHostAct(agentName, call, host, turnId); + // RFC-014: toolsets--act calls a server's dynamic tool, down the same + // path as a direct call of it (dispatchToolsetAct). + case 'toolset-act': { + const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id); + if (scriptWaiter) scriptWaiter.route.toServer = true; + this.dispatchToolsetAct(agentName, enrichedCall); return; } - } - // RFC-014: toolsets--act calls a server's dynamic tool, down the same - // path as a direct call of it (dispatchToolsetAct). - if (enrichedCall.name === META_TOOL_NAMES.act && this.toolsetHub && this.mcplServerRegistry) { - const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id); - if (scriptWaiter) scriptWaiter.route.toServer = true; - this.dispatchToolsetAct(agentName, enrichedCall); - return; - } - - // Route MCPL tool calls to the appropriate server via prefix map - const mcplMatch = this.resolveMcplTool(enrichedCall.name); - if (mcplMatch && this.mcplServerRegistry) { - // A script's call: its waiter keeps that it went to a server, for the - // script lane's exit (scriptToolWaiters). - const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id); - if (scriptWaiter) scriptWaiter.route.toServer = true; - this.dispatchMcplToolCall(agentName, enrichedCall, mcplMatch[0], mcplMatch[1]); - return; - } + case 'mcpl': { + // A script's call: its waiter keeps that it went to a server, for the + // script lane's exit (scriptToolWaiters). + const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id); + if (scriptWaiter) scriptWaiter.route.toServer = true; + this.dispatchMcplToolCall(agentName, enrichedCall, route.serverId, route.prefix); + return; + } - // Route synthesized channel tools - if (enrichedCall.name.startsWith('channel_') && this.channelRegistry) { - this.dispatchChannelToolCall(agentName, enrichedCall); - return; - } + case 'channel': { + this.dispatchChannelToolCall(agentName, enrichedCall); + return; + } - if (enrichedCall.name === 'agent_settings') { - this.dispatchAgentSettingsToolCall(agentName, enrichedCall); - return; - } + case 'agent-settings': { + this.dispatchAgentSettingsToolCall(agentName, enrichedCall); + return; + } - if (enrichedCall.name === 'save_recent_image') { - this.dispatchSaveImageToolCall(agentName, enrichedCall); - return; - } + case 'save-image': { + this.dispatchSaveImageToolCall(agentName, enrichedCall); + return; + } - if (enrichedCall.name === 'read_image') { - this.dispatchReadImageToolCall(agentName, enrichedCall); - return; - } + case 'read-image': { + this.dispatchReadImageToolCall(agentName, enrichedCall); + return; + } - if (enrichedCall.name === 'fetch_reference') { - this.dispatchFetchReferenceToolCall(agentName, enrichedCall); - return; - } + case 'fetch-reference': { + this.dispatchFetchReferenceToolCall(agentName, enrichedCall); + return; + } - // Route gate_status tool - if (enrichedCall.name === 'gate_status' && this.eventGate) { - this.dispatchGateToolCall(agentName, enrichedCall); - return; - } + case 'gate-status': { + this.dispatchGateToolCall(agentName, enrichedCall); + return; + } - // Route the tune-out tool (residents) and the subconscious's surface - if ((enrichedCall.name === 'tune_out' || enrichedCall.name === 'focus') && this.tuneOutCoordinator) { - this.dispatchTuneOutToolCall(agentName, enrichedCall); - return; - } - if ((SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(enrichedCall.name)) { - const routed = this.readerToolCall(agentName, enrichedCall); - if ('refused' in routed) { - const error = routed.refused; - const result = this.refusedResult(agentName, call.id, error); - this.emitTrace({ type: 'tool:failed', module: 'tune-out', tool: call.name, callId: call.id, error }); - this.pushEvent({ - type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result, - }); + case 'tune-out': { + this.dispatchTuneOutToolCall(agentName, enrichedCall); return; } - void routed.result - .then((result) => { - // Through pushEvent, like every other tool's result, so one that - // arrives after stop() is dropped rather than rejected unhandled. + case 'subconscious': { + const routed = this.readerToolCall(agentName, enrichedCall); + if ('refused' in routed) { + const error = routed.refused; + const result = this.refusedResult(agentName, call.id, error); + this.emitTrace({ type: 'tool:failed', module: 'tune-out', tool: call.name, callId: call.id, error }); this.pushEvent({ - type: 'tool-result', - callId: enrichedCall.id, - toolName: enrichedCall.name, - agentName, - moduleName: 'tune-out', - result, + type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result, }); - }); - return; - } + return; + } + void routed.result + .then((result) => { + // Through pushEvent, like every other tool's result, so one that + // arrives after stop() is dropped rather than rejected unhandled. + this.pushEvent({ + type: 'tool-result', + callId: enrichedCall.id, + toolName: enrichedCall.name, + agentName, + moduleName: 'tune-out', + result, + }); + }); + return; + } - // Route sleep / wake tools - if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) { - this.dispatchSleepToolCall(agentName, enrichedCall); - return; - } + case 'sleep': { + this.dispatchSleepToolCall(agentName, enrichedCall); + return; + } - // Route the heartbeat's tools (heartbeat--…) - if (this.heartbeat?.ownsTool(enrichedCall.name)) { - this.dispatchHeartbeatToolCall(agentName, enrichedCall); - return; - } + case 'heartbeat': { + this.dispatchHeartbeatToolCall(agentName, enrichedCall); + return; + } - // Route wake-rule tools (runtime gate.json policy add/remove) - if ((enrichedCall.name === 'wake_add_rule' || enrichedCall.name === 'wake_remove_rule') && this.eventGate) { - this.dispatchWakeRuleToolCall(agentName, enrichedCall); - return; - } + case 'wake-rule': { + this.dispatchWakeRuleToolCall(agentName, enrichedCall); + return; + } - // Route event_tags (tag/ontology discovery) - if (enrichedCall.name === 'event_tags' && this.eventGate) { - this.dispatchEventTagsToolCall(agentName, enrichedCall); - return; - } + case 'event-tags': { + this.dispatchEventTagsToolCall(agentName, enrichedCall); + return; + } - // Route synthesized 'think' (private reasoning), 'journal' (private - // long-form notes) and 'skip_reply' (deliberate stay-silent) tools — handled by the channel registry like the other - // synthesized channel tools, but they aren't `channel_`-prefixed so they - // need an explicit route here. - if ((enrichedCall.name === 'think' || enrichedCall.name === 'journal' || enrichedCall.name === 'skip_reply') && this.channelRegistry) { - // skip_reply(wake_in_seconds): arm a gate self-wake so "not replying - // NOW" can also mean "back in a moment" — ends the turn, then wakes - // the agent after N seconds unless something else wakes it first - // (any turn start cancels the pending self-wake). Armed here because - // the framework owns the EventGate; the registry only words the - // confirmation. A wake the host can't set is refused, so the model - // learns that its turn remains open and no wake was armed: any - // wake_in_seconds without a gate, and one that isn't a number of - // seconds with one. - if (enrichedCall.name === 'skip_reply') { - const error = this.skipReplyRefusal(enrichedCall); - if (error !== undefined) { - const wakeValue = (enrichedCall.input as { wake_in_seconds?: unknown } | null | undefined)?.wake_in_seconds; - console.error(`[self-wake] agent=${agentName} wake_in_seconds=${logValue(wakeValue)} skip_reply refused: ${logDetail(this.eventGate ? "wake_in_seconds isn't a number of seconds" : 'No EventGate configured (FrameworkConfig.gate is unset)')}`); - const result = this.refusedResult(agentName, enrichedCall.id, error); - this.emitTrace({ type: 'tool:failed', module: 'channels', tool: enrichedCall.name, callId: enrichedCall.id, error }); - this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'channels', result }); - return; - } - const skipping = this.callingTurnOf(agentName, enrichedCall); - if (skipping) { - skipping.privateEnd = true; - delete skipping.unpostedSpeech; - } - const wake = readSkipReplyWake(enrichedCall.input); - if (wake.kind === 'wake' && this.eventGate) { - const armedAt = Date.now(); - const { inMs, armed } = this.eventGate.armSelfWake(agentName, wake.seconds, 'skip_reply'); - // The heartbeat's tick or reminder due before the timer: if it wakes - // the agent, its turn cancels the timer, so the confirmation names - // it and the time the self-wake would have left. - const earlierWake = armed ? this.heartbeatWakeBefore(agentName, armedAt + inMs) : undefined; - console.error(armed - ? `[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)` - + (earlierWake ? `; due sooner, in ~${earlierWake.inSeconds}s: ${logDetail(earlierWake.what)}` : '') - : `[self-wake] agent=${agentName} not armed: the framework has stopped (skip_reply)`); - this.dispatchChannelToolCall(agentName, enrichedCall, earlierWake); - return; + case 'agent-verb': { + // skip_reply(wake_in_seconds): arm a gate self-wake so "not replying + // NOW" can also mean "back in a moment" — ends the turn, then wakes + // the agent after N seconds unless something else wakes it first + // (any turn start cancels the pending self-wake). Armed here because + // the framework owns the EventGate; the registry only words the + // confirmation. A wake the host can't set is refused, so the model + // learns that its turn remains open and no wake was armed: any + // wake_in_seconds without a gate, and one that isn't a number of + // seconds with one. + if (enrichedCall.name === 'skip_reply') { + const error = this.skipReplyRefusal(enrichedCall); + if (error !== undefined) { + const wakeValue = (enrichedCall.input as { wake_in_seconds?: unknown } | null | undefined)?.wake_in_seconds; + console.error(`[self-wake] agent=${agentName} wake_in_seconds=${logValue(wakeValue)} skip_reply refused: ${logDetail(this.eventGate ? "wake_in_seconds isn't a number of seconds" : 'No EventGate configured (FrameworkConfig.gate is unset)')}`); + const result = this.refusedResult(agentName, enrichedCall.id, error); + this.emitTrace({ type: 'tool:failed', module: 'channels', tool: enrichedCall.name, callId: enrichedCall.id, error }); + this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'channels', result }); + return; + } + const skipping = this.callingTurnOf(agentName, enrichedCall); + if (skipping) { + skipping.privateEnd = true; + delete skipping.unpostedSpeech; + } + const wake = readSkipReplyWake(enrichedCall.input); + if (wake.kind === 'wake' && this.eventGate) { + const armedAt = Date.now(); + const { inMs, armed } = this.eventGate.armSelfWake(agentName, wake.seconds, 'skip_reply'); + // The heartbeat's tick or reminder due before the timer: if it wakes + // the agent, its turn cancels the timer, so the confirmation names + // it and the time the self-wake would have left. + const earlierWake = armed ? this.heartbeatWakeBefore(agentName, armedAt + inMs) : undefined; + console.error(armed + ? `[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)` + + (earlierWake ? `; due sooner, in ~${earlierWake.inSeconds}s: ${logDetail(earlierWake.what)}` : '') + : `[self-wake] agent=${agentName} not armed: the framework has stopped (skip_reply)`); + this.dispatchChannelToolCall(agentName, enrichedCall, earlierWake); + return; + } } + this.dispatchChannelToolCall(agentName, enrichedCall); + return; } - this.dispatchChannelToolCall(agentName, enrichedCall); - return; - } - // Client-side programmatic tool calling: run a script that calls the - // agent's other tools in-process (script-inner calls come back through - // this very dispatcher with waiter-tracked call IDs). - if (enrichedCall.name === CODE_EXECUTION_TOOL_NAME && this.codeExecution) { - this.dispatchCodeExecutionToolCall(agentName, enrichedCall); - return; - } + // Client-side programmatic tool calling: run a script that calls the + // agent's other tools in-process (script-inner calls come back through + // this very dispatcher with waiter-tracked call IDs). + case 'code-execution': { + this.dispatchCodeExecutionToolCall(agentName, enrichedCall); + return; + } - // drafts: the resident's own held drafts (list/read/resend/dismiss). - // Answered by the framework; resend publishes through the registry's - // publish executor, so the result arrives asynchronously. - if (enrichedCall.name === 'drafts' && this.channelRegistry) { - const startedAt = Date.now(); - this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input }); - void this.draftsToolResult(agentName, enrichedCall.input, this.callingTurnOf(agentName, enrichedCall), enrichedCall.id) - .then((result) => { - this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt }); - this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result }); + // drafts: the resident's own held drafts (list/read/resend/dismiss). + // Answered by the framework; resend publishes through the registry's + // publish executor, so the result arrives asynchronously. + case 'drafts': { + const startedAt = Date.now(); + this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input }); + void this.draftsToolResult(agentName, enrichedCall.input, this.callingTurnOf(agentName, enrichedCall), enrichedCall.id) + .then((result) => { + this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt }); + this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result }); + }); + return; + } + + // prose_help: on-demand routing reference for explicit-prose agents. + // Answered inline — the grammar is a tool RESULT (model-requested), never + // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce). + case 'prose-help': { + this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'prose_help', callId: enrichedCall.id, durationMs: 0 }); + this.pushEvent({ + type: 'tool-result', + callId: enrichedCall.id, + toolName: enrichedCall.name, + agentName, + moduleName: 'framework', + result: { success: true, data: PROSE_ROUTING_HELP }, }); - return; - } + return; + } - // prose_help: on-demand routing reference for explicit-prose agents. - // Answered inline — the grammar is a tool RESULT (model-requested), never - // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce). - if (enrichedCall.name === 'prose_help') { - this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'prose_help', callId: enrichedCall.id, durationMs: 0 }); - this.pushEvent({ - type: 'tool-result', - callId: enrichedCall.id, - toolName: enrichedCall.name, - agentName, - moduleName: 'framework', - result: { success: true, data: PROSE_ROUTING_HELP }, - }); - return; + case 'module': + break; + default: + route satisfies never; } const sepIndex = enrichedCall.name.indexOf('--'); @@ -36390,7 +36499,7 @@ export class AgentFramework { * (same path as gate.json load), so an invalid rule is surfaced as a tool * error and nothing is written. */ - private dispatchWakeRuleToolCall(agentName: string, call: ToolCall): void { + private dispatchWakeRuleToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'gate')): void { this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input }); // What wake_add_rule's input held that does nothing, checkToolInput's notes and // then the gate's on a rule it saved: a refusal names it too, so the call that @@ -36405,7 +36514,7 @@ export class AgentFramework { module: 'gate', tool: call.name, callId: call.id, durationMs: 0, ...(result.isError ? { error: result.error } : {}), }); - this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result }); + deliver(result); }; try { @@ -36895,7 +37004,7 @@ export class AgentFramework { return names.find((n) => /(^|--)(fetch_history|channel_read|read_history|history)$/.test(n)) ?? null; } - private dispatchSleepToolCall(agentName: string, call: ToolCall): void { + private dispatchSleepToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'gate')): void { // The gate's sleep quiets the whole agent, every conversation of it, so a // conversation fork or an ephemeral run neither sets one nor ends the // agent's own. Refused before the tool runs, as a fork's channel_open is. @@ -36914,7 +37023,7 @@ export class AgentFramework { ? `Nothing was set: a sleep quiets the whole agent, not one ${one}.${comeBack}` : `Nothing was changed: a sleep is the agent's, so this ${one} can't end one.`; this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: `${agentName}: ${error}` }); - this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result: this.refusedResult(agentName, call.id, error) }); + deliver(this.refusedResult(agentName, call.id, error)); return; } this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input }); @@ -36923,7 +37032,7 @@ export class AgentFramework { const finish = (result: ToolResult) => { this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 }); - this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result }); + deliver(result); }; if (call.name === 'wake') { @@ -37043,30 +37152,34 @@ export class AgentFramework { */ /** RFC-005 on-demand dereference: [ref id] → workspace file, via the * origin-bound, ceiling-bounded, digest-verified host fetcher. */ - private dispatchFetchReferenceToolCall(agentName: string, call: ToolCall): void { + private dispatchFetchReferenceToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'workspace')): void { this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input }); const startTime = Date.now(); void (async () => { - const args = (call.input ?? {}) as Record; - const refId = String(args.ref_id ?? ''); - const record = referenceRegistry.get(refId); let result: { success: boolean; data?: string; error?: string; isError?: boolean }; - if (!record) { - // Vector 20: a stale or unknown id is a defined miss, never a - // different record. - result = { success: false, isError: true, - error: `unknown reference: ${refId || '(missing ref_id)'} — it may have been evicted; ids are never reused` }; - } else { - const maxBytes = typeof args.max_bytes === 'number' - ? Math.min(args.max_bytes, DEFAULT_FETCH_MAX_BYTES) : undefined; - const outcome = await this.getReferenceFetcher().fetch(record, { maxBytes }); - result = outcome.ok - ? { success: true, data: JSON.stringify({ path: outcome.path, bytes: outcome.bytes, - mime_type: outcome.mimeType, digest_verified: outcome.digestVerified ?? 'no digest claimed' }) } - // The fetcher says what happened as a clause about the reference, - // and what calling again can do as a sentence of its own. - : { success: false, isError: true, - error: `fetch_reference couldn't fetch "${refId}": ${outcome.error}. ${outcome.remedy}` }; + try { + const args = (call.input ?? {}) as Record; + const refId = String(args.ref_id ?? ''); + const record = referenceRegistry.get(refId); + if (!record) { + // Vector 20: a stale or unknown id is a defined miss, never a + // different record. + result = { success: false, isError: true, + error: `unknown reference: ${refId || '(missing ref_id)'} — it may have been evicted; ids are never reused` }; + } else { + const maxBytes = typeof args.max_bytes === 'number' + ? Math.min(args.max_bytes, DEFAULT_FETCH_MAX_BYTES) : undefined; + const outcome = await this.getReferenceFetcher().fetch(record, { maxBytes }); + result = outcome.ok + ? { success: true, data: JSON.stringify({ path: outcome.path, bytes: outcome.bytes, + mime_type: outcome.mimeType, digest_verified: outcome.digestVerified ?? 'no digest claimed' }) } + // The fetcher says what happened as a clause about the reference, + // and what calling again can do as a sentence of its own. + : { success: false, isError: true, + error: `fetch_reference couldn't fetch "${refId}": ${outcome.error}. ${outcome.remedy}` }; + } + } catch (error) { + result = { success: false, isError: true, error: `fetch_reference couldn't answer: ${thrownText(error)}` }; } this.emitTrace({ type: result.isError ? 'tool:failed' : 'tool:completed', @@ -37074,14 +37187,11 @@ export class AgentFramework { durationMs: Date.now() - startTime, ...(result.isError ? { error: result.error } : {}), }); - this.pushEvent({ - type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', - result: { ...result, isError: result.isError ?? false }, - }); + deliver({ ...result, isError: result.isError ?? false }); })(); } - private dispatchReadImageToolCall(agentName: string, call: ToolCall): void { + private dispatchReadImageToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'workspace')): void { this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input }); const finish = (result: ToolResult): void => { this.emitTrace({ @@ -37092,7 +37202,7 @@ export class AgentFramework { durationMs: 0, ...(result.isError ? { error: result.error } : {}), }); - this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result }); + deliver(result); }; void (async (): Promise => { try { @@ -37184,13 +37294,11 @@ export class AgentFramework { * every sibling in the round to settle; if one never does, fail closed * rather than trust an index the batch may still change. Sibling saves * produce no images and are not waited on (two saves in one batch would - * otherwise deadlock into a double timeout). A script's own calls - * (`pytc-…`) skip the barrier: the batch's pending siblings include the - * code_execution call running that script, so waiting on them would wait - * on itself. + * otherwise deadlock into a double timeout). Only the round's own calls + * wait: a call that isn't among its pending calls, a script's (`pytc-…`) + * or an awaited call's, counts the images the agent has now. */ private async awaitSiblingToolCalls(agent: Agent, call: ToolCall): Promise { - if (call.id.startsWith('pytc-')) return; const BARRIER_MS = 120_000; const POLL_MS = 20; const startedAt = Date.now(); @@ -37198,6 +37306,7 @@ export class AgentFramework { const state = agent.state; // Idle (puppet path) or the round was torn down: nothing to wait on. if (state.status !== 'waiting_for_tools') return; + if (!state.pending.has(call.id)) return; const open = [...state.pending.values()].filter((p) => p.id !== call.id && p.name !== 'save_recent_image'); if (open.length === 0) return; if (Date.now() - startedAt > BARRIER_MS) { @@ -37226,7 +37335,7 @@ export class AgentFramework { * nothing. It never slides to an older image — a filename must not * authenticate bytes from another surface. */ - private dispatchSaveImageToolCall(agentName: string, call: ToolCall): void { + private dispatchSaveImageToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'workspace')): void { this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input }); const finish = (result: ToolResult): void => { this.emitTrace({ @@ -37237,7 +37346,7 @@ export class AgentFramework { durationMs: 0, ...(result.isError ? { error: result.error } : {}), }); - this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result }); + deliver(result); }; type Candidate = | { kind: 'attachment'; data: string | null; mediaType: string; messagesBack: number } @@ -37663,7 +37772,7 @@ export class AgentFramework { : `Changed before the refusal: ${changed.join(', ')}; no other setting reads differently.`; } - private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall): void { + private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'agent')): void { this.emitTrace({ type: 'tool:started', module: 'agent', @@ -37851,14 +37960,7 @@ export class AgentFramework { durationMs: 0, ...(result.isError ? { error: result.error } : {}), }); - this.pushEvent({ - type: 'tool-result', - callId: call.id, - toolName: call.name, - agentName, - moduleName: 'agent', - result, - }); + deliver(result); } /** Aggregate the event-tag vocabulary: reserved chat:* core + each connected @@ -37883,7 +37985,7 @@ export class AgentFramework { }; } - private dispatchEventTagsToolCall(agentName: string, call: ToolCall): void { + private dispatchEventTagsToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'gate')): void { this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input }); let result: import('./types/events.js').ToolResult; try { @@ -37895,10 +37997,10 @@ export class AgentFramework { this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id); result = { success: false, error: err.message, isError: true }; } - this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result }); + deliver(result); } - private dispatchGateToolCall(agentName: string, call: ToolCall): void { + private dispatchGateToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'gate')): void { this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input }); const startTime = Date.now(); @@ -37906,27 +38008,13 @@ export class AgentFramework { .then((result) => { const durationMs = Date.now() - startTime; this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs }); - this.pushEvent({ - type: 'tool-result', - callId: call.id, - toolName: call.name, - agentName, - moduleName: 'gate', - result, - }); + deliver(result); }) .catch((error) => { const err = error instanceof Error ? error : new Error(String(error)); this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message }); this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id); - this.pushEvent({ - type: 'tool-result', - callId: call.id, - toolName: call.name, - agentName, - moduleName: 'gate', - result: { success: false, error: err.message, isError: true }, - }); + deliver({ success: false, error: err.message, isError: true }); }); } diff --git a/test/awaited-tool-routes.test.ts b/test/awaited-tool-routes.test.ts new file mode 100644 index 000000000..fb58fea0e --- /dev/null +++ b/test/awaited-tool-routes.test.ts @@ -0,0 +1,220 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ContentBlock } from '@animalabs/membrane'; +import { fixture } from './helpers/coalescing-fixture.js'; +import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js'; +import { AgentFramework, WorkspaceModule } from '../src/index.js'; +import type { Module, ModuleContext, ProcessEvent, ToolResult } from '../src/index.js'; + +const GIF = 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + +async function setup(t: { after(fn: () => unknown): void }, opts: { presentation?: boolean } = {}) { + let ctx: { callTool(call: unknown): Promise } | undefined; + const probe = { + name: 'probe', + async start(c: typeof ctx) { ctx = c; }, + async stop() {}, + getTools() { return []; }, + async handleToolCall() { return { success: false, error: 'no tools', isError: true }; }, + async onProcess() { return {}; }, + }; + const mount = mkdtempSync(join(tmpdir(), 'awaited-routes-')); + const workspace = new WorkspaceModule({ mounts: [{ name: 'work', path: mount, mode: 'read-write', watch: 'never' }] }); + const f = await fixture({ + agents: [{ + name: 'agent', model: 'test', systemPrompt: 'test', proseRouting: 'explicit', + ...(opts.presentation ? { toolPresentation: { path: join(mount, 'tools.json'), cataloguePath: 'work/tools.md' } } : {}), + }], + framework: { modules: [probe, workspace], gate: { config: { policies: [], default: 'skip' } } } as never, + }); + t.after(async () => { await f.close(); rmSync(mount, { recursive: true, force: true }); }); + const internals = f.framework as unknown as { pushEvent(event: unknown): void; dispatchToolCall(agent: string, call: unknown): void }; + const viaModel = (id: string, name: string, input: unknown) => new Promise((resolve) => { + const original = internals.pushEvent; + internals.pushEvent = (event: unknown) => { + const e = event as { type: string; callId?: string; result?: ToolResult }; + if (e.type === 'tool-result' && e.callId === id) { internals.pushEvent = original; resolve(e.result!); } + else original.call(internals, event); + }; + internals.dispatchToolCall('agent', { id, name, input }); + }); + const viaPuppet = async (name: string, input: Record) => (await f.framework.puppetToolCall('agent', name, input)).result; + return { f, ctx: () => ctx!, viaModel, viaPuppet }; +} + +const fields = (r: ToolResult) => ({ success: r.success, data: r.data, error: r.error, isError: r.isError }); + +async function answersAsTheAgent(h: Awaited>, calls: Array<[string, Record]>) { + for (const [i, [name, input]] of calls.entries()) { + const puppet = await h.viaPuppet(name, input); + const model = await h.viaModel(`m-${i}`, name, input); + assert.deepEqual(fields(puppet), fields(model), `${name} ${JSON.stringify(input)}`); + } +} + +test('the puppet\'s agent_settings answers as the agent\'s own call does', { timeout: 30_000 }, async (t) => { + const h = await setup(t); + const got = await h.viaPuppet('agent_settings', { action: 'get' }); + assert.equal(got.success, true, String(got.error)); + await answersAsTheAgent(h, [['agent_settings', { action: 'get' }]]); +}); + +test('the puppet\'s workspace tools answer as the agent\'s own calls do, and a host\'s fetch whose reference id can\'t be read answers with the error, as the agent\'s own fetch does', { timeout: 30_000 }, async (t) => { + const h = await setup(t); + await answersAsTheAgent(h, [ + ['save_recent_image', {}], + ['read_image', { path: 'work/zz-missing.png' }], + ['fetch_reference', { ref_id: 'zz-none' }], + ]); + const unreadable = { ref_id: { toString: 1, valueOf: 1 } }; + const host = await h.f.framework.executeToolCall({ id: 'host-fetch', name: 'fetch_reference', input: unreadable, callerAgentName: 'agent' }); + assert.equal(host.isError, true); + assert.match(String(host.error), /^fetch_reference couldn't answer: .+/); + assert.deepEqual(fields(host), fields(await h.viaModel('m-fetch', 'fetch_reference', unreadable))); +}); + +test('the puppet\'s gate tools answer as the agent\'s own calls do', { timeout: 30_000 }, async (t) => { + const h = await setup(t); + await answersAsTheAgent(h, [ + ['gate_status', {}], + ['event_tags', {}], + ['wake_add_rule', {}], + ['wake_remove_rule', { name: 'zz-none' }], + ['sleep', { seconds: 0 }], + ['wake', {}], + ]); +}); + +test('the puppet\'s think and journal answer as the agent\'s own calls do, and skip_reply, which ends the turn it\'s called in, is refused off a turn: by the puppet before anything is stored, and to a host as its result', { timeout: 30_000 }, async (t) => { + const h = await setup(t); + await answersAsTheAgent(h, [ + ['think', { content: 'zz-thought' }], + ['journal', { content: 'zz-entry' }], + ]); + const before = h.f.context(); + await assert.rejects(h.viaPuppet('skip_reply', { reason: 'zz' }), /puppet refused: skip_reply ends the turn it's called in/); + assert.equal(h.f.context(), before, 'the refused puppet call stored nothing'); + const skip = await h.f.framework.executeToolCall({ id: 'host-skip', name: 'skip_reply', input: { reason: 'zz' }, callerAgentName: 'agent' }); + assert.equal(skip.isError, true); + assert.match(String(skip.error), /skip_reply ends the turn it's called in/); + assert.match(String(skip.error), /nothing was done/); +}); + +test('the puppet refuses a skip_reply reached through toolsets--act as it refuses one called by name, and stores nothing', { timeout: 30_000 }, async (t) => { + const h = await setup(t, { presentation: true }); + assert.equal((await h.viaPuppet('set_tool_visibility', { name: 'skip_reply', visible: false })).success, true); + assert.equal((await h.viaPuppet('toolsets--open', { toolset: 'settings' })).success, true); + const before = h.f.context(); + await assert.rejects(h.viaPuppet('toolsets--act', { tool: 'skip_reply', args: { reason: 'zz' } }), /puppet refused: skip_reply ends the turn it's called in/); + assert.equal(h.f.context(), before, 'the refused puppet call stored nothing'); +}); + +test('prose_help is on an explicit-prose agent\'s surface for the puppet, and answers it with the routing reference, as it answers the agent', { timeout: 30_000 }, async (t) => { + const h = await setup(t); + const help = await h.viaPuppet('prose_help', {}); + assert.equal(help.success, true, String(help.error)); + assert.match(String(help.data), />>/); + await answersAsTheAgent(h, [['prose_help', {}]]); +}); + +test('a module\'s call of the agent\'s own verbs, think, journal, skip_reply and drafts, is refused as the agent\'s own, and does nothing', { timeout: 30_000 }, async (t) => { + const h = await setup(t); + for (const name of ['think', 'journal', 'skip_reply', 'drafts']) { + const got = await h.ctx().callTool({ id: `mod-${name}`, name, input: {}, callerAgentName: 'agent' }); + assert.equal(got.isError, true, name); + assert.match(String(got.error), new RegExp(`^${name} is the agent's own`), name); + assert.match(String(got.error), /nothing was done/, name); + } +}); + +test('a call with no agent behind it: think and journal answer it as they answer the agent\'s own call, and skip_reply is refused, since no turn is going', { timeout: 30_000 }, async (t) => { + const h = await setup(t); + for (const name of ['think', 'journal']) { + const got = await h.f.framework.executeToolCall({ id: `anon-${name}`, name, input: { content: 'zz' } }); + const model = await h.viaModel(`anon-m-${name}`, name, { content: 'zz' }); + assert.deepEqual(fields(got), fields(model), name); + } + const skip = await h.f.framework.executeToolCall({ id: 'anon-skip', name: 'skip_reply', input: {} }); + assert.equal(skip.isError, true); + assert.match(String(skip.error), /skip_reply ends the turn it's called in/); + assert.match(String(skip.error), /nothing was done/); +}); + +test('an awaited call the dispatcher would refuse, a conversation fork\'s sleep, gets the refusal with no refused claim', { timeout: 30_000 }, async (t) => { + const dir = mkdtempSync(join(tmpdir(), 'awaited-refused-')); + const membrane = new MockMembrane(); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'trunk', model: 'test-model', systemPrompt: 'You are the trunk.' }], + modules: [], + conversations: { templateAgent: 'trunk' }, + gate: { config: { policies: [], default: 'always' }, configPath: join(dir, 'gate.json') }, + }); + t.after(async () => { await framework.stop(); rmSync(dir, { recursive: true, force: true }); }); + framework.pushEvent({ + type: 'mcpl:channel-incoming', serverId: 'srv', channelId: 'slack:D9', messageId: 'm-1', + author: { id: 'U1', name: 'alice' }, content: [{ type: 'text', text: 'hi' }], + timestamp: new Date().toISOString(), metadata: { mentioned: false, channel_type: 'im' }, triggerInference: true, + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + const fork = framework.getAllAgents().map((a) => a.name).find((name) => name.startsWith('conversation-')); + assert.ok(fork, 'the message made a conversation fork'); + const got = await framework.executeToolCall({ id: 'fork-sleep', name: 'sleep', input: { seconds: 60, announce: false }, callerAgentName: fork }); + assert.equal(got.isError, true); + assert.match(String(got.error), /a sleep quiets the whole agent, not one conversation/); + assert.equal('refused' in got, false); +}); + +test('a module that saves an image from inside its own tool call is answered at once, not held by the round its own call is in', { timeout: 30_000 }, async (t) => { + const dir = mkdtempSync(join(tmpdir(), 'awaited-save-')); + const membrane = new MockMembrane(); + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'call-save-now', name: 'saver--save_now', input: {} }] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); + let saved: (result: ToolResult) => void = () => {}; + const answered = new Promise((resolve) => { saved = resolve; }); + let ctx: ModuleContext | undefined; + const saver: Module = { + name: 'saver', + async start(c: ModuleContext) { ctx = c; }, + async stop() {}, + getTools() { return [{ name: 'save_now', description: 'Save the newest image.', inputSchema: { type: 'object', properties: {} } }]; }, + async handleToolCall() { + const result = await ctx!.callTool({ id: 'mod-save', name: 'save_recent_image', input: { path: 'work/zz.gif', index: 0 }, callerAgentName: 'agent' }); + saved(result); + return result; + }, + async onProcess(event: ProcessEvent) { + return event.type === 'external-message' + ? { addMessages: [{ participant: 'User', content: (event as { content: unknown }).content }], requestInference: true } + : {}; + }, + } as unknown as Module; + const workspace = new WorkspaceModule({ mounts: [{ name: 'work', path: dir, mode: 'read-write', watch: 'never' }] }); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', allowedTools: 'all' }], + modules: [saver, workspace as unknown as Module], + syncIntervalMs: 0, + }); + workspace.initStore(framework.getStore()); + t.after(async () => { await framework.stop(); rmSync(dir, { recursive: true, force: true }); }); + framework.getAgent('agent')!.getContextManager().addMessage('user', [ + { type: 'text', text: 'here is my avatar' }, + { type: 'image', source: { type: 'base64', data: GIF, mediaType: 'image/gif' } }, + ] as ContentBlock[]); + framework.start(); + framework.pushEvent({ + type: 'external-message', source: 'test', content: [{ type: 'text', text: 'save it' }], metadata: {}, triggerInference: true, + } as unknown as ProcessEvent); + const result = await answered; + assert.equal(result.success, true, String(result.error)); + const written = await workspace.readBinary('work/zz.gif'); + assert.ok('data' in written, 'the save wrote work/zz.gif'); + assert.equal(written.data.toString('base64'), GIF); +});