mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge pull request #702 from rowboatlabs/skills-with-tools
feat(x): skill-scoped contextual tool loading for the copilot
This commit is contained in:
commit
0d74244134
18 changed files with 1328 additions and 76 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
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",
|
||||
];
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
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 }]);
|
||||
});
|
||||
});
|
||||
|
||||
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 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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
} 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 },
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue