summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-05 21:19:45 +0900
committerAdam Malczewski <[email protected]>2026-06-05 21:19:45 +0900
commit4283d1f8a0bc3953e65962a2364c903d0015f047 (patch)
tree4de5d2d2c1114301f03236b6dfc98a638a7c61c0
parent368be032ef57638b558db659d70bfac00cb95cdd (diff)
downloaddispatch-4283d1f8a0bc3953e65962a2364c903d0015f047.tar.gz
dispatch-4283d1f8a0bc3953e65962a2364c903d0015f047.zip
feat(kernel): listModels/ModelInfo + per-turn cwd contracts; add transport-contract wire package
-rw-r--r--bun.lock31
-rw-r--r--packages/kernel/src/contracts/index.ts1
-rw-r--r--packages/kernel/src/contracts/provider.ts20
-rw-r--r--packages/kernel/src/contracts/runtime.ts9
-rw-r--r--packages/kernel/src/contracts/tool.ts8
-rw-r--r--packages/kernel/src/runtime/dispatch.ts4
-rw-r--r--packages/kernel/src/runtime/run-turn.test.ts65
-rw-r--r--packages/kernel/src/runtime/run-turn.ts3
-rw-r--r--packages/transport-contract/package.json11
-rw-r--r--packages/transport-contract/src/index.ts57
-rw-r--r--packages/transport-contract/tsconfig.json6
-rw-r--r--tsconfig.json1
12 files changed, 216 insertions, 0 deletions
diff --git a/bun.lock b/bun.lock
index 18be1c5..67f6277 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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" },