diff options
| author | Adam Malczewski <[email protected]> | 2026-06-10 17:03:23 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-10 17:03:23 +0900 |
| commit | f6b45507210e04e9884256b0132900640de4334b (patch) | |
| tree | 73c5779bf2eec5a1d03732be0f6a8b698b8a2c7f /packages/session-orchestrator/src | |
| parent | bf862168f0fd7b10d02ae04a9d82f7c37b9d85e5 (diff) | |
| download | dispatch-f6b45507210e04e9884256b0132900640de4334b.tar.gz dispatch-f6b45507210e04e9884256b0132900640de4334b.zip | |
feat(skills): skill system + load_skill tool via per-turn tools filter
Skills are markdown in .skills/ dirs (~/.skills + <cwd>/.skills, cwd shadows home;
name = filename). Format: line1 summary, line2 ---, body line3+; load strips the
first two lines; malformed = no summary but still loadable.
Mechanism (first use of the context-assembly filter chain, ยง3.2):
- kernel: expose HostAPI.applyFilters (delegates to bus.applyFilters)
- session-orchestrator: define/export toolsFilter + ToolAssembly; apply once per turn
before runTurn (cache-stable across steps), threading cwd + conversationId
- skills (new ext): pure parse/merge/render + load_skill tool (live read, path-contained)
+ a toolsFilter filter rewriting load_skill's description + name enum per cwd
- host-bin: register skills in CORE_EXTENSIONS
- transport-http: fix HostAPI test stub for the new applyFilters method (fan-out)
734 vitest + 109 bun = 843 tests; tsc -b EXIT 0; biome clean; clean live boot.
Diffstat (limited to 'packages/session-orchestrator/src')
| -rw-r--r-- | packages/session-orchestrator/src/extension.ts | 2 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 119 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 12 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/tools-filter.ts | 16 |
5 files changed, 148 insertions, 2 deletions
diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts index fbb7c15..697eb4a 100644 --- a/packages/session-orchestrator/src/extension.ts +++ b/packages/session-orchestrator/src/extension.ts @@ -8,6 +8,7 @@ import { sessionOrchestratorHandle, } from "./orchestrator.js"; import { selectFirstProvider } from "./pure.js"; +import { toolsFilter } from "./tools-filter.js"; export const manifest: Manifest = { id: "session-orchestrator", @@ -36,6 +37,7 @@ export function activate(host: HostAPI): void { const provider = host.getProviders().get(r.providerId); return provider ? { provider, model: r.model } : undefined; }, + applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly), runTurn, logger: host.logger, now: () => Date.now(), diff --git a/packages/session-orchestrator/src/index.ts b/packages/session-orchestrator/src/index.ts index 3270c46..071b616 100644 --- a/packages/session-orchestrator/src/index.ts +++ b/packages/session-orchestrator/src/index.ts @@ -11,3 +11,4 @@ export { generateTurnId, selectFirstProvider, } from "./pure.js"; +export { type ToolAssembly, toolsFilter } from "./tools-filter.js"; diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index ea564c5..dcaad7d 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -13,6 +13,7 @@ import type { import { runTurn } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; import { createSessionOrchestrator } from "./orchestrator.js"; +import type { ToolAssembly } from "./tools-filter.js"; function createInMemoryStore(): ConversationStore & { readonly data: Map<string, ChatMessage[]>; @@ -87,6 +88,10 @@ function createFakeTool( }; } +function identityApplyToolsFilter(assembly: ToolAssembly): Promise<ToolAssembly> { + return Promise.resolve(assembly); +} + describe("handleMessage integration", () => { it("loads history, runs turn, emits events, and persists result", async () => { const store = createInMemoryStore(); @@ -103,6 +108,7 @@ describe("handleMessage integration", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -154,6 +160,7 @@ describe("handleMessage integration", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -200,6 +207,7 @@ describe("handleMessage integration", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -229,6 +237,7 @@ describe("handleMessage integration", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, resolveDispatch: () => ({ maxConcurrent: 4, eager: false }), runTurn, }); @@ -277,6 +286,7 @@ describe("handleMessage model resolution", () => { conversationStore: store, resolveProvider: () => fallbackProvider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, resolveModel: (name) => { if (name === "cred/gpt-4") return { provider: resolvedProvider, model: "gpt-4" }; return undefined; @@ -308,6 +318,7 @@ describe("handleMessage model resolution", () => { conversationStore: store, resolveProvider: () => fallbackProvider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, resolveModel: () => undefined, runTurn: captureRunTurn, }); @@ -338,6 +349,7 @@ describe("handleMessage model resolution", () => { conversationStore: store, resolveProvider: () => fallbackProvider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, resolveModel: () => ({ provider: { id: "should-not-use", stream: async function* () {} }, model: "x", @@ -365,6 +377,7 @@ describe("handleMessage model resolution", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn: captureRunTurn, }); @@ -398,6 +411,7 @@ describe("handleMessage model resolution", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn: captureRunTurn, now: fakeNow, }); @@ -422,6 +436,7 @@ describe("handleMessage model resolution", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn: captureRunTurn, }); @@ -450,6 +465,7 @@ describe("turn-sealed event", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -502,6 +518,7 @@ describe("turn-sealed event", () => { conversationStore: wrappedStore, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -548,6 +565,7 @@ describe("turn-sealed event", () => { conversationStore: failingStore, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -581,6 +599,7 @@ describe("turn metrics persistence", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, now: () => 1000, }); @@ -640,6 +659,7 @@ describe("turn metrics persistence", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [tool], + applyToolsFilter: identityApplyToolsFilter, runTurn, now: () => 1000, }); @@ -698,6 +718,7 @@ describe("turn metrics persistence", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, now: clock.now, }); @@ -764,6 +785,7 @@ describe("turn metrics persistence", () => { conversationStore: store, resolveProvider: () => provider, resolveTools: () => [tool], + applyToolsFilter: identityApplyToolsFilter, runTurn, now: () => 1000, }); @@ -817,6 +839,7 @@ describe("turn metrics persistence", () => { conversationStore: failingMetricsStore, resolveProvider: () => provider, resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, runTurn, }); @@ -836,6 +859,102 @@ describe("turn metrics persistence", () => { }); }); +describe("tools filter", () => { + it("applies the tools filter once and passes the result to runTurn", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const toolA = createFakeTool("tool-a", async () => ({ content: "a" })); + const toolB = createFakeTool("tool-b", async () => ({ content: "b" })); + + let filterCallCount = 0; + const transformingFilter = (assembly: ToolAssembly): Promise<ToolAssembly> => { + filterCallCount++; + return Promise.resolve({ ...assembly, tools: [toolB] }); + }; + + const orchestrator = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [toolA], + applyToolsFilter: transformingFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-filter-once", + text: "hi", + onEvent: () => {}, + }); + + expect(filterCallCount).toBe(1); + expect(captured).toHaveLength(1); + expect(captured[0]?.tools).toHaveLength(1); + expect(captured[0]?.tools[0]?.name).toBe("tool-b"); + }); + + it("tools filter identity is a no-op (same tools reach runTurn)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const toolA = createFakeTool("tool-a", async () => ({ content: "a" })); + const toolB = createFakeTool("tool-b", async () => ({ content: "b" })); + + const orchestrator = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [toolA, toolB], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-filter-identity", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.tools).toHaveLength(2); + expect(captured[0]?.tools[0]?.name).toBe("tool-a"); + expect(captured[0]?.tools[1]?.name).toBe("tool-b"); + }); + + it("threads cwd and conversationId into the tool assembly", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + let receivedAssembly: ToolAssembly | undefined; + const capturingFilter = (assembly: ToolAssembly): Promise<ToolAssembly> => { + receivedAssembly = assembly; + return Promise.resolve(assembly); + }; + + const orchestrator = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: capturingFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-filter-threads", + text: "hi", + onEvent: () => {}, + cwd: "/test/dir", + }); + + expect(receivedAssembly).toBeDefined(); + expect(receivedAssembly?.conversationId).toBe("conv-filter-threads"); + expect(receivedAssembly?.cwd).toBe("/test/dir"); + expect(receivedAssembly?.tools).toEqual([]); + }); +}); + function createCounterNow(): { now: () => number; tick: (ms: number) => void } { let t = 0; return { diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index d84b805..2d1bbf5 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -13,6 +13,7 @@ import type { import { defineService } from "@dispatch/kernel"; import { createMetricsAccumulator } from "./metrics.js"; import { buildUserMessage, defaultDispatchPolicy, generateTurnId } from "./pure.js"; +import type { ToolAssembly } from "./tools-filter.js"; export interface SessionOrchestrator { handleMessage(input: { @@ -38,6 +39,8 @@ export interface SessionOrchestratorDeps { modelName: string, ) => { provider: ProviderContract; model: string } | undefined; readonly runTurn: (input: RunTurnInput) => Promise<RunTurnResult>; + /** Apply the per-turn tools filter chain. Injected for testability. */ + readonly applyToolsFilter: (assembly: ToolAssembly) => Promise<ToolAssembly>; /** Base logger (auto-scoped to this extension); childed per turn for span capture. */ readonly logger?: Logger; /** Injected monotonic-ish clock (ms) forwarded to RunTurnInput for timing events. */ @@ -71,7 +74,12 @@ export function createSessionOrchestrator(deps: SessionOrchestratorDeps): Sessio provider = deps.resolveProvider(); } - const tools = deps.resolveTools(); + const baseTools = deps.resolveTools(); + const assembled = await deps.applyToolsFilter({ + tools: baseTools, + conversationId, + ...(cwd !== undefined ? { cwd } : {}), + }); const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy(); const turnLogger = deps.logger?.child({ conversationId, turnId }); const metrics = createMetricsAccumulator(); @@ -84,7 +92,7 @@ export function createSessionOrchestrator(deps: SessionOrchestratorDeps): Sessio const opts: RunTurnInput = { provider, messages: [...history, userMsg], - tools, + tools: assembled.tools, dispatch, emit: emitAndAccumulate, conversationId, diff --git a/packages/session-orchestrator/src/tools-filter.ts b/packages/session-orchestrator/src/tools-filter.ts new file mode 100644 index 0000000..19b2eb3 --- /dev/null +++ b/packages/session-orchestrator/src/tools-filter.ts @@ -0,0 +1,16 @@ +import { defineFilter, type FilterDescriptor, type ToolContract } from "@dispatch/kernel"; + +/** Per-turn tool-assembly value threaded through the `tools` filter chain. */ +export interface ToolAssembly { + /** The tool set resolved for this turn (the value filters transform). */ + readonly tools: readonly ToolContract[]; + /** This turn's working directory (verbatim from the request), for cwd-aware filters. */ + readonly cwd?: string; + /** The conversation this turn belongs to. */ + readonly conversationId: string; +} + +/** Filter chain run once per turn to transform the tool set before it reaches runTurn. */ +export const toolsFilter: FilterDescriptor<ToolAssembly> = defineFilter<ToolAssembly>( + "session-orchestrator/tools", +); |
