diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 20:24:18 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 20:24:18 +0900 |
| commit | 12955cdf1d822ff395fd62d30916fbdb02d10e12 (patch) | |
| tree | c23a4b0700a08926eaf3636af7e5ca92a39fbe2f | |
| parent | c5c34ed70e0f04b7b936fa7a1d88ef807472fb96 (diff) | |
| download | dispatch-12955cdf1d822ff395fd62d30916fbdb02d10e12.tar.gz dispatch-12955cdf1d822ff395fd62d30916fbdb02d10e12.zip | |
feat(heartbeat): resolve [type:name] variables in heartbeat prompts (CR-HB-1)
| -rw-r--r-- | bun.lock | 2 | ||||
| -rw-r--r-- | packages/heartbeat/package.json | 2 | ||||
| -rw-r--r-- | packages/heartbeat/src/config-store.test.ts | 12 | ||||
| -rw-r--r-- | packages/heartbeat/src/config-store.ts | 9 | ||||
| -rw-r--r-- | packages/heartbeat/src/extension.ts | 49 | ||||
| -rw-r--r-- | packages/heartbeat/src/heartbeat.test.ts | 87 | ||||
| -rw-r--r-- | packages/heartbeat/src/heartbeat.ts | 56 | ||||
| -rw-r--r-- | packages/heartbeat/src/index.ts | 18 | ||||
| -rw-r--r-- | packages/heartbeat/src/run-store.ts | 14 | ||||
| -rw-r--r-- | packages/heartbeat/src/scheduler.ts | 18 | ||||
| -rw-r--r-- | packages/heartbeat/tsconfig.json | 4 | ||||
| -rw-r--r-- | packages/host-bin/src/main.ts | 2 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 11 | ||||
| -rw-r--r-- | packages/system-prompt/src/service.test.ts | 100 | ||||
| -rw-r--r-- | packages/system-prompt/src/service.ts | 71 | ||||
| -rw-r--r-- | packages/system-prompt/src/types.ts | 22 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 5 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 2 |
18 files changed, 400 insertions, 84 deletions
@@ -62,8 +62,10 @@ "name": "@dispatch/heartbeat", "version": "0.0.0", "dependencies": { + "@dispatch/conversation-store": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/system-prompt": "workspace:*", "@dispatch/transport-contract": "workspace:*", }, }, diff --git a/packages/heartbeat/package.json b/packages/heartbeat/package.json index 40212b9..dc3d9a2 100644 --- a/packages/heartbeat/package.json +++ b/packages/heartbeat/package.json @@ -6,8 +6,10 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "dependencies": { + "@dispatch/conversation-store": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/system-prompt": "workspace:*", "@dispatch/transport-contract": "workspace:*" } } diff --git a/packages/heartbeat/src/config-store.test.ts b/packages/heartbeat/src/config-store.test.ts index 0cb6afa..17d3ee9 100644 --- a/packages/heartbeat/src/config-store.test.ts +++ b/packages/heartbeat/src/config-store.test.ts @@ -48,15 +48,15 @@ describe("applyConfigUpdate (pure)", () => { }); it("clamps intervalMinutes to a minimum of 1", () => { - expect(applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 0 }).intervalMinutes).toBe( - 1, - ); + expect( + applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 0 }).intervalMinutes, + ).toBe(1); expect( applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: -5 }).intervalMinutes, ).toBe(1); - expect(applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 7 }).intervalMinutes).toBe( - 7, - ); + expect( + applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 7 }).intervalMinutes, + ).toBe(7); }); it("truncates a non-integer interval to an integer", () => { diff --git a/packages/heartbeat/src/config-store.ts b/packages/heartbeat/src/config-store.ts index 74c01e2..5791601 100644 --- a/packages/heartbeat/src/config-store.ts +++ b/packages/heartbeat/src/config-store.ts @@ -63,9 +63,7 @@ export interface HeartbeatConfigStore { readonly listWorkspaceIds: () => Promise<readonly string[]>; } -export function createHeartbeatConfigStore( - storage: StorageNamespace, -): HeartbeatConfigStore { +export function createHeartbeatConfigStore(storage: StorageNamespace): HeartbeatConfigStore { return { async get(workspaceId: string): Promise<HeartbeatConfig> { const raw = await storage.get(configKey(workspaceId)); @@ -88,10 +86,7 @@ export function createHeartbeatConfigStore( } }, - async update( - workspaceId: string, - update: UpdateHeartbeatRequest, - ): Promise<HeartbeatConfig> { + async update(workspaceId: string, update: UpdateHeartbeatRequest): Promise<HeartbeatConfig> { const current = await this.get(workspaceId); const next = applyConfigUpdate(current, update); await storage.set(configKey(workspaceId), JSON.stringify(next)); diff --git a/packages/heartbeat/src/extension.ts b/packages/heartbeat/src/extension.ts index 66580e6..e79b01f 100644 --- a/packages/heartbeat/src/extension.ts +++ b/packages/heartbeat/src/extension.ts @@ -3,14 +3,21 @@ * * Wires the heartbeat service against the session-orchestrator + a storage * namespace, registers the typed service handle, and arms every enabled - * workspace's scheduler on boot. + * workspace's scheduler on boot. Prompt templates (`systemPrompt` / + * `taskPrompt`) are resolved against the SAME variable catalog the global + * system-prompt template uses (via the system-prompt service's `resolveText`), + * so `[type:name]` placeholders reach the model substituted — not raw. */ +import type { ConversationStore } from "@dispatch/conversation-store"; +import { conversationStoreHandle } from "@dispatch/conversation-store"; import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { type SessionOrchestrator, sessionOrchestratorHandle, } from "@dispatch/session-orchestrator"; +import type { SystemPromptService } from "@dispatch/system-prompt"; +import { systemPromptHandle } from "@dispatch/system-prompt"; import { createHeartbeatService, heartbeatServiceHandle } from "./heartbeat.js"; export const manifest: Manifest = { @@ -19,7 +26,12 @@ export const manifest: Manifest = { version: "0.0.0", apiVersion: "^0.1.0", trust: "bundled", - dependsOn: ["session-orchestrator"], + // system-prompt provides `resolveText` (the variable resolver used to + // substitute [type:name] placeholders in heartbeat prompts); conversation- + // store resolves the workspace's default cwd (the resolver runs git / reads + // files against it, mirroring the global template). Both lookups are lazy + // (at fire time, not activation), but declaring them keeps the DAG honest. + dependsOn: ["session-orchestrator", "system-prompt", "conversation-store"], activation: "eager", contributes: { services: ["heartbeat"] }, }; @@ -34,7 +46,38 @@ export const extension: Extension = { const storage = host.storage("heartbeat"); const logger = host.logger; - const service = createHeartbeatService({ storage, orchestrator, logger }); + // Resolve [type:name] placeholders in heartbeat prompts via the + // system-prompt service (same resolver + variables as the global + // template). The cwd is the workspace's defaultCwd (resolved the same + // way the orchestrator resolves a new conversation's effective cwd); + // falling back to process.cwd() when the workspace has none. Both + // services are declared `dependsOn` (always activated before heartbeat). + const systemPromptService = host.getService<SystemPromptService>(systemPromptHandle); + const conversationStore = host.getService<ConversationStore>(conversationStoreHandle); + + const resolvePrompt = async ( + template: string, + ctx: { + readonly workspaceId: string; + readonly conversationId: string; + readonly model: string; + }, + ): Promise<string> => { + const workspace = await conversationStore.getWorkspace(ctx.workspaceId); + const cwd = workspace?.defaultCwd ?? process.cwd(); + return systemPromptService.resolveText(template, cwd, { + ...(ctx.conversationId !== "" ? { conversationId: ctx.conversationId } : {}), + ...(ctx.model !== "" ? { model: ctx.model } : {}), + workspaceId: ctx.workspaceId, + }); + }; + + const service = createHeartbeatService({ + storage, + orchestrator, + logger, + resolvePrompt, + }); // Reconcile stale runs + arm enabled workspaces on boot. await service.startAll(); diff --git a/packages/heartbeat/src/heartbeat.test.ts b/packages/heartbeat/src/heartbeat.test.ts index c185c92..4c01904 100644 --- a/packages/heartbeat/src/heartbeat.test.ts +++ b/packages/heartbeat/src/heartbeat.test.ts @@ -145,6 +145,14 @@ const flush = async (): Promise<void> => { function createService(opts: { readonly orch: ReturnType<typeof createFakeOrchestrator>; readonly storage?: StorageNamespace; + readonly resolvePrompt?: ( + template: string, + ctx: { + readonly workspaceId: string; + readonly conversationId: string; + readonly model: string; + }, + ) => Promise<string>; }) { const fake = createFakeTimers(); let id = 0; @@ -154,6 +162,7 @@ function createService(opts: { orchestrator: opts.orch, timers: fake.timers, generateId: () => `id-${++id}`, + ...(opts.resolvePrompt !== undefined ? { resolvePrompt: opts.resolvePrompt } : {}), }); return { svc, advance: fake.advance, storage }; } @@ -225,14 +234,75 @@ describe("createHeartbeatService", () => { await flush(); }); + it("resolves [type:name] variables in both systemPrompt and taskPrompt before sending", async () => { + const orch = createFakeOrchestrator(); + // A fake resolver that mirrors the real resolver's contract: substitute + // known [type:name] placeholders, leave unknown text verbatim. + const resolvePrompt = async ( + template: string, + ctx: { + readonly workspaceId: string; + readonly conversationId: string; + readonly model: string; + }, + ): Promise<string> => { + return template + .replaceAll("[system:os]", "Linux (WSL)") + .replaceAll("[prompt:cwd]", "/repo") + .replaceAll("[prompt:workspace_id]", ctx.workspaceId) + .replaceAll("[prompt:conversation_id]", ctx.conversationId) + .replaceAll("[prompt:model]", ctx.model); + }; + const { svc, advance } = createService({ orch, resolvePrompt }); + await svc.updateConfig("ws-1", { + enabled: true, + systemPrompt: "You run on [system:os] in [prompt:cwd] (ws [prompt:workspace_id]).", + taskPrompt: "Check chats for [prompt:conversation_id] on [system:os].", + model: "opencode/gpt-4o", + intervalMinutes: 1, + }); + + advance(60_000); + await flush(); + expect(orch.pending).toHaveLength(1); + const turn = orch.pending[0]!; + // Variables substituted — NOT left as literal [type:name] text. + expect(turn.systemPrompt).toBe("You run on Linux (WSL) in /repo (ws ws-1)."); + expect(turn.text).toBe(`Check chats for ${turn.conversationId} on Linux (WSL).`); + expect(turn.modelName).toBe("opencode/gpt-4o"); + turn.resolve(); + await flush(); + }); + + it("passes prompts through raw when no resolver is wired (resolution is optional)", async () => { + const orch = createFakeOrchestrator(); + // No resolvePrompt → raw pass-through (the default). + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { + enabled: true, + systemPrompt: "raw [system:os] prompt", + taskPrompt: "raw [system:date] task", + intervalMinutes: 1, + }); + + advance(60_000); + await flush(); + const turn = orch.pending[0]!; + // Unresolved — literals reach the orchestrator verbatim. + expect(turn.systemPrompt).toBe("raw [system:os] prompt"); + expect(turn.text).toBe("raw [system:date] task"); + turn.resolve(); + await flush(); + }); + it("stopRun aborts the turn and marks the run stopped (not overwritten on completion)", async () => { const orch = createFakeOrchestrator(); const { svc, advance } = createService({ orch }); await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); advance(60_000); await flush(); - const conversationId = orch.pending[0]!.conversationId; - const runId = (await svc.listRuns("ws-1"))[0]!.id; + const conversationId = orch.pending[0]?.conversationId; + const runId = (await svc.listRuns("ws-1"))[0]?.id; const res = await svc.stopRun("ws-1", runId); expect(res).toEqual({ ok: true }); @@ -248,10 +318,10 @@ describe("createHeartbeatService", () => { await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); advance(60_000); await flush(); - orch.pending[0]!.resolve(); + orch.pending[0]?.resolve(); await flush(); - const runId = (await svc.listRuns("ws-1"))[0]!.id; + const runId = (await svc.listRuns("ws-1"))[0]?.id; const res = await svc.stopRun("ws-1", runId); expect(res).toEqual({ ok: true }); expect(orch.stopped).toEqual([]); // no abort on a completed run @@ -284,7 +354,14 @@ describe("createHeartbeatService", () => { ); await storage.set( "config:ws-1", - JSON.stringify({ enabled: false, systemPrompt: "", taskPrompt: "", intervalMinutes: 30, model: "", reasoningEffort: null }), + JSON.stringify({ + enabled: false, + systemPrompt: "", + taskPrompt: "", + intervalMinutes: 30, + model: "", + reasoningEffort: null, + }), ); const { svc } = createService({ orch: createFakeOrchestrator(), storage }); diff --git a/packages/heartbeat/src/heartbeat.ts b/packages/heartbeat/src/heartbeat.ts index d1efe47..7311479 100644 --- a/packages/heartbeat/src/heartbeat.ts +++ b/packages/heartbeat/src/heartbeat.ts @@ -7,12 +7,9 @@ import type { StopHeartbeatRunResponse, UpdateHeartbeatRequest, } from "@dispatch/transport-contract"; -import { - type HeartbeatConfigStore, - createHeartbeatConfigStore, -} from "./config-store.js"; -import { type HeartbeatRunStore, createHeartbeatRunStore } from "./run-store.js"; -import { HeartbeatScheduler, type Timers, realTimers } from "./scheduler.js"; +import { createHeartbeatConfigStore, type HeartbeatConfigStore } from "./config-store.js"; +import { createHeartbeatRunStore, type HeartbeatRunStore } from "./run-store.js"; +import { HeartbeatScheduler, realTimers, type Timers } from "./scheduler.js"; /** * The heartbeat service surface — what transport-http consumes and what the @@ -36,10 +33,7 @@ export interface HeartbeatService { * Stop an in-flight run (abort its turn). Idempotent for an already-finished * run. Throws when the run id is unknown (→ HTTP 404). */ - readonly stopRun: ( - workspaceId: string, - runId: string, - ) => Promise<StopHeartbeatRunResponse>; + readonly stopRun: (workspaceId: string, runId: string) => Promise<StopHeartbeatRunResponse>; /** Boot: sweep stale runs + arm every enabled workspace's scheduler. */ readonly startAll: () => Promise<void>; /** Shutdown: stop every scheduler. */ @@ -60,6 +54,23 @@ export interface HeartbeatServiceDeps { readonly timers?: Timers; /** Injectable id generator (default: crypto.randomUUID). */ readonly generateId?: () => string; + /** + * Resolve `[type:name]` variable placeholders in a prompt template against + * the current environment — the SAME resolver + variable catalog the global + * system-prompt template uses (system/file/prompt/git groups). Applied once + * per run, when the turn is constructed (mirrors the global template's + * construct-once-per-conversation resolution). When omitted, templates pass + * through UNRESOLVED (raw) — the extension wires the real resolver; tests + * inject a fake. + */ + readonly resolvePrompt?: ( + template: string, + ctx: { + readonly workspaceId: string; + readonly conversationId: string; + readonly model: string; + }, + ) => Promise<string>; } interface ActiveRun { @@ -75,6 +86,10 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer const configStore: HeartbeatConfigStore = createHeartbeatConfigStore(deps.storage); const runStore: HeartbeatRunStore = createHeartbeatRunStore(deps.storage); const orchestrator = deps.orchestrator; + // Default: pass templates through UNRESOLVED (raw). The extension wires the + // real resolver so [type:name] placeholders are substituted like the global + // system-prompt template; tests inject a fake. + const resolvePrompt = deps.resolvePrompt ?? ((template: string) => Promise.resolve(template)); // runId → active-run tracking (in-memory; the durable record lives in the // run store). Used to (a) map a stop request to its conversation, and @@ -106,17 +121,32 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer logger?.info("heartbeat: run started", { workspaceId, runId, conversationId }); + // Resolve [type:name] variables in the system/task prompts ONCE, when + // the turn is constructed — the same resolver + variable catalog the + // global system-prompt template uses. The orchestrator sends an + // explicit systemPrompt override AS-IS (bypassing its own templated + // prompt), so resolution must happen HERE, before handleMessage. For + // prompts using stable variables (os/cwd/git) this yields a stable, + // cache-warm prompt across runs; time-bearing variables refresh per + // run (mirroring the global template's per-conversation resolution). + const resolveCtx = { workspaceId, conversationId, model: config.model }; + const [systemPrompt, taskPrompt] = await Promise.all([ + resolvePrompt(config.systemPrompt, resolveCtx), + resolvePrompt(config.taskPrompt, resolveCtx), + ]); + try { await orchestrator.handleMessage({ conversationId, - text: config.taskPrompt, + text: taskPrompt, // Fire-and-forget: the heartbeat loop does not consume the // streamed events (it only awaits turn completion to mark the // run done). A no-op onEvent satisfies the required callback. onEvent: () => {}, // Always an explicit override (incl. the empty string = no system - // prompt) — bypasses the templated workspace prompt. - systemPrompt: config.systemPrompt, + // prompt) — bypasses the templated workspace prompt. Resolved + // above so [type:name] placeholders are substituted, not raw. + systemPrompt, ...(config.model !== "" ? { modelName: config.model } : {}), ...(config.reasoningEffort !== null ? { reasoningEffort: config.reasoningEffort } : {}), workspaceId, diff --git a/packages/heartbeat/src/index.ts b/packages/heartbeat/src/index.ts index 3886be5..985ad68 100644 --- a/packages/heartbeat/src/index.ts +++ b/packages/heartbeat/src/index.ts @@ -1,16 +1,16 @@ +export { + applyConfigUpdate, + createHeartbeatConfigStore, + DEFAULT_HEARTBEAT_CONFIG, + type HeartbeatConfigStore, + MIN_INTERVAL_MINUTES, +} from "./config-store.js"; export { extension, manifest } from "./extension.js"; export { - heartbeatServiceHandle, createHeartbeatService, type HeartbeatService, type HeartbeatServiceDeps, + heartbeatServiceHandle, } from "./heartbeat.js"; -export { - createHeartbeatConfigStore, - applyConfigUpdate, - DEFAULT_HEARTBEAT_CONFIG, - MIN_INTERVAL_MINUTES, - type HeartbeatConfigStore, -} from "./config-store.js"; export { createHeartbeatRunStore, type HeartbeatRunStore } from "./run-store.js"; -export { HeartbeatScheduler, realTimers, type Timers, type TimerHandle } from "./scheduler.js"; +export { HeartbeatScheduler, realTimers, type TimerHandle, type Timers } from "./scheduler.js"; diff --git a/packages/heartbeat/src/run-store.ts b/packages/heartbeat/src/run-store.ts index 7522aab..819ae00 100644 --- a/packages/heartbeat/src/run-store.ts +++ b/packages/heartbeat/src/run-store.ts @@ -19,10 +19,7 @@ function parseRunId(key: string, workspaceId: string): string { export interface HeartbeatRunStore { /** Create a new run record (status `"running"`) and persist it. */ - readonly create: ( - workspaceId: string, - run: HeartbeatRun, - ) => Promise<HeartbeatRun>; + readonly create: (workspaceId: string, run: HeartbeatRun) => Promise<HeartbeatRun>; /** Update the status of an existing run. No-op if the run is unknown. */ readonly setStatus: ( workspaceId: string, @@ -35,13 +32,8 @@ export interface HeartbeatRunStore { readonly list: (workspaceId: string) => Promise<readonly HeartbeatRun[]>; } -export function createHeartbeatRunStore( - storage: StorageNamespace, -): HeartbeatRunStore { - async function readRun( - workspaceId: string, - runId: string, - ): Promise<HeartbeatRun | null> { +export function createHeartbeatRunStore(storage: StorageNamespace): HeartbeatRunStore { + async function readRun(workspaceId: string, runId: string): Promise<HeartbeatRun | null> { const raw = await storage.get(runKey(workspaceId, runId)); if (raw === null) return null; try { diff --git a/packages/heartbeat/src/scheduler.ts b/packages/heartbeat/src/scheduler.ts index 53ffb96..e366bbc 100644 --- a/packages/heartbeat/src/scheduler.ts +++ b/packages/heartbeat/src/scheduler.ts @@ -72,17 +72,25 @@ export class HeartbeatScheduler { * run is in progress, the new `intervalMinutes` takes effect on the next * re-arm (the in-progress run is never cancelled here). */ - arm(workspaceId: string, config: { - readonly enabled: boolean; - readonly intervalMinutes: number; - }): void { + arm( + workspaceId: string, + config: { + readonly enabled: boolean; + readonly intervalMinutes: number; + }, + ): void { if (!config.enabled) { this.disarm(workspaceId); return; } let schedule = this.schedules.get(workspaceId); if (schedule === undefined) { - schedule = { intervalMinutes: config.intervalMinutes, timer: undefined, running: false, armed: true }; + schedule = { + intervalMinutes: config.intervalMinutes, + timer: undefined, + running: false, + armed: true, + }; this.schedules.set(workspaceId, schedule); } else { schedule.intervalMinutes = config.intervalMinutes; diff --git a/packages/heartbeat/tsconfig.json b/packages/heartbeat/tsconfig.json index 088ee0f..0c18985 100644 --- a/packages/heartbeat/tsconfig.json +++ b/packages/heartbeat/tsconfig.json @@ -5,6 +5,8 @@ "references": [ { "path": "../kernel" }, { "path": "../transport-contract" }, - { "path": "../session-orchestrator" } + { "path": "../session-orchestrator" }, + { "path": "../system-prompt" }, + { "path": "../conversation-store" } ] } diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index 26015ed..9919432 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -2,10 +2,10 @@ import { existsSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { extension as authApikeyExt } from "@dispatch/auth-apikey"; import { extension as cacheWarmingExt } from "@dispatch/cache-warming"; -import { extension as heartbeatExt } from "@dispatch/heartbeat"; import { extension as conversationStoreExt } from "@dispatch/conversation-store"; import { createCredentialStoreExtension } from "@dispatch/credential-store"; import { createExecBackendExtension } from "@dispatch/exec-backend"; +import { extension as heartbeatExt } from "@dispatch/heartbeat"; import { createJournalSink } from "@dispatch/journal-sink"; import { type ConfigAccess, diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index 9778d1f..ca9ce93 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -796,7 +796,16 @@ export function createSessionOrchestrator( } const orchestrator: SessionOrchestrator = { - startTurn({ conversationId, text, modelName, cwd, computerId, reasoningEffort, workspaceId, systemPrompt }) { + startTurn({ + conversationId, + text, + modelName, + cwd, + computerId, + reasoningEffort, + workspaceId, + systemPrompt, + }) { if (activeTurns.has(conversationId)) { return { started: false, reason: "already-active" }; } diff --git a/packages/system-prompt/src/service.test.ts b/packages/system-prompt/src/service.test.ts index 37c1c0d..31c55da 100644 --- a/packages/system-prompt/src/service.test.ts +++ b/packages/system-prompt/src/service.test.ts @@ -224,4 +224,104 @@ describe("system-prompt service", () => { expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null }); expect(secondMeta.cwd).not.toBe("/dir-a"); }); + + // ── resolveText: resolve an arbitrary template (no persistence) ──────────── + + it("resolveText substitutes [type:name] variables in an arbitrary template", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + const result = await service.resolveText( + "os=[system:os] cwd=[prompt:cwd] ws=[prompt:workspace_id] conv=[prompt:conversation_id] model=[prompt:model]", + "/work", + { model: "gpt-4", conversationId: "c1", workspaceId: "ws-1" }, + ); + + expect(result).toBe("os=linux cwd=/work ws=ws-1 conv=c1 model=gpt-4"); + }); + + it("resolveText uses the SAME resolution as construct (same resolver + variables)", async () => { + // A template resolved via resolveText must equal the same template + // resolved via construct (both go through the shared resolveTemplate). + const storage = memoryStorage(); + const template = "os=[system:os] cwd=[prompt:cwd] date=[system:date]"; + await storage.set("template", template); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const viaConstruct = await service.construct("conv-rt", "/work"); + const viaResolveText = await service.resolveText(template, "/work"); + expect(viaResolveText).toBe(viaConstruct); + }); + + it("resolveText does NOT persist (unlike construct)", async () => { + const storage = memoryStorage(); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + await service.resolveText("[prompt:cwd]", "/work", { conversationId: "no-persist" }); + + // No resolved:* / resolved-cwd:* / resolved-computer:* keys written. + expect(await storage.get("resolved:no-persist")).toBeNull(); + expect(await storage.get("resolved-cwd:no-persist")).toBeNull(); + expect(await storage.get("resolved-computer:no-persist")).toBeNull(); + }); + + it("resolveText resolves dynamic file:<path> variables referenced by the template", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map([["/work/AGENTS.md", "RULES"]])), + }); + + const result = await service.resolveText("[file:AGENTS.md]", "/work"); + expect(result).toBe("RULES"); + }); + + it("resolveText handles conditional blocks ([if]/[else]/[endif])", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + // [prompt:model] exists (string) → then-branch renders. + const withModel = await service.resolveText( + "[if prompt:model]has model[else]no model[endif]", + "/work", + { model: "gpt-4" }, + ); + expect(withModel).toBe("has model"); + + // model absent → else-branch renders. + const noModel = await service.resolveText( + "[if prompt:model]has model[else]no model[endif]", + "/work", + ); + expect(noModel).toBe("no model"); + }); + + it("resolveText leaves unknown [type:name] tags substituted as empty (mirrors parser)", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + // An unknown variable (not in the catalog) → empty string, matching + // parseTemplate's "key absent from the map → empty string" rule. + const result = await service.resolveText("x=[unknown:thing]", "/work"); + expect(result).toBe("x="); + }); + + it("resolveText on an empty template returns an empty string", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + expect(await service.resolveText("", "/work")).toBe(""); + }); }); diff --git a/packages/system-prompt/src/service.ts b/packages/system-prompt/src/service.ts index 8d6ede5..f695f49 100644 --- a/packages/system-prompt/src/service.ts +++ b/packages/system-prompt/src/service.ts @@ -46,6 +46,48 @@ export interface SystemPromptServiceDeps { } /** + * Resolve a template against the current environment (the shared resolution + * path used by both `construct` — which persists the result — and + * `resolveText`, which does not). Always resolves the fixed catalog + * (`system:*`, `prompt:*`, `git:*`) plus any `file:<path>` keys referenced by + * the template. Selects remote-backed adapters when `context.computerId` is + * set, mirroring `construct`. + */ +async function resolveTemplate( + deps: SystemPromptServiceDeps, + template: string, + cwd: string, + context?: { + readonly model?: string; + readonly conversationId?: string; + readonly workspaceId?: string; + readonly computerId?: string; + }, +): Promise<string> { + const referencedKeys = extractVariables(template); + const resolverContext: ResolverContext = { + ...(context?.conversationId !== undefined ? { conversationId: context.conversationId } : {}), + ...(context?.model !== undefined ? { model: context.model } : {}), + ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}), + }; + + // Select adapters: when computerId is set, use remote-backed adapters + // (read files / run commands on the REMOTE machine via SSH). Otherwise + // use the local adapters. + const computerId = context?.computerId; + const adapters = + computerId !== undefined && deps.resolveRemoteAdapters !== undefined + ? await deps.resolveRemoteAdapters(computerId, cwd) + : deps.adapters; + + const vars = await resolveVariables(cwd, adapters, { + context: resolverContext, + referencedKeys, + }); + return parseTemplate(template, vars); +} + +/** * Create a `SystemPromptService` backed by a storage namespace + adapters. * State is owned (not ambient): the storage reference lives in this closure. */ @@ -55,36 +97,29 @@ export function createSystemPromptService(deps: SystemPromptServiceDeps): System let template = await deps.storage.get(TEMPLATE_KEY); if (template === null) template = DEFAULT_TEMPLATE; - const referencedKeys = extractVariables(template); - const resolverContext: ResolverContext = { + const result = await resolveTemplate(deps, template, cwd, { conversationId, ...(context?.model !== undefined ? { model: context.model } : {}), ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}), - }; - - // Select adapters: when computerId is set, use remote-backed adapters - // (read files / run commands on the REMOTE machine via SSH). Otherwise - // use the local adapters. - const computerId = context?.computerId; - const adapters = - computerId !== undefined && deps.resolveRemoteAdapters !== undefined - ? await deps.resolveRemoteAdapters(computerId, cwd) - : deps.adapters; - - const vars = await resolveVariables(cwd, adapters, { - context: resolverContext, - referencedKeys, + ...(context?.computerId !== undefined ? { computerId: context.computerId } : {}), }); - const result = parseTemplate(template, vars); await deps.storage.set(resolvedKey(conversationId), result); await deps.storage.set(resolvedCwdKey(conversationId), cwd); // Store the computerId (or empty string for local) so the cache can be // invalidated when the computer changes. - await deps.storage.set(resolvedComputerIdKey(conversationId), computerId ?? ""); + await deps.storage.set(resolvedComputerIdKey(conversationId), context?.computerId ?? ""); return result; }, + async resolveText(template, cwd, context) { + // An empty template has no variables to resolve; short-circuit to + // avoid spawning git / reading files for nothing (the heartbeat's + // default-empty prompts hit this every run). + if (template === "") return ""; + return resolveTemplate(deps, template, cwd, context); + }, + async get(conversationId) { return deps.storage.get(resolvedKey(conversationId)); }, diff --git a/packages/system-prompt/src/types.ts b/packages/system-prompt/src/types.ts index a9fe3ca..670430a 100644 --- a/packages/system-prompt/src/types.ts +++ b/packages/system-prompt/src/types.ts @@ -35,6 +35,28 @@ export interface SystemPromptService { }, ): Promise<string>; + /** + * Resolve an ARBITRARY template string against the current environment, + * using the SAME variable resolver + adapter set as `construct` (so a + * caller that owns its own prompt — e.g. the heartbeat extension — gets + * `[type:name]` placeholders substituted identically to the global + * template). Pure resolution: nothing is persisted (the result is the + * caller's to use). An empty template yields an empty string. + * + * Like `construct`, when `context.computerId` is set the resolver uses + * remote-backed adapters (reading the remote's OS/hostname/git via SSH). + */ + resolveText( + template: string, + cwd: string, + context?: { + readonly model?: string; + readonly conversationId?: string; + readonly workspaceId?: string; + readonly computerId?: string; + }, + ): Promise<string>; + /** Read the persisted resolved system prompt, or `null` if never constructed. */ get(conversationId: string): Promise<string | null>; diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 36b27a4..b9327b3 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -1,5 +1,5 @@ -import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel"; import { DEFAULT_HEARTBEAT_CONFIG } from "@dispatch/heartbeat"; +import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel"; import { DEFAULT_TEMPLATE, getVariableCatalog } from "@dispatch/system-prompt"; import type { CloseConversationResponse, @@ -1445,8 +1445,7 @@ export function createApp(opts: CreateServerOptions): Hono { if (obj.reasoningEffort !== null && !isValidReasoningEffort(obj.reasoningEffort)) { return c.json( { - error: - "Field 'reasoningEffort' must be one of: low, medium, high, xhigh, max, or null", + error: "Field 'reasoningEffort' must be one of: low, medium, high, xhigh, max, or null", }, 400, ); diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index e3f9ffc..eb14c55 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -93,7 +93,7 @@ export function createTransportHttpExtension(): Extension & { const lspService = host.getService(lspServiceHandle); const mcpService = host.getService(mcpServiceHandle); const systemPromptService = host.getService(systemPromptHandle); - const heartbeatService = host.getService(heartbeatServiceHandle); + const heartbeatService = host.getService(heartbeatServiceHandle); // Optional: the `ssh` extension provides ComputerService. It is NOT in // dependsOn (ssh may be absent), so resolve defensively — when no // provider registered the handle, the computer routes degrade to |
