diff options
| author | Adam Malczewski <[email protected]> | 2026-06-05 21:19:45 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-05 21:19:45 +0900 |
| commit | 4283d1f8a0bc3953e65962a2364c903d0015f047 (patch) | |
| tree | 4de5d2d2c1114301f03236b6dfc98a638a7c61c0 | |
| parent | 368be032ef57638b558db659d70bfac00cb95cdd (diff) | |
| download | dispatch-4283d1f8a0bc3953e65962a2364c903d0015f047.tar.gz dispatch-4283d1f8a0bc3953e65962a2364c903d0015f047.zip | |
feat(kernel): listModels/ModelInfo + per-turn cwd contracts; add transport-contract wire package
| -rw-r--r-- | bun.lock | 31 | ||||
| -rw-r--r-- | packages/kernel/src/contracts/index.ts | 1 | ||||
| -rw-r--r-- | packages/kernel/src/contracts/provider.ts | 20 | ||||
| -rw-r--r-- | packages/kernel/src/contracts/runtime.ts | 9 | ||||
| -rw-r--r-- | packages/kernel/src/contracts/tool.ts | 8 | ||||
| -rw-r--r-- | packages/kernel/src/runtime/dispatch.ts | 4 | ||||
| -rw-r--r-- | packages/kernel/src/runtime/run-turn.test.ts | 65 | ||||
| -rw-r--r-- | packages/kernel/src/runtime/run-turn.ts | 3 | ||||
| -rw-r--r-- | packages/transport-contract/package.json | 11 | ||||
| -rw-r--r-- | packages/transport-contract/src/index.ts | 57 | ||||
| -rw-r--r-- | packages/transport-contract/tsconfig.json | 6 | ||||
| -rw-r--r-- | tsconfig.json | 1 |
12 files changed, 216 insertions, 0 deletions
@@ -18,6 +18,13 @@ "@dispatch/kernel": "workspace:*", }, }, + "packages/cli": { + "name": "@dispatch/cli", + "version": "0.0.0", + "dependencies": { + "@dispatch/transport-contract": "workspace:*", + }, + }, "packages/conversation-store": { "name": "@dispatch/conversation-store", "version": "0.0.0", @@ -25,12 +32,20 @@ "@dispatch/kernel": "workspace:*", }, }, + "packages/credential-store": { + "name": "@dispatch/credential-store", + "version": "0.0.0", + "dependencies": { + "@dispatch/kernel": "workspace:*", + }, + }, "packages/host-bin": { "name": "@dispatch/host-bin", "version": "0.0.0", "dependencies": { "@dispatch/auth-apikey": "workspace:*", "@dispatch/conversation-store": "workspace:*", + "@dispatch/credential-store": "workspace:*", "@dispatch/journal-sink": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/provider-openai-compat": "workspace:*", @@ -72,6 +87,7 @@ "version": "0.0.0", "dependencies": { "@dispatch/conversation-store": "workspace:*", + "@dispatch/credential-store": "workspace:*", "@dispatch/kernel": "workspace:*", }, }, @@ -100,12 +116,21 @@ "@dispatch/kernel": "workspace:*", }, }, + "packages/transport-contract": { + "name": "@dispatch/transport-contract", + "version": "0.0.0", + "dependencies": { + "@dispatch/kernel": "workspace:*", + }, + }, "packages/transport-http": { "name": "@dispatch/transport-http", "version": "0.0.0", "dependencies": { + "@dispatch/credential-store": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/transport-contract": "workspace:*", "hono": "^4.0.0", }, }, @@ -131,8 +156,12 @@ "@dispatch/auth-apikey": ["@dispatch/auth-apikey@workspace:packages/auth-apikey"], + "@dispatch/cli": ["@dispatch/cli@workspace:packages/cli"], + "@dispatch/conversation-store": ["@dispatch/conversation-store@workspace:packages/conversation-store"], + "@dispatch/credential-store": ["@dispatch/credential-store@workspace:packages/credential-store"], + "@dispatch/host-bin": ["@dispatch/host-bin@workspace:packages/host-bin"], "@dispatch/journal-sink": ["@dispatch/journal-sink@workspace:packages/journal-sink"], @@ -153,6 +182,8 @@ "@dispatch/trace-store": ["@dispatch/trace-store@workspace:packages/trace-store"], + "@dispatch/transport-contract": ["@dispatch/transport-contract@workspace:packages/transport-contract"], + "@dispatch/transport-http": ["@dispatch/transport-http@workspace:packages/transport-http"], "@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/packages/kernel/src/contracts/index.ts b/packages/kernel/src/contracts/index.ts index a4c965a..6f6954e 100644 --- a/packages/kernel/src/contracts/index.ts +++ b/packages/kernel/src/contracts/index.ts @@ -83,6 +83,7 @@ export type { } from "./logging.js"; export type { FinishEvent, + ModelInfo, ProviderContract, ProviderErrorEvent, ProviderEvent, diff --git a/packages/kernel/src/contracts/provider.ts b/packages/kernel/src/contracts/provider.ts index 62dc8e9..ee58c1d 100644 --- a/packages/kernel/src/contracts/provider.ts +++ b/packages/kernel/src/contracts/provider.ts @@ -104,6 +104,17 @@ export interface ProviderStreamOptions { } /** + * Metadata describing a single model a provider can serve. Returned by + * `listModels` so a catalog (e.g. the credential-store) can enumerate the + * `<credentialName>/<model>` choices a client may select. Kept minimal — `id` + * is the wire model identifier; `displayName` is an optional human label. + */ +export interface ModelInfo { + readonly id: string; + readonly displayName?: string; +} + +/** * What a provider extension registers with the kernel. The kernel calls * `stream` and consumes the async iterable of events — it never knows which * concrete LLM API is behind it. @@ -122,4 +133,13 @@ export interface ProviderContract { tools: readonly ToolContract[], opts?: ProviderStreamOptions, ) => AsyncIterable<ProviderEvent>; + + /** + * Enumerate the models this provider can serve, each in its own way (e.g. an + * OpenAI-compatible provider GETs `/v1/models`). Optional: a provider that + * cannot (or chooses not to) enumerate omits it, and a catalog simply lists + * none for it. A future multi-credential design may pass per-credential + * credentials in; today the provider uses the key it resolved at activate. + */ + readonly listModels?: () => Promise<readonly ModelInfo[]>; } diff --git a/packages/kernel/src/contracts/runtime.ts b/packages/kernel/src/contracts/runtime.ts index 1e8f14f..8917709 100644 --- a/packages/kernel/src/contracts/runtime.ts +++ b/packages/kernel/src/contracts/runtime.ts @@ -76,6 +76,15 @@ export interface RunTurnInput { readonly signal?: AbortSignal; /** + * Working directory for this turn's tool execution. The kernel does NOT + * interpret it — it forwards the value verbatim to each `ToolExecuteContext.cwd` + * so tools resolve/contain paths against it. It never enters the model prompt, + * so it does not affect prompt caching. When omitted, tools fall back to their + * own configured/default workdir. + */ + readonly cwd?: string; + + /** * Optional logger for structured span instrumentation. The runtime opens * turn/step/tool-call spans using this logger. If omitted, no spans are * emitted (backward-compatible with callers that don't yet pass a logger). diff --git a/packages/kernel/src/contracts/tool.ts b/packages/kernel/src/contracts/tool.ts index 0699d05..f617f42 100644 --- a/packages/kernel/src/contracts/tool.ts +++ b/packages/kernel/src/contracts/tool.ts @@ -62,6 +62,14 @@ export interface ToolExecuteContext { * turnId, and spanId automatically. */ readonly log: Logger; + + /** + * Working directory for this turn, forwarded verbatim from `RunTurnInput.cwd`. + * Tools that touch the filesystem resolve and contain paths against it. + * Optional: when omitted, a tool falls back to its own configured/default + * workdir. The kernel never interprets it. + */ + readonly cwd?: string; } /** diff --git a/packages/kernel/src/runtime/dispatch.ts b/packages/kernel/src/runtime/dispatch.ts index 1ba0849..d168319 100644 --- a/packages/kernel/src/runtime/dispatch.ts +++ b/packages/kernel/src/runtime/dispatch.ts @@ -17,6 +17,7 @@ export async function executeToolCall( conversationId: string, turnId: string, toolSpan?: Span, + cwd?: string, ): Promise<ToolResult> { if (tool === undefined) { return { content: `Unknown tool: ${call.name}`, isError: true }; @@ -31,6 +32,7 @@ export async function executeToolCall( emit(toolOutputEvent(conversationId, turnId, call.id, data, stream)); }, log: toolSpan?.log ?? createNoopLogger(), + ...(cwd !== undefined ? { cwd } : {}), }; try { return await tool.execute(call.input, ctx); @@ -54,6 +56,7 @@ export function createStepDispatcher( conversationId: string, turnId: string, toolSpans: Map<string, Span>, + cwd?: string, ): StepDispatcher { let activeCount = 0; let unsafeRunning = false; @@ -91,6 +94,7 @@ export function createStepDispatcher( conversationId, turnId, tcSpan, + cwd, ); activeCount--; if (entry.tool?.concurrencySafe === false) unsafeRunning = false; diff --git a/packages/kernel/src/runtime/run-turn.test.ts b/packages/kernel/src/runtime/run-turn.test.ts index 2dd5d2e..48a6fbc 100644 --- a/packages/kernel/src/runtime/run-turn.test.ts +++ b/packages/kernel/src/runtime/run-turn.test.ts @@ -740,6 +740,71 @@ describe("runTurn", () => { } }); + it("forwards cwd from RunTurnInput to ToolExecuteContext", async () => { + let capturedCwd: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("cwdcheck", async (_input, ctx) => { + capturedCwd = ctx.cwd; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "cwdcheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + cwd: "/some/dir", + }); + + expect(capturedCwd).toBe("/some/dir"); + }); + + it("forwards undefined cwd when RunTurnInput has no cwd", async () => { + let capturedCwd: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("cwdcheck", async (_input, ctx) => { + capturedCwd = ctx.cwd; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "cwdcheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + expect(capturedCwd).toBeUndefined(); + }); + it("aggregates usage across multiple steps", async () => { const provider = createFakeProvider([ [ diff --git a/packages/kernel/src/runtime/run-turn.ts b/packages/kernel/src/runtime/run-turn.ts index 01b280b..6b07e24 100644 --- a/packages/kernel/src/runtime/run-turn.ts +++ b/packages/kernel/src/runtime/run-turn.ts @@ -80,6 +80,7 @@ interface StepContext { readonly logger: Logger; readonly turnSpan: Span | undefined; readonly toolSpans: Map<string, Span>; + readonly cwd: string | undefined; } interface StepResult { @@ -202,6 +203,7 @@ async function executeStep(ctx: StepContext): Promise<StepResult> { ctx.conversationId, ctx.turnId, ctx.toolSpans, + ctx.cwd, ); try { @@ -364,6 +366,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> { logger: turnSpan?.log ?? logger ?? createNoopLogger(), turnSpan, toolSpans, + cwd: input.cwd, }); totalUsage = addUsage(totalUsage, stepResult.usage); diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json new file mode 100644 index 0000000..83c8a71 --- /dev/null +++ b/packages/transport-contract/package.json @@ -0,0 +1,11 @@ +{ + "name": "@dispatch/transport-contract", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } +} diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts new file mode 100644 index 0000000..5f16d8a --- /dev/null +++ b/packages/transport-contract/src/index.ts @@ -0,0 +1,57 @@ +/** + * Transport contract — the typed description of Dispatch's HTTP API. + * + * This package is types-only (zero runtime). It is the single shared surface + * every client imports to know how to talk to the backend — the CLI, the web + * frontend (in its own repo), any third-party client — and the transport-http + * server imports to know what it must accept and emit. + * + * Each side owns its OWN (de)serialization: there is deliberately no shared + * parse/serialize helper here (isolation-over-DRY). The contract is the SHAPES, + * not the codec. The streaming response payload is the kernel's `AgentEvent` + * union, re-exported here so a client has one import for the whole wire. + */ + +export type { AgentEvent } from "@dispatch/kernel"; + +/** + * Request body for `POST /chat` (sent as JSON). + * + * The response is an NDJSON stream: one JSON-encoded `AgentEvent` per line. + * The resolved conversation id is also returned in the `X-Conversation-Id` + * response header (useful when `conversationId` was omitted). + */ +export interface ChatRequest { + /** + * The conversation to continue. Omit to start a fresh conversation — the + * server mints an id and returns it via the `X-Conversation-Id` header. + */ + readonly conversationId?: string; + + /** The user's message text for this turn. */ + readonly message: string; + + /** + * The model to use, as a model name in `<credentialName>/<model>` form — one + * of the exact strings returned by `GET /models`. Omit to use the server's + * default credential + model. + */ + readonly model?: string; + + /** + * Working directory for this turn's tool execution. Defaults server-side when + * omitted. Forwarded to tools for path resolution; never part of the model + * prompt (so it does not affect prompt caching). + */ + readonly cwd?: string; +} + +/** + * Response body for `GET /models` — the model catalog. + * + * Each entry is a model name in `<credentialName>/<model>` form: exactly the + * string a client passes back as `ChatRequest.model`. + */ +export interface ModelsResponse { + readonly models: readonly string[]; +} diff --git a/packages/transport-contract/tsconfig.json b/packages/transport-contract/tsconfig.json new file mode 100644 index 0000000..ff99a43 --- /dev/null +++ b/packages/transport-contract/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] +} diff --git a/tsconfig.json b/tsconfig.json index b0fd70a..fdea52f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./packages/kernel" }, + { "path": "./packages/transport-contract" }, { "path": "./packages/storage-sqlite" }, { "path": "./packages/auth-apikey" }, { "path": "./packages/provider-openai-compat" }, |
