feat(x/core): real bridges + DI assembly for the turn runtime (stage 4)

Five bridges adapt existing app code to the runtime seams, each unit-
tested against injected fakes:

- RealAgentResolver: loadAgent + dynamic builders -> immutable
  ResolvedAgent. Model precedence override > agent > app default; tool
  descriptors from BuiltinTools (zod -> JSON schema) and MCP
  attachments (toolId schemes builtin:/mcp:server:tool); ask-human as
  the async requiresHuman tool. System prompt composed via
  composeSystemInstructions — extracted verbatim from streamAgent so
  old and new runtimes share one implementation — driven by the new
  opaque RequestedAgent.overrides.composition (session-sticky inputs
  preserve provider prefix caching; per-message context stays on
  UserMessage.userMessageContext as today).
- RealModelRegistry: models.json -> live AI SDK model; one streamText
  step (stopWhen: stepCountIs(1)) normalized into deltas, durable step
  events, and the completed assistant message.
- RealToolRegistry: execTool dispatch (builtins + MCP) with per-call
  abortRegistry bracketing and tool-output-stream -> durable progress.
- RealPermissionChecker: getToolPermissionMetadata rules (command
  allowlist, workspace file boundaries); session grants deferred.
- RealPermissionClassifier: classifyToolPermissions with conversation
  context now threaded through the classifier batch.

Interface refinements (doc updated): ToolExecutionContext gains
turnId/toolCallId; classifier takes a batch with turnId + messages;
TurnRuntime dep renamed bus -> lifecycleBus and SessionsImpl bus ->
sessionBus for strict-PROXY DI. Container registers the whole stack
(FS repos rooted at WorkDir/storage); the legacy agentRuntime
registration became lazy to survive the new import-order cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-02 10:45:35 +05:30
parent ccf38c6a0f
commit 1d5782fa75
23 changed files with 1734 additions and 99 deletions

View file

