From f694abdc12a4caf79ff38ba4f5260fc7ee1a92f2 Mon Sep 17 00:00:00 2001 From: Alfred-2451 Date: Thu, 29 Oct 2026 16:06:02 -0400 Subject: [PATCH 1/4] test: an awaited call of a framework tool answers as the agent's own call does The admin puppet (puppetToolCall), a host's executeToolCall and a module's ModuleContext.callTool all run through executeToolCallFrom, which routed only some of the framework's own tools. The rest fell to 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. A household's operator met it as `/puppet agent_settings`. The file pins what an awaited call must do: - the puppet's agent_settings, workspace tools (save_recent_image, read_image, fetch_reference), gate tools (gate_status, event_tags, wake_add_rule, wake_remove_rule, sleep, wake), think, journal and prose_help each answer as the agent's own call of the same input does, and prose_help, on an explicit-prose agent's surface for the puppet, answers with the routing reference; - skip_reply, which ends the turn it's called in, is refused off a turn: the puppet refuses it, by name or through a toolsets--act that reaches it, with the agent's window unchanged, and a host's call gets the refusal as its result; - a module's call of the agent's own tools (think, journal, skip_reply, drafts) is refused with a reason naming the tool as the agent's own, even when it names the agent as its caller; - a call with no agent behind it (no callerAgentName): think and journal answer as they answer the agent's own call, skip_reply is refused; - a conversation fork's awaited sleep gets the dispatcher's refusal without `refused`, which ToolResult reserves for the dispatcher's own calls; - a host's fetch_reference whose ref_id has no text form answers with the error, as the agent's own call does; - a module that saves an image from inside its own tool call is answered at once, and the image is written, rather than held by the round its own call is in. Against the base's src all ten fail, each on the defect: "Invalid tool name format", the puppet's "not on agent's surface" refusal, or no refusal where one is due. --- test/awaited-tool-routes.test.ts | 220 +++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 test/awaited-tool-routes.test.ts 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); +}); From 43aab6542ca7e65031b9cd6f13eea1e3d6043a0d Mon Sep 17 00:00:00 2001 From: Alfred-2451 Date: Thu, 29 Oct 2026 16:06:02 -0400 Subject: [PATCH 2/4] fix: the turn's dispatch and an awaited tool call take one route per tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toolRoute decides where a call goes, from its name, in the order the turn's dispatch decided it, and both dispatchToolCall and executeToolCallFrom switch on its answer. Each switch covers every ToolRoute kind (dispatch's ends in `route satisfies never`, and the awaited path must return), so a route added for one caller is a compile error until the other handles it. Before, executeToolCallFrom kept its own list, which lacked 14 of the names the turn routes: 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 and prose_help. Each fell to the module registry and failed with "Invalid tool name format". - The eight dispatch handlers those routes need (agent settings, save and read image, fetch reference, gate status, sleep and wake, wake rules, event tags) take a `deliver`, defaulting to the tool-result push they made before, so the awaited path runs the handler the turn runs and gets its answer through awaitedToolAnswer. That drops `refused` (ToolResult.refused: a programmatic call is never refused) and answers a synchronous throw with its text. - think and journal on an awaited call with agent origin go to the channel registry as the awaited channel tools already did. skip_reply is refused there: it ends the turn it's called in, and an awaited call isn't made in one. - The puppet refuses skip_reply, by name or through a toolsets--act that reaches it, before anything runs or is stored, as it refuses a tool off the agent's surface: a stored pair must be an act the agent could have made, and the agent's own skip_reply never answers "not in a turn". - A module's call of think, journal, skip_reply or drafts is refused as the agent's own (agentsOwnVerbRefusal). This keeps the rule from e86ae335's review round, that these verbs are the agent's, for every caller but the agent itself; the old comment saying they were "deliberately not routed here" is removed with the list it described. - The awaited path now takes the turn's order: code_execution after toolsets--act, MCPL, the channel tools and the framework's own tools, and drafts and the heartbeat after tune_out, focus and the subconscious's tools. None of these names can reach another of those routes (an MCPL name always carries `prefix--`, a heartbeat tool `heartbeat--`), so nothing a caller meets changes from the order alone. - The puppet's surface check reads availableToolsForPresentation (the tools the agent may call, prose_help included for an explicit-prose agent) instead of getToolsForAgent filtered by canUseTool. - fetch_reference's detached body had no catch, so a throw before it answered (a ref_id with no text form) left the agent's own call unanswered, as it would have an awaited one. It now answers once, with "fetch_reference couldn't answer: …". - save_recent_image's sibling barrier waits only for a call that is one of its round's pending calls. A call outside the round, a script's or an awaited call's, counts the images the agent has now. Only a script's was exempt before, so a module's save from inside its own pending tool call, which this change lets reach the handler, would have waited on its own call until the barrier's bound. --- changelog.d/awaited-call-routes.fixed.md | 35 ++ src/framework.ts | 770 +++++++++++++---------- 2 files changed, 462 insertions(+), 343 deletions(-) create mode 100644 changelog.d/awaited-call-routes.fixed.md diff --git a/changelog.d/awaited-call-routes.fixed.md b/changelog.d/awaited-call-routes.fixed.md new file mode 100644 index 000000000..66531a273 --- /dev/null +++ b/changelog.d/awaited-call-routes.fixed.md @@ -0,0 +1,35 @@ +- **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: + - a call of one of these tools 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 (`__ephemeral__` where it names none); + - 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 734a4a36c..6dd2b880a 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 }>; @@ -26885,8 +26916,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. @@ -26938,14 +26972,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. @@ -27084,96 +27125,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); } /** @@ -29020,6 +29088,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 @@ -29070,217 +29185,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('--'); @@ -36333,7 +36438,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 @@ -36348,7 +36453,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 { @@ -36838,7 +36943,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. @@ -36857,7 +36962,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 }); @@ -36866,7 +36971,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') { @@ -36986,30 +37091,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', @@ -37017,14 +37126,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({ @@ -37035,7 +37141,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 { @@ -37127,13 +37233,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(); @@ -37141,6 +37245,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) { @@ -37169,7 +37274,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({ @@ -37180,7 +37285,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 } @@ -37606,7 +37711,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', @@ -37794,14 +37899,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 @@ -37826,7 +37924,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 { @@ -37838,10 +37936,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(); @@ -37849,27 +37947,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 }); }); } From d1be0036a38b698c8755bd4454541074e691a406 Mon Sep 17 00:00:00 2001 From: Alfred-2451 Date: Thu, 29 Oct 2026 17:08:30 -0400 Subject: [PATCH 3/4] docs(framework): executeToolCall's doc says it reaches the framework's own tools Its doc named only "(module registry or MCPL)" as the handlers it routes to, which said less than the method does once it takes the turn's routing: it also reaches the framework's own tools and the channel tools, and refuses skip_reply, which ends the turn it's called in. A host's reach is an addition under the rule for published libraries (a host that doesn't call these names meets no change), so it gets its own `.added` entry beside the `.fixed` one. --- changelog.d/awaited-call-routes.added.md | 7 +++++++ src/framework.ts | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 changelog.d/awaited-call-routes.added.md diff --git a/changelog.d/awaited-call-routes.added.md b/changelog.d/awaited-call-routes.added.md new file mode 100644 index 000000000..d662be7d6 --- /dev/null +++ b/changelog.d/awaited-call-routes.added.md @@ -0,0 +1,7 @@ +- **A host's `executeToolCall` reaches the framework's own tools.** + `agent_settings`, `save_recent_image`, `read_image`, `fetch_reference`, + `gate_status`, `event_tags`, `wake_add_rule`, `wake_remove_rule`, `sleep`, + `wake`, `think`, `journal` and `prose_help` now run for the agent the call + names and return their answer, where before each failed with "Invalid tool + name format". `skip_reply` is refused, since the call isn't made in a turn. + A host that doesn't call these names meets no change. diff --git a/src/framework.ts b/src/framework.ts index 5fec61835..b49bbb2a0 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -26926,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 (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. From 42567acbaf309dbc2f3b59b35ea8180b9581fd26 Mon Sep 17 00:00:00 2001 From: Alfred-2451 Date: Thu, 29 Oct 2026 17:56:30 -0400 Subject: [PATCH 4/4] docs(framework): say which of the framework's tools a host's call reaches The gate tools route only with an event gate, and think, journal and skip_reply only with a channel registry, which an MCPL server brings, on the turn's dispatch and the awaited path alike. executeToolCall's doc and the Fixed fragment said the calls run unconditionally; they now say they reach the tools the agent has. - The Fixed fragment's "(__ephemeral__ where it names none)" for a module's call is dropped: tune_out and focus take the primary agent when no callerAgentName is given, as on main. - The Added fragment is trimmed to one line that points at the Fixed entry, which already lists the tools and the skip_reply refusal. It no longer says a host that doesn't call these names meets no change, since a module's call of the agent's verbs now gets a named refusal. --- changelog.d/awaited-call-routes.added.md | 10 +++------- changelog.d/awaited-call-routes.fixed.md | 15 +++++++++------ src/framework.ts | 8 ++++---- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/changelog.d/awaited-call-routes.added.md b/changelog.d/awaited-call-routes.added.md index d662be7d6..a44ae6175 100644 --- a/changelog.d/awaited-call-routes.added.md +++ b/changelog.d/awaited-call-routes.added.md @@ -1,7 +1,3 @@ -- **A host's `executeToolCall` reaches the framework's own tools.** - `agent_settings`, `save_recent_image`, `read_image`, `fetch_reference`, - `gate_status`, `event_tags`, `wake_add_rule`, `wake_remove_rule`, `sleep`, - `wake`, `think`, `journal` and `prose_help` now run for the agent the call - names and return their answer, where before each failed with "Invalid tool - name format". `skip_reply` is refused, since the call isn't made in a turn. - A host that doesn't call these names meets no change. +- 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 index 66531a273..c08a14cea 100644 --- a/changelog.d/awaited-call-routes.fixed.md +++ b/changelog.d/awaited-call-routes.fixed.md @@ -6,11 +6,14 @@ `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: - - a call of one of these tools 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 + 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 @@ -19,7 +22,7 @@ - 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 (`__ephemeral__` where it names none); + `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 diff --git a/src/framework.ts b/src/framework.ts index b49bbb2a0..f06eca940 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -26927,10 +26927,10 @@ export class AgentFramework { /** * Execute a tool call and return the result. * Routes it where the agent's own call of the name goes: to the - * framework's own tools (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. + * 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.