diff --git a/.env.example b/.env.example index 845f169c..d28a0da2 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 4e3ef036..0171efa3 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. * @@ -59,11 +66,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), }; } @@ -136,6 +147,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", admission, 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..d1d99a1c 100644 --- a/apps/server/src/harness/atomic/full.test.ts +++ b/apps/server/src/harness/atomic/full.test.ts @@ -65,6 +65,8 @@ 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" }) } : { kind: "text", text: "Ready." } ); afterAll(stub.stop); @@ -72,7 +74,7 @@ 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 }: { host?: HostInput; /** False runs the Planner in an empty directory, as a channel without a checkout does. */ checkout?: boolean; @@ -80,6 +82,8 @@ 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; } = {}, ) { let root = await mkdtemp(join(tmpdir(), "chopin-atomic-full-")); @@ -87,9 +91,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 ? [operatorPackageDir] : undefined, providers: { stub: { baseUrl: stub.baseUrl, @@ -157,6 +163,43 @@ async function run( await mkdir(join(cwd, ".atomic"), { recursive: true }); await writeFile(join(cwd, ".atomic", "settings.json"), JSON.stringify({ packages: [pkg] })); } + 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, @@ -288,6 +331,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 { 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 22c62468..f08f1dde 100644 --- a/apps/server/src/testing/config.ts +++ b/apps/server/src/testing/config.ts @@ -26,6 +26,7 @@ export function configured(overrides: Record = {}) { WEB_RESEARCH: 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 70f2cc72..16221cd3 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -26,6 +26,7 @@ import { beforeInputSelection, boundedChatError, chatSendPayload, + destinationCue, insertReference, MAX_REFERENCES, prepareDraftSubmission, @@ -107,6 +108,7 @@ export function Chat( draftRef.current = draft; let pickerId = useId(); let instructionsId = useId(); + let cueId = useId(); let synchronized = useRef(undefined); let activity = useRef(onActivity); let reportedBusy = useRef(false); @@ -239,6 +241,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; @@ -339,7 +345,11 @@ export function Chat( : undefined} aria-autocomplete={referencesEnabled ? "list" : undefined} aria-controls={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={referencesEnabled ? pickerOpen : undefined} aria-haspopup={referencesEnabled ? "listbox" : undefined} @@ -443,6 +453,16 @@ export function Chat( {sendError} )} + {cue && ( +

+ {cue.text} +

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