feat(x): skill-scoped contextual tool loading for the copilot

Skills now own their tools. The copilot attaches only a small hardcoded
base set (~16 tools instead of all ~42); loading a skill via loadSkill
attaches its declared tools as native tool definitions from the very
next model step, and they stay attached for the rest of the session.

- tools_extended: new durable turn event (the one sanctioned exception
  to per-turn tool immutability — explicit, replayable, never silent).
  Reducer tracks extensions per model-call index; effectiveTools()
  composes base + extensions for both the live loop and inspect.
- Runtime: sync tool results may carry metadata.toolAdditions; dedupe
  and the durable append run atomically on the serialized commit chain
  (safe under concurrent sync tools). Crash recovery rebuilds the
  extended toolset from the log.
- Skills declare tools (SkillDefinition.tools; SKILL.md tools:/
  allowed-tools frontmatter); catalog entries list them; loadSkill
  returns them via a reserved $toolAdditions key the registry lifts
  into result metadata. builtin-tools skill = escape hatch, derived as
  "every non-base builtin" at module init.
- Sessions derive composition.activeSkills from the previous turn's
  request + its tools_extended events; the resolver attaches active
  skills' tools on top of the base set (stable order, so snapshot
  inheritance keeps working).
- Legacy code-mode path keeps working on the base set (which includes
  code_agent_run/launch-code-task) and strips $toolAdditions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-09 11:00:13 +05:30
parent 53eddaa6a4
commit 2e45035ece
18 changed files with 1302 additions and 35 deletions

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,