diff --git a/README.md b/README.md index 74b533c67..a54efc619 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,33 @@ Chronicle's audit history. model: 'claude-opus-4-20250514', systemPrompt: '...', strategy: new AutobiographicalStrategy({ recentWindowTokens: 30000 }), - allowedTools: ['search', 'read', 'write'], // or 'all' + allowedTools: ['search--query', 'utils', 'files--archive'], // or 'all' triggerSources: ['discord'], // or 'all' maxTokens: 8192, maxStreamTokens: 150_000, // input token budget before stream restart } ``` +`allowedTools` is enforced at dispatch for registered agents. Omission or +`'all'` permits all tools; an explicit list matches exact names, including +`module--utility` names. Utilities aren't tools of their own: an agent's model +and its scripts are offered them only through `utils`, whose `run` checks the +utility's own name. An agent that should use a utility lists both `utils` and +`module--utility`. The name alone passes the dispatch check for any call by +that name, whether the host makes it with `executeToolCall` on the agent's +behalf or the model or a script makes it without being offered it. The slot +is hidden when the agent can use no registered utility. If utilities exist +but none are permitted, `list` returns a failed `ToolResult` with the registered +and permitted counts and migration advice, and a list containing `utils` +logs one warning per Agent instance after registration. +An empty registry still returns a successful empty list. + +Trusted module calls are exempt from agent allowlists. Genuinely unidentified +public callers, including unknown legacy caller names, remain unrestricted. +Framework-created ephemeral and conversation identities fail closed while +unregistered. Hosts using `createEphemeralAgent` must register and run the +returned agent through `runEphemeralToCompletion` for tool authority. + **Inference modes:** - **Streaming** (default in framework): `startStreamWithInjections()` returns a `YieldingStream` that emits tokens, tool calls, and completion events. The framework drives the stream, dispatches tools, and resumes automatically. - **Request/response**: `runInferenceWithInjections()` for simple complete-and-return usage with optional `AbortSignal` for cancellation. diff --git a/changelog.d/agent-allowed-tools.breaking.md b/changelog.d/agent-allowed-tools.breaking.md new file mode 100644 index 000000000..ae9462e68 --- /dev/null +++ b/changelog.d/agent-allowed-tools.breaking.md @@ -0,0 +1 @@ +- **Hosts:** explicit `allowedTools` lists must name each permitted utility as `module--utility`, or use `"all"`. Utilities aren't tools of their own: an agent's model and its scripts are offered them only through `utils`, whose `run` checks the utility's own name. An agent that should use a utility lists both `utils` and `module--utility`. The name alone passes the dispatch check for any call by that name, whether the host makes it with `executeToolCall` on the agent's behalf or the model or a script makes it without being offered it. `utils` alone no longer exposes a usable slot. When utilities are registered but none are permitted, `list` returns a failed `ToolResult` with both counts and migration advice instead of listing every registered utility, as earlier releases do. The slot is hidden in that case, and explicit lists containing `utils` log one warning per Agent instance. Hosts that construct depth-limited lists can obtain the existing namespaced utility catalogue through `getAllUtilities()`. Callers using `"all"` retain the permission policy; an empty registry continues to omit the slot and return a successful empty list. Hosts that drive `createEphemeralAgent` results themselves must use `runEphemeralToCompletion` to register and run them; framework-created identities have tool authority only while registered, and calls before a run or after retirement fail with a registration error. Adopt this change with the first connectome-host release after 0.10.2, which contains https://github.com/anima-research/connectome-host/pull/191. diff --git a/changelog.d/agent-allowed-tools.fixed.md b/changelog.d/agent-allowed-tools.fixed.md new file mode 100644 index 000000000..c6c0114eb --- /dev/null +++ b/changelog.d/agent-allowed-tools.fixed.md @@ -0,0 +1 @@ +- Enforce each registered agent's allowedTools before model, script and public agent-origin dispatch, including utility names inside utils run. Filter utility list/describe to the same executable names. Refused calls retain paired error results without inner lifecycle state. A refusal withholds a call's effect, never the model's choice about its own words: a refused send changes nothing about prose, so its round and what follows go out; a `skip_reply` that a round calls itself (not one a `code_execution` script makes) keeps that round's prose private and silences nothing after it, as a refused wake already did, when the agent's allowedTools doesn't name it, another module's namespaced `skip_reply` included, or when it's the registry's own `skip_reply` asking for a wake this host can't set; and a refused `think` is read as a permitted one is, so the private same-round policy keeps its round private wherever it applies, which is where rounds are routed live. Framework-created identities (ephemerals and conversation forks) have no tool authority while unregistered, including after disposal; genuine unregistered legacy callers and trusted module closures retain their behavior. Prose primers and bounce notices advertise prose_help only when permitted, and `think`'s and `journal`'s descriptions tell an agent to call `skip_reply` only when it may. `think`'s result and `prose_help`'s reference no longer point at the `skip_reply` tool. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 7f01da266..25ad17af9 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -89,8 +89,8 @@ The grammar is never injected into context. The mode switch appends one short durable notice (persisted per agent, so reboots never re-notice); the first undelivered text produces a bounce notice carrying the exact resend syntax — the model learns by doing, and the knowledge accretes into its own history. -The full reference is available on demand via the **`prose_help` tool**, whose -result is model-requested content. The system prompt is never touched: those +When the agent's `allowedTools` permits `prose_help`, the full reference is +available on demand through that tool. Its result is model-requested content. The system prompt is never touched: those bytes belong to the recipe, and the deepest KV prefix must not vary with framework code. diff --git a/src/framework.ts b/src/framework.ts index b9ec18f1c..543fb17c4 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -101,7 +101,7 @@ import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.j import { ProseStreamRouter } from './mcpl/prose-stream-router.js'; import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js'; import { InferenceRouter } from './mcpl/inference-router.js'; -import { ChannelRegistry, connectorText, type ChannelToolOrigin, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; +import { ChannelRegistry, connectorText, journalToolDescription, type ChannelToolOrigin, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; @@ -180,9 +180,11 @@ function sniffImageMediaType(data: Buffer): string | undefined { * another external message is injected. The suppression prevents a * `send_message` followed by "sent it" from double-posting, but a new message * starts a new conversational round and must be answerable with plain prose. - * `skip_reply` ends the turn at the tool-result boundary, unless it's refused - * (a wake_in_seconds without an EventGate, or one that isn't a number of - * seconds): then the turn goes on, and only its own round stays silent. + * `skip_reply` ends the turn at the tool-result boundary, unless it's refused, + * because the agent's allowedTools doesn't name it or because it asks for a + * wake_in_seconds this host can't set (one without an EventGate, or one that + * isn't a number of seconds): then the turn goes on, and only its own round + * stays silent. */ const SILENCING_TOOLS = new Set([ 'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm', @@ -364,7 +366,7 @@ const isAddressedMessage = ( // envelopes can never disagree on what is a prefix. /** One-time primer appended when an agent's proseRouting mode changes. */ -function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus' | 'disabled'): string { +function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus' | 'disabled', helpAvailable: boolean): string { if (mode === 'locus') { return '[prose-routing] Mode change: your plain text auto-routes to the ' + 'conversational locus again. `>>` destination prefixes are no longer needed.'; @@ -381,14 +383,14 @@ function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus' | 'disabled'): st // message drew a deterministic reasoning_extraction refusal (ablation A/B). // The grammar is TAUGHT BY BOUNCE: the first undelivered text produces a // notice with the exact resend syntax, and the full reference is available - // on demand via the prose_help tool (a tool RESULT is model-requested + // on demand via prose_help when allowed by the agent's list (a tool RESULT is model-requested // content — the safest role there is). Nothing is injected into the system // prompt: those bytes belong to the recipe, and the deepest KV prefix must // not vary with framework code. return ( '[prose-routing] Output mode changed: plain text is delivered only with a ' + - 'destination prefix line, e.g. ">>#channel-name your text". ' + - 'The prose_help tool shows the full syntax.' + 'destination prefix line, e.g. ">>#channel-name your text".' + + (helpAvailable ? ' The prose_help tool shows the full syntax.' : '') ); } @@ -402,7 +404,7 @@ const PROSE_ROUTING_HELP = ' The first destination in a turn applies to the rest of that turn.\n' + ' Append " !" after the destination (e.g. ">>#ops !") to start your next turn\n' + ' immediately when this one ends, instead of pausing until the next event.\n' + - ' >>skip_reply — text stays in your context only, like the skip_reply tool.\n' + + ' >>skip_reply — text stays in your context only.\n' + 'Text without a destination is not delivered: it is held as a draft, and a notice\n' + 'names it — reply e.g. ">>#channel {{unsent}}" to deliver your latest bounce draft\n' + 'unchanged, or use the drafts tool to list, resend or dismiss any draft by id.\n' + @@ -417,6 +419,9 @@ const PROSE_HELP_TOOL: import('./types/index.js').ToolDefinition = { /** Strip the `server--` MCPL prefix from a tool name. */ const bareToolName = (n: string): string => n.split('--').pop()!; +/** "1 registered utility", "2 registered utilities". */ +const registeredUtilities = (n: number): string => `${n} registered ${n === 1 ? 'utility' : 'utilities'}`; + /** The single slot behind which every rarely-used capability lives. Only * advertised when at least one module contributes utilities — with none * registered the surface is byte-identical to before this existed. */ @@ -424,7 +429,7 @@ const UTILS_TOOL: import('./types/index.js').ToolDefinition = { name: 'utils', description: 'Rarely-used utilities, behind one slot instead of each costing a tool. ' + - '"list" names them all with one-liners; "describe" returns one utility\'s full ' + + '"list" names the utilities you can use, with one-liners; "describe" returns one utility\'s full ' + 'input schema; "run" invokes it (args are validated against that schema — a ' + 'miss bounces back with the schema and the specific error).', inputSchema: { @@ -2049,6 +2054,8 @@ export class AgentFramework { private usedEphemeralAgentNames: Set = new Set(); /** One-shot generation tickets minted by createEphemeralAgent. */ private ephemeralCandidates: WeakMap = new WeakMap(); + /** Warn only once per Agent instance after utilities are registered. */ + private emptyUtilityPermissionWarnings: WeakSet = new WeakSet(); /** Physical ephemeral frames deliberately disposed by their run watchdog/caller. * The name is single-generation, so these frames still own their terminal * typing/outgoing close even after runEphemeralToCompletion deregisters them. */ @@ -2674,6 +2681,9 @@ export class AgentFramework { for (const module of config.modules) { await framework.addModule(module); } + for (const agent of framework.agents.values()) { + framework.warnEmptyUtilityPermissions(agent); + } for (const [agentName, presentation] of framework.toolPresentations) { const workspace = framework.getWorkspaceModule(); if (!workspace) throw new Error('Tool presentation requires workspace'); @@ -4684,6 +4694,11 @@ export class AgentFramework { return this.moduleRegistry.getAllModules(); } + /** Read the namespaced utility catalogue without adding first-class model tools. */ + getAllUtilities(): import('./types/index.js').ToolDefinition[] { + return this.moduleRegistry.getAllUtilities(); + } + /** * Get all available tools from all modules and MCPL servers. */ @@ -5029,8 +5044,10 @@ export class AgentFramework { return guards; } + /** `think` for one agent: its same-round policy, and `skip_reply` named only where the agent may call it. */ private buildThinkTool( policy: SameRoundThinkTextPolicy, + canSkipReply: boolean, ): import('./types/index.js').ToolDefinition { return { name: 'think', @@ -5041,7 +5058,8 @@ export class AgentFramework { ? 'Your current same_round_think_text_policy is private, so ordinary text emitted in the SAME native assistant round as think() is withheld from channel routing. Later rounds without think() route normally.' : 'Your current same_round_think_text_policy is public, so ordinary text emitted in the SAME native assistant round as think() may still be routed publicly as your speech. Later rounds without think() route normally.' ) + - ' Use think() to work things out privately. To inspect or change this policy, use agent_settings get/update on same_round_think_text_policy. To deliberately not reply this turn, call skip_reply instead.', + ' Use think() to work things out privately. To inspect or change this policy, use agent_settings get/update on same_round_think_text_policy.' + + (canSkipReply ? ' To deliberately not reply this turn, call skip_reply instead.' : ''), inputSchema: { type: 'object' as const, properties: { @@ -5094,10 +5112,23 @@ export class AgentFramework { }; } + private warnEmptyUtilityPermissions(agent: Agent): void { + if (agent.allowedTools === 'all' || !agent.allowedTools.includes('utils') + || this.emptyUtilityPermissionWarnings.has(agent)) return; + const utilities = this.moduleRegistry.getAllUtilities(); + if (utilities.length === 0 || utilities.some(tool => agent.canUseTool(tool.name))) return; + console.warn(`[utils] Agent '${agent.name}': ${registeredUtilities(utilities.length)}, 0 permitted; none permitted by this agent's allowedTools. Add each required module--utility name to allowedTools.`); + this.emptyUtilityPermissionWarnings.add(agent); + } + private getToolsForAgent( agentName: string, snapshot?: InferenceToolSnapshot, ): import('./types/index.js').ToolDefinition[] { + const agent = this.agents.get(agentName); + // Model-facing advice names skip_reply only where dispatch would let this + // agent call it: a refused skip_reply leaves the turn going on. + const canSkipReply = this.canAgentUseTool(agentName, 'skip_reply'); // The subconscious gets its own small surface (plus think/skip_reply/ // end_turn), not the residents' tool board: no channel lifecycle, no // gate rules, no workspace — it observes, judges, and reports. @@ -5108,17 +5139,23 @@ export class AgentFramework { ? this.buildThinkTool( snapshot?.sameRoundThinkTextPolicy ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy, + canSkipReply, ) : t); return [...SUBCONSCIOUS_TOOLS, ...basics]; } - return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName)!.config.cataloguePath) : [])].map((tool) => { + if (agent) this.warnEmptyUtilityPermissions(agent); + const hasPermittedUtility = !agent || this.moduleRegistry.getAllUtilities().some(tool => agent.canUseTool(tool.name)); + return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName)!.config.cataloguePath) : [])] + .filter(tool => tool.name !== 'utils' || hasPermittedUtility).map((tool) => { if (tool.name === 'think') { return this.buildThinkTool( snapshot?.sameRoundThinkTextPolicy ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy, + canSkipReply, ); } + if (tool.name === 'journal' && !canSkipReply) return { ...tool, description: journalToolDescription(false) }; return tool; }); } @@ -7194,8 +7231,10 @@ export class AgentFramework { /** * Create an ephemeral agent that is NOT registered in the main event loop. * - * Used by SubagentModule (and similar) to create short-lived agents - * that are driven externally (not by the framework's event loop). + * Used by SubagentModule (and similar) to prepare short-lived agents. + * Call runEphemeralToCompletion(agent, contextManager) to register and run + * the returned agent. Framework-created identities have tool authority only + * while registered; public tool calls before a run or after retirement fail. * The returned agent has its own ContextManager on a namespaced state * within the framework's shared Chronicle store — messages go to * `{namespace}/messages`, context log to `{namespace}/context`. @@ -14548,7 +14587,7 @@ export class AgentFramework { const delivered = after.outcome === 'unknown' ? 'may not have been delivered' : 'was not delivered'; const notice = `[prose-routing] Your text (${text.length} chars) ${delivered} — ${reason}.${cand} ${kept}${risky}` + - (prefix === '>>' ? ' The prose_help tool shows the full syntax.' : ''); + (prefix === '>>' && this.canAgentUseTool(agent.name, 'prose_help') ? ' The prose_help tool shows the full syntax.' : ''); const target = this.noticeTargetFor(name, 'prose-bounce'); if (target) { try { @@ -14624,7 +14663,7 @@ export class AgentFramework { try { const id = agent.getContextManager().addMessage( 'user', - [{ type: 'text', text: proseModePrimer(mode) }], + [{ type: 'text', text: proseModePrimer(mode, this.canAgentUseTool(agent.name, 'prose_help')) }], { system: true, kind: 'prose-routing-primer' }, ); this.emitTrace({ type: 'message:added', messageId: id, source: 'prose-routing-primer' }); @@ -15834,11 +15873,18 @@ export class AgentFramework { (event.context as { supportsInjectedMessages?: boolean }).supportsInjectedMessages, ); - // A refused call doesn't silence: the silence before it, and its - // round's other silencing calls, come back at the next round, each - // with why it silences. - if (event.calls.some((call) => this.refusesSelfWake(call))) { - const otherCause = silenceCauseOf(event.calls.filter((call) => !this.refusesSelfWake(call))); + // A refused skip_reply, whether the agent's allowedTools doesn't + // name it or this host can't set the wake it asks for, keeps its + // own round private and silences nothing after it: the silence + // before it, and its round's other permitted silencing calls, + // come back at the next round, each with why it silences. A + // refusal withholds the call's effect, not the model's choice to + // keep its words private. + const refusedSkipReply = (call: ToolCall): boolean => bareToolName(call.name) === 'skip_reply' + && (!this.canAgentUseTool(agent.name, call.name) || this.refusesSelfWake(call)); + if (event.calls.some(refusedSkipReply)) { + const otherCause = silenceCauseOf(event.calls.filter((call) => + this.canAgentUseTool(agent.name, call.name) && !refusedSkipReply(call))); silenceBeforeRefusal = silenceCause === 'private' || otherCause === 'private' ? 'private' : (silenceCause ?? otherCause); @@ -15868,11 +15914,17 @@ export class AgentFramework { // with verbatim roundContent are live-routed (see liveProseRouting // note above — the fallback preamble is cumulative in XML mode). if (this.channelRegistry) { + // Every call the model made in the round, refused or not: a + // skip_reply or think refused still asks for the round's words + // to stay private. A refused send posted nothing, so it + // silences nothing; a refused skip_reply silences its own + // round, and the silence before it comes back at the next. const roundToolNames = event.calls.map((c) => c.name); const hasSameRoundPrivateThink = roundToolNames.includes('think') && requestSnapshot.sameRoundThinkTextPolicy === 'private'; - const roundCause = silenceCauseOf(event.calls); + const roundCause = silenceCauseOf(event.calls.filter((c) => + this.canAgentUseTool(agent.name, c.name) || refusedSkipReply(c))); if (roundCause) { turnSilenced = true; silenceCause = roundCause === 'private' || silenceCause === 'private' ? 'private' : 'send'; @@ -16442,7 +16494,8 @@ export class AgentFramework { const toolCalls = response.content .filter((b) => b.type === 'tool_use') .map((b) => b as unknown as { name?: unknown; input?: unknown }) - .filter((c): c is { name: string; input?: unknown } => typeof c.name === 'string'); + .filter((c): c is { name: string; input?: unknown } => + typeof c.name === 'string' && this.canAgentUseTool(agent.name, c.name)); const toolNames = toolCalls.map((c) => c.name); // A refused skip_reply isn't a silencing call; a plain one is. const fallbackCause = liveProseRouting @@ -17228,8 +17281,12 @@ export class AgentFramework { * call entered through — a public method shared with model callers can * never confer module trust, whatever the input claims. Module origin * exists only through the private closure handed to ModuleRegistry - * (ctx.callTool → executeToolCallFrom with {kind:'module'}). Callers that - * don't identify themselves fail safe to agent semantics too. + * (ctx.callTool → executeToolCallFrom with {kind:'module'}), which is exempt + * from agent allowlists. Registered agents' allowedTools is enforced at + * dispatch, including namespaced utility names; utils only grants the + * list/describe/run slot. Genuinely unidentified public callers (including + * unknown legacy names) remain unrestricted. Framework-created identities + * are refused while unregistered, even if their configured list allows it. */ async executeToolCall(call: ToolCall): Promise { return this.executeToolCallFrom(call, { @@ -17416,6 +17473,11 @@ export class AgentFramework { } private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise { + if (origin.kind === 'agent') { + const refusal = this.agentToolPermissionFailure(origin.agentName, call); + if (refusal) return refusal; + } + if (isPresentationTool(call.name)) return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call); // Client-side programmatic tool calling for promise-based callers // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral @@ -17457,7 +17519,7 @@ export class AgentFramework { // ephemerals, ModuleContext.callTool) reach utilities the same way the // model does. if (call.name === 'utils') { - return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call, { inProcess: true }); + return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call, { inProcess: true, trustedModule: origin.kind === 'module' }); } // Module tools. The caller uses the result in process, so it gets the @@ -18744,6 +18806,27 @@ export class AgentFramework { return best; } + /** Registered policy; framework-created identities have no dispatch authority + * while unregistered. Genuine legacy caller names retain their surface. */ + private canAgentUseTool(agentName: string, toolName: string): boolean { + const agent = this.agents.get(agentName); + return agent ? agent.canUseTool(toolName) : !this.usedEphemeralAgentNames.has(agentName); + } + + private agentToolPermissionFailure(agentName: string, call: ToolCall, reportRefusal = true): ToolResult | undefined { + if (this.canAgentUseTool(agentName, call.name)) return undefined; + const error = !this.agents.has(agentName) && this.usedEphemeralAgentNames.has(agentName) + ? `Caller '${agentName}' is not a running agent; framework-created agents have tool authority only while registered.` + : `Tool '${call.name}' is not permitted by this agent's allowedTools.`; + if (reportRefusal) { + this.toolLifecycleEmitter?.refuse(agentName, call.id); + this.emitTrace({ + type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error, + }); + } + return { success: false, error, isError: true }; + } + /** * Why this host refuses a skip_reply call, as the model will read it, or * undefined when it doesn't (skipReplyWakeRefusal): without an EventGate, @@ -18772,6 +18855,14 @@ export class AgentFramework { * background script's call has none. */ private dispatchToolCall(agentName: string, call: ToolCall, turnId?: number): void { + const refusal = this.agentToolPermissionFailure(agentName, call); + if (refusal) { + this.pushEvent({ + type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'framework', result: refusal, + }); + return; + } + // Enrich call with caller identity so modules can resolve the calling agent, // and with the turn it was made in. Here both are the framework's to set: // a turn id the call arrives with never survives. @@ -23765,28 +23856,46 @@ export class AgentFramework { * (Module.getUtilities). Shared by the model-facing dispatcher and the * programmatic path (executeToolCall), so code_execution scripts and * ephemerals reach utilities too. */ - private async handleUtilsToolCall(agentName: string, call: ToolCall, options: { inProcess?: boolean } = {}): Promise { + private async handleUtilsToolCall(agentName: string, call: ToolCall, options: { inProcess?: boolean; trustedModule?: boolean } = {}): Promise { const input = (call.input ?? {}) as { action?: unknown; name?: unknown; args?: unknown }; - const utilities = this.moduleRegistry.getAllUtilities(); - const available = (): string => - utilities.length ? utilities.map((u) => u.name).join(', ') : '(none registered)'; + const allUtilities = this.moduleRegistry.getAllUtilities(); + const utilities = allUtilities.filter(def => options.trustedModule || this.canAgentUseTool(agentName, def.name)); + const counts = `${registeredUtilities(allUtilities.length)}, ${utilities.length} permitted`; + const available = (): string => utilities.length + ? `Available: ${utilities.map((u) => u.name).join(', ')} (${counts})` + : allUtilities.length + ? `${counts}; none permitted by this agent's allowedTools. Add each required module--utility name to allowedTools.` + : `${counts} (none registered)`; switch (input.action) { case 'list': + if (allUtilities.length > 0 && utilities.length === 0) { + return { success: false, isError: true, error: available() }; + } return { success: true, data: utilities.map((u) => ({ name: u.name, description: utilityOneLiner(u.description) })), }; case 'describe': { - const def = utilities.find((u) => u.name === input.name); + const def = allUtilities.find((u) => u.name === input.name); if (!def) { - return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` }; + return { success: false, isError: true, error: `No utility "${String(input.name)}". ${available()}` }; + } + if (!options.trustedModule) { + const refusal = this.agentToolPermissionFailure(agentName, { ...call, name: def.name }, false); + if (refusal) return refusal; } return { success: true, data: def }; } case 'run': { - const def = utilities.find((u) => u.name === input.name); + const def = allUtilities.find((u) => u.name === input.name); if (!def) { - return { success: false, isError: true, error: `No utility "${String(input.name)}". Available: ${available()}` }; + return { success: false, isError: true, error: `No utility "${String(input.name)}". ${available()}` }; + } + if (!options.trustedModule) { + // The allowed outer utils call owns its lifecycle and terminal trace; + // refusing the utility does not create or refuse a separate call. + const refusal = this.agentToolPermissionFailure(agentName, { ...call, name: def.name }, false); + if (refusal) return refusal; } const args = input.args ?? {}; const problem = validateUtilityArgs(args, def.inputSchema); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 957fd5425..d46c150c4 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -252,6 +252,20 @@ interface McplChannelIncomingEvent { // Channel Tool Definitions // ============================================================================ +/** + * `journal`'s description. It tells the model to call `skip_reply` to end the + * turn only where the agent may call it: the framework builds each agent's + * surface with that agent's permission, and a refused `skip_reply` leaves the + * turn going on. + */ +export function journalToolDescription(canSkipReply: boolean): string { + return 'Write an entry in your private journal. The entry stays in your own context and ' + + 'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' + + 'than a line that you want to keep for yourself — reflections, what you decided and ' + + 'why, notes for later. It does not end your turn and does not affect where ordinary ' + + 'text is routed' + (canSkipReply ? '; to end the turn without replying, call skip_reply afterwards.' : '.'); +} + const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ { name: 'channel_list', @@ -329,6 +343,9 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'think', + // No agent's surface shows this description: getToolsForAgent gives every + // agent its own think (buildThinkTool), which names skip_reply only where + // that agent may call it. description: 'Reason privately. The content stays in your own context and is NOT sent to any ' + 'channel or surface. Same-round routing of ordinary text beside think() depends on ' + @@ -348,12 +365,7 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'journal', - description: - 'Write an entry in your private journal. The entry stays in your own context and ' + - 'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' + - 'than a line that you want to keep for yourself — reflections, what you decided and ' + - 'why, notes for later. It does not end your turn and does not affect where ordinary ' + - 'text is routed; to end the turn without replying, call skip_reply afterwards.', + description: journalToolDescription(true), inputSchema: { type: 'object' as const, properties: { @@ -2988,7 +3000,8 @@ export class ChannelRegistry { * Handle the synthesized `think` tool — a private reasoning scratchpad. It * sends nothing, and (unlike before) does NOT silence the turn: trailing * plain text is still routed as the reply. The thought stays in the agent's - * own context/chronicle. To deliberately not reply, the agent uses skip_reply. + * own context/chronicle. To deliberately not reply, an agent that may call + * skip_reply uses it. */ private handleToolThink(input: { content?: string }): ToolResult { return { @@ -2997,10 +3010,12 @@ export class ChannelRegistry { // tool_use block; don't duplicate it in the result. data: { noted: true, + // No tool to end the turn with: this handler can't see whether the + // caller may call skip_reply, and think's own description says so + // where it may. note: 'Thought recorded (private — not sent anywhere). Same-round text routing depends on ' + - 'your current same_round_think_text_policy; use agent_settings get to inspect it, or ' + - 'call skip_reply to end the turn without replying.', + 'your current same_round_think_text_policy; use agent_settings get to inspect it.', }, }; } diff --git a/src/types/agent.ts b/src/types/agent.ts index b1e82dd2f..c088e0bb2 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -32,9 +32,19 @@ export interface AgentConfig { strategy?: ContextStrategy; /** - * Which tools this agent can use. + * Enforced at dispatch for registered agent callers; omitted means 'all'. * - 'all': all available tools - * - string[]: specific tool names (with module prefix) + * - string[]: exact tool names, including module--utility names for utilities. + * Utilities aren't tools of their own: an agent's model and its scripts are + * offered them only through 'utils', whose run checks the utility's own + * name. An agent that should use a utility lists both 'utils' and + * 'module--utility'. The name alone passes the dispatch check for any call + * by that name, whether the host makes it with executeToolCall on the + * agent's behalf or the model or a script makes it without being offered + * it. The slot is hidden when no utility is permitted. + * Trusted module calls are exempt. Genuinely unidentified public callers, + * including unknown legacy names, remain unrestricted. Framework-created + * identities have tool authority only while registered. */ allowedTools?: 'all' | string[]; diff --git a/test/agent-allowed-tools.test.ts b/test/agent-allowed-tools.test.ts new file mode 100644 index 000000000..76d032b06 --- /dev/null +++ b/test/agent-allowed-tools.test.ts @@ -0,0 +1,877 @@ +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 { AgentFramework } from '../src/framework.js'; +import type { AgentConfig, Module, ModuleContext, ToolCall, ToolResult } from '../src/types/index.js'; +import { CapabilityGrant } from '../src/mcpl/capability-grant.js'; +import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ToolLifecycleEmitter, type ToolLifecycleParams } from '../src/mcpl/tool-lifecycle.js'; +import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js'; + +async function harness(config: Partial, probeHandler?: (call: ToolCall) => Promise, deferModule = false) { + const dir = mkdtempSync(join(tmpdir(), 'agent-allowed-tools-')); + const membrane = new MockMembrane(); + const effects: ToolCall[] = []; + let moduleContext: ModuleContext; + const module: Module = { + name: 'probe', + async start(ctx) { moduleContext = ctx; }, + async stop() {}, + getTools: () => ['allowed', 'denied'].map((name) => ({ + name, description: 'Inert dispatch counter', inputSchema: { type: 'object', properties: {} }, + })), + getUtilities: () => ['allowed_util', 'denied_util'].map((name) => ({ + name, description: 'Inert utility counter', inputSchema: { type: 'object', properties: {} }, + })), + async handleToolCall(call) { + effects.push(call); + return probeHandler ? await probeHandler(call) : { success: true, data: 'executed' }; + }, + async onProcess(event) { + return event.type === 'external-message' + ? { addMessages: [{ participant: 'user', content: [{ type: 'text', text: 'Run probe' }] }], requestInference: true } + : {}; + }, + }; + const framework = await AgentFramework.create({ + storePath: join(dir, 'store.chronicle'), membrane: membrane.asMembrane(), + agents: [{ name: 'assistant', model: 'test', systemPrompt: 'Test', ...config }], + modules: deferModule ? [] : [module], syncIntervalMs: 0, codeExecution: { enabled: true }, + conversations: { templateAgent: 'assistant', bind: { channel: 'mention' } }, + }); + const traces: Array> = []; + framework.onTrace((event) => traces.push(event as unknown as Record)); + const lifecycle: ToolLifecycleParams[] = []; + const emitter = new ToolLifecycleEmitter({ + observers: () => [{ + id: 'observer', grant: new CapabilityGrant(new Set(['toolLifecycle.observe']), []), + toolObserveFilter: null, sendToolLifecycle: (event) => lifecycle.push(event), + }], + configFor: () => undefined, describe: () => ({ class: [] }), + }); + // Use the real emitter with an in-memory observer, without an MCPL process. + const internal = framework as unknown as { + toolLifecycleEmitter: ToolLifecycleEmitter; + getToolsForAgent(name: string): ReturnType; + dispatchScriptToolCall(agent: string, tool: string, input: Record): Promise; + }; + internal.toolLifecycleEmitter = emitter; + return { + framework, membrane, effects, traces, lifecycle, emitter, internal, module, + moduleCall: (call: ToolCall) => moduleContext.callTool(call), + async turn(calls: ToolCall[]) { + membrane.pushResponse(createMockResponse(calls.map((call) => ({ + type: 'tool_use', id: call.id, name: call.name, input: call.input as Record, + })), 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done' }])); + framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await framework.runUntilIdle(); + return membrane.lastStream!.receivedToolResults[0] as Array<{ toolUseId: string; content: string; isError?: boolean }>; + }, + async close() { await framework.stop(); rmSync(dir, { recursive: true, force: true }); }, + }; +} + +const generic: ToolCall = { id: 'generic-denied', name: 'probe--denied', input: {} }; +const builtin: ToolCall = { + id: 'builtin-denied', name: 'agent_settings', + input: { action: 'update', same_round_think_text_policy: 'private' }, +}; + +for (const allowedTools of [['probe--allowed'], []]) { + for (const calls of [[generic], [builtin]]) { + test(`normal model dispatch denies ${calls[0].name} with ${JSON.stringify(allowedTools)}`, async () => { + const h = await harness({ allowedTools }); + try { + const before = h.framework.getAgentRuntimeSettings('assistant'); + const results = await h.turn(calls); + assert.equal(h.effects.length, 0, 'unlisted generic tool must have zero effects'); + assert.deepEqual(h.framework.getAgentRuntimeSettings('assistant'), before, 'unlisted builtin must have zero effects'); + assert.deepEqual(results.map((r) => r.toolUseId), calls.map((c) => c.id)); + for (const result of results) { + assert.equal(result.isError, true); + assert.match(result.content, /not permitted.*allowedTools/); + } + assert.deepEqual(h.lifecycle, [], 'refused calls must emit no RFC-007 lifecycle events'); + assert.equal(h.emitter.openCount, 0); + for (const call of calls) { + const events = h.traces.filter((e) => e.callId === call.id && String(e.type).startsWith('tool:')); + assert.equal(events.length, 1); + assert.equal(events[0].type, 'tool:failed'); + assert.equal(events[0].tool, call.name); + } + const compiled = await h.framework.getAgent('assistant')!.compileContext(); + const blocks = compiled.messages.flatMap((m) => m.content); + for (const call of calls) { + assert.ok(blocks.some((b) => b.type === 'tool_use' && b.id === call.id)); + assert.ok(blocks.some((b) => b.type === 'tool_result' && b.toolUseId === call.id && b.isError)); + } + } finally { await h.close(); } + }); + } +} + +for (const allowedTools of [undefined, 'all', ['probe--denied', 'agent_settings']] as Array) { + test(`allowed model calls retain effects and paired results: ${JSON.stringify(allowedTools)}`, async () => { + const h = await harness({ allowedTools }); + try { + const results = await h.turn([generic, builtin]); + assert.equal(h.effects.length, 1); + assert.equal(h.framework.getAgentRuntimeSettings('assistant').sameRoundThinkTextPolicy, 'private'); + assert.deepEqual(results.map((r) => r.toolUseId).sort(), [generic.id, builtin.id].sort()); + assert.ok(results.every((r) => !r.isError)); + assert.deepEqual(h.lifecycle.map((e) => e.phase).sort(), ['completed', 'completed', 'started', 'started']); + assert.equal(h.emitter.openCount, 0); + } finally { await h.close(); } + }); +} + +for (const permitted of [false, true]) { + test(`explicit prose helper follows current allowlist: ${permitted}`, async () => { + const h = await harness({ allowedTools: permitted ? ['prose_help'] : [], proseRouting: 'explicit' }); + try { + assert.equal(h.framework.listToolClasses('assistant').some(t => t.tool === 'prose_help'), permitted); + const [result] = await h.turn([{ id: 'help', name: 'prose_help', input: {} }]); + assert.equal(result.toolUseId, 'help'); + assert.equal(Boolean(result.isError), !permitted); + assert.match(result.content, permitted ? />>/ : /not permitted.*allowedTools/); + if (permitted) { + // The reference names the `>>skip_reply` envelope, which no list refuses, and not the + // skip_reply tool, which this agent's list doesn't name. + assert.match(result.content, />>skip_reply/); + assert.doesNotMatch(result.content, /skip_reply tool/); + } + } finally { await h.close(); } + }); +} + +test('shared script dispatch enforces the resident list and resolves its real event waiter', async () => { + const h = await harness({ allowedTools: ['probe--allowed'] }); + try { + const before = h.framework.getAgentRuntimeSettings('assistant'); + for (const call of [generic, builtin, { id: 'allowed', name: 'probe--allowed', input: {} }]) { + const pending = h.internal.dispatchScriptToolCall('assistant', call.name, call.input as Record); + await h.framework.runUntilIdle(); + const result = await pending; + assert.equal(result.success, call.name === 'probe--allowed'); + if (!result.success) { + assert.equal(result.isError, true); + assert.match(result.error!, /not permitted.*allowedTools/); + } + } + assert.deepEqual(h.effects.map((c) => c.name), ['allowed']); + assert.deepEqual(h.framework.getAgentRuntimeSettings('assistant'), before); + } finally { await h.close(); } +}); + +test('model-issued Python cannot forge unlisted inner generic or builtin calls', async () => { + const h = await harness({ allowedTools: ['code_execution', 'probe--allowed'] }); + try { + const before = h.framework.getAgentRuntimeSettings('assistant'); + const [result] = await h.turn([{ + id: 'script', name: 'code_execution', input: { code: [ + 'make = probe__allowed.__globals__["_make_tool_fn"]', + 'for name, args in [("probe--denied", {}), ("agent_settings", {"action": "update", "same_round_think_text_policy": "private"})]:', + ' try:', + ' print(await make(name, "forged")(args))', + ' except Exception as error:', + ' print(str(error))', + 'print(await probe__allowed())', + ].join('\n') }, + }]); + assert.equal(result.toolUseId, 'script'); + assert.ok(!result.isError); + const execution = JSON.parse(result.content) as { stdout: string; stderr: string; return_code: number }; + assert.equal(execution.return_code, 0, execution.stderr); + assert.equal((execution.stdout.match(/not permitted.*allowedTools/g) ?? []).length, 2); + assert.deepEqual(h.effects.map((c) => c.name), ['allowed']); + assert.deepEqual(h.framework.getAgentRuntimeSettings('assistant'), before); + assert.deepEqual(h.lifecycle.filter(event => ['probe--denied', 'agent_settings'].includes(event.tool)), []); + for (const tool of ['code_execution', 'probe--allowed']) { + assert.deepEqual(h.lifecycle.filter(event => event.tool === tool).map(event => event.phase), ['started', 'completed']); + } + assert.equal(h.emitter.openCount, 0); + assert.equal((h.emitter as unknown as { calls: Map }).calls.size, 0); + assert.equal((h.framework as unknown as { scriptToolWaiters: Map }).scriptToolWaiters.size, 0); + } finally { await h.close(); } +}); + +test('unregistered programmatic ephemeral script callers retain their full surface', async () => { + const h = await harness({ allowedTools: [] }); + try { + const pending = h.framework.executeToolCall({ + id: 'ephemeral-script', name: 'code_execution', callerAgentName: 'unregistered-ephemeral', + input: { code: 'print(await probe__denied())' }, + }); + // The Python subprocess enqueues its inner call after startup. + const processing = setInterval(() => { void h.framework.runUntilIdle(); }, 10); + try { + const result = await pending; + assert.equal(result.success, true); + assert.equal((result.data as { return_code: number }).return_code, 0); + assert.equal(h.effects.length, 1); + } finally { clearInterval(processing); } + } finally { await h.close(); } +}); + +for (const call of [generic, { id: 'public-script', name: 'code_execution', input: { code: 'print("forbidden")' } }]) { + test(`registered public dispatch refuses ${call.name} before effects`, async () => { + const h = await harness({ allowedTools: [] }); + try { + const result = await h.framework.executeToolCall({ ...call, callerAgentName: 'assistant' }); + assert.equal(result.success, false); + assert.equal(result.isError, true); + assert.match(result.error!, /not permitted.*allowedTools/); + assert.equal(h.effects.length, 0); + assert.deepEqual(h.lifecycle, []); + assert.equal(h.traces.filter((e) => e.callId === call.id && e.type === 'tool:failed').length, 1); + // A refused code_execution must not even create an interpreter. + assert.equal((h.framework as unknown as { codeExecutionRunners: Map }).codeExecutionRunners.size, 0); + } finally { await h.close(); } + }); +} + +test('trusted module closure bypasses resident restrictions even with a registered caller identity', async () => { + const h = await harness({ allowedTools: [] }); + try { + const result = await h.moduleCall({ ...generic, callerAgentName: 'assistant' }); + assert.equal(result.success, true); + assert.deepEqual(h.effects.map((c) => c.name), ['denied']); + } finally { await h.close(); } +}); + +// Same channel capture contract as explicit-prose-routing.test.ts. +function captureRouting(framework: AgentFramework) { + const routed: string[] = []; + const registry: Record = { + handleChannelToolCall: async () => ({ success: true, data: 'ok' }), + routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:commons' }; }, + resolveLocus: () => 'world:commons', getDefaultPublishChannel: () => null, + isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + }; + (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(registry, { + get: (target, prop: string) => prop in target ? target[prop] : () => undefined, + }); + return routed; +} + +/** + * XML tool mode, as an older membrane streams it: no round carries its + * verbatim roundContent, so none is routed live, and the turn-end completion + * holds the cumulative tool round, its prose, its call and its result, as + * XML mode parses them, before the final text. + */ +function asXmlTurn(membrane: MockMembrane, call: { type: 'tool_use'; id: string; name: string; input: Record }): void { + const streamYielding = membrane.streamYielding.bind(membrane); + membrane.streamYielding = (request, options) => { + const stream = streamYielding(request, options); + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (event.type === 'tool-calls') { + yield { ...event, context: { ...event.context, roundContent: undefined } }; + } else if (event.type === 'complete') { + const round = [{ type: 'text' as const, text: 'Public round prose' }, call, { type: 'tool_result' as const, toolUseId: call.id, content: 'result' }]; + yield { ...event, response: { ...event.response, content: [...round, ...event.response.content] } }; + } else yield event; + } + }; + return stream; + }; +} + +for (const fallback of [false, true]) { + for (const name of ['skip_reply', 'think', 'channel_publish']) { + for (const permitted of [false, true]) { + test(`${fallback ? 'fallback' : 'native'} prose disposition ${permitted ? 'honors permitted' : 'of a refused'} ${name}`, async () => { + const h = await harness({ allowedTools: permitted ? [name] : [], sameRoundThinkTextPolicy: 'private' }); + try { + const routed = captureRouting(h.framework); + const call = { type: 'tool_use' as const, id: 'disposition', name, input: { reason: 'test', content: 'test' } }; + if (fallback) asXmlTurn(h.membrane, call); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public round prose' }, call], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await h.framework.runUntilIdle(); + const results = h.membrane.lastStream!.receivedToolResults[0] as Array<{ isError?: boolean; content: string }>; + if (!permitted) { + assert.equal(results[0].isError, true); + assert.match(results[0].content, /not permitted.*allowedTools/); + assert.ok(routed.includes('Public trailing prose'), 'a refused call silences nothing after its round'); + if (name === 'channel_publish') { + // A refused send posted nothing, so it has nothing to keep from repeating. + assert.ok(routed.includes('Public round prose'), "a refused send's round goes out"); + } else if (name === 'skip_reply') { + // The model's choice to keep its words private holds without the call's effect. + assert.ok(!routed.includes('Public round prose'), + `a refused skip_reply keeps its own round private${fallback ? ', dropped by its place at turn end' : ''}`); + } else if (!fallback) { + // Where rounds are routed live, the private same-round policy + // reads a refused think as it reads a permitted one. + assert.ok(!routed.includes('Public round prose'), 'a refused think keeps its round private under the private same-round policy'); + } + } else if (name === 'think') { + assert.ok(routed.includes('Public trailing prose')); + if (!fallback) assert.ok(!routed.includes('Public round prose'), 'permitted private think retains same-round policy'); + } else { + assert.deepEqual(routed, [], 'permitted silencing calls retain prose disposition'); + } + } finally { await h.close(); } + }); + } + } +} + +for (const fallback of [false, true]) { + test(`${fallback ? 'fallback' : 'native'}: a refused skip_reply another module names (probe--skip_reply) keeps its round private by its bare name, and silences nothing after it`, async () => { + const h = await harness({ allowedTools: [] }); + try { + const routed = captureRouting(h.framework); + const call = { type: 'tool_use' as const, id: 'namespaced', name: 'probe--skip_reply', input: { reason: 'test' } }; + if (fallback) asXmlTurn(h.membrane, call); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public round prose' }, call], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await h.framework.runUntilIdle(); + const results = h.membrane.lastStream!.receivedToolResults[0] as Array<{ isError?: boolean; content: string }>; + assert.equal(results[0].isError, true); + assert.match(results[0].content, /not permitted.*allowedTools/); + assert.ok(!routed.includes('Public round prose'), 'the round that calls it stays private, as for skip_reply'); + assert.ok(routed.includes('Public trailing prose'), 'and what follows goes out'); + } finally { await h.close(); } + }); +} + + +test("XML tool mode: a refused think's round goes as a permitted think's does", async () => { + const dispositions: string[][] = []; + for (const permitted of [true, false]) { + const h = await harness({ allowedTools: permitted ? ['think'] : [], sameRoundThinkTextPolicy: 'private' }); + try { + const routed = captureRouting(h.framework); + const call = { type: 'tool_use' as const, id: 'think', name: 'think', input: { content: 'test' } }; + asXmlTurn(h.membrane, call); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public round prose' }, call], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await h.framework.runUntilIdle(); + const results = h.membrane.lastStream!.receivedToolResults[0] as Array<{ isError?: boolean }>; + assert.equal(results[0].isError === true, !permitted); + dispositions.push(routed); + } finally { await h.close(); } + } + assert.ok(dispositions[0].includes('Public round prose'), "a permitted think's round goes out in XML tool mode, where no round is routed live"); + assert.deepEqual(dispositions[1], dispositions[0], 'the refusal changes nothing about which prose goes out'); +}); + +for (const skipName of ['skip_reply', 'probe--skip_reply']) test(`explicit prose routing: a refused ${skipName}'s round stays private, and what follows goes on as unprefixed prose does`, async () => { + const h = await harness({ allowedTools: [], proseRouting: 'explicit' }); + try { + const routed = captureRouting(h.framework); + const bounced: number[] = []; + h.framework.onTrace((e) => { + const ev = e as { type: string; source?: string }; + if (ev.type === 'message:added' && ev.source === 'prose-bounce') bounced.push(1); + }); + h.membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Private round prose' }, + { type: 'tool_use', id: 'skip', name: skipName, input: { reason: 'quiet' } }, + ], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Unprefixed trailing prose' }])); + // A bounce wakes the agent again, and a stream takes the responses queued + // when it starts: answer each later turn privately as it starts. + let turns = 0; + h.framework.onTrace((e) => { + if ((e as { type: string }).type !== 'inference:started') return; + if (++turns >= 2) h.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>skip_reply\nnoted' }])); + }); + const firstTurn: Array> = []; + const streamYielding = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (request, options) => { + const stream = streamYielding(request, options); + if (firstTurn.length === 0) firstTurn.push(h.membrane.lastStream!); + return stream; + }; + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await h.framework.runUntilIdle(); + const results = firstTurn[0]!.receivedToolResults[0] as Array<{ isError?: boolean }>; + assert.equal(results[0].isError, true); + assert.deepEqual(routed, [], 'nothing is delivered'); + assert.equal(bounced.length, 1, "only the trailing round's unprefixed prose bounces; the refused skip_reply's round stays in context"); + } finally { await h.close(); } +}); + +test("a refused skip_reply beside a refused send keeps its round private, and neither silences what follows", async () => { + const h = await harness({ allowedTools: [] }); + try { + const routed = captureRouting(h.framework); + h.membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Public round prose' }, + { type: 'tool_use', id: 'skip', name: 'skip_reply', input: { reason: 'quiet' } }, + { type: 'tool_use', id: 'send', name: 'channel_publish', input: { content: 'never posted' } }, + ], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await h.framework.runUntilIdle(); + const results = h.membrane.lastStream!.receivedToolResults[0] as Array<{ isError?: boolean }>; + assert.deepEqual(results.map((r) => r.isError), [true, true]); + assert.ok(!routed.includes('Public round prose'), 'the skip_reply keeps its round private'); + assert.ok(routed.includes('Public trailing prose'), 'the refused send silences nothing after the round'); + } finally { await h.close(); } +}); + +for (const fallback of [false, true]) { + test(`${fallback ? 'fallback' : 'native'}: a skip_reply the list doesn't name, asking for a wake on a host with no EventGate, is refused by the list first, and its round stays private`, async () => { + const h = await harness({ allowedTools: [] }); + try { + const routed = captureRouting(h.framework); + assert.equal((h.framework as unknown as { eventGate?: unknown }).eventGate ?? undefined, undefined, 'no EventGate'); + const call = { type: 'tool_use' as const, id: 'wake', name: 'skip_reply', input: { reason: 'later', wake_in_seconds: 60 } }; + if (fallback) asXmlTurn(h.membrane, call); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public round prose' }, call], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await h.framework.runUntilIdle(); + const results = h.membrane.lastStream!.receivedToolResults[0] as Array<{ isError?: boolean; content: string }>; + assert.equal(results[0].isError, true); + assert.match(results[0].content, /not permitted.*allowedTools/, 'the permission refusal comes first'); + assert.ok(!routed.includes('Public round prose'), 'its round stays private'); + assert.ok(routed.includes('Public trailing prose'), 'and silences nothing after it'); + } finally { await h.close(); } + }); +} + +for (const name of ['set_tool_visibility', 'set_tool_description']) { + test(`presentation tools obey allowlist on model, script and registered public routes: ${name}`, async () => { + const h = await harness({ allowedTools: ['probe--allowed'] }); + try { + const input = name === 'set_tool_visibility' + ? { name: 'probe--allowed', visible: false } + : { name: 'probe--allowed', description: 'Changed' }; + const call = { id: 'presentation-denied', name, input, callerAgentName: 'assistant' }; + const [model] = await h.turn([call]); + assert.equal(model.isError, true); + assert.match(model.content, /not permitted.*allowedTools/); + const pending = h.internal.dispatchScriptToolCall('assistant', name, input); + await h.framework.runUntilIdle(); + const script = await pending; + assert.equal(script.isError, true); + assert.match(script.error!, /not permitted.*allowedTools/); + const direct = await h.framework.executeToolCall(call); + assert.equal(direct.isError, true); + assert.match(direct.error!, /not permitted.*allowedTools/); + assert.deepEqual(h.lifecycle, [], 'denied presentation calls must not dispatch'); + assert.equal(h.framework.inspectToolPresentation('assistant'), null); + } finally { await h.close(); } + }); +} + +for (const route of ['model', 'public', 'script'] as const) { + test(`utility run checks the utility name on the ${route} route`, async () => { + const h = await harness({ allowedTools: ['utils', 'probe--allowed_util'] }); + const input = { action: 'run', name: 'probe--denied_util', args: {} }; + try { + if (route === 'model') { + const [result] = await h.turn([{ id: 'utility-denied', name: 'utils', input }]); + assert.equal(result.isError, true); assert.match(result.content, /not permitted.*allowedTools/); + const terminals = h.traces.filter(event => event.callId === 'utility-denied' && + ['tool:failed', 'tool:completed'].includes(event.type as string)); + assert.deepEqual(terminals.map(event => event.tool), ['utils']); + assert.deepEqual(h.lifecycle.map(event => [event.tool, event.phase]), [['utils', 'started'], ['utils', 'completed']]); + assert.equal(h.emitter.openCount, 0); + assert.equal((h.emitter as unknown as { calls: Map }).calls.size, 0); + assert.equal((h.framework as unknown as { scriptToolWaiters: Map }).scriptToolWaiters.size, 0); + } else if (route === 'public') { + const result = await h.framework.executeToolCall({ id: 'utility-denied', name: 'utils', input, callerAgentName: 'assistant' }); + assert.equal(result.isError, true); assert.match(result.error!, /not permitted.*allowedTools/); + } else { + const pending = h.internal.dispatchScriptToolCall('assistant', 'utils', input); + const processing = setInterval(() => { void h.framework.runUntilIdle(); }, 5); + try { + const result = await pending; + assert.equal(result.isError, true); assert.match(result.error!, /not permitted.*allowedTools/); + } finally { clearInterval(processing); } + } + assert.deepEqual(h.effects, []); + } finally { await h.close(); } + }); +} + +for (const allowedTools of [['utils', 'probe--allowed_util'], 'all'] as AgentConfig['allowedTools'][]) { + test(`utility list and describe expose only executable names: ${JSON.stringify(allowedTools)}`, async () => { + const h = await harness({ allowedTools }); + const call = (input: unknown) => h.framework.executeToolCall({ id: 'utility', name: 'utils', input, callerAgentName: 'assistant' }); + try { + const listed = await call({ action: 'list' }); assert.equal(listed.success, true); + const expected = allowedTools === 'all' ? ['probe--allowed_util', 'probe--denied_util'] : (allowedTools as string[]).filter(n => n !== 'utils'); + assert.deepEqual((listed.data as Array<{ name: string }>).map(v => v.name), expected); + const described = await call({ action: 'describe', name: 'probe--denied_util' }); + assert.equal(described.success, allowedTools === 'all'); + if (allowedTools !== 'all') assert.ok(!JSON.stringify(described).includes('Inert utility counter')); + for (const name of expected) assert.equal((await call({ action: 'run', name, args: {} })).success, true); + assert.deepEqual(h.effects.map(c => 'probe--' + c.name), expected); + } finally { await h.close(); } + }); +} + +test('trusted module utility call retains its authority with a restricted caller name', async () => { + const h = await harness({ allowedTools: [] }); + try { + const result = await h.moduleCall({ id: 'trusted-utility', name: 'utils', input: { action: 'run', name: 'probe--denied_util', args: {} }, callerAgentName: 'assistant' }); + assert.equal(result.success, true); assert.deepEqual(h.effects.map(c => c.name), ['denied_util']); + } finally { await h.close(); } +}); + +test('refused inner script call clears lifecycle and its waiter without execution', async () => { + const h = await harness({ allowedTools: [] }); + try { + const pending = h.internal.dispatchScriptToolCall('assistant', generic.name, {}); + await h.framework.runUntilIdle(); assert.equal((await pending).isError, true); + assert.deepEqual(h.lifecycle, []); assert.equal(h.emitter.openCount, 0); + assert.equal((h.emitter as unknown as { calls: Map }).calls.size, 0); + assert.equal((h.framework as unknown as { scriptToolWaiters: Map }).scriptToolWaiters.size, 0); + assert.deepEqual(h.effects, []); + } finally { await h.close(); } +}); + +for (const name of ['skip_reply', 'think'] as const) { + test(`mixed refused call and allowed ${name} preserve independent results and prose`, async () => { + const h = await harness({ allowedTools: [name], sameRoundThinkTextPolicy: 'private' }); + try { + const routed = captureRouting(h.framework); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Round prose' }, + { type: 'tool_use', id: 'denied', name: generic.name, input: {} }, + { type: 'tool_use', id: 'allowed', name, input: { reason: 'test' } }], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Trailing prose' }])); + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run', metadata: {} }); await h.framework.runUntilIdle(); + const results = h.membrane.lastStream!.receivedToolResults[0] as Array<{ toolUseId: string; isError?: boolean }>; + assert.equal(results.length, 2); assert.equal(results.find(r => r.toolUseId === 'denied')!.isError, true); + assert.ok(!results.find(r => r.toolUseId === 'allowed')!.isError); assert.deepEqual(h.effects, []); + assert.deepEqual(routed, name === 'skip_reply' ? [] : ['Trailing prose']); + } finally { await h.close(); } + }); +} + +for (const permitted of [false, true]) { + test(`explicit prose primer and bounce advertise help only when permitted: ${permitted}`, async () => { + const h = await harness({ allowedTools: permitted ? ['prose_help'] : [], proseRouting: 'explicit' }); + try { + const agent = h.framework.getAgent('assistant')!; + const internal = h.framework as unknown as { + maybePrimeProseMode(value: typeof agent): void; + routingOf(name: string): unknown; + bounceProse(value: typeof agent, text: string, reason: string, candidates: undefined, prefix: '>>', round: { routing: unknown; notice: 'later' }): void; + }; + internal.maybePrimeProseMode(agent); + internal.bounceProse(agent, 'Unrouted prose', 'no destination', undefined, '>>', { routing: internal.routingOf('assistant'), notice: 'later' }); + const compiled = await agent.compileContext(); const text = JSON.stringify(compiled.messages); + assert.equal(text.includes('The prose_help tool shows the full syntax.'), permitted); + assert.ok(text.includes('destination prefix') && text.includes('To deliver it unchanged')); + } finally { await h.close(); } + }); +} + +for (const permitted of [false, true]) { + test(`think's and journal's descriptions point at skip_reply only where the list permits it: ${permitted}`, async () => { + const h = await harness({ allowedTools: permitted ? ['think', 'journal', 'skip_reply'] : ['think', 'journal'] }); + const slot = h.framework as unknown as { channelRegistry: unknown }; + const original = slot.channelRegistry; + try { + // A host with channel servers has the registry, whose think and journal these are. + const registry = new ChannelRegistry({} as never, {} as never, () => {}, () => {}, {}); + slot.channelRegistry = registry; + const surface = h.internal.getToolsForAgent('assistant'); + for (const name of ['think', 'journal']) { + const tool = surface.find((t) => t.name === name); + assert.ok(tool, `${name} is on the surface`); + assert.equal(tool.description.includes('skip_reply'), permitted, `${name}'s description names skip_reply only where the agent may call it`); + } + // The result's handler can't see the caller's list, so it names no tool to end the turn with. + const result = await registry.handleChannelToolCall('think', { content: 'working it out' }); + assert.equal(result.success, true); + assert.ok(!JSON.stringify(result.data).includes('skip_reply'), `think's result names no skip_reply: ${JSON.stringify(result.data)}`); + } finally { slot.channelRegistry = original; await h.close(); } + }); +} + +test('script in flight cannot gain legacy permissions when its ephemeral run disposes', async () => { + let enter!: () => void, release!: () => void; + const entered = new Promise(resolve => { enter = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + const h = await harness({ allowedTools: [] }, async call => { + if ((call.input as {phase: string}).phase === 'barrier') { enter(); await released; } + return { success: true, data: 'released' }; + }); + let streamEntered!: () => void; + const streamCreated = new Promise(resolve => { streamEntered = resolve; }); + const originalStream = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (...args: Parameters) => { + const stream = originalStream(...args); + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + await entered; + const iterator = iterate(); + while (true) { + const next = await iterator.next(); + if (next.done) return; + yield next.value; + } + }; + streamEntered(); return stream; + }; + try { + const created = await h.framework.createEphemeralAgent({ name: 'retiring-worker', model: 'test', systemPrompt: 'Test', allowedTools: ['code_execution', 'probe--allowed'] }); + created.contextManager.addMessage('user', [{ type: 'text', text: 'Complete once.' }]); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Completed' }])); + const run = h.framework.runEphemeralToCompletion(created.agent, created.contextManager); + h.framework.start(); await streamCreated; + const script = h.framework.executeToolCall({ id: 'in-flight', name: 'code_execution', callerAgentName: created.agent.name, input: { code: [ + 'await probe__allowed({"phase": "barrier"})', + 'try:', ' print(await probe__allowed({"phase": "late"}))', + 'except Exception as error:', ' print(str(error))', + ].join('\n') } }); + await entered; + assert.equal(h.framework.getAgent(created.agent.name), created.agent); + await run; + assert.equal(h.framework.getAgent(created.agent.name), null); + release(); + const result = await script; assert.equal(result.success, true); + assert.match((result.data as { stdout: string }).stdout, /not a running agent.*only while registered/); + assert.deepEqual(h.effects.map(call => call.input), [{phase: 'barrier'}]); + assert.equal(h.emitter.openCount, 0); + assert.equal((h.framework as unknown as { scriptToolWaiters: Map }).scriptToolWaiters.size, 0); + } finally { release(); await h.close(); } +}); + +test('disposed conversation fork cannot regain legacy tool permissions', async () => { + const h = await harness({ allowedTools: ['probe--allowed'] }); + try { + const internals = h.framework as unknown as { + createConversationAgent(name: string, channel: string): Promise<{name: string}>; + disposeConversationAgent(name: string): void; + }; + const fork = await internals.createConversationAgent('conversation-probe-g1', 'world:probe'); + assert.ok(h.framework.getAgent(fork.name)); + const call = {id: 'fork-permitted', name: 'probe--allowed', input: {}, callerAgentName: fork.name}; + assert.equal((await h.framework.executeToolCall(call)).success, true); + internals.disposeConversationAgent(fork.name); + assert.equal(h.framework.getAgent(fork.name), null); + const before = h.effects.length; + assert.equal((await h.framework.executeToolCall({...call,id:'fork-late'})).success, false); + assert.equal(h.effects.length, before); + assert.equal(h.emitter.openCount, 0); + } finally { await h.close(); } +}); + +test('public utility catalogue preserves names and the collapsed model surface', async () => { + const h = await harness({ allowedTools: 'all' }); + try { + const framework = h.framework as typeof h.framework & {getAllUtilities(): ReturnType}; + const utilities = framework.getAllUtilities(); + assert.deepEqual(utilities.map(tool => tool.name), ['probe--allowed_util', 'probe--denied_util']); + assert.ok(h.framework.getAllTools().some(tool => tool.name === 'utils')); + assert.ok(!h.framework.getAllTools().some(tool => tool.name === 'probe--allowed_util')); + utilities.pop(); + assert.equal(framework.getAllUtilities().length, 2); + } finally { await h.close(); } +}); + + +for (const allowedTools of [['utils'], ['utils', 'probe--allowed']]) { + test(`empty permitted utility surface reports migration once: ${JSON.stringify(allowedTools)}`, async () => { + const warnings: string[] = []; + const warn = console.warn; + console.warn = (...args) => { + const line = args.join(' '); + if (!line.startsWith('[deprecated]')) warnings.push(line); + }; + const h = await harness({ allowedTools }); + try { + assert.equal(warnings.length, 1, 'resident warns after initial modules register'); + assert.match(warnings[0], /assistant.*2 registered.*none permitted.*allowedTools.*module--utility/); + assert.ok(h.framework.getAllTools().some(tool => tool.name === 'utils'), 'global catalogue is unchanged'); + for (let i = 0; i < 2; i++) { + assert.ok(!h.internal.getToolsForAgent('assistant').some(tool => tool.name === 'utils')); + assert.ok(!(await h.framework.previewActivation('assistant')).tools?.some(tool => tool.name === 'utils')); + } + const call = (input: unknown) => h.framework.executeToolCall({ id: 'empty-utilities', name: 'utils', input, callerAgentName: 'assistant' }); + const listed = await call({ action: 'list' }); + assert.equal(listed.success, false); assert.equal(listed.isError, true); + assert.match(listed.error!, /2 registered.*0 permitted.*none permitted.*allowedTools.*module--utility/); + assert.equal(listed.data, undefined, 'empty permission diagnostic uses existing failure shape'); + const unknown = await call({ action: 'describe', name: 'missing' }); + assert.equal(unknown.isError, true); + assert.match(unknown.error!, /2 registered.*0 permitted.*none permitted.*allowedTools/); + assert.ok(!JSON.stringify(unknown).includes('probe--denied_util'), 'unknown diagnostics reveal no denied names'); + assert.ok(!JSON.stringify(unknown).includes('Inert utility counter')); + assert.equal(warnings.length, 1, 'repeat surfaces do not repeat the warning'); + assert.deepEqual(h.effects, []); + } finally { await h.close(); console.warn = warn; } + }); +} + +test('zero-module snapshots do not consume the later utility migration warning', async () => { + const warnings: string[] = []; + const warn = console.warn; + console.warn = (...args) => { + const line = args.join(' '); + if (!line.startsWith('[deprecated]')) warnings.push(line); + }; + const h = await harness({ allowedTools: ['utils'] }, undefined, true); + try { + assert.ok(!h.internal.getToolsForAgent('assistant').some(tool => tool.name === 'utils')); + assert.deepEqual(warnings, []); + const empty = await h.framework.executeToolCall({ id: 'no-utilities', name: 'utils', input: { action: 'list' }, callerAgentName: 'assistant' }); + assert.equal(empty.success, true); assert.deepEqual(empty.data, []); + await h.framework.addModule(h.module); + assert.ok(!h.internal.getToolsForAgent('assistant').some(tool => tool.name === 'utils')); + h.internal.getToolsForAgent('assistant'); + assert.equal(warnings.length, 1); assert.match(warnings[0], /assistant.*2 registered/); + } finally { await h.close(); console.warn = warn; } +}); + +test('one registered utility is counted as one utility', async () => { + const warnings: string[] = []; + const warn = console.warn; + console.warn = (...args) => { + const line = args.join(' '); + if (!line.startsWith('[deprecated]')) warnings.push(line); + }; + const h = await harness({ allowedTools: ['utils'] }, undefined, true); + try { + await h.framework.addModule({ + name: 'single', + async start() {}, async stop() {}, + getTools: () => [], + getUtilities: () => [{ name: 'only', description: 'Inert utility', inputSchema: { type: 'object', properties: {} } }], + async handleToolCall() { return { success: true, data: 'executed' }; }, + async onProcess() { return {}; }, + }); + h.internal.getToolsForAgent('assistant'); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /: 1 registered utility, 0 permitted;/); + const listed = await h.framework.executeToolCall({ id: 'one-utility', name: 'utils', input: { action: 'list' }, callerAgentName: 'assistant' }); + assert.equal(listed.isError, true); + assert.match(listed.error!, /\b1 registered utility, 0 permitted;/); + } finally { await h.close(); console.warn = warn; } +}); + +for (const allowedTools of [['utils', 'probe--allowed_util'], ['probe--allowed_util'], 'all'] as AgentConfig['allowedTools'][]) { + test(`permitted utilities retain surface without migration warnings: ${JSON.stringify(allowedTools)}`, async () => { + const warnings: string[] = []; + const warn = console.warn; + console.warn = (...args) => { + const line = args.join(' '); + if (!line.startsWith('[deprecated]')) warnings.push(line); + }; + const h = await harness({ allowedTools }); + try { + const surface = (await h.framework.previewActivation('assistant')).tools ?? []; + assert.equal(surface.some(tool => tool.name === 'utils'), allowedTools === 'all' || allowedTools!.includes('utils')); + assert.deepEqual(warnings, []); + } finally { await h.close(); console.warn = warn; } + }); +} + +for (const route of ['model', 'public', 'script'] as const) { + test(`denied utility describe matches run refusal on ${route}`, async () => { + const h = await harness({ allowedTools: ['utils', 'probe--allowed_util'] }); + try { + const errors: string[] = []; + for (const action of ['describe', 'run']) { + const input = { action, name: 'probe--denied_util', args: {} }; + const id = `denied-${action}`; + if (route === 'model') { + const [result] = await h.turn([{ id, name: 'utils', input }]); + assert.equal(result.isError, true); errors.push(result.content); + const terminals = h.traces.filter(event => event.callId === id && ['tool:failed', 'tool:completed'].includes(event.type as string)); + assert.deepEqual(terminals.map(event => event.tool), ['utils']); + } else { + const pending = route === 'public' + ? h.framework.executeToolCall({ id, name: 'utils', input, callerAgentName: 'assistant' }) + : h.internal.dispatchScriptToolCall('assistant', 'utils', input); + const processing = setInterval(() => { void h.framework.runUntilIdle(); }, 5); + try { + const result = await pending; + assert.equal(result.isError, true); errors.push(result.error!); + } finally { clearInterval(processing); } + } + } + assert.equal(errors[0], errors[1]); + assert.match(errors[0], /not permitted.*allowedTools/); + assert.ok(!errors[0].includes('Inert utility counter')); + assert.equal(h.emitter.openCount, 0); assert.deepEqual(h.effects, []); + const unknown = await h.framework.executeToolCall({ id: 'unknown', name: 'utils', input: { action: 'describe', name: 'missing' }, callerAgentName: 'assistant' }); + assert.match(unknown.error!, /2 registered.*1 permitted/); + assert.ok(!unknown.error!.includes('probe--denied_util')); + } finally { await h.close(); } + }); + + test(`named utility grant alone executes on ${route}`, async () => { + const h = await harness({ allowedTools: ['probe--allowed_util'] }); + const call = { id: 'direct-utility', name: 'probe--allowed_util', input: {}, callerAgentName: 'assistant' }; + try { + if (route === 'model') { + const [result] = await h.turn([call]); assert.ok(!result.isError); + } else { + const pending = route === 'public' ? h.framework.executeToolCall(call) : h.internal.dispatchScriptToolCall('assistant', call.name, {}); + const processing = setInterval(() => { void h.framework.runUntilIdle(); }, 5); + try { assert.equal((await pending).success, true); } + finally { clearInterval(processing); } + } + assert.deepEqual(h.effects.map(effect => effect.name), ['allowed_util']); + } finally { await h.close(); } + }); +} + +test('never-run and retired ephemerals report missing registration before utility migration advice', async () => { + const h = await harness({ allowedTools: 'all' }); + try { + const created = await h.framework.createEphemeralAgent({ name: 'identity-worker', model: 'test', systemPrompt: 'Test', allowedTools: ['utils', 'probe--allowed_util'] }); + const check = async () => { + const before = h.effects.length; + for (const [name, input] of [['probe--allowed_util', {}], ['utils', { action: 'list' }], ['utils', { action: 'describe', name: 'missing' }]] as const) { + const result = await h.framework.executeToolCall({ id: `unregistered-${name}`, name, input, callerAgentName: created.agent.name }); + assert.equal(result.success, false); assert.equal(result.isError, true); + assert.match(result.error!, /identity-worker.*not a running agent.*only while registered/); + assert.ok(!result.error!.includes('allowedTools'), 'identity failure must not give allowlist advice'); + } + assert.equal(h.effects.length, before); assert.deepEqual(h.lifecycle, []); assert.equal(h.emitter.openCount, 0); + }; + await check(); + created.contextManager.addMessage('user', [{ type: 'text', text: 'Complete.' }]); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Completed' }])); + const run = h.framework.runEphemeralToCompletion(created.agent, created.contextManager); + await h.framework.runUntilIdle(); await run; + assert.equal(h.framework.getAgent(created.agent.name), null); + await check(); created.cleanup(); + } finally { await h.close(); } +}); + +test('ephemeral and conversation fork first surfaces warn once per Agent instance', async () => { + const h = await harness({ allowedTools: ['utils'] }); + const warnings: string[] = []; + const warn = console.warn; + console.warn = (...args) => { + const line = args.join(' '); + if (!line.startsWith('[deprecated]')) warnings.push(line); + }; + try { + const internals = h.framework as unknown as { createConversationAgent(name: string, channel: string): Promise<{name: string}>; disposeConversationAgent(name: string): void }; + const fork = await internals.createConversationAgent('utility-fork-g1', 'world:probe'); + const created = await h.framework.createEphemeralAgent({ name: 'utility-worker', model: 'test', systemPrompt: 'Test', allowedTools: ['utils'] }); + created.contextManager.addMessage('user', [{ type: 'text', text: 'Complete.' }]); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Completed' }])); + const run = h.framework.runEphemeralToCompletion(created.agent, created.contextManager); + for (const name of [fork.name, created.agent.name]) { + assert.ok(!h.internal.getToolsForAgent(name).some(tool => tool.name === 'utils')); + h.internal.getToolsForAgent(name); + assert.equal(warnings.filter(line => line.includes(name)).length, 1); + } + await h.framework.runUntilIdle(); await run; + assert.equal(warnings.length, 2); + internals.disposeConversationAgent(fork.name); created.cleanup(); + } finally { await h.close(); console.warn = warn; } +}); diff --git a/test/same-round-think-policy.test.ts b/test/same-round-think-policy.test.ts index b3b3f252e..3268a397d 100644 --- a/test/same-round-think-policy.test.ts +++ b/test/same-round-think-policy.test.ts @@ -109,7 +109,7 @@ function makeChannelRegistry(framework: AgentFramework) { data: { noted: true, note: - 'Thought recorded (private — not sent anywhere). Same-round text routing depends on your current same_round_think_text_policy; use agent_settings get to inspect it, or call skip_reply to end the turn without replying.', + 'Thought recorded (private — not sent anywhere). Same-round text routing depends on your current same_round_think_text_policy; use agent_settings get to inspect it.', }, }; }