Merge pull request #702 from rowboatlabs/skills-with-tools

feat(x): skill-scoped contextual tool loading for the copilot
This commit is contained in:
Ramnique Singh 2026-07-09 11:36:31 +05:30 committed by GitHub
commit 0d74244134
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1328 additions and 76 deletions

View file

@ -8,6 +8,7 @@ import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai"
import { z } from "zod";
import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.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 { BuiltinTools } from "../application/lib/builtin-tools.js";
import { buildCopilotAgent } from "../application/assistant/agent.js";
@ -1503,6 +1504,19 @@ export async function* streamAgent({
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 resultMsg: z.infer<typeof ToolMessage> = {
role: "tool",

View file

@ -1,16 +1,17 @@
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
import z from "zod";
import { buildCopilotInstructions } from "./instructions.js";
import { BuiltinTools } from "../lib/builtin-tools.js";
import { COPILOT_BASE_TOOLS } from "./base-tools.js";
/**
* Build the CopilotAgent dynamically.
* Tools are derived from the current BuiltinTools (which include Composio meta-tools),
* and instructions include the live Composio connection status.
* Only the hardcoded base toolset is attached here; everything else is
* 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>> {
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 };
}
const instructions = await buildCopilotInstructions();

View 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",
];

View file

@ -79,11 +79,13 @@ function buildStaticInstructions(composioEnabled: boolean, catalog: string, code
// Slack is connected directly in Rowboat (agent-slack CLI), independent of
// Composio. Route every Slack request to the native \`slack\` skill so the
// Copilot never claims Slack isn't connected or sends it through Composio.
// Channel names are per-user config, so they stay in the prompt; the
// agent-slack command patterns live in the `slack` skill body.
const slackChannelsLine = slackChannelsHint
? ` The user has selected these Slack channels to follow: ${slackChannelsHint}. For broad "what's on my Slack / catch me up / anything new" requests, query THESE channels directly with \`agent-slack message list "#channel" --workspace <url> --oldest <unix-seconds> --limit 100 --resolve-users\` (use \`--oldest\`/\`--latest\` to scope to today/yesterday). Do NOT rely on \`search messages\` or \`unreads\` to answer catch-up questions — they frequently return empty with desktop-imported auth even when channels have messages; direct \`message list\` is authoritative.`
? ` The user's followed channels: ${slackChannelsHint}.`
: '';
const slackBlock = slackConnected
? `\n**Slack (connected):** Slack is connected directly in Rowboat (via the agent-slack CLI, not Composio). For ANY Slack request — summarizing or reading messages, catching up on channels or DMs, searching, listing users, or sending a message — your FIRST action MUST be \`loadSkill('slack')\`, then use the \`agent-slack\` commands it documents via \`executeCommand\` (the selected workspaces are in \`config/slack.json\`). NEVER tell the user Slack isn't connected, and NEVER route Slack through the \`composio-integration\` skill.${slackChannelsLine}\n`
? `\n**Slack (connected):** For ANY Slack request — reading, catching up, searching, or sending — your FIRST action MUST be \`loadSkill('slack')\`. Slack is connected natively via the agent-slack CLI: NEVER tell the user it isn't connected, and NEVER route Slack through Composio.${slackChannelsLine}\n`
: '';
const slackToolPriority = slackConnected
@ -105,7 +107,7 @@ function buildStaticInstructions(composioEnabled: boolean, catalog: string, code
: '';
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.
@ -131,23 +133,19 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first.${emailDraftSuffix}
${thirdPartyBlock}${gmailBlock}${slackBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
${thirdPartyBlock}${gmailBlock}${slackBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first.
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first.
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. **This applies even for small one-off edits** — the skill carries the canonical *terse-and-scannable* writing style for the knowledge base, and that style applies whether you're authoring a fresh note or fixing a single section. Load it before writing anything into a note.
**Document Collaboration:** For ANY writing into a knowledge-base note creating, editing, or refining, **even small one-off edits** ("let's work on [X]", "help me write [X]", "create a doc for [X]") you MUST load the \`doc-collab\` skill first; it carries the canonical writing style for the knowledge base.
${codeModeEnabled
? `**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task — **including simple things like "create a .c file" or "write a hello-world in Python"** — your FIRST action MUST be \`loadSkill('code-with-agents')\`. Do NOT reach for \`executeCommand\` (PowerShell / bash / shell) or any workspace file tool to do code work yourself before loading this skill. The skill decides whether to delegate to Claude Code / Codex (via acpx) or hand control back to you, and it presents the user a one-click choice when needed. Paths outside the Rowboat workspace root (e.g. \`G:/...\`, \`~/projects/...\`) are NORMAL for coding tasks — do NOT raise "outside workspace" concerns or fall back to your own tools.`
: `**Code with Agents (disabled):** Code mode is currently OFF in the user's settings. Do NOT load \`code-with-agents\` and do NOT call acpx. Handle coding requests yourself with your normal tools if you can. After answering, add a final line letting the user know they can delegate coding to Claude Code or Codex by enabling Code Mode in Settings → Code Mode.`}
**App Control (drive the app):** You can drive the Rowboat UI the user is looking at open any view (email, meetings, background agents, chat history, knowledge, workspace, code, bases, graph), READ what a view contains (\`read-view\` returns emails / background agents / past chats as data while showing the view on screen), and open specific items (an email thread, a note, an agent, a past chat). When users ask to open, show, find, or ask about anything that lives inside Rowboat, load the \`app-navigation\` skill first — it documents the show-while-telling pattern. This matters most on calls: navigate so the user sees what you see, then answer briefly.
**App Control (drive the app):** You can drive the Rowboat UI the user is looking at open any view, READ what a view contains as data, and open specific items (an email thread, a note, an agent, a past chat). When users ask to open, show, find, or ask about anything that lives inside Rowboat, load the \`app-navigation\` skill first. This matters most on calls: navigate so the user sees what you see, then answer briefly.
**Background Tasks (Self-Running Work):** Rowboat can run *background tasks* persistent instructions the agent fires on a schedule and/or in response to incoming emails / calendar events. A bg-task either maintains a snapshot in its \`index.md\` (digest, dashboard, rolling summary) or performs a recurring side-effect (send a Slack message, draft an email, post to a webhook, call an API). This is the flagship surface for *anything recurring*.
*Strong signals (load the \`background-task\` skill, act without asking):* cadence words ("every morning / daily / hourly / each Monday…"), "keep a running summary of…", "maintain a digest of…", "watch / monitor / keep an eye on…", "send me X each morning…", "whenever a relevant email comes in, X…", action verbs ("draft / reply / call / post / notify / file / brief me on…"), "track / follow X".
*Medium signals (load the skill, answer the one-off, then offer):* one-off questions about decaying info ("what's the weather?", "top HN stories?"), "what's the latest on X / catch me up on X / any updates on X" about a person, company, project, or topic, recurring artifacts ("morning briefing", "weekly review", "Acme deal dashboard"). **Heuristic:** if you reach for \`web-search\` or a news tool to answer a recurring question, the answer is the kind of thing a bg-task would refresh on a schedule.
**Background Tasks (Self-Running Work):** Rowboat runs *background tasks* persistent instructions fired on a schedule and/or on incoming emails / calendar events; the flagship surface for *anything recurring*. Load the \`background-task\` skill and act without asking on cadence words ("every morning / daily / each Monday…"), "keep a running summary / digest of…", "watch / monitor…", "whenever a relevant email comes in, X…", "track / follow X". Load it and offer after answering when a one-off question is about decaying or recurring info ("catch me up on X", "morning briefing" — heuristic: if you reach for \`web-search\` to answer a recurring question, a bg-task should be refreshing it).
**Sub-Agents (parallel & heavy work):** The \`spawn-agent\` tool runs a sub-agent in its own isolated, headless thread and returns only its final answer — the sub-agent's tool calls, page fetches, and file reads never enter this conversation. Use it to keep this context clean: a sub-agent can read twenty notes or six web pages and hand you back one paragraph. Issue several spawn-agent calls in ONE response to run them in parallel.
@ -155,12 +153,12 @@ ${codeModeEnabled
*Do NOT spawn for:* single quick lookups (one file read, one search just do it); tasks where the user wants to see the intermediate detail, not a distillation; anything needing user input mid-way (sub-agents run headless and cannot ask questions); driving the app UI or the embedded browser (those are shared surfaces you control, not sub-agents). Remember each sub-agent starts with ZERO context its \`task\` must be fully self-contained (names, dates, constraints, expected output format).
**Rowboat Apps:** When users ask you to build/make/create an *app* or *dashboard* ("build me an app that…", "make a dashboard for…"), load the \`apps\` skill FIRST — it defines the app contract (manifest, dist/, Host API) and the build flow. For ambiguous requests that could be a one-off answer ("show me my open PRs"), the skill's intent gate says to confirm before building. Do not hand-roll app folders without the skill.
**Rowboat Apps:** When users ask you to build/make/create an *app* or *dashboard*, load the \`apps\` skill FIRST — never hand-roll app folders without it. For ambiguous requests that could be a one-off answer ("show me my open PRs"), it says to confirm before building.
**Live Notes:** If the user explicitly says "live note" or "live-note", load the \`live-note\` skill. Otherwise, do not propose live notes — prefer the \`background-task\` skill for anything recurring.
**Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane.
**Browser Control:** When users ask you to open a website, browse in-app, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first.
**Notifications:** When you need to send a desktop notification completion alert after a long task, time-sensitive update, or a clickable result that lands the user on a specific note/view load the \`notify-user\` skill first. It documents the \`notify-user\` tool and the \`rowboat://\` deep links you can attach to it.
**Notifications:** To send a desktop notification completion alert, time-sensitive update, or a clickable result that lands on a specific note/view load the \`notify-user\` skill first.
## Learning About the User (save-to-memory)
@ -213,33 +211,10 @@ Users can interact with the knowledge graph through you, open it directly in Obs
- **WRONG:** \`file-grep({ pattern: "John", searchPath: "" })\` or \`searchPath: "."\` or any absolute path to the workspace root
- **CORRECT:** \`file-grep({ pattern: "John", searchPath: "knowledge/" })\`
Use the builtin file tools to search and read the knowledge base:
**Finding notes:**
\`\`\`
# List all people notes
file-list("knowledge/People")
# Search for a person by name - MUST include knowledge/ in path
file-grep({ pattern: "Sarah Chen", searchPath: "knowledge/" })
# Find notes mentioning a company - MUST include knowledge/ in path
file-grep({ pattern: "Acme Corp", searchPath: "knowledge/" })
\`\`\`
**Reading notes:**
\`\`\`
# Read a specific person's note
file-readText("knowledge/People/Sarah Chen.md")
# Read an organization note
file-readText("knowledge/Organizations/Acme Corp.md")
\`\`\`
**When a user mentions someone by name:**
1. First, search for them: \`file-grep({ pattern: "John", searchPath: "knowledge/" })\`
2. Read their note to get full context: \`file-readText("knowledge/People/John Smith.md")\`
3. Use the context (role, organization, past interactions, commitments) in your response
Use the base file tools to search and read it:
- Find: \`file-grep({ pattern: "Sarah Chen", searchPath: "knowledge/" })\`; list a category with \`file-list("knowledge/People")\`.
- Read: \`file-readText("knowledge/People/Sarah Chen.md")\`.
- When a user mentions someone by name: grep for them in \`knowledge/\`, read their note, and use that context (role, organization, past interactions, commitments) in your response.
**NEVER use an empty search path or root path for knowledge lookup. ALWAYS set searchPath to \`knowledge/\` or a subfolder like \`knowledge/People/\`.**
@ -268,11 +243,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:
- 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.
${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
- Be concise and direct. Avoid verbose explanations unless the user asks for details.
@ -318,20 +294,21 @@ ${runtimeContextPrompt}
## Builtin Tools vs Shell Commands
**IMPORTANT**: Rowboat provides builtin tools:
- \`file-readText\`, \`file-writeText\`, \`file-editText\`, \`file-remove\` - File operations
- \`file-list\`, \`file-exists\`, \`file-stat\`, \`file-glob\`, \`file-grep\` - Directory exploration and file search
- \`file-mkdir\`, \`file-rename\`, \`file-copy\` - File/directory management
**IMPORTANT**: Rowboat provides builtin tools. Your always-attached base set:
- \`file-readText\`, \`file-list\`, \`file-exists\`, \`file-glob\`, \`file-grep\`, \`file-getRoot\` - Read-side file operations, directory exploration, and search
- \`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.
- \`analyzeAgent\` - Agent analysis
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
- \`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.**
- \`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\`.
- \`fetch-url\` - Fetch a URL's contents
- \`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\`.
@ -435,6 +412,7 @@ export async function buildCopilotInstructions(): Promise<string> {
const excludeIds: string[] = [];
if (!composioEnabled) excludeIds.push('composio-integration');
if (!codeModeEnabled) excludeIds.push('code-with-agents');
if (!slackConnected) excludeIds.push('slack');
// Always build from the live skill set so disk skills added/removed at
// runtime (after refreshDiskSkills + cache invalidation) are reflected.
const catalog = buildSkillCatalog({ excludeIds });

View file

@ -15,6 +15,11 @@ export type DiskSkill = {
content: string; // full raw SKILL.md text
dir: string; // absolute path to the skill folder
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
@ -31,6 +36,25 @@ export const SKILL_ROOTS = [
const slugify = (value: string): string =>
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.
* 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);
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 } : {}),
});
}
}

View file

@ -18,6 +18,7 @@ import liveNoteSkill from "./live-note/skill.js";
import backgroundTaskSkill from "./background-task/skill.js";
import notifyUserSkill from "./notify-user/skill.js";
import appsSkill from "./apps/skill.js";
import slackSkill from "./slack/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills";
@ -29,6 +30,11 @@ type SkillDefinition = {
title: string;
summary: 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 = {
@ -43,18 +49,21 @@ const definitions: SkillDefinition[] = [
title: "Create Presentations",
summary: "Create PDF presentations and slide decks from natural language requests using knowledge base context.",
content: createPresentationsSkill,
tools: ["file-writeText", "file-mkdir"],
},
{
id: "doc-collab",
title: "Document Collaboration",
summary: "Collaborate on documents - create, edit, and refine notes and documents in the knowledge base.",
content: docCollabSkill,
tools: ["file-writeText", "file-editText", "file-mkdir"],
},
{
id: "draft-emails",
title: "Draft Emails",
summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.",
content: draftEmailsSkill,
tools: ["composio-search-tools", "composio-execute-tool"],
},
{
id: "meeting-prep",
@ -67,36 +76,58 @@ const definitions: SkillDefinition[] = [
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.",
content: organizeFilesSkill,
tools: [
"file-stat",
"file-writeText",
"file-editText",
"file-mkdir",
"file-rename",
"file-copy",
"file-remove",
],
},
{
id: "builtin-tools",
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,
// Every non-base builtin: kept in sync by a startup assertion in
// builtin-tools.ts rather than by hand.
tools: [],
},
{
id: "mcp-integration",
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.",
content: mcpIntegrationSkill,
tools: ["addMcpServer", "listMcpServers", "listMcpTools", "executeMcpTool"],
},
{
id: "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.",
content: composioIntegrationSkill,
tools: [
"composio-list-toolkits",
"composio-search-tools",
"composio-execute-tool",
"composio-connect-toolkit",
"app-navigation",
],
},
{
id: "deletion-guardrails",
title: "Deletion Guardrails",
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
content: deletionGuardrailsSkill,
tools: ["file-remove"],
},
{
id: "app-navigation",
title: "App Navigation",
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
content: appNavigationSkill,
tools: ["app-navigation", "app-set-data"],
},
{
id: "code-with-agents",
@ -109,30 +140,57 @@ const definitions: SkillDefinition[] = [
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.",
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",
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.",
content: backgroundTaskSkill,
tools: [
"create-background-task",
"patch-background-task",
"run-background-task-agent",
"file-writeText",
"file-editText",
],
},
{
id: "live-note",
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.",
content: liveNoteSkill,
tools: ["run-live-note-agent", "file-writeText", "file-editText"],
},
{
id: "browser-control",
title: "Browser Control",
summary: "Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.",
content: browserControlSkill,
tools: ["browser-control", "load-browser-skill"],
},
{
// Excluded from the catalog when Slack isn't connected (see
// buildCopilotInstructions excludeIds), mirroring composio-integration.
id: "slack",
title: "Slack",
summary: "Read, search, and send Slack messages via the agent-slack CLI — catch up on channels, summarize threads, list users. Load FIRST for ANY Slack request; Slack is connected natively, never through Composio.",
content: slackSkill,
},
{
id: "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.",
content: notifyUserSkill,
tools: ["notify-user"],
},
];
@ -167,6 +225,7 @@ function loadDiskEntries(): SkillEntry[] {
title: skill.title,
summary: skill.summary,
content: skill.content,
...(skill.tools ? { tools: skill.tools } : {}),
// For disk skills the catalog/loadSkill reference is the absolute SKILL.md path.
catalogPath: skill.skillFile,
skillFile: skill.skillFile,
@ -190,6 +249,9 @@ export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
`## ${entry.title}`,
`- **Skill file:** \`${entry.catalogPath}\``,
`- **Use it for:** ${entry.summary}`,
...(entry.tools && entry.tools.length > 0
? [`- **Tools it attaches:** ${entry.tools.join(", ")}`]
: []),
].join("\n"));
return [
"# Rowboat Skill Catalog",
@ -310,3 +372,27 @@ export function resolveSkill(identifier: string): ResolvedSkill | null {
// Bundled wins over disk on any alias collision.
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];
}
}

View file

@ -4,7 +4,11 @@ import * as os from "os";
import * as fs from "fs/promises";
import { executeCommand, executeCommandAbortable } from "./command-executor.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 container from "../../di/container.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 {
success: true,
skillName: resolved.id,
path: resolved.catalogPath,
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),
),
);

View 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>>;
}

View file

@ -842,3 +842,194 @@ describe("headless standalone turns (13.8)", () => {
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" });
});
});

View file

@ -13,6 +13,7 @@ import {
type JsonValue,
type ModelDescriptor,
type ToolResultData,
type TurnState,
deriveTurnStatus,
inlineAgentId,
isInlineAgentRequest,
@ -151,8 +152,11 @@ export class SessionsImpl implements ISessions {
const events = await this.sessionRepo.read(sessionId);
const state = reduceSession(events);
let agentRequest = config.agent;
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 (
status !== "completed" &&
status !== "failed" &&
@ -164,10 +168,14 @@ export class SessionsImpl implements ISessions {
status,
);
}
agentRequest = withActiveSkills(
agentRequest,
deriveActiveSkills(turnState),
);
}
const turnId = await this.turnRuntime.createTurn({
agent: config.agent,
agent: agentRequest,
sessionId,
context: state.latestTurnId
? { previousTurnId: state.latestTurnId }
@ -416,6 +424,72 @@ function errorMessage(error: unknown): string {
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 {
const text =
typeof input.content === "string"

View file

@ -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([]);
});
});

View file

@ -13,6 +13,7 @@ import {
loadUserWorkDir,
} from "../../agents/runtime.js";
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
import { skillToolNames } from "../../application/assistant/skills/index.js";
import { getDefaultModelAndProvider } from "../../models/defaults.js";
import {
builtinToolDescriptor,
@ -62,6 +63,11 @@ const CompositionOverrides = z.object({
// 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.
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 {
@ -70,6 +76,7 @@ export interface RealAgentResolverDeps {
defaultModel?: () => Promise<{ model: string; provider: string }>;
loadNotes?: () => string | null;
loadWorkDir?: (workDirId: string) => string | null;
skillTools?: typeof skillToolNames;
}
// 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 loadNotes: () => string | null;
private readonly loadWorkDir: (workDirId: string) => string | null;
private readonly skillTools: typeof skillToolNames;
constructor(deps: RealAgentResolverDeps = {}) {
this.load = deps.load ?? loadAgent;
@ -91,6 +99,7 @@ export class RealAgentResolver {
this.defaultModel = deps.defaultModel ?? getDefaultModelAndProvider;
this.loadNotes = deps.loadNotes ?? loadAgentNotesContext;
this.loadWorkDir = deps.loadWorkDir ?? loadUserWorkDir;
this.skillTools = deps.skillTools ?? skillToolNames;
}
async resolve(
@ -138,6 +147,9 @@ export class RealAgentResolver {
const tools = await this.resolveTools(agent, {
subagent: composition.subagent ?? false,
});
if (copilotContext && composition.activeSkills?.length) {
await this.appendSkillTools(tools, composition.activeSkills);
}
return ResolvedAgent.parse({
agentId: requested.agentId,
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(
agent: z.infer<typeof Agent>,
options: { subagent: boolean },

View file

@ -8,6 +8,7 @@ import {
} from "@x/shared/dist/turns.js";
import { execTool } from "../../application/lib/exec-tool.js";
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
import { TOOL_ADDITIONS_KEY } from "../../application/lib/tool-additions.js";
import {
type IAbortRegistry,
InMemoryAbortRegistry,
@ -214,9 +215,15 @@ export class RealToolRegistry implements IToolRegistry {
},
},
);
const { output, additions } = splitToolAdditions(
toJsonValue(value === undefined ? null : value),
);
return {
output: toJsonValue(value === undefined ? null : value),
output,
isError: false,
...(additions === undefined
? {}
: { metadata: { toolAdditions: additions } }),
};
} finally {
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> {
return input && typeof input === "object"
? (input as Record<string, unknown>)

View file

@ -5,14 +5,16 @@ import {
type ResolvedAgent,
type ToolDescriptor,
type TurnState,
effectiveTools,
requestMessagesFor,
} from "@x/shared/dist/turns.js";
import type { IContextResolver } from "./context-resolver.js";
// The exact provider payload for one model call, rebuilt deterministically
// from durable state (turn-runtime-design.md §8.3):
// - systemPrompt and tools come from the resolved agent snapshot (their
// single canonical copy in turn_created),
// - systemPrompt and base tools come from the resolved agent snapshot
// (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
// 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
@ -47,7 +49,9 @@ export function composeModelRequest(
return {
systemPrompt: agent.systemPrompt,
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,
};
}

View file

@ -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");
});
});

View file

@ -1,4 +1,4 @@
import type { z } from "zod";
import { z } from "zod";
import {
DEFAULT_MAX_MODEL_CALLS,
MODEL_CALL_LIMIT_ERROR_CODE,
@ -11,7 +11,7 @@ import {
assistantRef,
toolResultRef,
type ToolCallState,
type ToolDescriptor,
ToolDescriptor,
type ToolInvocationRequested,
type ToolPermissionRequired,
type ToolPermissionResolved,
@ -22,6 +22,7 @@ import {
type TurnState,
type TurnStreamEvent,
type TurnSuspended,
effectiveTools,
outstandingAsyncTools,
outstandingPermissions,
reduceTurn,
@ -228,8 +229,14 @@ export class TurnRuntime implements ITurnRuntime {
definition.agent.resolved,
);
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>();
for (const descriptor of resolvedAgent.tools) {
for (const descriptor of effectiveTools(
state,
state.modelCalls.length,
resolvedAgent.tools,
)) {
const tool = await this.toolRegistry.resolve(descriptor);
if (
tool.descriptor.toolId !== descriptor.toolId ||
@ -264,6 +271,7 @@ export class TurnRuntime implements ITurnRuntime {
model,
usageReporter: this.usageReporter,
toolsByName,
resolveTool: (descriptor) => this.toolRegistry.resolve(descriptor),
signal: controller.signal,
turnRepo: this.turnRepo,
clock: this.clock,
@ -293,6 +301,9 @@ class TurnAdvance {
private readonly model: ResolvedModel;
private readonly usageReporter: IUsageReporter;
private readonly toolsByName: Map<string, RuntimeTool>;
private readonly resolveTool: (
descriptor: z.infer<typeof ToolDescriptor>,
) => Promise<RuntimeTool>;
private readonly signal: AbortSignal;
private readonly turnRepo: ITurnRepo;
private readonly clock: IClock;
@ -315,6 +326,9 @@ class TurnAdvance {
model: ResolvedModel;
usageReporter: IUsageReporter;
toolsByName: Map<string, RuntimeTool>;
resolveTool: (
descriptor: z.infer<typeof ToolDescriptor>,
) => Promise<RuntimeTool>;
signal: AbortSignal;
turnRepo: ITurnRepo;
clock: IClock;
@ -330,6 +344,7 @@ class TurnAdvance {
this.model = init.model;
this.usageReporter = init.usageReporter;
this.toolsByName = init.toolsByName;
this.resolveTool = init.resolveTool;
this.signal = init.signal;
this.turnRepo = init.turnRepo;
this.clock = init.clock;
@ -363,6 +378,26 @@ class TurnAdvance {
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> {
await this.turnRepo.append(this.turnId, batch);
this.events.push(...batch);
@ -808,6 +843,7 @@ class TurnAdvance {
tc: ToolCallState,
syncTool: SyncRuntimeTool,
): Promise<void> {
let settled: z.infer<typeof ToolResultData> | undefined;
try {
const result = await syncTool.execute(tc.input, {
turnId: this.turnId,
@ -824,6 +860,7 @@ class TurnAdvance {
});
},
});
settled = ToolResultData.parse(result);
await this.append({
type: "tool_result",
turnId: this.turnId,
@ -831,7 +868,7 @@ class TurnAdvance {
toolCallId: tc.toolCallId,
toolName: tc.toolName,
source: "sync",
result: ToolResultData.parse(result),
result: settled,
});
} catch (error) {
if (this.signal.aborted) {
@ -852,6 +889,87 @@ class TurnAdvance {
source: "sync",
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 {
return error instanceof Error ? error.message : String(error);
}

View file

@ -15,6 +15,7 @@ import {
ToolPermissionResolved,
ToolProgress,
ToolResult,
ToolsExtended,
TurnCancelled,
TurnCompleted,
TurnCorruptionError,
@ -23,6 +24,8 @@ import {
TurnFailed,
TurnSuspended,
deriveTurnStatus,
effectiveTools,
extendedToolsFor,
outstandingAsyncTools,
outstandingPermissions,
reduceTurn,
@ -1419,3 +1422,159 @@ describe("derivations", () => {
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/,
);
});
});

View file

@ -361,6 +361,22 @@ export const ToolResult = z.object({
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({
type: z.literal("turn_suspended"),
turnId: z.string(),
@ -424,6 +440,7 @@ export const TurnEvent = z.discriminatedUnion("type", [
ToolInvocationRequested,
ToolProgress,
ToolResult,
ToolsExtended,
TurnSuspended,
TurnCompleted,
TurnFailed,
@ -496,10 +513,18 @@ export interface ToolCallState {
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 {
definition: z.infer<typeof TurnCreated>;
modelCalls: ModelCallState[];
toolCalls: ToolCallState[];
toolExtensions: ToolExtensionState[];
suspension?: z.infer<typeof TurnSuspended>;
terminal?:
| z.infer<typeof TurnCompleted>
@ -675,11 +700,17 @@ function applyModelCallCompleted(
fail(`duplicate tool call id: ${part.toolCallId}`);
}
const resolved = state.definition.agent.resolved;
// Inherited snapshots resolve outside the reducer; identity fields
// then arrive via tool_invocation_requested events.
const descriptor = isInheritedSnapshot(resolved)
? undefined
: resolved.tools.find((tool) => tool.name === part.toolName);
// Mid-turn extensions are searched first (their descriptors are
// always in the log); inherited base snapshots resolve outside the
// reducer, so identity fields for those tools arrive via
// tool_invocation_requested events instead.
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({
modelCallIndex: event.modelCallIndex,
order: order++,
@ -841,6 +872,44 @@ function applyToolResult(
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 {
return [...ids].sort().join(",");
}
@ -929,6 +998,7 @@ export function reduceTurn(
definition: first,
modelCalls: [],
toolCalls: [],
toolExtensions: [],
usage: {},
};
@ -978,6 +1048,9 @@ export function reduceTurn(
case "tool_result":
applyToolResult(state, event);
break;
case "tools_extended":
applyToolsExtended(state, event);
break;
case "turn_suspended":
applyTurnSuspended(state, event);
break;
@ -1006,6 +1079,29 @@ export function reduceTurn(
// 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[] {
return state.toolCalls.filter(
(tc) => tc.permission && !tc.permission.resolved && !tc.result,