@ -247,6 +247,12 @@ interface RequestedAgent {
agentId: string;
overrides?: {
model?: ModelDescriptor;
// Opaque composition hints interpreted by the agent resolver (e.g.
// work-dir id, voice/search/code modes). Persisted verbatim for audit.
// The resolver decides which keys affect system-prompt bytes; callers
// should keep prompt-affecting inputs session-sticky to preserve
// provider prefix caching across turns.
composition?: JsonValue;
};
}
```
@ -608,9 +614,17 @@ interface PermissionClassification {
reason: string;
}
interface PermissionClassificationBatch {
turnId: string;
// Conversation context: resolved context plus current-turn settled
// messages (the pending batch's tool results are not yet terminal).
messages: ConversationMessage[];
requests: PermissionClassificationInput[];
}
interface IPermissionClassifier {
classify(
requests: PermissionClassificationInput[],
batch: PermissionClassificationBatch,
signal: AbortSignal,
): Promise<PermissionClassification[]>;
}
@ -710,6 +724,8 @@ Sync and async execution are immutable tool metadata:
```ts
interface ToolExecutionContext {
turnId: string;
toolCallId: string;
signal: AbortSignal;
reportProgress(progress: JsonValue): Promise<void>;
}
@ -1125,7 +1141,7 @@ interface TurnRuntimeDependencies {
contextResolver: IContextResolver;
permissionChecker: IPermissionChecker;
permissionClassifier: IPermissionClassifier;
bus: IBus;
lifecycleBus: ITurnLifecycleBus;
}
class TurnRuntime implements ITurnRuntime {
@ -1139,7 +1155,7 @@ class TurnRuntime implements ITurnRuntime {
contextResolver,
permissionChecker,
permissionClassifier,
bus,
lifecycleBus,
}: TurnRuntimeDependencies);
}
```

View file

@ -114,7 +114,7 @@ function filePermissionTargets(toolName: string, args: Record<string, unknown>):
}
}
async function getToolPermissionMetadata(
export async function getToolPermissionMetadata(
toolCall: z.infer<typeof ToolCallPart>,
underlyingTool: z.infer<typeof ToolAttachment>,
sessionAllowedCommands: Set<string>,
@ -172,7 +172,7 @@ async function getToolPermissionMetadata(
};
}
function loadUserWorkDir(runId: string): string | null {
export function loadUserWorkDir(runId: string): string | null {
try {
const file = workDirConfigFile(runId);
if (!fs.existsSync(file)) return null;
@ -185,7 +185,7 @@ function loadUserWorkDir(runId: string): string | null {
}
}
function loadAgentNotesContext(): string | null {
export function loadAgentNotesContext(): string | null {
const sections: string[] = [];
const userFile = path.join(AGENT_NOTES_DIR, 'user.md');
@ -327,6 +327,87 @@ If Middle pane state is note, the supplied path and content are available so you
If Middle pane state is browser, only the URL and page title are supplied; the page content itself is NOT included. If you need the page content to answer, use the browser tools available to you to read the page. The user may or may not be talking about this page. Only reference or act on this page when the user's message clearly relates to it, such as "this page", "this article", "what I'm looking at", "this site", or "summarize this". For unrelated questions, ignore this page entirely and answer normally. Do not mention that you can see the browser unless it is relevant to the answer.`;
export interface ComposeSystemInstructionsInput {
instructions: string;
agentNotesContext: string | null;
userWorkDir: string | null;
voiceInput: boolean;
voiceOutput: 'summary' | 'full' | null;
searchEnabled: boolean;
codeMode: 'claude' | 'codex' | null;
codeCwd: string | null;
}
// System-prompt assembly, extracted verbatim from streamAgent so the new turn
// runtime's agent resolver composes byte-identical prompts. Pure: callers
// load agent notes / work dir themselves.
export function composeSystemInstructions({
instructions,
agentNotesContext,
userWorkDir,
voiceInput,
voiceOutput,
searchEnabled,
codeMode,
codeCwd,
}: ComposeSystemInstructionsInput): string {
let instructionsWithDateTime = `${instructions}\n\n${USER_CONTEXT_SYSTEM_INSTRUCTIONS}`;
if (agentNotesContext) {
instructionsWithDateTime += `\n\n${agentNotesContext}`;
}
if (userWorkDir) {
instructionsWithDateTime += `\n\n# User Work Directory
The user has chosen the following directory as their current **work directory**:
\`${userWorkDir}\`
Treat this as the **default location** for file operations whenever the user refers to files generically:
- "list the files", "show me what's in here", "what's the latest report" list or look in the work directory.
- "save this", "export it", "write that to a file" write the output into the work directory unless the user names another location.
- "open the file I was just working on", "the doc from earlier" assume the work directory first.
Use absolute paths rooted at this directory with the \`file-*\` tools. For example, list with \`file-list({ path: "${userWorkDir}" })\`, read text with \`file-readText\`, and write text with \`file-writeText\`. For PDFs, Office docs, images, scanned docs, and other non-text files, use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first.
**Exceptions these ALWAYS take precedence over the work directory default:**
1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use file tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory.
2. **Explicit paths.** If the user names a different directory or gives an absolute/relative path (e.g. "in ~/Downloads", "from /tmp/foo", "the Desktop"), honor that path exactly and ignore the work-directory default for that request.
3. **Workspace-specific operations.** Anything that obviously belongs in the Rowboat workspace (config files, MCP servers, agent schedules, etc.) stays in the workspace, not the work directory.
Do not announce the work directory unless it's relevant. Just use it.`;
}
if (voiceInput) {
instructionsWithDateTime += `\n\n# Voice Input\nThe user's message was transcribed from speech. Be aware that:\n- There may be transcription errors. Silently correct obvious ones (e.g. homophones, misheard words). If an error is genuinely ambiguous, briefly mention your interpretation (e.g. "I'm assuming you meant X").\n- Spoken messages are often long-winded. The user may ramble, repeat themselves, or correct something they said earlier in the same message. Focus on their final intent, not every word verbatim.`;
}
if (voiceOutput === 'summary') {
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY — READ THIS FIRST)\nThe user has voice output enabled. THIS IS YOUR #1 PRIORITY: you MUST start your response with <voice></voice> tags. If your response does not begin with <voice> tags, the user will hear nothing — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. Do not start with markdown, headings, or any other text. The literal first characters of your response must be "<voice>".\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n5. After all <voice> tags, you may include detailed written content (markdown, tables, code, etc.) that will be shown visually but not spoken.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Alex — March 11\n### Roadmap\n- Agreed to push Q2 launch to April 15...\n(detailed written content continues)\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You have five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you requested and Taylor flagged a contract issue.</voice>\n<voice>There's also a warm intro from a VC partner connecting you with someone at a prospective customer.</voice>\n<voice>I've drafted responses for three of them. The details and drafts are below.</voice>\n\n(email blocks, tables, and detailed content follow)\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a pretty packed day — seven meetings starting with standup at 9.</voice>\n<voice>The big ones are your investor call at 11, lunch with a partner from your lead VC at 12:30, and a customer call at 4.</voice>\n<voice>Your only free block for deep work is 2:30 to 4.</voice>\n\n(calendar block with full event details follows)\n\nExample 4 — User asks: "draft an email to Sam with our metrics"\n\n<voice>Done — I've drafted the email to Sam with your latest WAU and churn numbers.</voice>\n<voice>Take a look at the draft below and send it when you're ready.</voice>\n\n(email block with draft follows)\n\nREMEMBER: If you do not start with <voice> tags, the user hears silence. Always speak first, then write.`;
} else if (voiceOutput === 'full') {
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY — READ THIS FIRST)\nThe user wants your ENTIRE response spoken aloud. THIS IS YOUR #1 PRIORITY: every single sentence must be wrapped in <voice></voice> tags. If you write anything outside <voice> tags, the user will not hear it — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. The literal first characters of your response must be "<voice>".\n2. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n3. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n4. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n5. EVERY sentence MUST be inside a <voice> tag. Do not leave ANY content outside <voice> tags. If it's not in a <voice> tag, the user cannot hear it.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Alex will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You've got five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you asked for, and Taylor flagged a contract issue that needs your sign-off.</voice>\n<voice>There's a warm intro from a VC partner connecting you with an engineering lead at a potential customer.</voice>\n<voice>And someone from a prospective client wants to confirm your API tier before your call this afternoon.</voice>\n<voice>I've drafted replies for three of them — the metrics update, the intro, and the API question.</voice>\n<voice>The only one I left for you is Taylor's contract redline, since that needs your judgment on the liability cap.</voice>\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a packed day — seven meetings starting with standup at 9.</voice>\n<voice>The highlights are your investor call at 11, lunch with a VC partner at 12:30, and a customer call at 4.</voice>\n<voice>Your only open block for deep work is 2:30 to 4, so plan accordingly.</voice>\n<voice>Oh, and your 1-on-1 with your co-founder is at 5:30 — that's a walking meeting.</voice>\n\nExample 4 — User asks: "how are our metrics looking?"\n\n<voice>Metrics are looking strong this week.</voice>\n<voice>You hit 2,573 weekly active users, which is up 12% week over week.</voice>\n<voice>That means you've crossed the 2,500 milestone — worth calling out in your next investor update.</voice>\n<voice>Churn is down to 4.1%, improving month over month.</voice>\n<voice>The trailing 8-week compound growth rate is about 10%.</voice>\n\nREMEMBER: Start with <voice> immediately. No preamble, no markdown before it. Speak first.`;
}
if (searchEnabled) {
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
}
if (codeMode) {
const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex';
instructionsWithDateTime += `\n\n# Code Mode (Active) — Agent: ${agentDisplay}
The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). For EVERY coding task this turn, use **${agentDisplay}**, and narrate that agent ("Using ${agentDisplay} to …").
The chip is the single source of truth for which agent runs:
- Do NOT carry over a different agent from earlier in this thread even if a previous run used the other agent, use **${agentDisplay}** now.
- Do NOT switch agents based on an in-chat text request ("use codex", "switch to claude"). The agent only changes when the user toggles the chip; if they ask in chat, tell them to toggle the chip.
**How to run coding work call the \`code_agent_run\` tool** with:
- \`agent\`: \`${codeMode}\` (always — match the chip).
- \`cwd\`: ${codeCwd ? `\`${codeCwd}\` (always — this coding session is pinned to that directory; never use another path)` : `the absolute project/working directory (resolve it per the code-with-agents skill — a path the user named, the "# User Work Directory" block, or ask once)`}.
- \`prompt\`: a clear, self-contained coding instruction.
The tool runs the agent on-device and streams its tool calls, file diffs, and plan into the chat; any action needing approval surfaces as an inline permission card, so you do NOT pre-confirm with an in-chat "reply yes". This chat keeps ONE persistent agent session, so follow-up coding requests automatically resume with full context just call \`code_agent_run\` again. Do NOT shell out to \`acpx\` or \`executeCommand\` for coding, and do NOT fall back to your own file tools.
If the user's message is clearly NOT a coding request (small talk, an unrelated question), answer directly without invoking the coding agent. Code mode signals readiness, not that every message must route through the agent.`;
}
return instructionsWithDateTime;
}
export interface IAgentRuntime {
trigger(runId: string): Promise<void>;
}
@ -1423,70 +1504,17 @@ export async function* streamAgent({
loopLogger.log('running llm turn');
// stream agent response and build message
const messageBuilder = new StreamStepMessageBuilder();
let instructionsWithDateTime = `${agent.instructions}\n\n${USER_CONTEXT_SYSTEM_INSTRUCTIONS}`;
// Inject Agent Notes context for copilot
if (state.agentName === 'copilot' || state.agentName === 'rowboatx') {
const agentNotesContext = loadAgentNotesContext();
if (agentNotesContext) {
instructionsWithDateTime += `\n\n${agentNotesContext}`;
}
const userWorkDir = loadUserWorkDir(runId);
if (userWorkDir) {
loopLogger.log('injecting user work directory', userWorkDir);
instructionsWithDateTime += `\n\n# User Work Directory
The user has chosen the following directory as their current **work directory**:
\`${userWorkDir}\`
Treat this as the **default location** for file operations whenever the user refers to files generically:
- "list the files", "show me what's in here", "what's the latest report" list or look in the work directory.
- "save this", "export it", "write that to a file" write the output into the work directory unless the user names another location.
- "open the file I was just working on", "the doc from earlier" assume the work directory first.
Use absolute paths rooted at this directory with the \`file-*\` tools. For example, list with \`file-list({ path: "${userWorkDir}" })\`, read text with \`file-readText\`, and write text with \`file-writeText\`. For PDFs, Office docs, images, scanned docs, and other non-text files, use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first.
**Exceptions these ALWAYS take precedence over the work directory default:**
1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use file tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory.
2. **Explicit paths.** If the user names a different directory or gives an absolute/relative path (e.g. "in ~/Downloads", "from /tmp/foo", "the Desktop"), honor that path exactly and ignore the work-directory default for that request.
3. **Workspace-specific operations.** Anything that obviously belongs in the Rowboat workspace (config files, MCP servers, agent schedules, etc.) stays in the workspace, not the work directory.
Do not announce the work directory unless it's relevant. Just use it.`;
}
}
if (voiceInput) {
loopLogger.log('voice input enabled, injecting voice input prompt');
instructionsWithDateTime += `\n\n# Voice Input\nThe user's message was transcribed from speech. Be aware that:\n- There may be transcription errors. Silently correct obvious ones (e.g. homophones, misheard words). If an error is genuinely ambiguous, briefly mention your interpretation (e.g. "I'm assuming you meant X").\n- Spoken messages are often long-winded. The user may ramble, repeat themselves, or correct something they said earlier in the same message. Focus on their final intent, not every word verbatim.`;
}
if (voiceOutput === 'summary') {
loopLogger.log('voice output enabled (summary mode), injecting voice output prompt');
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY — READ THIS FIRST)\nThe user has voice output enabled. THIS IS YOUR #1 PRIORITY: you MUST start your response with <voice></voice> tags. If your response does not begin with <voice> tags, the user will hear nothing — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. Do not start with markdown, headings, or any other text. The literal first characters of your response must be "<voice>".\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n5. After all <voice> tags, you may include detailed written content (markdown, tables, code, etc.) that will be shown visually but not spoken.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Alex — March 11\n### Roadmap\n- Agreed to push Q2 launch to April 15...\n(detailed written content continues)\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You have five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you requested and Taylor flagged a contract issue.</voice>\n<voice>There's also a warm intro from a VC partner connecting you with someone at a prospective customer.</voice>\n<voice>I've drafted responses for three of them. The details and drafts are below.</voice>\n\n(email blocks, tables, and detailed content follow)\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a pretty packed day — seven meetings starting with standup at 9.</voice>\n<voice>The big ones are your investor call at 11, lunch with a partner from your lead VC at 12:30, and a customer call at 4.</voice>\n<voice>Your only free block for deep work is 2:30 to 4.</voice>\n\n(calendar block with full event details follows)\n\nExample 4 — User asks: "draft an email to Sam with our metrics"\n\n<voice>Done — I've drafted the email to Sam with your latest WAU and churn numbers.</voice>\n<voice>Take a look at the draft below and send it when you're ready.</voice>\n\n(email block with draft follows)\n\nREMEMBER: If you do not start with <voice> tags, the user hears silence. Always speak first, then write.`;
} else if (voiceOutput === 'full') {
loopLogger.log('voice output enabled (full mode), injecting voice output prompt');
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY — READ THIS FIRST)\nThe user wants your ENTIRE response spoken aloud. THIS IS YOUR #1 PRIORITY: every single sentence must be wrapped in <voice></voice> tags. If you write anything outside <voice> tags, the user will not hear it — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. The literal first characters of your response must be "<voice>".\n2. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n3. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n4. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n5. EVERY sentence MUST be inside a <voice> tag. Do not leave ANY content outside <voice> tags. If it's not in a <voice> tag, the user cannot hear it.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Alex will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You've got five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you asked for, and Taylor flagged a contract issue that needs your sign-off.</voice>\n<voice>There's a warm intro from a VC partner connecting you with an engineering lead at a potential customer.</voice>\n<voice>And someone from a prospective client wants to confirm your API tier before your call this afternoon.</voice>\n<voice>I've drafted replies for three of them — the metrics update, the intro, and the API question.</voice>\n<voice>The only one I left for you is Taylor's contract redline, since that needs your judgment on the liability cap.</voice>\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a packed day — seven meetings starting with standup at 9.</voice>\n<voice>The highlights are your investor call at 11, lunch with a VC partner at 12:30, and a customer call at 4.</voice>\n<voice>Your only open block for deep work is 2:30 to 4, so plan accordingly.</voice>\n<voice>Oh, and your 1-on-1 with your co-founder is at 5:30 — that's a walking meeting.</voice>\n\nExample 4 — User asks: "how are our metrics looking?"\n\n<voice>Metrics are looking strong this week.</voice>\n<voice>You hit 2,573 weekly active users, which is up 12% week over week.</voice>\n<voice>That means you've crossed the 2,500 milestone — worth calling out in your next investor update.</voice>\n<voice>Churn is down to 4.1%, improving month over month.</voice>\n<voice>The trailing 8-week compound growth rate is about 10%.</voice>\n\nREMEMBER: Start with <voice> immediately. No preamble, no markdown before it. Speak first.`;
}
if (searchEnabled) {
loopLogger.log('search enabled, injecting search prompt');
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
}
if (codeMode) {
loopLogger.log('code mode enabled, injecting coding-agent context', codeMode);
const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex';
instructionsWithDateTime += `\n\n# Code Mode (Active) — Agent: ${agentDisplay}
The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). For EVERY coding task this turn, use **${agentDisplay}**, and narrate that agent ("Using ${agentDisplay} to …").
The chip is the single source of truth for which agent runs:
- Do NOT carry over a different agent from earlier in this thread even if a previous run used the other agent, use **${agentDisplay}** now.
- Do NOT switch agents based on an in-chat text request ("use codex", "switch to claude"). The agent only changes when the user toggles the chip; if they ask in chat, tell them to toggle the chip.
**How to run coding work call the \`code_agent_run\` tool** with:
- \`agent\`: \`${codeMode}\` (always — match the chip).
- \`cwd\`: ${codeCwd ? `\`${codeCwd}\` (always — this coding session is pinned to that directory; never use another path)` : `the absolute project/working directory (resolve it per the code-with-agents skill — a path the user named, the "# User Work Directory" block, or ask once)`}.
- \`prompt\`: a clear, self-contained coding instruction.
The tool runs the agent on-device and streams its tool calls, file diffs, and plan into the chat; any action needing approval surfaces as an inline permission card, so you do NOT pre-confirm with an in-chat "reply yes". This chat keeps ONE persistent agent session, so follow-up coding requests automatically resume with full context just call \`code_agent_run\` again. Do NOT shell out to \`acpx\` or \`executeCommand\` for coding, and do NOT fall back to your own file tools.
If the user's message is clearly NOT a coding request (small talk, an unrelated question), answer directly without invoking the coding agent. Code mode signals readiness, not that every message must route through the agent.`;
}
const composeCopilotContext = state.agentName === 'copilot' || state.agentName === 'rowboatx';
const instructionsWithDateTime = composeSystemInstructions({
instructions: agent.instructions,
agentNotesContext: composeCopilotContext ? loadAgentNotesContext() : null,
userWorkDir: composeCopilotContext ? loadUserWorkDir(runId) : null,
voiceInput,
voiceOutput,
searchEnabled,
codeMode,
codeCwd,
});
let streamError: string | null = null;
for await (const event of streamLlm(
model,

View file

@ -1,4 +1,6 @@
import { asClass, asValue, createContainer, InjectionMode } from "awilix";
import path from "node:path";
import { asClass, asFunction, asValue, createContainer, InjectionMode } from "awilix";
import { WorkDir } from "../config/config.js";
import { FSModelConfigRepo, IModelConfigRepo } from "../models/repo.js";
import { FSMcpConfigRepo, IMcpConfigRepo } from "../mcp/repo.js";
import { FSAgentsRepo, IAgentsRepo } from "../agents/repo.js";
@ -24,6 +26,27 @@ import { CodeSessionService } from "../code-mode/sessions/service.js";
import { CodeSessionStatusTracker } from "../code-mode/sessions/status-tracker.js";
import type { IBrowserControlService } from "../application/browser-control/service.js";
import type { INotificationService } from "../application/notification/service.js";
import { SystemClock, type IClock } from "../turns/clock.js";
import { FSTurnRepo } from "../turns/fs-repo.js";
import type { ITurnRepo } from "../turns/repo.js";
import { TurnRepoContextResolver, type IContextResolver } from "../turns/context-resolver.js";
import { EmitterTurnLifecycleBus, type ITurnLifecycleBus } from "../turns/bus.js";
import { TurnRuntime } from "../turns/runtime.js";
import type { ITurnRuntime } from "../turns/api.js";
import type { IAgentResolver } from "../turns/agent-resolver.js";
import type { IModelRegistry } from "../turns/model-registry.js";
import type { IToolRegistry } from "../turns/tool-registry.js";
import type { IPermissionChecker, IPermissionClassifier } from "../turns/permission.js";
import { RealAgentResolver } from "../turns/bridges/real-agent-resolver.js";
import { RealModelRegistry } from "../turns/bridges/real-model-registry.js";
import { RealToolRegistry } from "../turns/bridges/real-tool-registry.js";
import { RealPermissionChecker } from "../turns/bridges/real-permission-checker.js";
import { RealPermissionClassifier } from "../turns/bridges/real-permission-classifier.js";
import { FSSessionRepo } from "../sessions/fs-repo.js";
import type { ISessionRepo } from "../sessions/repo.js";
import { EmitterSessionBus, type ISessionBus } from "../sessions/bus.js";
import { SessionsImpl } from "../sessions/sessions.js";
import type { ISessions } from "../sessions/api.js";
const container = createContainer({
injectionMode: InjectionMode.PROXY,
@ -36,7 +59,15 @@ container.register({
bus: asClass<IBus>(InMemoryBus).singleton(),
runsLock: asClass<IRunsLock>(InMemoryRunsLock).singleton(),
abortRegistry: asClass<IAbortRegistry>(InMemoryAbortRegistry).singleton(),
agentRuntime: asClass<IAgentRuntime>(AgentRuntime).singleton(),
// Lazy: agents/runtime.js participates in an import cycle with this
// module (and is now also reachable via the turn-runtime bridges), so the
// class binding may not be initialized yet when this body runs.
agentRuntime: asFunction<IAgentRuntime>(
(cradle) =>
new AgentRuntime(
cradle as unknown as ConstructorParameters<typeof AgentRuntime>[0],
),
).singleton(),
mcpConfigRepo: asClass<IMcpConfigRepo>(FSMcpConfigRepo).singleton(),
modelConfigRepo: asClass<IModelConfigRepo>(FSModelConfigRepo).singleton(),
@ -62,6 +93,25 @@ container.register({
codeSessionsRepo: asClass<ICodeSessionsRepo>(FSCodeSessionsRepo).singleton(),
codeSessionService: asClass(CodeSessionService).singleton(),
codeSessionStatusTracker: asClass(CodeSessionStatusTracker).singleton(),
// New turn/session runtime (turn-runtime-design.md / session-design.md).
// Bridges are constructed via asFunction so their optional test seams
// don't collide with strict PROXY cradle resolution.
clock: asClass<IClock>(SystemClock).singleton(),
turnsRootDir: asValue(path.join(WorkDir, "storage", "turns")),
sessionsRootDir: asValue(path.join(WorkDir, "storage", "sessions")),
turnRepo: asClass<ITurnRepo>(FSTurnRepo).singleton(),
contextResolver: asClass<IContextResolver>(TurnRepoContextResolver).singleton(),
lifecycleBus: asClass<ITurnLifecycleBus>(EmitterTurnLifecycleBus).singleton(),
agentResolver: asFunction<IAgentResolver>(() => new RealAgentResolver()).singleton(),
modelRegistry: asFunction<IModelRegistry>(() => new RealModelRegistry()).singleton(),
toolRegistry: asFunction<IToolRegistry>(() => new RealToolRegistry()).singleton(),
permissionChecker: asFunction<IPermissionChecker>(() => new RealPermissionChecker()).singleton(),
permissionClassifier: asFunction<IPermissionClassifier>(() => new RealPermissionClassifier()).singleton(),
turnRuntime: asClass<ITurnRuntime>(TurnRuntime).singleton(),
sessionRepo: asClass<ISessionRepo>(FSSessionRepo).singleton(),
sessionBus: asClass<ISessionBus>(EmitterSessionBus).singleton(),
sessions: asClass<ISessions>(SessionsImpl).singleton(),
});
export default container;

View file

@ -5,3 +5,24 @@ import type { SessionBusEvent } from "@x/shared/dist/sessions.js";
export interface ISessionBus {
publish(event: SessionBusEvent): void;
}
// Default in-process fan-out; the app layer subscribes and forwards over IPC.
// Listener errors are swallowed: observers must never affect sessions.
export class EmitterSessionBus implements ISessionBus {
private listeners = new Set<(event: SessionBusEvent) => void>();
publish(event: SessionBusEvent): void {
for (const listener of this.listeners) {
try {
listener(event);
} catch {
// observational only
}
}
}
subscribe(listener: (event: SessionBusEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}

View file

@ -306,7 +306,7 @@ function makeSessions(opts: { repo?: ISessionRepo; fake?: FakeTurnRuntime } = {}
turnRuntime: fake,
idGenerator: new FakeIdGen(),
clock: new FakeClock(),
bus,
sessionBus: bus,
});
return { sessions, repo, fake, bus };
}

View file

@ -39,7 +39,7 @@ export interface SessionsDependencies {
turnRuntime: ITurnRuntime;
idGenerator: IMonotonicallyIncreasingIdGenerator;
clock: IClock;
bus: ISessionBus;
sessionBus: ISessionBus;
}
// The session layer per session-design.md: owns conversations as ordered
@ -52,7 +52,7 @@ export class SessionsImpl implements ISessions {
private readonly turnRuntime: ITurnRuntime;
private readonly idGenerator: IMonotonicallyIncreasingIdGenerator;
private readonly clock: IClock;
private readonly bus: ISessionBus;
private readonly sessionBus: ISessionBus;
private readonly index = new SessionIndex();
// Ephemeral: executions this process started, for stopTurn's abort path.
@ -66,13 +66,13 @@ export class SessionsImpl implements ISessions {
turnRuntime,
idGenerator,
clock,
bus,
sessionBus,
}: SessionsDependencies) {
this.sessionRepo = sessionRepo;
this.turnRuntime = turnRuntime;
this.idGenerator = idGenerator;
this.clock = clock;
this.bus = bus;
this.sessionBus = sessionBus;
}
// §8.2: scan session files, read each session's latest turn for status.
@ -293,7 +293,7 @@ export class SessionsImpl implements ISessions {
await this.sessionRepo.withLock(sessionId, async () => {
await this.sessionRepo.delete(sessionId);
this.index.remove(sessionId);
this.bus.publish({ kind: "index-changed", sessionId, entry: null });
this.sessionBus.publish({ kind: "index-changed", sessionId, entry: null });
});
}
@ -348,7 +348,7 @@ export class SessionsImpl implements ISessions {
try {
for await (const event of execution.events) {
if (sessionId !== null) {
this.bus.publish({
this.sessionBus.publish({
kind: "turn-event",
sessionId,
turnId,
@ -391,7 +391,7 @@ export class SessionsImpl implements ISessions {
private publishEntry(entry: SessionIndexEntry): void {
this.index.upsert(entry);
this.bus.publish({
this.sessionBus.publish({
kind: "index-changed",
sessionId: entry.sessionId,
entry,

View file

@ -0,0 +1,5 @@
export * from "./real-agent-resolver.js";
export * from "./real-model-registry.js";
export * from "./real-permission-checker.js";
export * from "./real-permission-classifier.js";
export * from "./real-tool-registry.js";

View file

@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import type { Agent } from "@x/shared/dist/agent.js";
import { composeSystemInstructions } from "../../agents/runtime.js";
import type { BuiltinTools } from "../../application/lib/builtin-tools.js";
import { RealAgentResolver } from "./real-agent-resolver.js";
const DEFAULTS = async () => ({ model: "gpt-default", provider: "openai" });
function makeAgent(
overrides: Partial<z.infer<typeof Agent>> = {},
): z.infer<typeof Agent> {
return {
name: "copilot",
instructions: "You are Copilot.",
tools: {},
...overrides,
};
}
// A minimal fake builtin catalog (the real one is heavy and irrelevant here).
const fakeBuiltins = {
"file-list": {
description: "List files",
inputSchema: z.object({ path: z.string() }),
execute: async () => null,
},
"web-search": {
description: "Search the web",
inputSchema: z.object({ query: z.string() }),
execute: async () => null,
isAvailable: async () => false,
},
} as unknown as typeof BuiltinTools;
function makeResolver(agent: z.infer<typeof Agent>, deps: Partial<ConstructorParameters<typeof RealAgentResolver>[0]> = {}) {
return new RealAgentResolver({
load: async () => agent,
builtins: fakeBuiltins,
defaultModel: DEFAULTS,
loadNotes: () => null,
loadWorkDir: () => null,
...deps,
});
}
describe("RealAgentResolver", () => {
it("applies model precedence: override > agent config > app default", async () => {
const withNothing = makeResolver(makeAgent());
expect((await withNothing.resolve({ agentId: "copilot" })).model).toEqual({
provider: "openai",
model: "gpt-default",
});
const withAgentModel = makeResolver(
makeAgent({ provider: "anthropic", model: "claude-x" }),
);
expect(
(await withAgentModel.resolve({ agentId: "copilot" })).model,
).toEqual({ provider: "anthropic", model: "claude-x" });
expect(
(
await withAgentModel.resolve({
agentId: "copilot",
overrides: { model: { provider: "google", model: "gemini-x" } },
})
).model,
).toEqual({ provider: "google", model: "gemini-x" });
});
it("maps builtins to descriptors with JSON schemas, filtering unavailable ones", async () => {
const resolver = makeResolver(
makeAgent({
tools: {
"file-list": { type: "builtin", name: "file-list" },
"web-search": { type: "builtin", name: "web-search" }, // unavailable
ghost: { type: "builtin", name: "ghost" }, // not in catalog
},
}),
);
const resolved = await resolver.resolve({ agentId: "copilot" });
expect(resolved.tools).toHaveLength(1);
expect(resolved.tools[0]).toMatchObject({
toolId: "builtin:file-list",
name: "file-list",
description: "List files",
execution: "sync",
requiresHuman: false,
});
expect(resolved.tools[0].inputSchema).toMatchObject({
type: "object",
properties: { path: { type: "string" } },
});
});
it("passes MCP schemas through and skips agent-as-tool attachments", async () => {
const resolver = makeResolver(
makeAgent({
tools: {
lookup: {
type: "mcp",
name: "lookup",
description: "Look things up",
inputSchema: { type: "object", properties: { q: { type: "string" } } },
mcpServerName: "kb",
},
subflow: { type: "agent", name: "researcher" },
},
}),
);
const resolved = await resolver.resolve({ agentId: "copilot" });
expect(resolved.tools).toHaveLength(1);
expect(resolved.tools[0]).toMatchObject({
toolId: "mcp:kb:lookup",
name: "lookup",
description: "Look things up",
execution: "sync",
});
});
it("maps ask-human to an async human-dependent descriptor", async () => {
const resolver = makeResolver(
makeAgent({
tools: { "ask-human": { type: "builtin", name: "ask-human" } },
}),
);
const resolved = await resolver.resolve({ agentId: "copilot" });
expect(resolved.tools[0]).toMatchObject({
toolId: "builtin:ask-human",
execution: "async",
requiresHuman: true,
});
});
it("composes the system prompt byte-identically to the shared composer", async () => {
const resolver = makeResolver(makeAgent(), {
loadNotes: () => "# Agent Notes\nremember X",
loadWorkDir: (id) => (id === "sess-1" ? "/Users/me/work" : null),
});
const resolved = await resolver.resolve({
agentId: "copilot",
overrides: {
composition: {
workDirId: "sess-1",
searchEnabled: true,
codeMode: "claude",
},
},
});
expect(resolved.systemPrompt).toBe(
composeSystemInstructions({
instructions: "You are Copilot.",
agentNotesContext: "# Agent Notes\nremember X",
userWorkDir: "/Users/me/work",
voiceInput: false,
voiceOutput: null,
searchEnabled: true,
codeMode: "claude",
codeCwd: null,
}),
);
});
it("is prompt-stable: identical composition yields identical bytes; unknown keys are ignored", async () => {
const resolver = makeResolver(makeAgent());
const a = await resolver.resolve({ agentId: "copilot" });
const b = await resolver.resolve({
agentId: "copilot",
overrides: { composition: { someUnknownKey: 42 } },
});
expect(b.systemPrompt).toBe(a.systemPrompt);
});
it("does not load notes/work-dir for non-copilot agents", async () => {
let notesLoaded = 0;
const resolver = makeResolver(makeAgent({ name: "background-task-agent" }), {
loadNotes: () => {
notesLoaded += 1;
return "notes";
},
});
await resolver.resolve({ agentId: "background-task-agent" });
expect(notesLoaded).toBe(0);
});
it("rejects when the agent cannot be loaded, creating nothing", async () => {
const resolver = new RealAgentResolver({
load: async () => {
throw new Error("no such agent");
},
builtins: fakeBuiltins,
defaultModel: DEFAULTS,
});
await expect(resolver.resolve({ agentId: "ghost" })).rejects.toThrowError(
"no such agent",
);
});
});

View file

@ -0,0 +1,198 @@
import { z } from "zod";
import type { Agent } from "@x/shared/dist/agent.js";
import {
type JsonValue,
RequestedAgent,
ResolvedAgent,
type ToolDescriptor,
} from "@x/shared/dist/turns.js";
import {
composeSystemInstructions,
loadAgent,
loadAgentNotesContext,
loadUserWorkDir,
} from "../../agents/runtime.js";
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
import { getDefaultModelAndProvider } from "../../models/defaults.js";
import type { IAgentResolver } from "../agent-resolver.js";
export const ASK_HUMAN_TOOL = "ask-human";
const ASK_HUMAN_DESCRIPTOR: z.infer<typeof ToolDescriptor> = {
toolId: `builtin:${ASK_HUMAN_TOOL}`,
name: ASK_HUMAN_TOOL,
description:
"Ask a human before proceeding. Optionally pass `options` (an array of short button labels) when a small set of choices would help the human answer quickly.",
inputSchema: {
type: "object",
properties: {
question: {
type: "string",
description: "The question to ask the human",
},
options: {
type: "array",
items: { type: "string" },
description: "Optional short button labels the human can pick from",
},
},
required: ["question"],
additionalProperties: false,
},
execution: "async",
requiresHuman: true,
};
// Recognized keys of the opaque RequestedAgent.overrides.composition value.
// Unknown keys are ignored. Prompt-affecting inputs should be session-sticky:
// every key here alters system-prompt bytes and therefore busts provider
// prefix caching when it changes between turns.
const CompositionOverrides = z.object({
workDirId: z.string().nullable().optional(),
voiceInput: z.boolean().optional(),
voiceOutput: z.enum(["summary", "full"]).nullable().optional(),
searchEnabled: z.boolean().optional(),
codeMode: z.enum(["claude", "codex"]).nullable().optional(),
codeCwd: z.string().nullable().optional(),
});
export interface RealAgentResolverDeps {
load?: typeof loadAgent;
builtins?: typeof BuiltinTools;
defaultModel?: () => Promise<{ model: string; provider: string }>;
loadNotes?: () => string | null;
loadWorkDir?: (workDirId: string) => string | null;
}
// Bridges the existing agent system (loadAgent + dynamic builders, the
// BuiltinTools catalog, MCP attachments) to the immutable ResolvedAgent
// snapshot. The composed system prompt is byte-identical to the old
// runtime's streamAgent assembly for the same inputs.
export class RealAgentResolver implements IAgentResolver {
private readonly load: typeof loadAgent;
private readonly builtins: typeof BuiltinTools;
private readonly defaultModel: () => Promise<{ model: string; provider: string }>;
private readonly loadNotes: () => string | null;
private readonly loadWorkDir: (workDirId: string) => string | null;
constructor(deps: RealAgentResolverDeps = {}) {
this.load = deps.load ?? loadAgent;
this.builtins = deps.builtins ?? BuiltinTools;
this.defaultModel = deps.defaultModel ?? getDefaultModelAndProvider;
this.loadNotes = deps.loadNotes ?? loadAgentNotesContext;
this.loadWorkDir = deps.loadWorkDir ?? loadUserWorkDir;
}
async resolve(
requested: z.infer<typeof RequestedAgent>,
): Promise<z.infer<typeof ResolvedAgent>> {
const agent = await this.load(requested.agentId);
if (!agent) {
throw new Error(`agent not found: ${requested.agentId}`);
}
// Model precedence: createTurn override > agent config > app default.
let model = requested.overrides?.model;
if (!model) {
const fallback = await this.defaultModel();
model = {
provider: agent.provider ?? fallback.provider,
model: agent.model ?? fallback.model,
};
}
const parsed = CompositionOverrides.safeParse(
requested.overrides?.composition ?? {},
);
const composition = parsed.success ? parsed.data : {};
// Agent notes and work-dir context are copilot-scoped, matching the
// old runtime's behavior.
const copilotContext =
requested.agentId === "copilot" || requested.agentId === "rowboatx";
const systemPrompt = composeSystemInstructions({
instructions: agent.instructions,
agentNotesContext: copilotContext ? this.loadNotes() : null,
userWorkDir:
copilotContext && composition.workDirId
? this.loadWorkDir(composition.workDirId)
: null,
voiceInput: composition.voiceInput ?? false,
voiceOutput: composition.voiceOutput ?? null,
searchEnabled: composition.searchEnabled ?? false,
codeMode: composition.codeMode ?? null,
codeCwd: composition.codeCwd ?? null,
});
const tools = await this.resolveTools(agent);
return ResolvedAgent.parse({
agentId: requested.agentId,
systemPrompt,
model,
tools,
});
}
private async resolveTools(
agent: z.infer<typeof Agent>,
): Promise<Array<z.infer<typeof ToolDescriptor>>> {
const tools: Array<z.infer<typeof ToolDescriptor>> = [];
for (const [name, attachment] of Object.entries(agent.tools ?? {})) {
if (attachment.type === "agent") {
continue; // agent-as-tool is not supported in v1
}
if (attachment.type === "mcp") {
tools.push({
toolId: `mcp:${attachment.mcpServerName}:${attachment.name}`,
name,
description: attachment.description,
inputSchema:
toJsonValue(attachment.inputSchema) ??
{ type: "object", properties: {} },
execution: "sync",
requiresHuman: false,
});
continue;
}
if (name === ASK_HUMAN_TOOL) {
tools.push(ASK_HUMAN_DESCRIPTOR);
continue;
}
const builtin = this.builtins[attachment.name];
if (!builtin) {
continue;
}
if (builtin.isAvailable && !(await builtin.isAvailable())) {
continue;
}
tools.push({
toolId: `builtin:${attachment.name}`,
name,
description: builtin.description,
inputSchema: toJsonSchema(builtin.inputSchema),
execution: "sync",
requiresHuman: false,
});
}
return tools;
}
}
function toJsonSchema(schema: unknown): JsonValue {
try {
return toJsonValue(z.toJSONSchema(schema as z.ZodType)) ?? {
type: "object",
properties: {},
};
} catch {
// An exotic zod schema must not break the whole turn.
return { type: "object", properties: {} };
}
}
function toJsonValue(value: unknown): JsonValue | undefined {
try {
return JSON.parse(JSON.stringify(value)) as JsonValue;
} catch {
return undefined;
}
}

View file

@ -0,0 +1,178 @@
import { describe, expect, it } from "vitest";
import type { LanguageModel } from "ai";
import type { LlmStreamEvent, ModelStreamRequest } from "../model-registry.js";
import { RealModelRegistry, type StreamTextInvoker } from "./real-model-registry.js";
type InvokerOptions = Parameters<StreamTextInvoker>[0];
function makeRegistry(parts: Array<Record<string, unknown>>, capture: InvokerOptions[]) {
const fakeModel = { modelId: "gpt-test" } as unknown as LanguageModel;
return new RealModelRegistry({
resolveProvider: async () => ({ flavor: "openai" }),
createProviderImpl: (() => ({
languageModel: () => fakeModel,
})) as never,
invoke: (options) => {
capture.push(options);
return {
fullStream: (async function* () {
yield* parts;
})(),
};
},
});
}
function request(overrides: Partial<ModelStreamRequest> = {}): ModelStreamRequest {
return {
systemPrompt: "SYS",
messages: [{ role: "user", content: "hello" }],
tools: [
{
toolId: "builtin:echo",
name: "echo",
description: "Echo",
inputSchema: { type: "object", properties: {} },
execution: "sync",
requiresHuman: false,
},
],
parameters: {},
signal: new AbortController().signal,
...overrides,
};
}
async function collect(registry: RealModelRegistry, req: ModelStreamRequest) {
const model = await registry.resolve({ provider: "openai", model: "gpt-test" });
const events: LlmStreamEvent[] = [];
for await (const event of model.stream(req)) {
events.push(event);
}
return events;
}
describe("RealModelRegistry", () => {
it("normalizes one streamText step into deltas, step events, and a completed message", async () => {
const capture: InvokerOptions[] = [];
const registry = makeRegistry(
[
{ type: "start" },
{ type: "text-start" },
{ type: "text-delta", text: "Hel" },
{ type: "text-delta", text: "lo" },
{ type: "text-end" },
{ type: "tool-call", toolCallId: "tc1", toolName: "echo", input: { x: 1 } },
{
type: "finish-step",
finishReason: "tool-calls",
usage: { inputTokens: 7, outputTokens: 3, totalTokens: 10 },
providerMetadata: { openai: { cached: true } },
},
],
capture,
);
const events = await collect(registry, request());
expect(events.map((e) => e.type)).toEqual([
"step_event", // text_start
"text_delta",
"text_delta",
"step_event", // text_end
"step_event", // tool_call
"step_event", // finish_step
"completed",
]);
expect(events[3]).toEqual({
type: "step_event",
event: { type: "text_end", text: "Hello" },
});
const completed = events[events.length - 1];
expect(completed).toMatchObject({
type: "completed",
finishReason: "tool-calls",
usage: { inputTokens: 7, outputTokens: 3, totalTokens: 10 },
providerMetadata: { openai: { cached: true } },
message: {
role: "assistant",
content: [
{ type: "text", text: "Hello" },
{
type: "tool-call",
toolCallId: "tc1",
toolName: "echo",
arguments: { x: 1 },
},
],
},
});
// The invoker received the system prompt, converted messages, and tools.
expect(capture[0].system).toBe("SYS");
expect(capture[0].messages[0]).toMatchObject({ role: "user" });
expect(Object.keys(capture[0].tools)).toEqual(["echo"]);
});
it("accumulates reasoning separately and emits reasoning deltas", async () => {
const registry = makeRegistry(
[
{ type: "reasoning-start" },
{ type: "reasoning-delta", text: "thinking…" },
{ type: "reasoning-end" },
{ type: "text-start" },
{ type: "text-delta", text: "done" },
{ type: "text-end" },
{ type: "finish-step", finishReason: "stop", usage: {} },
],
[],
);
const events = await collect(registry, request());
expect(events.filter((e) => e.type === "reasoning_delta")).toHaveLength(1);
const completed = events[events.length - 1];
expect(
completed.type === "completed" ? completed.message.content : undefined,
).toEqual([
{ type: "reasoning", text: "thinking…" },
{ type: "text", text: "done" },
]);
});
it("throws on provider error parts (a model failure, not a completion)", async () => {
const registry = makeRegistry(
[{ type: "error", error: new Error("rate limited") }],
[],
);
const model = await registry.resolve({ provider: "openai", model: "gpt-test" });
await expect(
(async () => {
for await (const event of model.stream(request())) {
void event;
}
})(),
).rejects.toThrowError("rate limited");
});
it("stops promptly when the signal aborts mid-stream", async () => {
const controller = new AbortController();
const registry = makeRegistry(
[
{ type: "text-delta", text: "a" },
{ type: "text-delta", text: "b" },
],
[],
);
const model = await registry.resolve({ provider: "openai", model: "gpt-test" });
const seen: string[] = [];
await expect(
(async () => {
for await (const event of model.stream(
request({ signal: controller.signal }),
)) {
seen.push(event.type);
controller.abort();
}
})(),
).rejects.toThrowError();
expect(seen).toEqual(["text_delta"]);
});
});

View file

@ -0,0 +1,255 @@
import {
jsonSchema,
stepCountIs,
streamText,
tool,
type LanguageModel,
type ModelMessage,
type ToolSet,
} from "ai";
import type { z } from "zod";
import type { LlmProvider } from "@x/shared/dist/models.js";
import type { AssistantContentPart } from "@x/shared/dist/message.js";
import type { JsonValue, ModelDescriptor, TurnUsage } from "@x/shared/dist/turns.js";
import { convertFromMessages } from "../../agents/runtime.js";
import { resolveProviderConfig } from "../../models/defaults.js";
import { createProvider } from "../../models/models.js";
import type {
IModelRegistry,
LlmStreamEvent,
ModelStreamRequest,
ResolvedModel,
} from "../model-registry.js";
// Injectable seam over streamText so normalization is testable without a
// provider. The bridge always requests exactly one step.
export type StreamTextInvoker = (options: {
model: LanguageModel;
system: string;
messages: ModelMessage[];
tools: ToolSet;
abortSignal: AbortSignal;
}) => { fullStream: AsyncIterable<unknown> };
const defaultInvoker: StreamTextInvoker = (options) =>
streamText({ ...options, stopWhen: stepCountIs(1) });
export interface RealModelRegistryDeps {
resolveProvider?: (name: string) => Promise<z.infer<typeof LlmProvider>>;
createProviderImpl?: typeof createProvider;
invoke?: StreamTextInvoker;
}
// Bridges models.json provider configs to live AI SDK models and normalizes
// one streamText step into LlmStreamEvents. Tools are declared without
// execute: the turn loop harvests tool calls and runs them itself.
export class RealModelRegistry implements IModelRegistry {
private readonly resolveProvider: (
name: string,
) => Promise<z.infer<typeof LlmProvider>>;
private readonly createProviderImpl: typeof createProvider;
private readonly invoke: StreamTextInvoker;
constructor(deps: RealModelRegistryDeps = {}) {
this.resolveProvider = deps.resolveProvider ?? resolveProviderConfig;
this.createProviderImpl = deps.createProviderImpl ?? createProvider;
this.invoke = deps.invoke ?? defaultInvoker;
}
async resolve(
descriptor: z.infer<typeof ModelDescriptor>,
): Promise<ResolvedModel> {
const providerConfig = await this.resolveProvider(descriptor.provider);
const provider = this.createProviderImpl(providerConfig);
const model = provider.languageModel(descriptor.model);
return {
descriptor,
stream: (request) => this.run(model, request),
};
}
private async *run(
model: LanguageModel,
request: ModelStreamRequest,
): AsyncGenerator<LlmStreamEvent, void, void> {
const tools: ToolSet = {};
for (const descriptor of request.tools) {
tools[descriptor.name] = tool({
...(descriptor.description
? { description: descriptor.description }
: {}),
inputSchema: jsonSchema(
(descriptor.inputSchema ?? {
type: "object",
properties: {},
}) as Parameters<typeof jsonSchema>[0],
),
});
}
const result = this.invoke({
model,
system: request.systemPrompt,
messages: convertFromMessages(request.messages),
tools,
abortSignal: request.signal,
});
const parts: Array<z.infer<typeof AssistantContentPart>> = [];
let textBuffer = "";
let reasoningBuffer = "";
let finishReason = "unknown";
let usage: z.infer<typeof TurnUsage> = {};
let providerMetadata: JsonValue | undefined;
for await (const raw of result.fullStream) {
request.signal.throwIfAborted();
const event = raw as {
type: string;
text?: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
finishReason?: string;
usage?: Record<string, number | undefined>;
providerMetadata?: unknown;
error?: unknown;
};
switch (event.type) {
case "text-start":
textBuffer = "";
yield { type: "step_event", event: { type: "text_start" } };
break;
case "text-delta": {
const delta = event.text ?? "";
textBuffer += delta;
const last = parts[parts.length - 1];
if (last?.type === "text") {
last.text += delta;
} else {
parts.push({ type: "text", text: delta });
}
yield { type: "text_delta", delta };
break;
}
case "text-end":
yield {
type: "step_event",
event: { type: "text_end", text: textBuffer },
};
break;
case "reasoning-start":
reasoningBuffer = "";
yield { type: "step_event", event: { type: "reasoning_start" } };
break;
case "reasoning-delta": {
const delta = event.text ?? "";
reasoningBuffer += delta;
const last = parts[parts.length - 1];
if (last?.type === "reasoning") {
last.text += delta;
} else {
parts.push({ type: "reasoning", text: delta });
}
yield { type: "reasoning_delta", delta };
break;
}
case "reasoning-end":
yield {
type: "step_event",
event: { type: "reasoning_end", text: reasoningBuffer },
};
break;
case "tool-call": {
const toolCall = {
type: "tool-call" as const,
toolCallId: String(event.toolCallId),
toolName: String(event.toolName),
arguments: event.input,
};
parts.push(toolCall);
yield { type: "step_event", event: { type: "tool_call", toolCall } };
break;
}
case "finish-step": {
finishReason = event.finishReason ?? "unknown";
usage = mapUsage(event.usage);
providerMetadata = toJsonValue(event.providerMetadata);
yield {
type: "step_event",
event: {
type: "finish_step",
finishReason,
usage,
...(providerMetadata === undefined
? {}
: { providerMetadata }),
},
};
break;
}
case "error":
throw event.error instanceof Error
? event.error
: new Error(formatStreamError(event.error));
default:
break;
}
}
yield {
type: "completed",
message: {
role: "assistant",
content: parts.length > 0 ? parts : "",
},
finishReason,
usage,
...(providerMetadata === undefined ? {} : { providerMetadata }),
};
}
}
function mapUsage(
usage: Record<string, number | undefined> | undefined,
): z.infer<typeof TurnUsage> {
const mapped: z.infer<typeof TurnUsage> = {};
if (!usage) {
return mapped;
}
for (const key of [
"inputTokens",
"outputTokens",
"totalTokens",
"reasoningTokens",
"cachedInputTokens",
] as const) {
const value = usage[key];
if (typeof value === "number" && Number.isFinite(value)) {
mapped[key] = value;
}
}
return mapped;
}
function toJsonValue(value: unknown): JsonValue | undefined {
if (value === undefined || value === null) {
return undefined;
}
try {
return JSON.parse(JSON.stringify(value)) as JsonValue;
} catch {
return undefined;
}
}
function formatStreamError(error: unknown): string {
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { getToolPermissionMetadata } from "../../agents/runtime.js";
import { RealPermissionChecker } from "./real-permission-checker.js";
type MetadataFn = typeof getToolPermissionMetadata;
type MetadataCall = {
toolCall: Parameters<MetadataFn>[0];
attachment: Parameters<MetadataFn>[1];
};
function makeChecker(result: Awaited<ReturnType<MetadataFn>> | Error) {
const calls: MetadataCall[] = [];
const checker = new RealPermissionChecker({
getMetadata: (async (toolCall, attachment) => {
calls.push({ toolCall, attachment });
if (result instanceof Error) {
throw result;
}
return result;
}) as MetadataFn,
});
return { checker, calls };
}
const input = {
turnId: "turn-1",
toolCallId: "tc-1",
toolId: "builtin:executeCommand",
toolName: "executeCommand",
input: { command: "rm -rf /" },
};
describe("RealPermissionChecker", () => {
it("gates builtins through getToolPermissionMetadata with empty session grants", async () => {
const metadata = { kind: "command" as const, commandNames: ["rm"] };
const { checker, calls } = makeChecker(metadata);
const result = await checker.check(input);
expect(result).toEqual({ required: true, request: metadata });
expect(calls[0].toolCall).toMatchObject({
type: "tool-call",
toolCallId: "tc-1",
toolName: "executeCommand",
arguments: { command: "rm -rf /" },
});
expect(calls[0].attachment).toEqual({
type: "builtin",
name: "executeCommand",
});
});
it("returns not-required when metadata is null", async () => {
const { checker } = makeChecker(null);
expect(await checker.check(input)).toEqual({ required: false });
});
it("never gates non-builtin tools", async () => {
const { checker, calls } = makeChecker(new Error("must not be called"));
expect(
await checker.check({
...input,
toolId: "mcp:kb:search",
toolName: "search",
}),
).toEqual({ required: false });
expect(calls).toHaveLength(0);
});
it("propagates metadata errors so the loop fails closed", async () => {
const { checker } = makeChecker(new Error("policy exploded"));
await expect(checker.check(input)).rejects.toThrowError("policy exploded");
});
});

View file

@ -0,0 +1,49 @@
import type { JsonValue } from "@x/shared/dist/turns.js";
import { getToolPermissionMetadata } from "../../agents/runtime.js";
import type {
IPermissionChecker,
PermissionCheckAllowed,
PermissionCheckInput,
PermissionCheckRequired,
} from "../permission.js";
export interface RealPermissionCheckerDeps {
getMetadata?: typeof getToolPermissionMetadata;
}
// Bridges the existing deterministic permission rules: only builtins are
// gated (executeCommand via the command allowlist, file tools via workspace
// boundaries and file-access grants). Session-scoped grants are deferred, so
// the session grant inputs are always empty. A thrown metadata error
// propagates: the turn loop fails closed on checker errors.
export class RealPermissionChecker implements IPermissionChecker {
private readonly getMetadata: typeof getToolPermissionMetadata;
constructor(deps: RealPermissionCheckerDeps = {}) {
this.getMetadata = deps.getMetadata ?? getToolPermissionMetadata;
}
async check(
input: PermissionCheckInput,
): Promise<PermissionCheckAllowed | PermissionCheckRequired> {
if (!input.toolId.startsWith("builtin:")) {
return { required: false };
}
const name = input.toolId.slice("builtin:".length);
const metadata = await this.getMetadata(
{
type: "tool-call",
toolCallId: input.toolCallId,
toolName: input.toolName,
arguments: input.input,
},
{ type: "builtin", name },
new Set<string>(), // session-scoped command grants: deferred
[], // session-scoped file grants: deferred
);
if (!metadata) {
return { required: false };
}
return { required: true, request: metadata as JsonValue };
}
}

View file

@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import type { classifyToolPermissions } from "../../security/auto-permission-classifier.js";
import { RealPermissionClassifier } from "./real-permission-classifier.js";
type ClassifierFn = typeof classifyToolPermissions;
type ClassifierInput = Parameters<ClassifierFn>[0];
function makeClassifier(
decisions: Awaited<ReturnType<ClassifierFn>> | Error,
) {
const calls: ClassifierInput[] = [];
const classifier = new RealPermissionClassifier({
classifier: (async (input) => {
calls.push(input);
if (decisions instanceof Error) {
throw decisions;
}
return decisions;
}) as ClassifierFn,
});
return { classifier, calls };
}
const batch = {
turnId: "turn-1",
messages: [
{ role: "user" as const, content: "list my downloads" },
{ role: "assistant" as const, content: "sure" },
],
requests: [
{
toolCallId: "tc-1",
toolName: "file-list",
input: { path: "/Users/me/Downloads" },
request: {
kind: "file",
operation: "list",
paths: ["/Users/me/Downloads"],
pathPrefix: "/Users/me/Downloads",
},
},
],
};
describe("RealPermissionClassifier", () => {
it("adapts the batch into classifyToolPermissions candidates with conversation context", async () => {
const { classifier, calls } = makeClassifier([
{ toolCallId: "tc-1", decision: "allow", reason: "user asked for it" },
]);
const decisions = await classifier.classify(batch);
expect(decisions).toEqual([
{ toolCallId: "tc-1", decision: "allow", reason: "user asked for it" },
]);
expect(calls[0].runId).toBe("turn-1");
expect(calls[0].useCase).toBe("copilot_chat");
expect(calls[0].messages).toHaveLength(2);
expect(calls[0].candidates[0]).toMatchObject({
toolCall: {
type: "tool-call",
toolCallId: "tc-1",
toolName: "file-list",
},
permission: { kind: "file", operation: "list" },
});
});
it("returns an empty result for an empty batch without calling the LLM", async () => {
const { classifier, calls } = makeClassifier(new Error("must not be called"));
expect(
await classifier.classify({ ...batch, requests: [] }),
).toEqual([]);
expect(calls).toHaveLength(0);
});
it("omitted decisions surface as missing entries (loop treats them as defer)", async () => {
const { classifier } = makeClassifier([]);
const decisions = await classifier.classify(batch);
expect(decisions).toEqual([]);
});
it("propagates classifier failures (loop normalizes to defer)", async () => {
const { classifier } = makeClassifier(new Error("llm unavailable"));
await expect(classifier.classify(batch)).rejects.toThrowError(
"llm unavailable",
);
});
it("rejects malformed permission metadata via schema parsing", async () => {
const { classifier } = makeClassifier([]);
await expect(
classifier.classify({
...batch,
requests: [{ ...batch.requests[0], request: { kind: "nonsense" } }],
}),
).rejects.toThrowError();
});
});

View file

@ -0,0 +1,57 @@
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import { convertFromMessages } from "../../agents/runtime.js";
import type { UseCase } from "../../analytics/use_case.js";
import { classifyToolPermissions } from "../../security/auto-permission-classifier.js";
import type {
IPermissionClassifier,
PermissionClassification,
PermissionClassificationBatch,
} from "../permission.js";
export interface RealPermissionClassifierDeps {
classifier?: typeof classifyToolPermissions;
useCase?: UseCase;
}
// Bridges the existing LLM auto-permission classifier. The underlying
// classifier only ever answers allow/deny; omitted decisions surface as
// missing entries, which the turn loop records as classification failures
// and treats as defer. A parse/LLM failure throws, which the loop likewise
// normalizes to defer for the whole batch.
export class RealPermissionClassifier implements IPermissionClassifier {
private readonly classifier: typeof classifyToolPermissions;
private readonly useCase: UseCase;
constructor(deps: RealPermissionClassifierDeps = {}) {
this.classifier = deps.classifier ?? classifyToolPermissions;
this.useCase = deps.useCase ?? "copilot_chat";
}
async classify(
batch: PermissionClassificationBatch,
): Promise<PermissionClassification[]> {
if (batch.requests.length === 0) {
return [];
}
const decisions = await this.classifier({
runId: batch.turnId,
agentName: null,
messages: convertFromMessages(batch.messages),
candidates: batch.requests.map((request) => ({
toolCall: {
type: "tool-call",
toolCallId: request.toolCallId,
toolName: request.toolName,
arguments: request.input,
},
permission: ToolPermissionMetadata.parse(request.request),
})),
useCase: this.useCase,
});
return decisions.map((decision) => ({
toolCallId: decision.toolCallId,
decision: decision.decision,
reason: decision.reason,
}));
}
}

View file

@ -0,0 +1,198 @@
import { describe, expect, it } from "vitest";
import type { z } from "zod";
import type { ToolDescriptor } from "@x/shared/dist/turns.js";
import type { execTool } from "../../application/lib/exec-tool.js";
import type { BuiltinTools } from "../../application/lib/builtin-tools.js";
import type { IAbortRegistry } from "../../runs/abort-registry.js";
import { TurnDependencyError } from "../api.js";
import type { SyncRuntimeTool, ToolExecutionContext } from "../tool-registry.js";
import { RealToolRegistry } from "./real-tool-registry.js";
type ExecCall = {
attachment: Parameters<typeof execTool>[0];
input: Record<string, unknown>;
ctx: NonNullable<Parameters<typeof execTool>[2]>;
};
class FakeAbortRegistry implements IAbortRegistry {
calls: string[] = [];
createForRun(runId: string): AbortSignal {
this.calls.push(`create:${runId}`);
return new AbortController().signal;
}
registerProcess(): void {}
unregisterProcess(): void {}
abort(runId: string): void {
this.calls.push(`abort:${runId}`);
}
forceAbort(): void {}
isAborted(): boolean {
return false;
}
cleanup(runId: string): void {
this.calls.push(`cleanup:${runId}`);
}
}
const fakeBuiltins = {
echo: { description: "Echo", inputSchema: {}, execute: async () => null },
} as unknown as typeof BuiltinTools;
function descriptor(
overrides: Partial<z.infer<typeof ToolDescriptor>> = {},
): z.infer<typeof ToolDescriptor> {
return {
toolId: "builtin:echo",
name: "echo",
description: "Echo",
inputSchema: {},
execution: "sync",
requiresHuman: false,
...overrides,
};
}
function makeCtx(overrides: Partial<ToolExecutionContext> = {}): ToolExecutionContext & {
progress: unknown[];
} {
const progress: unknown[] = [];
return {
turnId: "turn-1",
toolCallId: "tc-1",
signal: new AbortController().signal,
reportProgress: async (p) => {
progress.push(p);
},
progress,
...overrides,
};
}
function makeRegistry(execImpl: (call: ExecCall) => Promise<unknown>) {
const calls: ExecCall[] = [];
const abortRegistry = new FakeAbortRegistry();
const registry = new RealToolRegistry({
execToolImpl: (async (attachment, input, ctx) => {
const call = { attachment, input, ctx: ctx! };
calls.push(call);
return execImpl(call);
}) as typeof execTool,
abortRegistry,
builtins: fakeBuiltins,
});
return { registry, calls, abortRegistry };
}
describe("RealToolRegistry", () => {
it("executes builtins through execTool with a turn-scoped ToolContext", async () => {
const { registry, calls, abortRegistry } = makeRegistry(async () => ({ ok: 1 }));
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
const ctx = makeCtx();
const result = await tool.execute({ text: "hi" }, ctx);
expect(result).toEqual({ output: { ok: 1 }, isError: false });
expect(calls[0].attachment).toEqual({ type: "builtin", name: "echo" });
expect(calls[0].input).toEqual({ text: "hi" });
expect(calls[0].ctx).toMatchObject({ runId: "turn-1", toolCallId: "tc-1" });
// Abort registry bracketed per call.
expect(abortRegistry.calls).toEqual(["create:turn-1", "cleanup:turn-1"]);
});
it("normalizes undefined results to null and serializes objects", async () => {
const { registry } = makeRegistry(async () => undefined);
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
expect(await tool.execute({}, makeCtx())).toEqual({
output: null,
isError: false,
});
});
it("forwards tool-output-stream publishes as progress", async () => {
const { registry } = makeRegistry(async ({ ctx }) => {
await ctx.publish({
runId: "turn-1",
type: "tool-output-stream",
toolCallId: "tc-1",
toolName: "echo",
output: "chunk-1",
subflow: [],
});
return "done";
});
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
const ctx = makeCtx();
await tool.execute({}, ctx);
expect(ctx.progress).toEqual([{ kind: "tool-output", chunk: "chunk-1" }]);
});
it("wires the abort signal to the registry's force-kill path", async () => {
const controller = new AbortController();
const { registry, abortRegistry } = makeRegistry(async () => {
controller.abort();
return "late";
});
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
await tool.execute({}, makeCtx({ signal: controller.signal }));
expect(abortRegistry.calls).toEqual([
"create:turn-1",
"abort:turn-1",
"cleanup:turn-1",
]);
});
it("lets tool errors propagate (the loop converts them to error results) and still cleans up", async () => {
const { registry, abortRegistry } = makeRegistry(async () => {
throw new Error("tool exploded");
});
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
await expect(tool.execute({}, makeCtx())).rejects.toThrowError("tool exploded");
expect(abortRegistry.calls).toEqual(["create:turn-1", "cleanup:turn-1"]);
});
it("resolves mcp descriptors into mcp attachments (server:tool split on first colon)", async () => {
const { registry, calls } = makeRegistry(async () => "mcp result");
const tool = (await registry.resolve(
descriptor({
toolId: "mcp:kb:search:advanced",
name: "search:advanced",
description: "Search KB",
inputSchema: { type: "object" },
}),
)) as SyncRuntimeTool;
await tool.execute({ q: "x" }, makeCtx());
expect(calls[0].attachment).toEqual({
type: "mcp",
name: "search:advanced",
mcpServerName: "kb",
description: "Search KB",
inputSchema: { type: "object" },
});
});
it("resolves ask-human as an async tool with no executor", async () => {
const { registry } = makeRegistry(async () => null);
const tool = await registry.resolve(
descriptor({
toolId: "builtin:ask-human",
name: "ask-human",
execution: "async",
requiresHuman: true,
}),
);
expect("execute" in tool).toBe(false);
expect(tool.descriptor.execution).toBe("async");
});
it("rejects unknown builtins and malformed toolIds as dependency errors", async () => {
const { registry } = makeRegistry(async () => null);
await expect(
registry.resolve(descriptor({ toolId: "builtin:ghost", name: "ghost" })),
).rejects.toThrowError(TurnDependencyError);
await expect(
registry.resolve(descriptor({ toolId: "mcp:onlyserver" })),
).rejects.toThrowError(TurnDependencyError);
await expect(
registry.resolve(descriptor({ toolId: "weird:scheme" })),
).rejects.toThrowError(TurnDependencyError);
});
});

View file

@ -0,0 +1,141 @@
import type { z } from "zod";
import type { ToolAttachment } from "@x/shared/dist/agent.js";
import type { JsonValue, ToolDescriptor } from "@x/shared/dist/turns.js";
import { execTool } from "../../application/lib/exec-tool.js";
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
import {
type IAbortRegistry,
InMemoryAbortRegistry,
} from "../../runs/abort-registry.js";
import { TurnDependencyError } from "../api.js";
import type {
IToolRegistry,
RuntimeTool,
SyncRuntimeTool,
ToolExecutionContext,
} from "../tool-registry.js";
import { ASK_HUMAN_TOOL } from "./real-agent-resolver.js";
export interface RealToolRegistryDeps {
execToolImpl?: typeof execTool;
abortRegistry?: IAbortRegistry;
builtins?: typeof BuiltinTools;
}
// Bridges persisted tool descriptors to the existing dispatch: builtins via
// the BuiltinTools catalog, MCP tools via execTool's MCP path. toolId encodes
// the attachment: "builtin:<name>" or "mcp:<server>:<tool>". ask-human is the
// async human-dependent tool with no in-process executor.
export class RealToolRegistry implements IToolRegistry {
private readonly execToolImpl: typeof execTool;
private readonly abortRegistry: IAbortRegistry;
private readonly builtins: typeof BuiltinTools;
constructor(deps: RealToolRegistryDeps = {}) {
this.execToolImpl = deps.execToolImpl ?? execTool;
this.abortRegistry = deps.abortRegistry ?? new InMemoryAbortRegistry();
this.builtins = deps.builtins ?? BuiltinTools;
}
async resolve(
descriptor: z.infer<typeof ToolDescriptor>,
): Promise<RuntimeTool> {
if (descriptor.toolId === `builtin:${ASK_HUMAN_TOOL}`) {
return {
descriptor: descriptor as { execution: "async" } & z.infer<
typeof ToolDescriptor
>,
};
}
if (descriptor.toolId.startsWith("builtin:")) {
const name = descriptor.toolId.slice("builtin:".length);
const builtin = this.builtins[name];
if (!builtin?.execute) {
throw new TurnDependencyError(
`no live builtin tool for ${descriptor.toolId}`,
);
}
return this.syncTool(descriptor, { type: "builtin", name });
}
if (descriptor.toolId.startsWith("mcp:")) {
const rest = descriptor.toolId.slice("mcp:".length);
const separator = rest.indexOf(":");
if (separator <= 0 || separator === rest.length - 1) {
throw new TurnDependencyError(
`malformed mcp toolId: ${descriptor.toolId}`,
);
}
return this.syncTool(descriptor, {
type: "mcp",
name: rest.slice(separator + 1),
mcpServerName: rest.slice(0, separator),
description: descriptor.description,
inputSchema: descriptor.inputSchema,
});
}
throw new TurnDependencyError(
`unrecognized toolId scheme: ${descriptor.toolId}`,
);
}
private syncTool(
descriptor: z.infer<typeof ToolDescriptor>,
attachment: z.infer<typeof ToolAttachment>,
): SyncRuntimeTool {
return {
descriptor: descriptor as { execution: "sync" } & z.infer<
typeof ToolDescriptor
>,
execute: async (input, ctx: ToolExecutionContext) => {
// AbortSignal is the primary kill path; the abort registry is
// the secondary force-kill for spawned child processes,
// bracketed per call and keyed by turn.
this.abortRegistry.createForRun(ctx.turnId);
const onAbort = () => this.abortRegistry.abort(ctx.turnId);
ctx.signal.addEventListener("abort", onAbort, { once: true });
try {
const value = await this.execToolImpl(
attachment,
asArgs(input),
{
runId: ctx.turnId,
toolCallId: ctx.toolCallId,
signal: ctx.signal,
abortRegistry: this.abortRegistry,
publish: async (event) => {
if (event.type === "tool-output-stream") {
await ctx.reportProgress({
kind: "tool-output",
chunk: event.output,
});
}
},
},
);
return {
output: toJsonValue(value === undefined ? null : value),
isError: false,
};
} finally {
ctx.signal.removeEventListener("abort", onAbort);
this.abortRegistry.cleanup(ctx.turnId);
}
},
};
}
}
function asArgs(input: unknown): Record<string, unknown> {
return input && typeof input === "object"
? (input as Record<string, unknown>)
: {};
}
function toJsonValue(value: unknown): JsonValue {
try {
const parsed: unknown = JSON.parse(JSON.stringify(value));
return parsed === undefined ? null : (parsed as JsonValue);
} catch {
return String(value);
}
}

View file

@ -16,3 +16,24 @@ export type TurnLifecycleEvent = TurnProcessingStart | TurnProcessingEnd;
export interface ITurnLifecycleBus {
publish(event: TurnLifecycleEvent): void;
}
// Default in-process fan-out. Observers must never affect turn semantics, so
// listener errors are swallowed.
export class EmitterTurnLifecycleBus implements ITurnLifecycleBus {
private listeners = new Set<(event: TurnLifecycleEvent) => void>();
publish(event: TurnLifecycleEvent): void {
for (const listener of this.listeners) {
try {
listener(event);
} catch {
// observational only
}
}
}
subscribe(listener: (event: TurnLifecycleEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}

View file

@ -1,4 +1,5 @@
import type { JsonValue } from "@x/shared/dist/turns.js";
import type { z } from "zod";
import type { ConversationMessage, JsonValue } from "@x/shared/dist/turns.js";
export interface PermissionCheckInput {
turnId: string;
@ -41,12 +42,20 @@ export interface PermissionClassification {
reason: string;
}
export interface PermissionClassificationBatch {
turnId: string;
// Conversation context for the classifier: the turn's resolved context
// plus current-turn settled messages.
messages: Array<z.infer<typeof ConversationMessage>>;
requests: PermissionClassificationInput[];
}
// Handles all permission-required calls from one model response in one batch
// when automatic permission is enabled. Internal model calls are opaque to
// the turn loop. Failures and omitted decisions normalize to defer.
export interface IPermissionClassifier {
classify(
requests: PermissionClassificationInput[],
batch: PermissionClassificationBatch,
signal: AbortSignal,
): Promise<PermissionClassification[]>;
}

View file

@ -25,6 +25,7 @@ import type {
IPermissionClassifier,
PermissionCheckInput,
PermissionClassification,
PermissionClassificationBatch,
PermissionClassificationInput,
} from "./permission.js";
import { TurnRuntime } from "./runtime.js";
@ -221,6 +222,7 @@ class FakePermissionChecker implements IPermissionChecker {
class FakePermissionClassifier implements IPermissionClassifier {
batches: PermissionClassificationInput[][] = [];
contexts: Array<{ turnId: string; messageCount: number }> = [];
constructor(
private readonly impl?: (
@ -230,16 +232,20 @@ class FakePermissionClassifier implements IPermissionClassifier {
) {}
async classify(
requests: PermissionClassificationInput[],
batch: PermissionClassificationBatch,
): Promise<PermissionClassification[]> {
this.batches.push(requests);
this.batches.push(batch.requests);
this.contexts.push({
turnId: batch.turnId,
messageCount: batch.messages.length,
});
if (this.throws) {
throw new Error(this.throws);
}
if (!this.impl) {
throw new Error("classifier must not be called in this test");
}
return this.impl(requests);
return this.impl(batch.requests);
}
}
@ -302,7 +308,7 @@ function makeRuntime(opts: {
contextResolver: new TurnRepoContextResolver({ turnRepo: repo }),
permissionChecker: checker,
permissionClassifier: classifier,
bus,
lifecycleBus: bus,
});
return { runtime, repo, models, checker, classifier, bus };
}
@ -668,6 +674,11 @@ describe("automatic permission classification (26.4)", () => {
"CD",
"CF",
]);
// Conversation context reaches the classifier: input + batch response.
expect(classifier.contexts[0]).toEqual({
turnId,
messageCount: 2,
});
const log = await persisted(repo, turnId);
// Classifier provenance and effective decisions are distinct records.

View file

@ -63,7 +63,7 @@ export interface TurnRuntimeDependencies {
contextResolver: IContextResolver;
permissionChecker: IPermissionChecker;
permissionClassifier: IPermissionClassifier;
bus: ITurnLifecycleBus;
lifecycleBus: ITurnLifecycleBus;
}
// Immutable dependency container: holds no mutable per-turn state. All active
@ -78,7 +78,7 @@ export class TurnRuntime implements ITurnRuntime {
private readonly contextResolver: IContextResolver;
private readonly permissionChecker: IPermissionChecker;
private readonly permissionClassifier: IPermissionClassifier;
private readonly bus: ITurnLifecycleBus;
private readonly lifecycleBus: ITurnLifecycleBus;
constructor({
turnRepo,
@ -90,7 +90,7 @@ export class TurnRuntime implements ITurnRuntime {
contextResolver,
permissionChecker,
permissionClassifier,
bus,
lifecycleBus,
}: TurnRuntimeDependencies) {
this.turnRepo = turnRepo;
this.idGenerator = idGenerator;
@ -101,7 +101,7 @@ export class TurnRuntime implements ITurnRuntime {
this.contextResolver = contextResolver;
this.permissionChecker = permissionChecker;
this.permissionClassifier = permissionClassifier;
this.bus = bus;
this.lifecycleBus = lifecycleBus;
}
async createTurn(input: CreateTurnInput): Promise<string> {
@ -154,11 +154,11 @@ export class TurnRuntime implements ITurnRuntime {
externalSignal: AbortSignal | undefined,
stream: HotStream<TurnStreamEvent, TurnOutcome>,
): Promise<TurnOutcome> {
this.bus.publish({ type: "turn-processing-start", turnId });
this.lifecycleBus.publish({ type: "turn-processing-start", turnId });
try {
return await this.advance(turnId, input, externalSignal, stream);
} finally {
this.bus.publish({ type: "turn-processing-end", turnId });
this.lifecycleBus.publish({ type: "turn-processing-end", turnId });
}
}
@ -551,6 +551,21 @@ class TurnAdvance {
}
}
// Conversation context for the classifier: resolved context, the turn
// input, and completed assistant responses (the current batch's tool
// results are not yet terminal, so tool messages are omitted).
private conversationSoFar(): Array<z.infer<typeof ConversationMessage>> {
const messages: Array<z.infer<typeof ConversationMessage>> = [
this.state.definition.input,
];
for (const call of this.state.modelCalls) {
if (call.response !== undefined) {
messages.push(call.response);
}
}
return [...this.resolvedContext, ...messages];
}
// §9.3: one classifier batch per model response in auto mode.
// Checker-error calls and previously failed classifications skip the
// classifier and go straight to the human/deny fallback.
@ -573,13 +588,17 @@ class TurnAdvance {
let decisions;
try {
decisions = await this.permissionClassifier.classify(
candidates.map((tc) => ({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: tc.input,
request: (tc.permission as NonNullable<ToolCallState["permission"]>)
.required.request,
})),
{
turnId: this.turnId,
messages: this.conversationSoFar(),
requests: candidates.map((tc) => ({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: tc.input,
request: (tc.permission as NonNullable<ToolCallState["permission"]>)
.required.request,
})),
},
this.signal,
);
} catch (error) {
@ -708,6 +727,8 @@ class TurnAdvance {
const syncTool = tool as SyncRuntimeTool;
try {
const result = await syncTool.execute(tc.input, {
turnId: this.turnId,
toolCallId: tc.toolCallId,
signal: this.signal,
reportProgress: async (progress) => {
await this.append({

View file

@ -6,6 +6,8 @@ import type {
} from "@x/shared/dist/turns.js";
export interface ToolExecutionContext {
turnId: string;
toolCallId: string;
signal: AbortSignal;
// The loop appends a durable tool_progress event before resolving.
reportProgress(progress: JsonValue): Promise<void>;

View file

@ -32,6 +32,12 @@ export const RequestedAgent = z.object({
overrides: z
.object({
model: ModelDescriptor.optional(),
// Opaque composition hints interpreted by the agent resolver
// (e.g. work-dir id, voice/search/code modes). Persisted verbatim
// for audit. The resolver decides which keys affect the system
// prompt; keeping prompt-affecting inputs session-sticky is what
// preserves provider prefix caching across turns.
composition: z.json().optional(),
})
.optional(),
});