mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(x): skill-scoped contextual tool loading for the copilot
Skills now own their tools. The copilot attaches only a small hardcoded base set (~16 tools instead of all ~42); loading a skill via loadSkill attaches its declared tools as native tool definitions from the very next model step, and they stay attached for the rest of the session. - tools_extended: new durable turn event (the one sanctioned exception to per-turn tool immutability — explicit, replayable, never silent). Reducer tracks extensions per model-call index; effectiveTools() composes base + extensions for both the live loop and inspect. - Runtime: sync tool results may carry metadata.toolAdditions; dedupe and the durable append run atomically on the serialized commit chain (safe under concurrent sync tools). Crash recovery rebuilds the extended toolset from the log. - Skills declare tools (SkillDefinition.tools; SKILL.md tools:/ allowed-tools frontmatter); catalog entries list them; loadSkill returns them via a reserved $toolAdditions key the registry lifts into result metadata. builtin-tools skill = escape hatch, derived as "every non-base builtin" at module init. - Sessions derive composition.activeSkills from the previous turn's request + its tools_extended events; the resolver attaches active skills' tools on top of the base set (stable order, so snapshot inheritance keeps working). - Legacy code-mode path keeps working on the base set (which includes code_agent_run/launch-code-task) and strips $toolAdditions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
53eddaa6a4
commit
2e45035ece
18 changed files with 1302 additions and 35 deletions
|
|
@ -8,6 +8,7 @@ import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai"
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
|
import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
|
||||||
import { execTool } from "../application/lib/exec-tool.js";
|
import { execTool } from "../application/lib/exec-tool.js";
|
||||||
|
import { TOOL_ADDITIONS_KEY } from "../application/lib/tool-additions.js";
|
||||||
import { AskHumanRequestEvent, RunEvent, ToolPermissionMetadata, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
import { AskHumanRequestEvent, RunEvent, ToolPermissionMetadata, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||||
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||||
import { buildCopilotAgent } from "../application/assistant/agent.js";
|
import { buildCopilotAgent } from "../application/assistant/agent.js";
|
||||||
|
|
@ -1503,6 +1504,19 @@ export async function* streamAgent({
|
||||||
toolName: toolCall.toolName,
|
toolName: toolCall.toolName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// This legacy loop has no mid-run tool extension; drop the
|
||||||
|
// reserved tool-additions key (turns-runtime contract) so tool
|
||||||
|
// schemas never leak into the model-visible result text.
|
||||||
|
if (
|
||||||
|
result !== null &&
|
||||||
|
typeof result === "object" &&
|
||||||
|
!Array.isArray(result) &&
|
||||||
|
TOOL_ADDITIONS_KEY in result
|
||||||
|
) {
|
||||||
|
const rest = { ...(result as Record<string, unknown>) };
|
||||||
|
delete rest[TOOL_ADDITIONS_KEY];
|
||||||
|
result = rest;
|
||||||
|
}
|
||||||
const resultPayload = result === undefined ? null : result;
|
const resultPayload = result === undefined ? null : result;
|
||||||
const resultMsg: z.infer<typeof ToolMessage> = {
|
const resultMsg: z.infer<typeof ToolMessage> = {
|
||||||
role: "tool",
|
role: "tool",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { buildCopilotInstructions } from "./instructions.js";
|
import { buildCopilotInstructions } from "./instructions.js";
|
||||||
import { BuiltinTools } from "../lib/builtin-tools.js";
|
import { COPILOT_BASE_TOOLS } from "./base-tools.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the CopilotAgent dynamically.
|
* Build the CopilotAgent dynamically.
|
||||||
* Tools are derived from the current BuiltinTools (which include Composio meta-tools),
|
* Only the hardcoded base toolset is attached here; everything else is
|
||||||
* and instructions include the live Composio connection status.
|
* skill-scoped — loading a skill attaches its declared tools (turns runtime
|
||||||
|
* only). Instructions include the live Composio connection status.
|
||||||
*/
|
*/
|
||||||
export async function buildCopilotAgent(): Promise<z.infer<typeof Agent>> {
|
export async function buildCopilotAgent(): Promise<z.infer<typeof Agent>> {
|
||||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||||
for (const name of Object.keys(BuiltinTools)) {
|
for (const name of COPILOT_BASE_TOOLS) {
|
||||||
tools[name] = { type: "builtin", name };
|
tools[name] = { type: "builtin", name };
|
||||||
}
|
}
|
||||||
const instructions = await buildCopilotInstructions();
|
const instructions = await buildCopilotInstructions();
|
||||||
|
|
|
||||||
30
apps/x/packages/core/src/application/assistant/base-tools.ts
Normal file
30
apps/x/packages/core/src/application/assistant/base-tools.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// The copilot's always-attached toolset. Everything else is skill-scoped:
|
||||||
|
// skills declare the BuiltinTools they own, and loading a skill attaches its
|
||||||
|
// tools for the rest of the session. Keep this list small — every entry is
|
||||||
|
// schema bytes on every single model call, and tool-selection accuracy
|
||||||
|
// degrades as the attached count grows.
|
||||||
|
//
|
||||||
|
// code_agent_run and launch-code-task are here for the legacy code-mode path
|
||||||
|
// (runs/), which shares buildCopilotAgent and cannot gain tools mid-run;
|
||||||
|
// revisit once code-mode migrates to the turns runtime.
|
||||||
|
export const COPILOT_BASE_TOOLS: readonly string[] = [
|
||||||
|
"loadSkill",
|
||||||
|
"file-getRoot",
|
||||||
|
"file-exists",
|
||||||
|
"file-list",
|
||||||
|
"file-readText",
|
||||||
|
"file-glob",
|
||||||
|
"file-grep",
|
||||||
|
// Attachment reading is a hot path with no skill signal: users drop PDFs,
|
||||||
|
// Office docs, and images into chat/calls as path references the model
|
||||||
|
// must read immediately.
|
||||||
|
"parseFile",
|
||||||
|
"LLMParse",
|
||||||
|
"web-search",
|
||||||
|
"fetch-url",
|
||||||
|
"save-to-memory",
|
||||||
|
"executeCommand",
|
||||||
|
"spawn-agent",
|
||||||
|
"code_agent_run",
|
||||||
|
"launch-code-task",
|
||||||
|
];
|
||||||
|
|
@ -105,7 +105,7 @@ function buildStaticInstructions(composioEnabled: boolean, catalog: string, code
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const composioToolsLine = composioEnabled
|
const composioToolsLine = composioEnabled
|
||||||
? `- \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance.\n`
|
? `- Composio tools (\`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\`) attach when you load the \`composio-integration\` skill.\n`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
|
return `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
|
||||||
|
|
@ -268,11 +268,12 @@ In addition to Rowboat-specific workflow management, you can help users with gen
|
||||||
|
|
||||||
Use the catalog below to decide which skills to load for each user request. Before acting:
|
Use the catalog below to decide which skills to load for each user request. Before acting:
|
||||||
- Call the \`loadSkill\` tool with the skill's name or path so you can read its guidance string.
|
- Call the \`loadSkill\` tool with the skill's name or path so you can read its guidance string.
|
||||||
|
- Loading a skill also ATTACHES the tools listed in its catalog entry: they become real, callable tools on your very next step and stay attached for the rest of the session. Skills own their tools — this is how you gain capabilities beyond your small starting toolset.
|
||||||
- Apply the instructions from every loaded skill while working on the request.
|
- Apply the instructions from every loaded skill while working on the request.
|
||||||
|
|
||||||
${catalog}
|
${catalog}
|
||||||
|
|
||||||
Always consult this catalog first so you load the right skills before taking action.
|
Always consult this catalog first so you load the right skills before taking action. Your starting toolset is deliberately small: if a capability seems missing, find the skill that owns it in the catalog and load it — NEVER tell the user you can't do something before checking the catalog. If no specialized skill covers the tool you need, load the \`builtin-tools\` skill to attach the full builtin toolset.
|
||||||
|
|
||||||
## Communication Principles
|
## Communication Principles
|
||||||
- Be concise and direct. Avoid verbose explanations unless the user asks for details.
|
- Be concise and direct. Avoid verbose explanations unless the user asks for details.
|
||||||
|
|
@ -318,20 +319,21 @@ ${runtimeContextPrompt}
|
||||||
|
|
||||||
## Builtin Tools vs Shell Commands
|
## Builtin Tools vs Shell Commands
|
||||||
|
|
||||||
**IMPORTANT**: Rowboat provides builtin tools:
|
**IMPORTANT**: Rowboat provides builtin tools. Your always-attached base set:
|
||||||
- \`file-readText\`, \`file-writeText\`, \`file-editText\`, \`file-remove\` - File operations
|
- \`file-readText\`, \`file-list\`, \`file-exists\`, \`file-glob\`, \`file-grep\`, \`file-getRoot\` - Read-side file operations, directory exploration, and search
|
||||||
- \`file-list\`, \`file-exists\`, \`file-stat\`, \`file-glob\`, \`file-grep\` - Directory exploration and file search
|
|
||||||
- \`file-mkdir\`, \`file-rename\`, \`file-copy\` - File/directory management
|
|
||||||
- \`parseFile\` - Parse and extract text from files (PDF, Excel, CSV, Word .docx). Accepts absolute, ~/..., or relative paths — no need to copy files into the workspace first. Best for well-structured digital documents.
|
- \`parseFile\` - Parse and extract text from files (PDF, Excel, CSV, Word .docx). Accepts absolute, ~/..., or relative paths — no need to copy files into the workspace first. Best for well-structured digital documents.
|
||||||
- \`LLMParse\` - Send a file to the configured LLM as a multimodal attachment to extract content as markdown. Use this instead of \`parseFile\` for scanned PDFs, images with text, complex layouts, presentations, or any format where local parsing falls short. Supports documents and images.
|
- \`LLMParse\` - Send a file to the configured LLM as a multimodal attachment to extract content as markdown. Use this instead of \`parseFile\` for scanned PDFs, images with text, complex layouts, presentations, or any format where local parsing falls short. Supports documents and images.
|
||||||
- \`analyzeAgent\` - Agent analysis
|
- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
|
||||||
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
|
- \`fetch-url\` - Fetch a URL's contents
|
||||||
- \`loadSkill\` - Skill loading
|
|
||||||
${slackToolsLine}- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
|
|
||||||
- \`app-navigation\` - Drive the app UI: open any view, read a view's contents (emails / background agents / chat history), open specific items (email thread, note, agent, past chat), filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.**
|
|
||||||
- \`browser-control\` - Control the embedded browser pane: open sites, inspect the live page, switch tabs, and interact with indexed page elements. **Load the \`browser-control\` skill before using this tool.**
|
|
||||||
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
|
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
|
||||||
${composioToolsLine}
|
- \`loadSkill\` - Load a skill's guidance AND attach the tools it owns
|
||||||
|
- \`spawn-agent\` - Run sub-agents in isolated headless threads
|
||||||
|
- \`executeCommand\` - Shell commands (see below)
|
||||||
|
${slackToolsLine}${composioToolsLine}
|
||||||
|
Every other builtin is skill-scoped — load the owning skill from the catalog to attach it:
|
||||||
|
- Write-side file tools (\`file-writeText\`, \`file-editText\`, \`file-mkdir\`, \`file-rename\`, \`file-copy\`, \`file-remove\`, \`file-stat\`) via \`organize-files\`, \`doc-collab\`, and related skills
|
||||||
|
- MCP server management (\`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\`) via \`mcp-integration\`
|
||||||
|
- \`app-navigation\` / \`app-set-data\` via the \`app-navigation\` skill; \`browser-control\` via the \`browser-control\` skill; notifications via \`notify-user\`; background tasks via \`background-task\`; everything else via the \`builtin-tools\` escape hatch
|
||||||
|
|
||||||
**Prefer these tools whenever possible.** For file operations anywhere on the machine, use file tools instead of \`executeCommand\`.
|
**Prefer these tools whenever possible.** For file operations anywhere on the machine, use file tools instead of \`executeCommand\`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ export type DiskSkill = {
|
||||||
content: string; // full raw SKILL.md text
|
content: string; // full raw SKILL.md text
|
||||||
dir: string; // absolute path to the skill folder
|
dir: string; // absolute path to the skill folder
|
||||||
skillFile: string; // absolute path to the SKILL.md
|
skillFile: string; // absolute path to the SKILL.md
|
||||||
|
// Optional frontmatter `tools:` (or the Agent Skills spec's
|
||||||
|
// `allowed-tools:`) — BuiltinTools names the skill attaches when loaded.
|
||||||
|
// Names are validated against the live catalog where descriptors are
|
||||||
|
// built; unknown names are dropped there with a warning.
|
||||||
|
tools?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Locations scanned for <skill-name>/SKILL.md subfolders. The rowboat root is
|
// Locations scanned for <skill-name>/SKILL.md subfolders. The rowboat root is
|
||||||
|
|
@ -31,6 +36,25 @@ export const SKILL_ROOTS = [
|
||||||
const slugify = (value: string): string =>
|
const slugify = (value: string): string =>
|
||||||
value.trim().toLowerCase().replace(/[\s_]+/g, "-");
|
value.trim().toLowerCase().replace(/[\s_]+/g, "-");
|
||||||
|
|
||||||
|
// A YAML list of tool names ("tools: [a, b]" or block form). A single scalar
|
||||||
|
// ("tools: a") and comma-separated string are accepted too; anything else
|
||||||
|
// yields [].
|
||||||
|
const asStringList = (value: unknown): string[] => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronously scan the known skill roots (one level deep) for disk skills.
|
* Synchronously scan the known skill roots (one level deep) for disk skills.
|
||||||
* A missing/empty directory or an unreadable/invalid SKILL.md simply yields no
|
* A missing/empty directory or an unreadable/invalid SKILL.md simply yields no
|
||||||
|
|
@ -91,7 +115,16 @@ export function loadDiskSkills(): DiskSkill[] {
|
||||||
}
|
}
|
||||||
seen.set(id, dir);
|
seen.set(id, dir);
|
||||||
|
|
||||||
skills.push({ id, title: name, summary: description, content: raw, dir, skillFile });
|
const tools = asStringList(frontmatter.tools ?? frontmatter["allowed-tools"]);
|
||||||
|
skills.push({
|
||||||
|
id,
|
||||||
|
title: name,
|
||||||
|
summary: description,
|
||||||
|
content: raw,
|
||||||
|
dir,
|
||||||
|
skillFile,
|
||||||
|
...(tools.length > 0 ? { tools } : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ type SkillDefinition = {
|
||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
// BuiltinTools keys this skill owns. Loading the skill attaches these as
|
||||||
|
// native tool definitions (they are NOT in the copilot's base toolset).
|
||||||
|
// Names are validated against the live catalog where descriptors are
|
||||||
|
// built (loadSkill / the agent resolver); unknown names are dropped there.
|
||||||
|
tools?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolvedSkill = {
|
type ResolvedSkill = {
|
||||||
|
|
@ -43,18 +48,21 @@ const definitions: SkillDefinition[] = [
|
||||||
title: "Create Presentations",
|
title: "Create Presentations",
|
||||||
summary: "Create PDF presentations and slide decks from natural language requests using knowledge base context.",
|
summary: "Create PDF presentations and slide decks from natural language requests using knowledge base context.",
|
||||||
content: createPresentationsSkill,
|
content: createPresentationsSkill,
|
||||||
|
tools: ["file-writeText", "file-mkdir"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "doc-collab",
|
id: "doc-collab",
|
||||||
title: "Document Collaboration",
|
title: "Document Collaboration",
|
||||||
summary: "Collaborate on documents - create, edit, and refine notes and documents in the knowledge base.",
|
summary: "Collaborate on documents - create, edit, and refine notes and documents in the knowledge base.",
|
||||||
content: docCollabSkill,
|
content: docCollabSkill,
|
||||||
|
tools: ["file-writeText", "file-editText", "file-mkdir"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "draft-emails",
|
id: "draft-emails",
|
||||||
title: "Draft Emails",
|
title: "Draft Emails",
|
||||||
summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.",
|
summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.",
|
||||||
content: draftEmailsSkill,
|
content: draftEmailsSkill,
|
||||||
|
tools: ["composio-search-tools", "composio-execute-tool"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "meeting-prep",
|
id: "meeting-prep",
|
||||||
|
|
@ -67,36 +75,58 @@ const definitions: SkillDefinition[] = [
|
||||||
title: "Organize Files",
|
title: "Organize Files",
|
||||||
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
|
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
|
||||||
content: organizeFilesSkill,
|
content: organizeFilesSkill,
|
||||||
|
tools: [
|
||||||
|
"file-stat",
|
||||||
|
"file-writeText",
|
||||||
|
"file-editText",
|
||||||
|
"file-mkdir",
|
||||||
|
"file-rename",
|
||||||
|
"file-copy",
|
||||||
|
"file-remove",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "builtin-tools",
|
id: "builtin-tools",
|
||||||
title: "Builtin Tools Reference",
|
title: "Builtin Tools Reference",
|
||||||
summary: "Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions.",
|
summary: "Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions. Loading this attaches the FULL builtin toolset — the escape hatch when no specialized skill covers a tool you need.",
|
||||||
content: builtinToolsSkill,
|
content: builtinToolsSkill,
|
||||||
|
// Every non-base builtin: kept in sync by a startup assertion in
|
||||||
|
// builtin-tools.ts rather than by hand.
|
||||||
|
tools: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mcp-integration",
|
id: "mcp-integration",
|
||||||
title: "MCP Integration Guidance",
|
title: "MCP Integration Guidance",
|
||||||
summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.",
|
summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.",
|
||||||
content: mcpIntegrationSkill,
|
content: mcpIntegrationSkill,
|
||||||
|
tools: ["addMcpServer", "listMcpServers", "listMcpTools", "executeMcpTool"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "composio-integration",
|
id: "composio-integration",
|
||||||
title: "Composio Integration",
|
title: "Composio Integration",
|
||||||
summary: "Interact with third-party services (Gmail, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, etc.) via Composio. Search, connect, and execute tools.",
|
summary: "Interact with third-party services (Gmail, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, etc.) via Composio. Search, connect, and execute tools.",
|
||||||
content: composioIntegrationSkill,
|
content: composioIntegrationSkill,
|
||||||
|
tools: [
|
||||||
|
"composio-list-toolkits",
|
||||||
|
"composio-search-tools",
|
||||||
|
"composio-execute-tool",
|
||||||
|
"composio-connect-toolkit",
|
||||||
|
"app-navigation",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "deletion-guardrails",
|
id: "deletion-guardrails",
|
||||||
title: "Deletion Guardrails",
|
title: "Deletion Guardrails",
|
||||||
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
|
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
|
||||||
content: deletionGuardrailsSkill,
|
content: deletionGuardrailsSkill,
|
||||||
|
tools: ["file-remove"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "app-navigation",
|
id: "app-navigation",
|
||||||
title: "App Navigation",
|
title: "App Navigation",
|
||||||
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
|
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
|
||||||
content: appNavigationSkill,
|
content: appNavigationSkill,
|
||||||
|
tools: ["app-navigation", "app-set-data"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "code-with-agents",
|
id: "code-with-agents",
|
||||||
|
|
@ -109,30 +139,49 @@ const definitions: SkillDefinition[] = [
|
||||||
title: "Rowboat Apps",
|
title: "Rowboat Apps",
|
||||||
summary: "Build a Rowboat App the user opens inside Rowboat — a static web app on its own origin, powered by their integrations and an optional background agent. Use when the user asks to make/build/create an app or dashboard; for ambiguous 'show me X' requests, confirm whether they want an app first.",
|
summary: "Build a Rowboat App the user opens inside Rowboat — a static web app on its own origin, powered by their integrations and an optional background agent. Use when the user asks to make/build/create an app or dashboard; for ambiguous 'show me X' requests, confirm whether they want an app first.",
|
||||||
content: appsSkill,
|
content: appsSkill,
|
||||||
|
tools: [
|
||||||
|
"app-navigation",
|
||||||
|
"app-set-data",
|
||||||
|
"list-models",
|
||||||
|
"composio-search-tools",
|
||||||
|
"composio-execute-tool",
|
||||||
|
"create-background-task",
|
||||||
|
"run-background-task-agent",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "background-task",
|
id: "background-task",
|
||||||
title: "Background Tasks",
|
title: "Background Tasks",
|
||||||
summary: "Set up a recurring background task — persistent instructions the agent fires on a schedule and/or on matching events (Gmail, Calendar). Either maintains an `index.md` digest (OUTPUT mode) or performs a recurring side-effect like drafting a reply / posting to Slack / calling an API (ACTION mode). Flagship surface for anything recurring.",
|
summary: "Set up a recurring background task — persistent instructions the agent fires on a schedule and/or on matching events (Gmail, Calendar). Either maintains an `index.md` digest (OUTPUT mode) or performs a recurring side-effect like drafting a reply / posting to Slack / calling an API (ACTION mode). Flagship surface for anything recurring.",
|
||||||
content: backgroundTaskSkill,
|
content: backgroundTaskSkill,
|
||||||
|
tools: [
|
||||||
|
"create-background-task",
|
||||||
|
"patch-background-task",
|
||||||
|
"run-background-task-agent",
|
||||||
|
"file-writeText",
|
||||||
|
"file-editText",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "live-note",
|
id: "live-note",
|
||||||
title: "Live Notes",
|
title: "Live Notes",
|
||||||
summary: "Make a specific markdown note self-updating — a single `live:` objective in the frontmatter that the live-note agent maintains on a schedule or on incoming events. Load only when the user explicitly says 'live note' / 'live-note'; for anything else recurring, prefer the background-task skill.",
|
summary: "Make a specific markdown note self-updating — a single `live:` objective in the frontmatter that the live-note agent maintains on a schedule or on incoming events. Load only when the user explicitly says 'live note' / 'live-note'; for anything else recurring, prefer the background-task skill.",
|
||||||
content: liveNoteSkill,
|
content: liveNoteSkill,
|
||||||
|
tools: ["run-live-note-agent", "file-writeText", "file-editText"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "browser-control",
|
id: "browser-control",
|
||||||
title: "Browser Control",
|
title: "Browser Control",
|
||||||
summary: "Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.",
|
summary: "Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.",
|
||||||
content: browserControlSkill,
|
content: browserControlSkill,
|
||||||
|
tools: ["browser-control", "load-browser-skill"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "notify-user",
|
id: "notify-user",
|
||||||
title: "Notify User",
|
title: "Notify User",
|
||||||
summary: "Send native desktop notifications with optional clickable links — including rowboat:// deep links that open a specific note, chat, or view inside the app.",
|
summary: "Send native desktop notifications with optional clickable links — including rowboat:// deep links that open a specific note, chat, or view inside the app.",
|
||||||
content: notifyUserSkill,
|
content: notifyUserSkill,
|
||||||
|
tools: ["notify-user"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -167,6 +216,7 @@ function loadDiskEntries(): SkillEntry[] {
|
||||||
title: skill.title,
|
title: skill.title,
|
||||||
summary: skill.summary,
|
summary: skill.summary,
|
||||||
content: skill.content,
|
content: skill.content,
|
||||||
|
...(skill.tools ? { tools: skill.tools } : {}),
|
||||||
// For disk skills the catalog/loadSkill reference is the absolute SKILL.md path.
|
// For disk skills the catalog/loadSkill reference is the absolute SKILL.md path.
|
||||||
catalogPath: skill.skillFile,
|
catalogPath: skill.skillFile,
|
||||||
skillFile: skill.skillFile,
|
skillFile: skill.skillFile,
|
||||||
|
|
@ -190,6 +240,9 @@ export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
|
||||||
`## ${entry.title}`,
|
`## ${entry.title}`,
|
||||||
`- **Skill file:** \`${entry.catalogPath}\``,
|
`- **Skill file:** \`${entry.catalogPath}\``,
|
||||||
`- **Use it for:** ${entry.summary}`,
|
`- **Use it for:** ${entry.summary}`,
|
||||||
|
...(entry.tools && entry.tools.length > 0
|
||||||
|
? [`- **Tools it attaches:** ${entry.tools.join(", ")}`]
|
||||||
|
: []),
|
||||||
].join("\n"));
|
].join("\n"));
|
||||||
return [
|
return [
|
||||||
"# Rowboat Skill Catalog",
|
"# Rowboat Skill Catalog",
|
||||||
|
|
@ -310,3 +363,27 @@ export function resolveSkill(identifier: string): ResolvedSkill | null {
|
||||||
// Bundled wins over disk on any alias collision.
|
// Bundled wins over disk on any alias collision.
|
||||||
return aliasMap.get(normalized) ?? diskAliasMap.get(normalized) ?? null;
|
return aliasMap.get(normalized) ?? diskAliasMap.get(normalized) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The BuiltinTools names a skill declares (empty for guidance-only skills).
|
||||||
|
* Read live so it reflects disk-skill refreshes and the derived
|
||||||
|
* builtin-tools escape-hatch list.
|
||||||
|
*/
|
||||||
|
export function skillToolNames(skillId: string): string[] {
|
||||||
|
const entry = getSkillEntries().find((e) => e.id === skillId);
|
||||||
|
return entry?.tools ? [...entry.tools] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The builtin-tools skill attaches the full non-base toolset. Its list is
|
||||||
|
* derived from the live BuiltinTools catalog by builtin-tools.ts at module
|
||||||
|
* init — a hand-written list here would drift, and importing the catalog
|
||||||
|
* from this module would be an import cycle (builtin-tools.ts imports
|
||||||
|
* resolveSkill).
|
||||||
|
*/
|
||||||
|
export function setBuiltinToolsSkillTools(names: string[]): void {
|
||||||
|
const entry = bundledEntries.find((e) => e.id === "builtin-tools");
|
||||||
|
if (entry) {
|
||||||
|
entry.tools = [...names];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@ import * as os from "os";
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
|
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
|
||||||
import { agentSlackShimEnv } from "../../slack/agent-slack-exec.js";
|
import { agentSlackShimEnv } from "../../slack/agent-slack-exec.js";
|
||||||
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
|
import { resolveSkill, availableSkills, skillToolNames, setBuiltinToolsSkillTools } from "../assistant/skills/index.js";
|
||||||
|
import { COPILOT_BASE_TOOLS } from "../assistant/base-tools.js";
|
||||||
|
import { builtinToolDescriptor } from "../../turns/bridges/builtin-descriptors.js";
|
||||||
|
import { TOOL_ADDITIONS_KEY } from "./tool-additions.js";
|
||||||
|
import type { ToolDescriptor } from "@x/shared/dist/turns.js";
|
||||||
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
|
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
|
||||||
import container from "../../di/container.js";
|
import container from "../../di/container.js";
|
||||||
import { IMcpConfigRepo } from "../..//mcp/repo.js";
|
import { IMcpConfigRepo } from "../..//mcp/repo.js";
|
||||||
|
|
@ -184,11 +188,26 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The skill's declared tools ride the reserved additions key: the
|
||||||
|
// turn runtime records a durable tools_extended event and the
|
||||||
|
// model gets them as NATIVE tool definitions on its next call —
|
||||||
|
// never as schema text in this result. attachedTools names them
|
||||||
|
// so the model knows the capability landed.
|
||||||
|
const additions = await skillToolAdditions(resolved.id);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
skillName: resolved.id,
|
skillName: resolved.id,
|
||||||
path: resolved.catalogPath,
|
path: resolved.catalogPath,
|
||||||
content: resolved.content,
|
content: resolved.content,
|
||||||
|
...(additions.length > 0
|
||||||
|
? {
|
||||||
|
attachedTools: additions.map((tool) => tool.name),
|
||||||
|
[TOOL_ADDITIONS_KEY]: {
|
||||||
|
source: resolved.id,
|
||||||
|
tools: additions,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -2037,3 +2056,36 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Native ToolDescriptors for a skill's declared tools. Unknown names are
|
||||||
|
// dropped with a warning (they may come from a downloaded SKILL.md);
|
||||||
|
// availability-gated builtins (Composio, browser) drop out exactly as they
|
||||||
|
// do at agent resolution.
|
||||||
|
async function skillToolAdditions(
|
||||||
|
skillId: string,
|
||||||
|
): Promise<Array<z.infer<typeof ToolDescriptor>>> {
|
||||||
|
const descriptors: Array<z.infer<typeof ToolDescriptor>> = [];
|
||||||
|
for (const name of skillToolNames(skillId)) {
|
||||||
|
const builtin = BuiltinTools[name];
|
||||||
|
if (!builtin) {
|
||||||
|
console.warn(
|
||||||
|
`[skills] Skill '${skillId}' declares unknown tool '${name}'; skipping.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (builtin.isAvailable && !(await builtin.isAvailable())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
descriptors.push(builtinToolDescriptor(name, builtin));
|
||||||
|
}
|
||||||
|
return descriptors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The builtin-tools skill is the escape hatch: loading it attaches every
|
||||||
|
// builtin the copilot's base set leaves out. Derived here (not hand-written
|
||||||
|
// in the skill catalog) so new builtins can never silently fall outside it.
|
||||||
|
setBuiltinToolsSkillTools(
|
||||||
|
Object.keys(BuiltinTools).filter(
|
||||||
|
(name) => !COPILOT_BASE_TOOLS.includes(name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
|
||||||
17
apps/x/packages/core/src/application/lib/tool-additions.ts
Normal file
17
apps/x/packages/core/src/application/lib/tool-additions.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { z } from "zod";
|
||||||
|
import type { ToolDescriptor } from "@x/shared/dist/turns.js";
|
||||||
|
|
||||||
|
// Contract between builtin tools and the tool registries: a builtin whose
|
||||||
|
// result should extend the turn's toolset (loadSkill attaching a skill's
|
||||||
|
// declared tools) returns its value with this reserved key. The registry
|
||||||
|
// wrapper lifts it out of the model-visible output into
|
||||||
|
// ToolResultData.metadata.toolAdditions, where the turn runtime picks it up
|
||||||
|
// and records a durable tools_extended event. The legacy runs loop strips the
|
||||||
|
// key and ignores it (no mid-run tool extension there).
|
||||||
|
export const TOOL_ADDITIONS_KEY = "$toolAdditions";
|
||||||
|
|
||||||
|
export interface ToolAdditions {
|
||||||
|
// Human-readable origin for the durable event, e.g. the skill id.
|
||||||
|
source: string;
|
||||||
|
tools: Array<z.infer<typeof ToolDescriptor>>;
|
||||||
|
}
|
||||||
|
|
@ -842,3 +842,194 @@ describe("headless standalone turns (13.8)", () => {
|
||||||
expect(fake.advanceCalls).toEqual([{ turnId, input: undefined }]);
|
expect(fake.advanceCalls).toEqual([{ turnId, input: undefined }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("active-skill carry-forward", () => {
|
||||||
|
const skillTool = {
|
||||||
|
toolId: "builtin:file-writeText",
|
||||||
|
name: "file-writeText",
|
||||||
|
description: "Write",
|
||||||
|
inputSchema: {},
|
||||||
|
execution: "sync" as const,
|
||||||
|
requiresHuman: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A completed turn whose history loaded a skill mid-turn.
|
||||||
|
function skillLoadLog(
|
||||||
|
turnId: string,
|
||||||
|
sessionId: string,
|
||||||
|
agent: CreateTurnInput["agent"],
|
||||||
|
source = "organize-files",
|
||||||
|
): TEvent[] {
|
||||||
|
const created = createdEvent(turnId, {
|
||||||
|
agent,
|
||||||
|
sessionId,
|
||||||
|
context: [],
|
||||||
|
input: user("hi"),
|
||||||
|
config: { humanAvailable: true },
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
created,
|
||||||
|
{
|
||||||
|
type: "model_call_requested",
|
||||||
|
turnId,
|
||||||
|
ts: TS,
|
||||||
|
modelCallIndex: 0,
|
||||||
|
request: { messages: ["input"], parameters: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "model_call_completed",
|
||||||
|
turnId,
|
||||||
|
ts: TS,
|
||||||
|
modelCallIndex: 0,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "A",
|
||||||
|
toolName: "loadSkill",
|
||||||
|
arguments: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finishReason: "tool-calls",
|
||||||
|
usage: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool_invocation_requested",
|
||||||
|
turnId,
|
||||||
|
ts: TS,
|
||||||
|
toolCallId: "A",
|
||||||
|
toolId: "builtin:loadSkill",
|
||||||
|
toolName: "loadSkill",
|
||||||
|
execution: "sync",
|
||||||
|
input: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
turnId,
|
||||||
|
ts: TS,
|
||||||
|
toolCallId: "A",
|
||||||
|
toolName: "loadSkill",
|
||||||
|
source: "sync",
|
||||||
|
result: { output: { success: true }, isError: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tools_extended",
|
||||||
|
turnId,
|
||||||
|
ts: TS,
|
||||||
|
toolCallId: "A",
|
||||||
|
source,
|
||||||
|
tools: [skillTool],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "model_call_requested",
|
||||||
|
turnId,
|
||||||
|
ts: TS,
|
||||||
|
modelCallIndex: 1,
|
||||||
|
request: { messages: ["assistant:0", "toolResult:A"], parameters: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "model_call_completed",
|
||||||
|
turnId,
|
||||||
|
ts: TS,
|
||||||
|
modelCallIndex: 1,
|
||||||
|
message: assistantText("ok"),
|
||||||
|
finishReason: "stop",
|
||||||
|
usage: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "turn_completed",
|
||||||
|
turnId,
|
||||||
|
ts: TS,
|
||||||
|
output: assistantText("ok"),
|
||||||
|
finishReason: "stop",
|
||||||
|
usage: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
it("the next turn's composition carries skills recorded by tools_extended", async () => {
|
||||||
|
const { sessions, fake } = makeSessions();
|
||||||
|
const sessionId = await sessions.createSession();
|
||||||
|
const { turnId } = await sessions.sendMessage(sessionId, user("one"), {
|
||||||
|
agent: { agentId: "copilot" },
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
fake.setLog(turnId, skillLoadLog(turnId, sessionId, { agentId: "copilot" }));
|
||||||
|
|
||||||
|
await sessions.sendMessage(sessionId, user("two"), {
|
||||||
|
agent: { agentId: "copilot" },
|
||||||
|
});
|
||||||
|
const second = fake.createTurnInputs[1];
|
||||||
|
expect(second.agent).toMatchObject({
|
||||||
|
agentId: "copilot",
|
||||||
|
overrides: { composition: { activeSkills: ["organize-files"] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates across turns and unions with caller-supplied skills, preserving order", async () => {
|
||||||
|
const { sessions, fake } = makeSessions();
|
||||||
|
const sessionId = await sessions.createSession();
|
||||||
|
const first = await sessions.sendMessage(sessionId, user("one"), {
|
||||||
|
agent: { agentId: "copilot" },
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
fake.setLog(
|
||||||
|
first.turnId,
|
||||||
|
skillLoadLog(first.turnId, sessionId, { agentId: "copilot" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const second = await sessions.sendMessage(sessionId, user("two"), {
|
||||||
|
agent: { agentId: "copilot" },
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
// Turn 2 carried organize-files in its request and loaded another
|
||||||
|
// skill mid-turn.
|
||||||
|
fake.setLog(
|
||||||
|
second.turnId,
|
||||||
|
skillLoadLog(
|
||||||
|
second.turnId,
|
||||||
|
sessionId,
|
||||||
|
{
|
||||||
|
agentId: "copilot",
|
||||||
|
overrides: {
|
||||||
|
composition: { activeSkills: ["organize-files"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"doc-collab",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await sessions.sendMessage(sessionId, user("three"), {
|
||||||
|
agent: {
|
||||||
|
agentId: "copilot",
|
||||||
|
overrides: { composition: { activeSkills: ["notify-user"] } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const third = fake.createTurnInputs[2];
|
||||||
|
expect(third.agent).toMatchObject({
|
||||||
|
overrides: {
|
||||||
|
composition: {
|
||||||
|
activeSkills: ["organize-files", "doc-collab", "notify-user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a session with no skill loads adds no composition key", async () => {
|
||||||
|
const { sessions, fake } = makeSessions();
|
||||||
|
const sessionId = await sessions.createSession();
|
||||||
|
const { turnId } = await sessions.sendMessage(sessionId, user("one"), {
|
||||||
|
agent: { agentId: "copilot" },
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
fake.setLog(turnId, turnLog(turnId, sessionId, "completed"));
|
||||||
|
|
||||||
|
await sessions.sendMessage(sessionId, user("two"), {
|
||||||
|
agent: { agentId: "copilot" },
|
||||||
|
});
|
||||||
|
const second = fake.createTurnInputs[1];
|
||||||
|
expect(second.agent).toEqual({ agentId: "copilot" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
type JsonValue,
|
type JsonValue,
|
||||||
type ModelDescriptor,
|
type ModelDescriptor,
|
||||||
type ToolResultData,
|
type ToolResultData,
|
||||||
|
type TurnState,
|
||||||
deriveTurnStatus,
|
deriveTurnStatus,
|
||||||
inlineAgentId,
|
inlineAgentId,
|
||||||
isInlineAgentRequest,
|
isInlineAgentRequest,
|
||||||
|
|
@ -151,8 +152,11 @@ export class SessionsImpl implements ISessions {
|
||||||
const events = await this.sessionRepo.read(sessionId);
|
const events = await this.sessionRepo.read(sessionId);
|
||||||
const state = reduceSession(events);
|
const state = reduceSession(events);
|
||||||
|
|
||||||
|
let agentRequest = config.agent;
|
||||||
if (state.latestTurnId) {
|
if (state.latestTurnId) {
|
||||||
const status = await this.latestTurnStatus(state);
|
const turn = await this.turnRuntime.getTurn(state.latestTurnId);
|
||||||
|
const turnState = reduceTurn(turn.events);
|
||||||
|
const status = deriveTurnStatus(turnState);
|
||||||
if (
|
if (
|
||||||
status !== "completed" &&
|
status !== "completed" &&
|
||||||
status !== "failed" &&
|
status !== "failed" &&
|
||||||
|
|
@ -164,10 +168,14 @@ export class SessionsImpl implements ISessions {
|
||||||
status,
|
status,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
agentRequest = withActiveSkills(
|
||||||
|
agentRequest,
|
||||||
|
deriveActiveSkills(turnState),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const turnId = await this.turnRuntime.createTurn({
|
const turnId = await this.turnRuntime.createTurn({
|
||||||
agent: config.agent,
|
agent: agentRequest,
|
||||||
sessionId,
|
sessionId,
|
||||||
context: state.latestTurnId
|
context: state.latestTurnId
|
||||||
? { previousTurnId: state.latestTurnId }
|
? { previousTurnId: state.latestTurnId }
|
||||||
|
|
@ -416,6 +424,72 @@ function errorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Skill-scoped tool carry-forward --------------------------------------
|
||||||
|
// Skills loaded in earlier turns stay active for the whole session: the next
|
||||||
|
// turn's activeSkills = the previous turn's requested activeSkills plus any
|
||||||
|
// skills its durable tools_extended events recorded, in first-load order.
|
||||||
|
// Stable ordering keeps agent snapshots byte-identical across turns, which is
|
||||||
|
// what lets snapshot inheritance keep working.
|
||||||
|
|
||||||
|
function parseActiveSkills(composition: JsonValue | undefined): string[] {
|
||||||
|
if (
|
||||||
|
composition === undefined ||
|
||||||
|
composition === null ||
|
||||||
|
typeof composition !== "object" ||
|
||||||
|
Array.isArray(composition)
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const value = (composition as Record<string, JsonValue>).activeSkills;
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.filter((item): item is string => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveActiveSkills(turnState: TurnState): string[] {
|
||||||
|
const requested = turnState.definition.agent.requested;
|
||||||
|
const skills = isInlineAgentRequest(requested)
|
||||||
|
? []
|
||||||
|
: parseActiveSkills(requested.overrides?.composition);
|
||||||
|
for (const extension of turnState.toolExtensions) {
|
||||||
|
if (!skills.includes(extension.event.source)) {
|
||||||
|
skills.push(extension.event.source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withActiveSkills(
|
||||||
|
agent: SendMessageConfig["agent"],
|
||||||
|
activeSkills: string[],
|
||||||
|
): SendMessageConfig["agent"] {
|
||||||
|
if (isInlineAgentRequest(agent) || activeSkills.length === 0) {
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
const composition = agent.overrides?.composition;
|
||||||
|
const base =
|
||||||
|
composition !== undefined &&
|
||||||
|
composition !== null &&
|
||||||
|
typeof composition === "object" &&
|
||||||
|
!Array.isArray(composition)
|
||||||
|
? composition
|
||||||
|
: {};
|
||||||
|
// Carried-forward skills first (stable order), then any caller-supplied
|
||||||
|
// extras not already present.
|
||||||
|
const provided = parseActiveSkills(composition);
|
||||||
|
const merged = [
|
||||||
|
...activeSkills,
|
||||||
|
...provided.filter((id) => !activeSkills.includes(id)),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
...agent,
|
||||||
|
overrides: {
|
||||||
|
...agent.overrides,
|
||||||
|
composition: { ...base, activeSkills: merged },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function defaultTitle(input: z.infer<typeof UserMessage>): string {
|
function defaultTitle(input: z.infer<typeof UserMessage>): string {
|
||||||
const text =
|
const text =
|
||||||
typeof input.content === "string"
|
typeof input.content === "string"
|
||||||
|
|
|
||||||
|
|
@ -222,3 +222,70 @@ describe("RealAgentResolver", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("active skills (skill-scoped tools)", () => {
|
||||||
|
const skillTools = (skillId: string): string[] =>
|
||||||
|
skillId === "organize-files"
|
||||||
|
? ["file-list", "web-search", "ghost"]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
it("appends declared tools of active skills for copilot, skipping unknown and unavailable ones", async () => {
|
||||||
|
const resolver = makeResolver(
|
||||||
|
makeAgent({
|
||||||
|
tools: { "spawn-agent": { type: "builtin", name: "spawn-agent" } },
|
||||||
|
}),
|
||||||
|
{ skillTools },
|
||||||
|
);
|
||||||
|
const resolved = await resolver.resolve({
|
||||||
|
agentId: "copilot",
|
||||||
|
overrides: {
|
||||||
|
composition: { activeSkills: ["organize-files", "gone-skill"] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// spawn-agent (base) + file-list from the skill; web-search is
|
||||||
|
// unavailable and ghost unknown; gone-skill contributes nothing.
|
||||||
|
expect(resolved.tools.map((t) => t.name)).toEqual([
|
||||||
|
"spawn-agent",
|
||||||
|
"file-list",
|
||||||
|
]);
|
||||||
|
expect(resolved.tools[1]).toMatchObject({ toolId: "builtin:file-list" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never duplicates a tool already in the base set", async () => {
|
||||||
|
const resolver = makeResolver(
|
||||||
|
makeAgent({
|
||||||
|
tools: { "file-list": { type: "builtin", name: "file-list" } },
|
||||||
|
}),
|
||||||
|
{ skillTools },
|
||||||
|
);
|
||||||
|
const resolved = await resolver.resolve({
|
||||||
|
agentId: "copilot",
|
||||||
|
overrides: { composition: { activeSkills: ["organize-files"] } },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolved.tools.filter((t) => t.name === "file-list"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("identical activeSkills yield byte-identical snapshots", async () => {
|
||||||
|
const resolver = makeResolver(makeAgent(), { skillTools });
|
||||||
|
const request = {
|
||||||
|
agentId: "copilot",
|
||||||
|
overrides: { composition: { activeSkills: ["organize-files"] } },
|
||||||
|
};
|
||||||
|
const a = await resolver.resolve(request);
|
||||||
|
const b = await resolver.resolve(request);
|
||||||
|
expect(JSON.stringify(a.tools)).toBe(JSON.stringify(b.tools));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores activeSkills for non-copilot agents", async () => {
|
||||||
|
const resolver = makeResolver(makeAgent({ name: "writer" }), {
|
||||||
|
skillTools,
|
||||||
|
});
|
||||||
|
const resolved = await resolver.resolve({
|
||||||
|
agentId: "writer",
|
||||||
|
overrides: { composition: { activeSkills: ["organize-files"] } },
|
||||||
|
});
|
||||||
|
expect(resolved.tools).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
loadUserWorkDir,
|
loadUserWorkDir,
|
||||||
} from "../../agents/runtime.js";
|
} from "../../agents/runtime.js";
|
||||||
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
||||||
|
import { skillToolNames } from "../../application/assistant/skills/index.js";
|
||||||
import { getDefaultModelAndProvider } from "../../models/defaults.js";
|
import { getDefaultModelAndProvider } from "../../models/defaults.js";
|
||||||
import {
|
import {
|
||||||
builtinToolDescriptor,
|
builtinToolDescriptor,
|
||||||
|
|
@ -62,6 +63,11 @@ const CompositionOverrides = z.object({
|
||||||
// Set by spawn-agent for by-id children: strips the spawn tool so depth
|
// Set by spawn-agent for by-id children: strips the spawn tool so depth
|
||||||
// is capped at 1 regardless of which stored agent is spawned.
|
// is capped at 1 regardless of which stored agent is spawned.
|
||||||
subagent: z.boolean().optional(),
|
subagent: z.boolean().optional(),
|
||||||
|
// Skills the session has loaded (maintained by the sessions layer from
|
||||||
|
// prior turns' tools_extended events): their declared tools attach on
|
||||||
|
// top of the copilot's base set. Order is preserved so identical sets
|
||||||
|
// produce byte-identical snapshots and inheritance keeps working.
|
||||||
|
activeSkills: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface RealAgentResolverDeps {
|
export interface RealAgentResolverDeps {
|
||||||
|
|
@ -70,6 +76,7 @@ export interface RealAgentResolverDeps {
|
||||||
defaultModel?: () => Promise<{ model: string; provider: string }>;
|
defaultModel?: () => Promise<{ model: string; provider: string }>;
|
||||||
loadNotes?: () => string | null;
|
loadNotes?: () => string | null;
|
||||||
loadWorkDir?: (workDirId: string) => string | null;
|
loadWorkDir?: (workDirId: string) => string | null;
|
||||||
|
skillTools?: typeof skillToolNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bridges the existing agent system (loadAgent + dynamic builders, the
|
// Bridges the existing agent system (loadAgent + dynamic builders, the
|
||||||
|
|
@ -84,6 +91,7 @@ export class RealAgentResolver {
|
||||||
private readonly defaultModel: () => Promise<{ model: string; provider: string }>;
|
private readonly defaultModel: () => Promise<{ model: string; provider: string }>;
|
||||||
private readonly loadNotes: () => string | null;
|
private readonly loadNotes: () => string | null;
|
||||||
private readonly loadWorkDir: (workDirId: string) => string | null;
|
private readonly loadWorkDir: (workDirId: string) => string | null;
|
||||||
|
private readonly skillTools: typeof skillToolNames;
|
||||||
|
|
||||||
constructor(deps: RealAgentResolverDeps = {}) {
|
constructor(deps: RealAgentResolverDeps = {}) {
|
||||||
this.load = deps.load ?? loadAgent;
|
this.load = deps.load ?? loadAgent;
|
||||||
|
|
@ -91,6 +99,7 @@ export class RealAgentResolver {
|
||||||
this.defaultModel = deps.defaultModel ?? getDefaultModelAndProvider;
|
this.defaultModel = deps.defaultModel ?? getDefaultModelAndProvider;
|
||||||
this.loadNotes = deps.loadNotes ?? loadAgentNotesContext;
|
this.loadNotes = deps.loadNotes ?? loadAgentNotesContext;
|
||||||
this.loadWorkDir = deps.loadWorkDir ?? loadUserWorkDir;
|
this.loadWorkDir = deps.loadWorkDir ?? loadUserWorkDir;
|
||||||
|
this.skillTools = deps.skillTools ?? skillToolNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolve(
|
async resolve(
|
||||||
|
|
@ -138,6 +147,9 @@ export class RealAgentResolver {
|
||||||
const tools = await this.resolveTools(agent, {
|
const tools = await this.resolveTools(agent, {
|
||||||
subagent: composition.subagent ?? false,
|
subagent: composition.subagent ?? false,
|
||||||
});
|
});
|
||||||
|
if (copilotContext && composition.activeSkills?.length) {
|
||||||
|
await this.appendSkillTools(tools, composition.activeSkills);
|
||||||
|
}
|
||||||
return ResolvedAgent.parse({
|
return ResolvedAgent.parse({
|
||||||
agentId: requested.agentId,
|
agentId: requested.agentId,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
|
|
@ -146,6 +158,35 @@ export class RealAgentResolver {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skill-scoped tools carried across turns: each active skill's declared
|
||||||
|
// BuiltinTools attach on top of the base set. Unknown skill ids (deleted
|
||||||
|
// disk skill) and unknown/unavailable tool names skip gracefully.
|
||||||
|
// Iteration order (skills, then declaration order) is deterministic, so
|
||||||
|
// an unchanged activeSkills list yields a byte-identical snapshot and
|
||||||
|
// agent-snapshot inheritance keeps working.
|
||||||
|
private async appendSkillTools(
|
||||||
|
tools: Array<z.infer<typeof ToolDescriptor>>,
|
||||||
|
activeSkills: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const attached = new Set(tools.map((tool) => tool.name));
|
||||||
|
for (const skillId of activeSkills) {
|
||||||
|
for (const name of this.skillTools(skillId)) {
|
||||||
|
if (attached.has(name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const builtin = this.builtins[name];
|
||||||
|
if (!builtin) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (builtin.isAvailable && !(await builtin.isAvailable())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tools.push(builtinToolDescriptor(name, builtin));
|
||||||
|
attached.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveTools(
|
private async resolveTools(
|
||||||
agent: z.infer<typeof Agent>,
|
agent: z.infer<typeof Agent>,
|
||||||
options: { subagent: boolean },
|
options: { subagent: boolean },
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from "@x/shared/dist/turns.js";
|
} from "@x/shared/dist/turns.js";
|
||||||
import { execTool } from "../../application/lib/exec-tool.js";
|
import { execTool } from "../../application/lib/exec-tool.js";
|
||||||
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
||||||
|
import { TOOL_ADDITIONS_KEY } from "../../application/lib/tool-additions.js";
|
||||||
import {
|
import {
|
||||||
type IAbortRegistry,
|
type IAbortRegistry,
|
||||||
InMemoryAbortRegistry,
|
InMemoryAbortRegistry,
|
||||||
|
|
@ -214,9 +215,15 @@ export class RealToolRegistry implements IToolRegistry {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const { output, additions } = splitToolAdditions(
|
||||||
|
toJsonValue(value === undefined ? null : value),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
output: toJsonValue(value === undefined ? null : value),
|
output,
|
||||||
isError: false,
|
isError: false,
|
||||||
|
...(additions === undefined
|
||||||
|
? {}
|
||||||
|
: { metadata: { toolAdditions: additions } }),
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
ctx.signal.removeEventListener("abort", onAbort);
|
ctx.signal.removeEventListener("abort", onAbort);
|
||||||
|
|
@ -227,6 +234,26 @@ export class RealToolRegistry implements IToolRegistry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lift a builtin's reserved tool-additions key out of the model-visible
|
||||||
|
// output into result metadata: the model gets the added tools as native
|
||||||
|
// definitions on its next call, so repeating their schemas inside the tool
|
||||||
|
// result would only burn tokens. Shape validation happens in the runtime.
|
||||||
|
function splitToolAdditions(value: JsonValue): {
|
||||||
|
output: JsonValue;
|
||||||
|
additions?: JsonValue;
|
||||||
|
} {
|
||||||
|
if (
|
||||||
|
value !== null &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
TOOL_ADDITIONS_KEY in value
|
||||||
|
) {
|
||||||
|
const { [TOOL_ADDITIONS_KEY]: additions, ...output } = value;
|
||||||
|
return { output, additions };
|
||||||
|
}
|
||||||
|
return { output: value };
|
||||||
|
}
|
||||||
|
|
||||||
function asArgs(input: unknown): Record<string, unknown> {
|
function asArgs(input: unknown): Record<string, unknown> {
|
||||||
return input && typeof input === "object"
|
return input && typeof input === "object"
|
||||||
? (input as Record<string, unknown>)
|
? (input as Record<string, unknown>)
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,16 @@ import {
|
||||||
type ResolvedAgent,
|
type ResolvedAgent,
|
||||||
type ToolDescriptor,
|
type ToolDescriptor,
|
||||||
type TurnState,
|
type TurnState,
|
||||||
|
effectiveTools,
|
||||||
requestMessagesFor,
|
requestMessagesFor,
|
||||||
} from "@x/shared/dist/turns.js";
|
} from "@x/shared/dist/turns.js";
|
||||||
import type { IContextResolver } from "./context-resolver.js";
|
import type { IContextResolver } from "./context-resolver.js";
|
||||||
|
|
||||||
// The exact provider payload for one model call, rebuilt deterministically
|
// The exact provider payload for one model call, rebuilt deterministically
|
||||||
// from durable state (turn-runtime-design.md §8.3):
|
// from durable state (turn-runtime-design.md §8.3):
|
||||||
// - systemPrompt and tools come from the resolved agent snapshot (their
|
// - systemPrompt and base tools come from the resolved agent snapshot
|
||||||
// single canonical copy in turn_created),
|
// (their single canonical copy in turn_created); tools_extended events
|
||||||
|
// add descriptors for the calls requested after them,
|
||||||
// - messages are the cross-turn prefix plus every request's reference list
|
// - messages are the cross-turn prefix plus every request's reference list
|
||||||
// resolved against the turn's own events, encoded to wire form.
|
// resolved against the turn's own events, encoded to wire form.
|
||||||
// This is the SAME code path the loop sends through, so the debug view and
|
// This is the SAME code path the loop sends through, so the debug view and
|
||||||
|
|
@ -47,7 +49,9 @@ export function composeModelRequest(
|
||||||
return {
|
return {
|
||||||
systemPrompt: agent.systemPrompt,
|
systemPrompt: agent.systemPrompt,
|
||||||
messages: encode(structural),
|
messages: encode(structural),
|
||||||
tools: agent.tools,
|
// The snapshot's base tools plus any durable mid-turn extensions
|
||||||
|
// recorded before this call (tools_extended events).
|
||||||
|
tools: effectiveTools(state, modelCallIndex, agent.tools),
|
||||||
parameters: call.request.parameters,
|
parameters: call.request.parameters,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2071,3 +2071,246 @@ describe("concurrent sync tool execution (10.5)", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mid-turn tool extension", () => {
|
||||||
|
const writeDescriptor: z.infer<typeof ToolDescriptor> = {
|
||||||
|
toolId: "tool.write",
|
||||||
|
name: "write",
|
||||||
|
description: "Write tool",
|
||||||
|
inputSchema: {},
|
||||||
|
execution: "sync",
|
||||||
|
requiresHuman: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function additionsTool(
|
||||||
|
descriptor: z.infer<typeof ToolDescriptor>,
|
||||||
|
tools: Array<z.infer<typeof ToolDescriptor>>,
|
||||||
|
source = "organize-files",
|
||||||
|
): SyncRuntimeTool {
|
||||||
|
return syncTool(descriptor, async () => ({
|
||||||
|
output: { success: true },
|
||||||
|
isError: false,
|
||||||
|
metadata: { toolAdditions: { source, tools } },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeImpl = syncTool(writeDescriptor, async () => ({
|
||||||
|
output: "written",
|
||||||
|
isError: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("appends tools_extended and the added tool is callable on the next step", async () => {
|
||||||
|
const { runtime, repo, models } = makeRuntime({
|
||||||
|
models: [
|
||||||
|
respond(completedResp(assistantCalls(toolCallPart("tc1", "echo")))),
|
||||||
|
respond(completedResp(assistantCalls(toolCallPart("tc2", "write")))),
|
||||||
|
respond(completedResp(assistantText("done"))),
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
additionsTool(echoDescriptor, [writeDescriptor]),
|
||||||
|
writeImpl,
|
||||||
|
...defaultTools.slice(1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const turnId = await newTurn(runtime);
|
||||||
|
const { outcome, error } = await advanceAndSettle(runtime, turnId);
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
expect(outcome?.status).toBe("completed");
|
||||||
|
|
||||||
|
const log = await persisted(repo, turnId);
|
||||||
|
const extensions = log.filter((e) => e.type === "tools_extended");
|
||||||
|
expect(extensions).toHaveLength(1);
|
||||||
|
expect(extensions[0]).toMatchObject({
|
||||||
|
toolCallId: "tc1",
|
||||||
|
source: "organize-files",
|
||||||
|
tools: [writeDescriptor],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call 0 was composed without the tool; call 1 carries it natively.
|
||||||
|
expect(models.requests[0].tools.map((t) => t.name)).not.toContain("write");
|
||||||
|
expect(models.requests[1].tools.map((t) => t.name)).toContain("write");
|
||||||
|
|
||||||
|
// The reducer accepts the full history and the added tool executed.
|
||||||
|
const state = reduceTurn(log);
|
||||||
|
const tc2 = state.toolCalls.find((tc) => tc.toolCallId === "tc2");
|
||||||
|
expect(tc2?.toolId).toBe("tool.write");
|
||||||
|
expect(tc2?.result?.result.output).toBe("written");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("additions already attached dedupe to no event", async () => {
|
||||||
|
const { runtime, repo } = makeRuntime({
|
||||||
|
models: [
|
||||||
|
respond(completedResp(assistantCalls(toolCallPart("tc1", "echo")))),
|
||||||
|
respond(completedResp(assistantText("done"))),
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
additionsTool(echoDescriptor, [echoDescriptor], "self"),
|
||||||
|
...defaultTools.slice(1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const turnId = await newTurn(runtime);
|
||||||
|
const { outcome } = await advanceAndSettle(runtime, turnId);
|
||||||
|
expect(outcome?.status).toBe("completed");
|
||||||
|
const log = await persisted(repo, turnId);
|
||||||
|
expect(log.filter((e) => e.type === "tools_extended")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("additions without a live implementation are dropped, not fatal", async () => {
|
||||||
|
const ghost: z.infer<typeof ToolDescriptor> = {
|
||||||
|
...writeDescriptor,
|
||||||
|
toolId: "tool.ghost",
|
||||||
|
name: "ghost",
|
||||||
|
};
|
||||||
|
const { runtime, repo } = makeRuntime({
|
||||||
|
models: [
|
||||||
|
respond(completedResp(assistantCalls(toolCallPart("tc1", "echo")))),
|
||||||
|
respond(completedResp(assistantText("done"))),
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
additionsTool(echoDescriptor, [ghost]),
|
||||||
|
...defaultTools.slice(1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const turnId = await newTurn(runtime);
|
||||||
|
const { outcome } = await advanceAndSettle(runtime, turnId);
|
||||||
|
expect(outcome?.status).toBe("completed");
|
||||||
|
const log = await persisted(repo, turnId);
|
||||||
|
expect(log.filter((e) => e.type === "tools_extended")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("concurrent overlapping additions dedupe atomically to one descriptor", async () => {
|
||||||
|
const echo2Descriptor: z.infer<typeof ToolDescriptor> = {
|
||||||
|
...echoDescriptor,
|
||||||
|
toolId: "tool.echo2",
|
||||||
|
name: "echo2",
|
||||||
|
};
|
||||||
|
const { runtime, repo } = makeRuntime({
|
||||||
|
models: [
|
||||||
|
respond(
|
||||||
|
completedResp(
|
||||||
|
assistantCalls(
|
||||||
|
toolCallPart("tc1", "echo"),
|
||||||
|
toolCallPart("tc2", "echo2"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
respond(completedResp(assistantText("done"))),
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
additionsTool(echoDescriptor, [writeDescriptor], "skill-a"),
|
||||||
|
additionsTool(echo2Descriptor, [writeDescriptor], "skill-b"),
|
||||||
|
writeImpl,
|
||||||
|
...defaultTools.slice(1),
|
||||||
|
],
|
||||||
|
agent: {
|
||||||
|
...defaultAgent,
|
||||||
|
tools: [
|
||||||
|
echoDescriptor,
|
||||||
|
echo2Descriptor,
|
||||||
|
fetchDescriptor,
|
||||||
|
askHumanDescriptor,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const turnId = await newTurn(runtime);
|
||||||
|
const { outcome } = await advanceAndSettle(runtime, turnId);
|
||||||
|
expect(outcome?.status).toBe("completed");
|
||||||
|
|
||||||
|
const log = await persisted(repo, turnId);
|
||||||
|
const added = log
|
||||||
|
.filter((e) => e.type === "tools_extended")
|
||||||
|
.flatMap((e) => (e.type === "tools_extended" ? e.tools : []));
|
||||||
|
expect(added.filter((t) => t.name === "write")).toHaveLength(1);
|
||||||
|
// reduceTurn would throw on a collision; acceptance is the assertion.
|
||||||
|
expect(() => reduceTurn(log)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers from a log ending right after tools_extended", async () => {
|
||||||
|
const SEED_ID = "2026-07-02T10-00-00Z-0000009-000";
|
||||||
|
const repo = new InMemoryTurnRepo();
|
||||||
|
repo.seed([
|
||||||
|
{
|
||||||
|
type: "turn_created",
|
||||||
|
schemaVersion: 1,
|
||||||
|
turnId: SEED_ID,
|
||||||
|
ts: TS,
|
||||||
|
sessionId: null,
|
||||||
|
agent: { requested: { agentId: "copilot" }, resolved: defaultAgent },
|
||||||
|
context: [],
|
||||||
|
input: user("hello"),
|
||||||
|
config: {
|
||||||
|
autoPermission: false,
|
||||||
|
humanAvailable: true,
|
||||||
|
maxModelCalls: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "model_call_requested",
|
||||||
|
turnId: SEED_ID,
|
||||||
|
ts: TS,
|
||||||
|
modelCallIndex: 0,
|
||||||
|
request: { messages: ["input"], parameters: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "model_call_completed",
|
||||||
|
turnId: SEED_ID,
|
||||||
|
ts: TS,
|
||||||
|
modelCallIndex: 0,
|
||||||
|
message: assistantCalls(toolCallPart("tc1", "echo")),
|
||||||
|
finishReason: "stop",
|
||||||
|
usage: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool_invocation_requested",
|
||||||
|
turnId: SEED_ID,
|
||||||
|
ts: TS,
|
||||||
|
toolCallId: "tc1",
|
||||||
|
toolId: echoDescriptor.toolId,
|
||||||
|
toolName: "echo",
|
||||||
|
execution: "sync",
|
||||||
|
input: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
turnId: SEED_ID,
|
||||||
|
ts: TS,
|
||||||
|
toolCallId: "tc1",
|
||||||
|
toolName: "echo",
|
||||||
|
source: "sync",
|
||||||
|
result: { output: { success: true }, isError: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tools_extended",
|
||||||
|
turnId: SEED_ID,
|
||||||
|
ts: TS,
|
||||||
|
toolCallId: "tc1",
|
||||||
|
source: "organize-files",
|
||||||
|
tools: [writeDescriptor],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { runtime, models } = makeRuntime({
|
||||||
|
repo,
|
||||||
|
models: [
|
||||||
|
respond(completedResp(assistantCalls(toolCallPart("tc2", "write")))),
|
||||||
|
respond(completedResp(assistantText("done"))),
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
additionsTool(echoDescriptor, [writeDescriptor]),
|
||||||
|
writeImpl,
|
||||||
|
...defaultTools.slice(1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { outcome } = await advanceAndSettle(runtime, SEED_ID);
|
||||||
|
expect(outcome?.status).toBe("completed");
|
||||||
|
// The re-advance rebuilt the extended toolset from the durable log:
|
||||||
|
// its first composed request already includes the added tool, and the
|
||||||
|
// model's call to it executes.
|
||||||
|
expect(models.requests[0].tools.map((t) => t.name)).toContain("write");
|
||||||
|
const log = await persisted(repo, SEED_ID);
|
||||||
|
const state = reduceTurn(log);
|
||||||
|
expect(
|
||||||
|
state.toolCalls.find((tc) => tc.toolCallId === "tc2")?.result?.result
|
||||||
|
.output,
|
||||||
|
).toBe("written");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MAX_MODEL_CALLS,
|
DEFAULT_MAX_MODEL_CALLS,
|
||||||
MODEL_CALL_LIMIT_ERROR_CODE,
|
MODEL_CALL_LIMIT_ERROR_CODE,
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
assistantRef,
|
assistantRef,
|
||||||
toolResultRef,
|
toolResultRef,
|
||||||
type ToolCallState,
|
type ToolCallState,
|
||||||
type ToolDescriptor,
|
ToolDescriptor,
|
||||||
type ToolInvocationRequested,
|
type ToolInvocationRequested,
|
||||||
type ToolPermissionRequired,
|
type ToolPermissionRequired,
|
||||||
type ToolPermissionResolved,
|
type ToolPermissionResolved,
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
type TurnState,
|
type TurnState,
|
||||||
type TurnStreamEvent,
|
type TurnStreamEvent,
|
||||||
type TurnSuspended,
|
type TurnSuspended,
|
||||||
|
effectiveTools,
|
||||||
outstandingAsyncTools,
|
outstandingAsyncTools,
|
||||||
outstandingPermissions,
|
outstandingPermissions,
|
||||||
reduceTurn,
|
reduceTurn,
|
||||||
|
|
@ -228,8 +229,14 @@ export class TurnRuntime implements ITurnRuntime {
|
||||||
definition.agent.resolved,
|
definition.agent.resolved,
|
||||||
);
|
);
|
||||||
const model = await this.modelRegistry.resolve(resolvedAgent.model);
|
const model = await this.modelRegistry.resolve(resolvedAgent.model);
|
||||||
|
// Base snapshot plus every durable mid-turn extension already in the
|
||||||
|
// log — rebuilding from durable state IS the crash-recovery path.
|
||||||
const toolsByName = new Map<string, RuntimeTool>();
|
const toolsByName = new Map<string, RuntimeTool>();
|
||||||
for (const descriptor of resolvedAgent.tools) {
|
for (const descriptor of effectiveTools(
|
||||||
|
state,
|
||||||
|
state.modelCalls.length,
|
||||||
|
resolvedAgent.tools,
|
||||||
|
)) {
|
||||||
const tool = await this.toolRegistry.resolve(descriptor);
|
const tool = await this.toolRegistry.resolve(descriptor);
|
||||||
if (
|
if (
|
||||||
tool.descriptor.toolId !== descriptor.toolId ||
|
tool.descriptor.toolId !== descriptor.toolId ||
|
||||||
|
|
@ -264,6 +271,7 @@ export class TurnRuntime implements ITurnRuntime {
|
||||||
model,
|
model,
|
||||||
usageReporter: this.usageReporter,
|
usageReporter: this.usageReporter,
|
||||||
toolsByName,
|
toolsByName,
|
||||||
|
resolveTool: (descriptor) => this.toolRegistry.resolve(descriptor),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
turnRepo: this.turnRepo,
|
turnRepo: this.turnRepo,
|
||||||
clock: this.clock,
|
clock: this.clock,
|
||||||
|
|
@ -293,6 +301,9 @@ class TurnAdvance {
|
||||||
private readonly model: ResolvedModel;
|
private readonly model: ResolvedModel;
|
||||||
private readonly usageReporter: IUsageReporter;
|
private readonly usageReporter: IUsageReporter;
|
||||||
private readonly toolsByName: Map<string, RuntimeTool>;
|
private readonly toolsByName: Map<string, RuntimeTool>;
|
||||||
|
private readonly resolveTool: (
|
||||||
|
descriptor: z.infer<typeof ToolDescriptor>,
|
||||||
|
) => Promise<RuntimeTool>;
|
||||||
private readonly signal: AbortSignal;
|
private readonly signal: AbortSignal;
|
||||||
private readonly turnRepo: ITurnRepo;
|
private readonly turnRepo: ITurnRepo;
|
||||||
private readonly clock: IClock;
|
private readonly clock: IClock;
|
||||||
|
|
@ -315,6 +326,9 @@ class TurnAdvance {
|
||||||
model: ResolvedModel;
|
model: ResolvedModel;
|
||||||
usageReporter: IUsageReporter;
|
usageReporter: IUsageReporter;
|
||||||
toolsByName: Map<string, RuntimeTool>;
|
toolsByName: Map<string, RuntimeTool>;
|
||||||
|
resolveTool: (
|
||||||
|
descriptor: z.infer<typeof ToolDescriptor>,
|
||||||
|
) => Promise<RuntimeTool>;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
turnRepo: ITurnRepo;
|
turnRepo: ITurnRepo;
|
||||||
clock: IClock;
|
clock: IClock;
|
||||||
|
|
@ -330,6 +344,7 @@ class TurnAdvance {
|
||||||
this.model = init.model;
|
this.model = init.model;
|
||||||
this.usageReporter = init.usageReporter;
|
this.usageReporter = init.usageReporter;
|
||||||
this.toolsByName = init.toolsByName;
|
this.toolsByName = init.toolsByName;
|
||||||
|
this.resolveTool = init.resolveTool;
|
||||||
this.signal = init.signal;
|
this.signal = init.signal;
|
||||||
this.turnRepo = init.turnRepo;
|
this.turnRepo = init.turnRepo;
|
||||||
this.clock = init.clock;
|
this.clock = init.clock;
|
||||||
|
|
@ -363,6 +378,26 @@ class TurnAdvance {
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Like append, but the batch is computed inside this task's serialized
|
||||||
|
// commit slot, so the builder reads this.state with no interleaving
|
||||||
|
// commits. Required for the tools_extended dedupe: two concurrent tool
|
||||||
|
// results may each carry overlapping additions, and the second filter
|
||||||
|
// must see the first's committed extension. Returning undefined commits
|
||||||
|
// nothing.
|
||||||
|
private appendWith(build: () => TEvent[] | undefined): Promise<void> {
|
||||||
|
const task = this.appendChain.then(async () => {
|
||||||
|
const batch = build();
|
||||||
|
if (batch && batch.length > 0) {
|
||||||
|
await this.commit(batch);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.appendChain = task.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
private async commit(batch: TEvent[]): Promise<void> {
|
private async commit(batch: TEvent[]): Promise<void> {
|
||||||
await this.turnRepo.append(this.turnId, batch);
|
await this.turnRepo.append(this.turnId, batch);
|
||||||
this.events.push(...batch);
|
this.events.push(...batch);
|
||||||
|
|
@ -808,6 +843,7 @@ class TurnAdvance {
|
||||||
tc: ToolCallState,
|
tc: ToolCallState,
|
||||||
syncTool: SyncRuntimeTool,
|
syncTool: SyncRuntimeTool,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
let settled: z.infer<typeof ToolResultData> | undefined;
|
||||||
try {
|
try {
|
||||||
const result = await syncTool.execute(tc.input, {
|
const result = await syncTool.execute(tc.input, {
|
||||||
turnId: this.turnId,
|
turnId: this.turnId,
|
||||||
|
|
@ -824,6 +860,7 @@ class TurnAdvance {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
settled = ToolResultData.parse(result);
|
||||||
await this.append({
|
await this.append({
|
||||||
type: "tool_result",
|
type: "tool_result",
|
||||||
turnId: this.turnId,
|
turnId: this.turnId,
|
||||||
|
|
@ -831,7 +868,7 @@ class TurnAdvance {
|
||||||
toolCallId: tc.toolCallId,
|
toolCallId: tc.toolCallId,
|
||||||
toolName: tc.toolName,
|
toolName: tc.toolName,
|
||||||
source: "sync",
|
source: "sync",
|
||||||
result: ToolResultData.parse(result),
|
result: settled,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.signal.aborted) {
|
if (this.signal.aborted) {
|
||||||
|
|
@ -852,6 +889,87 @@ class TurnAdvance {
|
||||||
source: "sync",
|
source: "sync",
|
||||||
result: { output: errorMessage(error), isError: true },
|
result: { output: errorMessage(error), isError: true },
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.maybeExtendTools(tc, settled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mid-turn toolset extension (the one sanctioned exception to per-turn
|
||||||
|
// tool immutability): a successful sync tool may carry additional
|
||||||
|
// descriptors in result.metadata.toolAdditions — loadSkill attaching a
|
||||||
|
// skill's declared tools. Descriptors resolve to live implementations
|
||||||
|
// first; unresolvable ones are dropped so the durable event only ever
|
||||||
|
// records tools a re-advance can also resolve. The dedupe against the
|
||||||
|
// current effective set and the tools_extended append run atomically in
|
||||||
|
// one serialized commit slot, so concurrent extensions cannot race the
|
||||||
|
// reducer's collision check. Subsequent model calls in this turn see the
|
||||||
|
// added tools via effectiveTools.
|
||||||
|
private async maybeExtendTools(
|
||||||
|
tc: ToolCallState,
|
||||||
|
result: z.infer<typeof ToolResultData>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (result.isError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const additions = parseToolAdditions(result.metadata);
|
||||||
|
if (!additions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const live = new Map<string, RuntimeTool>();
|
||||||
|
const candidates: Array<z.infer<typeof ToolDescriptor>> = [];
|
||||||
|
for (const descriptor of additions.tools) {
|
||||||
|
if (live.has(descriptor.name)) {
|
||||||
|
continue; // duplicate within the payload
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tool = await this.resolveTool(descriptor);
|
||||||
|
if (
|
||||||
|
tool.descriptor.toolId !== descriptor.toolId ||
|
||||||
|
tool.descriptor.execution !== descriptor.execution
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
live.set(descriptor.name, tool);
|
||||||
|
candidates.push(descriptor);
|
||||||
|
} catch {
|
||||||
|
// No live implementation — skip the tool; the result that
|
||||||
|
// carried it (e.g. the skill guidance) still stands.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let added: Array<z.infer<typeof ToolDescriptor>> = [];
|
||||||
|
await this.appendWith(() => {
|
||||||
|
const attached = new Set(
|
||||||
|
effectiveTools(
|
||||||
|
this.state,
|
||||||
|
this.state.modelCalls.length,
|
||||||
|
this.resolvedAgent.tools,
|
||||||
|
).map((tool) => tool.name),
|
||||||
|
);
|
||||||
|
added = candidates.filter(
|
||||||
|
(descriptor) => !attached.has(descriptor.name),
|
||||||
|
);
|
||||||
|
if (added.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "tools_extended",
|
||||||
|
turnId: this.turnId,
|
||||||
|
ts: this.now(),
|
||||||
|
toolCallId: tc.toolCallId,
|
||||||
|
source: additions.source,
|
||||||
|
tools: added,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
for (const descriptor of added) {
|
||||||
|
const tool = live.get(descriptor.name);
|
||||||
|
if (tool) {
|
||||||
|
this.toolsByName.set(descriptor.name, tool);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1114,6 +1232,27 @@ class TurnAdvance {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The metadata contract populated by the tool-registry bridge from a
|
||||||
|
// builtin's reserved $toolAdditions return key (application/lib/
|
||||||
|
// tool-additions.ts). Anything malformed is ignored — a bad payload must
|
||||||
|
// never corrupt the turn.
|
||||||
|
const ToolAdditionsMetadata = z.object({
|
||||||
|
toolAdditions: z.object({
|
||||||
|
source: z.string(),
|
||||||
|
tools: z.array(ToolDescriptor).min(1),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseToolAdditions(
|
||||||
|
metadata: JsonValue | undefined,
|
||||||
|
): { source: string; tools: Array<z.infer<typeof ToolDescriptor>> } | undefined {
|
||||||
|
if (metadata === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = ToolAdditionsMetadata.safeParse(metadata);
|
||||||
|
return parsed.success ? parsed.data.toolAdditions : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
ToolPermissionResolved,
|
ToolPermissionResolved,
|
||||||
ToolProgress,
|
ToolProgress,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
|
ToolsExtended,
|
||||||
TurnCancelled,
|
TurnCancelled,
|
||||||
TurnCompleted,
|
TurnCompleted,
|
||||||
TurnCorruptionError,
|
TurnCorruptionError,
|
||||||
|
|
@ -23,6 +24,8 @@ import {
|
||||||
TurnFailed,
|
TurnFailed,
|
||||||
TurnSuspended,
|
TurnSuspended,
|
||||||
deriveTurnStatus,
|
deriveTurnStatus,
|
||||||
|
effectiveTools,
|
||||||
|
extendedToolsFor,
|
||||||
outstandingAsyncTools,
|
outstandingAsyncTools,
|
||||||
outstandingPermissions,
|
outstandingPermissions,
|
||||||
reduceTurn,
|
reduceTurn,
|
||||||
|
|
@ -1419,3 +1422,159 @@ describe("derivations", () => {
|
||||||
expect(() => turnTranscript(state)).toThrowError(/unresolved/);
|
expect(() => turnTranscript(state)).toThrowError(/unresolved/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mid-turn tool extension", () => {
|
||||||
|
const writeTool: z.infer<typeof ToolDescriptor> = {
|
||||||
|
toolId: "builtin:file-writeText",
|
||||||
|
name: "file-writeText",
|
||||||
|
description: "Write a text file",
|
||||||
|
inputSchema: {},
|
||||||
|
execution: "sync",
|
||||||
|
requiresHuman: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function extendedEv(
|
||||||
|
toolCallId: string,
|
||||||
|
tools: Array<z.infer<typeof ToolDescriptor>> = [writeTool],
|
||||||
|
source = "organize-files",
|
||||||
|
): z.infer<typeof ToolsExtended> {
|
||||||
|
return {
|
||||||
|
type: "tools_extended",
|
||||||
|
turnId: TURN_ID,
|
||||||
|
ts: TS,
|
||||||
|
toolCallId,
|
||||||
|
source,
|
||||||
|
tools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// created → call 0 → echo tc1 (success) → tools_extended riding tc1.
|
||||||
|
function extensionSequence(): TEvent[] {
|
||||||
|
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||||
|
return [
|
||||||
|
created(),
|
||||||
|
requested(0, ["input"]),
|
||||||
|
completed(0, call0),
|
||||||
|
permRequired("tc1", "echo"),
|
||||||
|
permResolved("tc1", "allow"),
|
||||||
|
invocation("tc1"),
|
||||||
|
result("tc1", "echo"),
|
||||||
|
extendedEv("tc1"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
it("round-trips through the TurnEvent schema", () => {
|
||||||
|
expect(TurnEvent.parse(extendedEv("tc1"))).toEqual(extendedEv("tc1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records the extension and applies it from the next model call on", () => {
|
||||||
|
const state = reduceTurn(extensionSequence());
|
||||||
|
expect(state.toolExtensions).toHaveLength(1);
|
||||||
|
expect(state.toolExtensions[0].firstAffectedModelCallIndex).toBe(1);
|
||||||
|
|
||||||
|
const base = [echoTool, fetchTool];
|
||||||
|
expect(effectiveTools(state, 0, base)).toEqual(base);
|
||||||
|
expect(effectiveTools(state, 1, base)).toEqual([...base, writeTool]);
|
||||||
|
expect(extendedToolsFor(state, 0)).toEqual([]);
|
||||||
|
expect(extendedToolsFor(state, 1)).toEqual([writeTool]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stamps descriptor identity on calls to extended tools", () => {
|
||||||
|
const state = reduceTurn([
|
||||||
|
...extensionSequence(),
|
||||||
|
requested(1, ["assistant:0", "toolResult:tc1"]),
|
||||||
|
completed(1, assistantCalls(toolCallPart("tc2", "file-writeText"))),
|
||||||
|
]);
|
||||||
|
const tc2 = state.toolCalls.find((tc) => tc.toolCallId === "tc2");
|
||||||
|
expect(tc2?.toolId).toBe("builtin:file-writeText");
|
||||||
|
expect(tc2?.execution).toBe("sync");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a full turn with a mid-turn extension completes cleanly", () => {
|
||||||
|
const state = reduceTurn([
|
||||||
|
...extensionSequence(),
|
||||||
|
requested(1, ["assistant:0", "toolResult:tc1"]),
|
||||||
|
completed(1, assistantText("done")),
|
||||||
|
turnCompletedEv(),
|
||||||
|
]);
|
||||||
|
expect(state.terminal?.type).toBe("turn_completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an extension referencing an unknown tool call", () => {
|
||||||
|
expectCorruption(
|
||||||
|
[created(), extendedEv("nope")],
|
||||||
|
/unknown tool call/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an extension before its tool call has a result", () => {
|
||||||
|
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||||
|
expectCorruption(
|
||||||
|
[
|
||||||
|
created(),
|
||||||
|
requested(0, ["input"]),
|
||||||
|
completed(0, call0),
|
||||||
|
permRequired("tc1", "echo"),
|
||||||
|
permResolved("tc1", "allow"),
|
||||||
|
invocation("tc1"),
|
||||||
|
extendedEv("tc1"),
|
||||||
|
],
|
||||||
|
/without a tool result/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an extension riding a failed tool result", () => {
|
||||||
|
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||||
|
expectCorruption(
|
||||||
|
[
|
||||||
|
created(),
|
||||||
|
requested(0, ["input"]),
|
||||||
|
completed(0, call0),
|
||||||
|
permRequired("tc1", "echo"),
|
||||||
|
permResolved("tc1", "allow"),
|
||||||
|
invocation("tc1"),
|
||||||
|
result("tc1", "echo", "sync", "boom", true),
|
||||||
|
extendedEv("tc1"),
|
||||||
|
],
|
||||||
|
/failed tool call/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a name colliding with the base snapshot", () => {
|
||||||
|
expectCorruption(
|
||||||
|
[
|
||||||
|
...extensionSequence().slice(0, -1),
|
||||||
|
extendedEv("tc1", [{ ...writeTool, name: "echo" }]),
|
||||||
|
],
|
||||||
|
/colliding tool name: echo/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a name colliding with a prior extension", () => {
|
||||||
|
expectCorruption(
|
||||||
|
[...extensionSequence(), extendedEv("tc1", [writeTool])],
|
||||||
|
/colliding tool name: file-writeText/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate names within one extension", () => {
|
||||||
|
expectCorruption(
|
||||||
|
[
|
||||||
|
...extensionSequence().slice(0, -1),
|
||||||
|
extendedEv("tc1", [writeTool, { ...writeTool }]),
|
||||||
|
],
|
||||||
|
/colliding tool name: file-writeText/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an extension while a model call is unsettled", () => {
|
||||||
|
expectCorruption(
|
||||||
|
[
|
||||||
|
...extensionSequence().slice(0, -1),
|
||||||
|
requested(1, ["assistant:0", "toolResult:tc1"]),
|
||||||
|
extendedEv("tc1"),
|
||||||
|
],
|
||||||
|
/model call is unsettled/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,22 @@ export const ToolResult = z.object({
|
||||||
result: ToolResultData,
|
result: ToolResultData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A successful sync tool extended the turn's toolset mid-turn (e.g. loadSkill
|
||||||
|
// attaching a skill's declared tools). The descriptors are snapshotted in the
|
||||||
|
// event so replay needs no external lookups; they apply to every model call
|
||||||
|
// requested after this point. This is the one sanctioned exception to the
|
||||||
|
// tool-set immutability rule — explicit and durable, never silent.
|
||||||
|
export const ToolsExtended = z.object({
|
||||||
|
type: z.literal("tools_extended"),
|
||||||
|
turnId: z.string(),
|
||||||
|
ts: z.string(),
|
||||||
|
// The tool result that carried these additions (metadata.addTools).
|
||||||
|
toolCallId: z.string(),
|
||||||
|
// Human-readable origin, e.g. the skill id that declared the tools.
|
||||||
|
source: z.string(),
|
||||||
|
tools: z.array(ToolDescriptor).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export const TurnSuspended = z.object({
|
export const TurnSuspended = z.object({
|
||||||
type: z.literal("turn_suspended"),
|
type: z.literal("turn_suspended"),
|
||||||
turnId: z.string(),
|
turnId: z.string(),
|
||||||
|
|
@ -424,6 +440,7 @@ export const TurnEvent = z.discriminatedUnion("type", [
|
||||||
ToolInvocationRequested,
|
ToolInvocationRequested,
|
||||||
ToolProgress,
|
ToolProgress,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
|
ToolsExtended,
|
||||||
TurnSuspended,
|
TurnSuspended,
|
||||||
TurnCompleted,
|
TurnCompleted,
|
||||||
TurnFailed,
|
TurnFailed,
|
||||||
|
|
@ -496,10 +513,18 @@ export interface ToolCallState {
|
||||||
result?: z.infer<typeof ToolResult>;
|
result?: z.infer<typeof ToolResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolExtensionState {
|
||||||
|
event: z.infer<typeof ToolsExtended>;
|
||||||
|
// Extensions land between model calls; every call requested from this
|
||||||
|
// index onward sees the added tools.
|
||||||
|
firstAffectedModelCallIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TurnState {
|
export interface TurnState {
|
||||||
definition: z.infer<typeof TurnCreated>;
|
definition: z.infer<typeof TurnCreated>;
|
||||||
modelCalls: ModelCallState[];
|
modelCalls: ModelCallState[];
|
||||||
toolCalls: ToolCallState[];
|
toolCalls: ToolCallState[];
|
||||||
|
toolExtensions: ToolExtensionState[];
|
||||||
suspension?: z.infer<typeof TurnSuspended>;
|
suspension?: z.infer<typeof TurnSuspended>;
|
||||||
terminal?:
|
terminal?:
|
||||||
| z.infer<typeof TurnCompleted>
|
| z.infer<typeof TurnCompleted>
|
||||||
|
|
@ -675,11 +700,17 @@ function applyModelCallCompleted(
|
||||||
fail(`duplicate tool call id: ${part.toolCallId}`);
|
fail(`duplicate tool call id: ${part.toolCallId}`);
|
||||||
}
|
}
|
||||||
const resolved = state.definition.agent.resolved;
|
const resolved = state.definition.agent.resolved;
|
||||||
// Inherited snapshots resolve outside the reducer; identity fields
|
// Mid-turn extensions are searched first (their descriptors are
|
||||||
// then arrive via tool_invocation_requested events.
|
// always in the log); inherited base snapshots resolve outside the
|
||||||
const descriptor = isInheritedSnapshot(resolved)
|
// reducer, so identity fields for those tools arrive via
|
||||||
? undefined
|
// tool_invocation_requested events instead.
|
||||||
: resolved.tools.find((tool) => tool.name === part.toolName);
|
const descriptor =
|
||||||
|
extendedToolsFor(state, event.modelCallIndex).find(
|
||||||
|
(tool) => tool.name === part.toolName,
|
||||||
|
) ??
|
||||||
|
(isInheritedSnapshot(resolved)
|
||||||
|
? undefined
|
||||||
|
: resolved.tools.find((tool) => tool.name === part.toolName));
|
||||||
state.toolCalls.push({
|
state.toolCalls.push({
|
||||||
modelCallIndex: event.modelCallIndex,
|
modelCallIndex: event.modelCallIndex,
|
||||||
order: order++,
|
order: order++,
|
||||||
|
|
@ -841,6 +872,44 @@ function applyToolResult(
|
||||||
toolCall.result = event;
|
toolCall.result = event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyToolsExtended(
|
||||||
|
state: TurnState,
|
||||||
|
event: z.infer<typeof ToolsExtended>,
|
||||||
|
): void {
|
||||||
|
if (openModelCall(state)) {
|
||||||
|
fail("tools extended while a model call is unsettled");
|
||||||
|
}
|
||||||
|
const toolCall = findToolCall(state, event.toolCallId);
|
||||||
|
if (!toolCall.result) {
|
||||||
|
fail(`tools extended without a tool result: ${event.toolCallId}`);
|
||||||
|
}
|
||||||
|
if (toolCall.result.result.isError) {
|
||||||
|
fail(`tools extended by a failed tool call: ${event.toolCallId}`);
|
||||||
|
}
|
||||||
|
// Collision check covers prior extensions and (when visible) the base
|
||||||
|
// snapshot; inherited snapshots are opaque to the reducer, so their base
|
||||||
|
// names are enforced by the runtime's dedupe instead.
|
||||||
|
const existing = new Set(
|
||||||
|
extendedToolsFor(state, state.modelCalls.length).map((t) => t.name),
|
||||||
|
);
|
||||||
|
const resolved = state.definition.agent.resolved;
|
||||||
|
if (!isInheritedSnapshot(resolved)) {
|
||||||
|
for (const tool of resolved.tools) {
|
||||||
|
existing.add(tool.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const tool of event.tools) {
|
||||||
|
if (existing.has(tool.name)) {
|
||||||
|
fail(`tools extended with a colliding tool name: ${tool.name}`);
|
||||||
|
}
|
||||||
|
existing.add(tool.name);
|
||||||
|
}
|
||||||
|
state.toolExtensions.push({
|
||||||
|
event,
|
||||||
|
firstAffectedModelCallIndex: state.modelCalls.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function sortedIds(ids: string[]): string {
|
function sortedIds(ids: string[]): string {
|
||||||
return [...ids].sort().join(",");
|
return [...ids].sort().join(",");
|
||||||
}
|
}
|
||||||
|
|
@ -929,6 +998,7 @@ export function reduceTurn(
|
||||||
definition: first,
|
definition: first,
|
||||||
modelCalls: [],
|
modelCalls: [],
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
|
toolExtensions: [],
|
||||||
usage: {},
|
usage: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -978,6 +1048,9 @@ export function reduceTurn(
|
||||||
case "tool_result":
|
case "tool_result":
|
||||||
applyToolResult(state, event);
|
applyToolResult(state, event);
|
||||||
break;
|
break;
|
||||||
|
case "tools_extended":
|
||||||
|
applyToolsExtended(state, event);
|
||||||
|
break;
|
||||||
case "turn_suspended":
|
case "turn_suspended":
|
||||||
applyTurnSuspended(state, event);
|
applyTurnSuspended(state, event);
|
||||||
break;
|
break;
|
||||||
|
|
@ -1006,6 +1079,29 @@ export function reduceTurn(
|
||||||
// Pure derivations over TurnState
|
// Pure derivations over TurnState
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Descriptors added by durable extensions in effect for a given model call.
|
||||||
|
export function extendedToolsFor(
|
||||||
|
state: TurnState,
|
||||||
|
modelCallIndex: number,
|
||||||
|
): Array<z.infer<typeof ToolDescriptor>> {
|
||||||
|
return state.toolExtensions
|
||||||
|
.filter((ext) => ext.firstAffectedModelCallIndex <= modelCallIndex)
|
||||||
|
.flatMap((ext) => ext.event.tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The full toolset a given model call sees: the agent snapshot's base tools
|
||||||
|
// plus every extension recorded before that call was requested. The base set
|
||||||
|
// comes from the materialized agent (inherited snapshots are opaque here),
|
||||||
|
// so callers pass it in.
|
||||||
|
export function effectiveTools(
|
||||||
|
state: TurnState,
|
||||||
|
modelCallIndex: number,
|
||||||
|
baseTools: Array<z.infer<typeof ToolDescriptor>>,
|
||||||
|
): Array<z.infer<typeof ToolDescriptor>> {
|
||||||
|
const extended = extendedToolsFor(state, modelCallIndex);
|
||||||
|
return extended.length === 0 ? baseTools : [...baseTools, ...extended];
|
||||||
|
}
|
||||||
|
|
||||||
export function outstandingPermissions(state: TurnState): ToolCallState[] {
|
export function outstandingPermissions(state: TurnState): ToolCallState[] {
|
||||||
return state.toolCalls.filter(
|
return state.toolCalls.filter(
|
||||||
(tc) => tc.permission && !tc.permission.resolved && !tc.result,
|
(tc) => tc.permission && !tc.permission.resolved && !tc.result,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue