mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(x): skill-scoped contextual tool loading for the copilot
Skills now own their tools. The copilot attaches only a small hardcoded base set (~16 tools instead of all ~42); loading a skill via loadSkill attaches its declared tools as native tool definitions from the very next model step, and they stay attached for the rest of the session. - tools_extended: new durable turn event (the one sanctioned exception to per-turn tool immutability — explicit, replayable, never silent). Reducer tracks extensions per model-call index; effectiveTools() composes base + extensions for both the live loop and inspect. - Runtime: sync tool results may carry metadata.toolAdditions; dedupe and the durable append run atomically on the serialized commit chain (safe under concurrent sync tools). Crash recovery rebuilds the extended toolset from the log. - Skills declare tools (SkillDefinition.tools; SKILL.md tools:/ allowed-tools frontmatter); catalog entries list them; loadSkill returns them via a reserved $toolAdditions key the registry lifts into result metadata. builtin-tools skill = escape hatch, derived as "every non-base builtin" at module init. - Sessions derive composition.activeSkills from the previous turn's request + its tools_extended events; the resolver attaches active skills' tools on top of the base set (stable order, so snapshot inheritance keeps working). - Legacy code-mode path keeps working on the base set (which includes code_agent_run/launch-code-task) and strips $toolAdditions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
53eddaa6a4
commit
2e45035ece
18 changed files with 1302 additions and 35 deletions
|
|
@ -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