summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-10 17:03:23 +0900
committerAdam Malczewski <[email protected]>2026-06-10 17:03:23 +0900
commitf6b45507210e04e9884256b0132900640de4334b (patch)
tree73c5779bf2eec5a1d03732be0f6a8b698b8a2c7f /packages/session-orchestrator/src
parentbf862168f0fd7b10d02ae04a9d82f7c37b9d85e5 (diff)
downloaddispatch-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.ts2
-rw-r--r--packages/session-orchestrator/src/index.ts1
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts119
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts12
-rw-r--r--packages/session-orchestrator/src/tools-filter.ts16
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",
+);