Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
*
Expand Down Expand Up @@ -59,11 +66,15 @@ function model(harness: string): string {
}
return DEFAULT_MODEL;
}
export function harnessSelection(): Pick<Config, "host" | "harness" | "harnessAuth"> {
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),
};
}

Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/harness/atomic/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ export type AtomicSettings = {
model?: string;
/** Providers registered by code rather than discovered from the host. */
providers?: Record<string, ProviderConfig>;
/**
* 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 {
Expand Down Expand Up @@ -475,7 +481,7 @@ export function createAtomicAdapter(
cwd,
agentDir,
settingsManager,
...(full ? {} : {
...(full ? { additionalExtensionPaths: [...settings.extensions ?? []] } : {
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
Expand Down
67 changes: 66 additions & 1 deletion apps/server/src/harness/atomic/full.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,31 +65,37 @@ 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);

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;
/** Also run an unregistered worker session on the same harness while the Planner is open. */
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-"));
let agentDir = join(root, "agent");
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/harness/harnesses.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
Expand Down
26 changes: 24 additions & 2 deletions apps/server/src/harness/harnesses.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<void> } {
// 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 = {
Expand All @@ -34,6 +40,7 @@ export const harnesses = {
credentials: (id: string) => string | undefined;
limits: (id: string) => { maxAiCredits: number } | undefined;
auth?: string;
extensions?: readonly string[];
}) => HarnessV1
>;

Expand All @@ -59,14 +66,28 @@ const AUTH_MODES = new Map<string, ReadonlySet<string>>([
["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);
Expand Down Expand Up @@ -94,6 +115,7 @@ export function harnessFor(config: {
return maxAiCredits === undefined ? undefined : { maxAiCredits };
},
auth,
extensions,
});
}

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/testing/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function configured(overrides: Record<string, string | undefined> = {}) {
WEB_RESEARCH: undefined,
HARNESS: undefined,
HARNESS_AUTH: undefined,
HARNESS_EXTENSIONS: undefined,
AUTH_MODE: undefined,
SERVER_HOST: undefined,
PORT: undefined,
Expand Down
22 changes: 21 additions & 1 deletion apps/web/src/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
beforeInputSelection,
boundedChatError,
chatSendPayload,
destinationCue,
insertReference,
MAX_REFERENCES,
prepareDraftSubmission,
Expand Down Expand Up @@ -107,6 +108,7 @@ export function Chat(
draftRef.current = draft;
let pickerId = useId();
let instructionsId = useId();
let cueId = useId();
let synchronized = useRef<Socket | undefined>(undefined);
let activity = useRef(onActivity);
let reportedBusy = useRef(false);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -443,6 +453,16 @@ export function Chat(
{sendError}
</TerminalAlert>
)}
{cue && (
<p
className="mr-auto min-w-0 text-sm text-text-tertiary [overflow-wrap:anywhere]"
data-destination={cue.to}
id={cueId}
role="status"
>
{cue.text}
</p>
)}
{agent && (busy || counts.active > 0) && (
<button
aria-label="Stop Planner"
Expand Down
Loading
Loading