diff --git a/.env.example b/.env.example index c6466218..e7b7c1b7 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,9 @@ HARNESS=copilot-sdk # HARNESS=atomic requires auto (the host's Atomic login and environment keys, # loopback only) or ai-gateway (AI_GATEWAY_API_KEY, any bind). #HARNESS_AUTH= +# HARNESS=atomic only: absolute extension or package paths every Planner +# session loads, as Atomic's --extension would, joined by ':' (';' on Windows). +#HARNESS_EXTENSIONS= BACKGROUND_JOBS=on WEB_RESEARCH=on diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 614aebb6..278b9dc8 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { delimiter } from "node:path"; import { describe as description } from "./config"; import { configured, LOCAL, REQUIRED } from "./testing/config"; @@ -108,6 +109,16 @@ describe("configuration", () => { } }); + it("splits HARNESS_EXTENSIONS on the path delimiter and names the paths at startup", () => { + let atomic = { HARNESS: "atomic", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model" }; + expect(configured(atomic).harnessExtensions).toEqual([]); + expect(description(configured(atomic))).not.toContain("Planner extensions"); + let paths = ["/opt/first", "/opt/second"]; + let config = configured({ ...atomic, HARNESS_EXTENSIONS: paths.join(delimiter) + delimiter }); + expect(config.harnessExtensions).toEqual(paths); + expect(description(config)).toContain("Planner extensions: /opt/first, /opt/second"); + }); + it("requires a valid PostgreSQL URL", () => { expect(() => configured({ DATABASE_URL: undefined })).toThrow("DATABASE_URL is required"); expect(() => configured({ DATABASE_URL: "https://database.test" })).toThrow("PostgreSQL URL"); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 537b235f..b8eef2c3 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,6 +6,8 @@ * than a puzzling behaviour three screens later. */ +import { delimiter } from "node:path"; + import { loadAuth } from "./auth/config"; import type { AuthConfig } from "./auth/config"; @@ -18,6 +20,11 @@ export type Config = { model: string; harness: string; harnessAuth: string | undefined; + /** + * Extension or package paths every atomic Planner session loads, as if + * passed to the CLI's `--extension`. Split on the platform path delimiter. + */ + harnessExtensions: string[]; /** * Whether to run the agent at all. * @@ -63,11 +70,15 @@ function model(harness: string): string { } return DEFAULT_MODEL; } -export function harnessSelection(): Pick { +export function harnessSelection(): Pick< + Config, + "host" | "harness" | "harnessAuth" | "harnessExtensions" +> { return { host: process.env.SERVER_HOST || "127.0.0.1", harness: process.env.HARNESS || "copilot-sdk", harnessAuth: process.env.HARNESS_AUTH || undefined, + harnessExtensions: (process.env.HARNESS_EXTENSIONS ?? "").split(delimiter).filter(Boolean), }; } @@ -160,6 +171,9 @@ export function describe(config: Config): string { ...(config.harness === "atomic" ? ["Planner: full Atomic session (shell and filesystem access as this process's user)"] : []), + ...(config.harnessExtensions.length + ? [`Planner extensions: ${config.harnessExtensions.join(", ")}`] + : []), config.backgroundJobs ? "background jobs: on" : "background jobs: off", config.webResearch ? "web research: on" : "web research: off", config.conversationPlan diff --git a/apps/server/src/harness/atomic/adapter.ts b/apps/server/src/harness/atomic/adapter.ts index c19d5840..4eb74c24 100644 --- a/apps/server/src/harness/atomic/adapter.ts +++ b/apps/server/src/harness/atomic/adapter.ts @@ -123,6 +123,12 @@ export type AtomicSettings = { model?: string; /** Providers registered by code rather than discovered from the host. */ providers?: Record; + /** + * Extension or package paths every Planner session loads, as the CLI's + * `--extension` would; their workflows and skills register too. Worker + * sessions never load them. + */ + extensions?: readonly string[]; }; export class ToolBoundaryError extends Error { @@ -475,7 +481,7 @@ export function createAtomicAdapter( cwd, agentDir, settingsManager, - ...(full ? {} : { + ...(full ? { additionalExtensionPaths: [...settings.extensions ?? []] } : { noExtensions: true, noSkills: true, noPromptTemplates: true, diff --git a/apps/server/src/harness/atomic/full.test.ts b/apps/server/src/harness/atomic/full.test.ts index 8a755590..c7912cc2 100644 --- a/apps/server/src/harness/atomic/full.test.ts +++ b/apps/server/src/harness/atomic/full.test.ts @@ -20,6 +20,8 @@ import { untilUnpaused, } from "./full"; import { startStubModelServer } from "../pi/model-stub"; +import * as Plan from "../../plan/service"; +import * as Store from "../../questions/store"; import { hostInputRoom } from "../../testing/decisions"; import type { HostInput, QuestionParams, SessionWorkflows } from "@bastani/atomic"; @@ -65,6 +67,14 @@ let stub = startStubModelServer((prompt, prior) => ? { kind: "tool", name: "ask_user_question", arguments: JSON.stringify(freeText[prompt]) } : prompt === "cwd" ? { kind: "tool", name: "bash", arguments: JSON.stringify({ command: "pwd" }) } + : prompt === "workflows" + ? { kind: "tool", name: "workflow", arguments: JSON.stringify({ action: "list" }) } + : prompt === "launch" + ? { + kind: "tool", + name: "workflow", + arguments: JSON.stringify({ action: "run", workflow: "asking-workflow", inputs: {} }), + } : { kind: "text", text: "Ready." } ); afterAll(stub.stop); @@ -72,7 +82,15 @@ afterAll(stub.stop); async function run( registered: boolean, prompt = "plain", - { host, checkout = true, worker = false, projectPackage = false }: { + { + host, + checkout = true, + worker = false, + projectPackage = false, + operatorPackage = false, + askingWorkflow = false, + afterTurn, + }: { host?: HostInput; /** False runs the Planner in an empty directory, as a channel without a checkout does. */ checkout?: boolean; @@ -80,6 +98,12 @@ async function run( worker?: boolean; /** Install a local package through the checkout's `.atomic/settings.json`. */ projectPackage?: boolean; + /** Load a local package through the operator's extension paths, as `HARNESS_EXTENSIONS` does. */ + operatorPackage?: boolean; + /** Load a workflow whose stage calls ask_user_question, through the operator's extension paths. */ + askingWorkflow?: boolean; + /** Runs once the Planner's turn has ended, while its session and workflow runs are still live. */ + afterTurn?: () => Promise; } = {}, ) { let root = await mkdtemp(join(tmpdir(), "chopin-atomic-full-")); @@ -87,9 +111,11 @@ async function run( let cwd = join(root, checkout ? "checkout" : "empty"); let previous = process.env.ATOMIC_CODING_AGENT_DIR; let received: QuestionParams[] = []; + let operatorPackageDir = join(root, "operator-package"); let harness = createAtomicAdapter({ auth: "ai-gateway", model: "stub/model", + extensions: operatorPackage || askingWorkflow ? [operatorPackageDir] : undefined, providers: { stub: { baseUrl: stub.baseUrl, @@ -157,6 +183,69 @@ async function run( await mkdir(join(cwd, ".atomic"), { recursive: true }); await writeFile(join(cwd, ".atomic", "settings.json"), JSON.stringify({ packages: [pkg] })); } + if (askingWorkflow) { + await mkdir(operatorPackageDir, { recursive: true }); + await writeFile( + join(operatorPackageDir, "package.json"), + JSON.stringify({ + name: "asking-package", + type: "module", + atomic: { workflows: ["./workflow.ts"] }, + }), + ); + await writeFile( + join(operatorPackageDir, "workflow.ts"), + `import { workflow } from "@bastani/atomic/workflows"; +export default workflow({ + name: "asking-workflow", + description: "A stage that asks the user a question", + inputs: {}, + outputs: {}, + run: async ctx => { + await ctx.task("grill-me-1", { prompt: "question" }); + return {}; + }, +}); +`, + ); + } + if (operatorPackage) { + await mkdir(join(operatorPackageDir, "skills", "operator-package-marker"), { + recursive: true, + }); + await writeFile( + join(operatorPackageDir, "package.json"), + JSON.stringify({ + name: "operator-package", + type: "module", + atomic: { + extensions: ["./extension.ts"], + skills: ["./skills"], + workflows: ["./workflow.ts"], + }, + }), + ); + await writeFile( + join(operatorPackageDir, "extension.ts"), + `export default api => api.registerTool({name: "operator_package_tool", label: "Operator package", description: "Marker", parameters: {type:"object"}, async execute() { return {content:[{type:"text",text:"operator package"}], details:{}}; }});`, + ); + await writeFile( + join(operatorPackageDir, "skills", "operator-package-marker", "SKILL.md"), + "---\nname: operator-package-marker\ndescription: OPERATOR-PACKAGE-SKILL-MARKER\n---\nOperator package skill.\n", + ); + await writeFile( + join(operatorPackageDir, "workflow.ts"), + `import { workflow } from "@bastani/atomic/workflows"; +export default workflow({ + name: "operator-package-workflow", + description: "OPERATOR-PACKAGE-WORKFLOW-MARKER", + inputs: {}, + outputs: {}, + run: async () => ({}), +}); +`, + ); + } process.env.ATOMIC_CODING_AGENT_DIR = agentDir; let humanInput: HostInput = host ?? { confirm: async () => false, @@ -197,6 +286,7 @@ async function run( let result = await agent.stream({ session, prompt }); await result.consumeStream(); await result.text; + await afterTurn?.(); let requests = [...stub.requests]; if (!worker) { let files = { @@ -288,6 +378,28 @@ test("a checkout's project settings add its packages without writing either sett expect(plain.requests[0]!.toolNames).not.toContain("project_tool"); }); +/** The first workflow tool call starts Atomic's durable backend, which falls back slowly without Postgres. */ +const WORKFLOW_TOOL_TIMEOUT_MS = 30_000; + +test( + "operator extension paths add a package's tools, skills, and workflows to Planner sessions only", + async () => { + let result = await run(true, "workflows", { operatorPackage: true, worker: true }); + let request = result.requests[0]!; + expect(request.toolNames).toContain("operator_package_tool"); + expect(request.system).toContain("OPERATOR-PACKAGE-SKILL-MARKER"); + expect(result.requests.at(-1)!.toolResults.join("\n")).toContain("operator-package-workflow"); + expect(result.workerRequests[0]!.toolNames).not.toContain("operator_package_tool"); + }, + WORKFLOW_TOOL_TIMEOUT_MS, +); + +test("without operator extension paths, Planner sessions have none of that package", async () => { + let plain = await run(true, "workflows"); + expect(plain.requests[0]!.toolNames).not.toContain("operator_package_tool"); + expect(plain.requests.at(-1)!.toolResults.join("\n")).not.toContain("operator-package-workflow"); +}, WORKFLOW_TOOL_TIMEOUT_MS); + test("free-text Decisions answers to multi-select and preview questions reach the model", async () => { let room = await hostInputRoom(); try { @@ -304,6 +416,53 @@ test("free-text Decisions answers to multi-select and preview questions reach th } }); +test( + "a workflow stage's ask_user_question reaches Decisions and its answer returns to the stage", + async () => { + let room = await hostInputRoom(); + // Atomic gives workflow stages a canned session under a test runtime. + let environment = process.env.NODE_ENV; + delete process.env.NODE_ENV; + try { + let seen: string[] = []; + await run(true, "launch", { + host: room.input, + askingWorkflow: true, + afterTurn: async () => { + // The stage starts after Atomic's workflow backend does. + let open = Store.outstanding(room.plan.questions); + for (let deadline = Date.now() + 30_000; !open.length && Date.now() < deadline;) { + await Bun.sleep(100); + open = Store.outstanding(room.plan.questions); + } + let [card] = open; + seen.push(card!.definition.questions[0].question); + seen.push(Plan.source(room.plan)); + await room.answer(card!.id, [1]); + // The stage receives the answer and its model replies. + let deadline = Date.now() + 15_000; + while ( + Date.now() < deadline + && !stub.requests.some(request => + request.toolResults.some(text => text.includes("Second")) + ) + ) await new Promise(resolve => setTimeout(resolve, 100)); + }, + }); + expect(seen[0]).toContain("Which option?"); + expect(seen[1]).toContain(" request.toolResults.some(text => text.includes("Second"))), + ) + .toBe(true); + } finally { + if (environment !== undefined) process.env.NODE_ENV = environment; + await room.close(); + } + }, + 60_000, +); + test("worker sessions stay isolated, even beside a full Planner session on the same harness", async () => { let alone = await run(false); expect(alone.requests[0]!.toolNames).toEqual(["host_tool"]); diff --git a/apps/server/src/harness/harnesses.test.ts b/apps/server/src/harness/harnesses.test.ts index 95360422..1751c071 100644 --- a/apps/server/src/harness/harnesses.test.ts +++ b/apps/server/src/harness/harnesses.test.ts @@ -1,4 +1,6 @@ import { afterEach, expect, it } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { harnesses, harnessFor, registerCredential, shutdownHarnesses } from "./harnesses"; @@ -66,6 +68,18 @@ it("refuses Atomic's host-login auto mode on a non-loopback bind but accepts ai- .toBe("atomic"); }); +it("accepts only existing absolute extension paths, and only for the atomic harness", () => { + let atomic = { harness: "atomic", harnessAuth: "auto", host: "127.0.0.1" }; + expect(() => harnessFor({ ...atomic, harnessExtensions: ["relative/package"] })) + .toThrow("HARNESS_EXTENSIONS path relative/package must be absolute"); + let missing = join(tmpdir(), `chopin-missing-${crypto.randomUUID()}`); + expect(() => harnessFor({ ...atomic, harnessExtensions: [missing] })) + .toThrow(`HARNESS_EXTENSIONS path ${missing} does not exist`); + expect(() => harnessFor({ harness: "pi", harnessAuth: "auto", harnessExtensions: [tmpdir()] })) + .toThrow("HARNESS_EXTENSIONS requires HARNESS=atomic, not pi"); + expect(harnessFor({ ...atomic, harnessExtensions: [tmpdir()] }).harnessId).toBe("atomic"); +}); + it("registers credentials per session without reuse or rewriting", () => { let token = "session-token"; let release = registerCredential("session-id", () => token); diff --git a/apps/server/src/harness/harnesses.ts b/apps/server/src/harness/harnesses.ts index d8928c26..685456f4 100644 --- a/apps/server/src/harness/harnesses.ts +++ b/apps/server/src/harness/harnesses.ts @@ -1,3 +1,6 @@ +import { existsSync } from "node:fs"; +import { isAbsolute } from "node:path"; + import { ATOMIC_AUTH_MODES, createAtomicAdapter } from "./atomic/adapter"; import { forgetWorkspaces } from "./atomic/workspace"; import { createCopilotSdk } from "./copilot-sdk/adapter"; @@ -18,10 +21,13 @@ function createPiHarness(settings: { auth?: string }): HarnessV1 & { shutdown(): } function createAtomicHarness( - settings: { auth?: string }, + settings: { auth?: string; extensions?: readonly string[] }, ): HarnessV1 & { shutdown(): Promise } { // Like Pi, Atomic takes neither a GitHub credential nor a credit limit. - return createAtomicAdapter({ auth: settings.auth as AtomicAuthMode }); + return createAtomicAdapter({ + auth: settings.auth as AtomicAuthMode, + extensions: settings.extensions, + }); } export const harnesses = { @@ -34,6 +40,7 @@ export const harnesses = { credentials: (id: string) => string | undefined; limits: (id: string) => { maxAiCredits: number } | undefined; auth?: string; + extensions?: readonly string[]; }) => HarnessV1 >; @@ -59,14 +66,28 @@ const AUTH_MODES = new Map>([ ["atomic", new Set(ATOMIC_AUTH_MODES)], ]); +/** Only the atomic Planner loads operator extensions, each an existing absolute path. */ +function checkedExtensions(harness: string, extensions: readonly string[] = []): readonly string[] { + if (extensions.length && harness !== "atomic") { + throw new Error(`HARNESS_EXTENSIONS requires HARNESS=atomic, not ${harness}`); + } + for (let path of extensions) { + if (!isAbsolute(path)) throw new Error(`HARNESS_EXTENSIONS path ${path} must be absolute`); + if (!existsSync(path)) throw new Error(`HARNESS_EXTENSIONS path ${path} does not exist`); + } + return extensions; +} + export function harnessFor(config: { harness: string; harnessAuth?: string; + harnessExtensions?: readonly string[]; host?: string; }): HarnessV1 { if (!Object.hasOwn(harnesses, config.harness)) { throw new Error(`Unknown harness: ${config.harness}`); } + let extensions = checkedExtensions(config.harness, config.harnessExtensions); let auth = config.harnessAuth; let isLoopback = loopback(config.host ?? "127.0.0.1"); let modes = AUTH_MODES.get(config.harness); @@ -94,6 +115,7 @@ export function harnessFor(config: { return maxAiCredits === undefined ? undefined : { maxAiCredits }; }, auth, + extensions, }); } diff --git a/apps/server/src/testing/config.ts b/apps/server/src/testing/config.ts index 011e99ad..8b9c1040 100644 --- a/apps/server/src/testing/config.ts +++ b/apps/server/src/testing/config.ts @@ -31,6 +31,7 @@ export function configured(overrides: Record = {}) { TYPESAFE_API_KEY: undefined, HARNESS: undefined, HARNESS_AUTH: undefined, + HARNESS_EXTENSIONS: undefined, AUTH_MODE: undefined, SERVER_HOST: undefined, PORT: undefined, diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index 1931e5a5..d8978c1e 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -36,6 +36,7 @@ import { beforeInputSelection, boundedChatError, chatSendPayload, + destinationCue, insertReference, MAX_REFERENCES, prepareDraftSubmission, @@ -146,6 +147,7 @@ export function Chat( let pickerId = useId(); let mentionPickerId = useId(); let instructionsId = useId(); + let cueId = useId(); let synchronized = useRef(undefined); let activity = useRef(onActivity); let reportedBusy = useRef(false); @@ -299,6 +301,10 @@ export function Chat( setDismissedPicker(undefined); }; + let cue = sendError + ? undefined + : destinationCue(draft.text, draft.references, agent, referencesEnabled); + let submit = () => { if (submission.current || !composerReady || !wire) return; let current = draftRef.current; @@ -445,7 +451,11 @@ export function Chat( : undefined} aria-autocomplete="list" aria-controls={mentionOpen ? mentionPickerId : pickerOpen ? pickerId : undefined} - aria-describedby={referencesEnabled ? instructionsId : undefined} + aria-describedby={[ + referencesEnabled ? instructionsId : undefined, + cue ? cueId : undefined, + ] + .filter(Boolean).join(" ") || undefined} aria-disabled={!composerReady || submitting} aria-expanded={pickerOpen || mentionOpen} aria-haspopup="listbox" @@ -581,6 +591,16 @@ export function Chat( {sendError} )} + {cue && ( +

+ {cue.text} +

+ )} {agent && (busy || counts.active > 0) && (