Skip to content
Merged
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
9 changes: 6 additions & 3 deletions apps/server/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ describe("configuration", () => {
});
});

it("accepts a remote Planner on a public bind with no MODEL, and only without background jobs", () => {
it("accepts a remote Planner on a public bind with no MODEL, with or without background jobs", () => {
let remote = { HARNESS: "remote", SERVER_HOST: "0.0.0.0", BACKGROUND_JOBS: "off" };
let config = configured(remote);
expect(config).toMatchObject({ harness: "remote", host: "0.0.0.0", model: "" });
Expand All @@ -127,8 +127,11 @@ describe("configuration", () => {
"Planner: attached client over /api/v1/agent (model and client tools run on its machine)",
);
expect(configured({ ...remote, MODEL: "openai/gpt-6" }).model).toBe("openai/gpt-6");
expect(() => configured({ HARNESS: "remote" }))
.toThrow("HARNESS=remote requires BACKGROUND_JOBS=off");
expect(configured({ HARNESS: "remote" })).toMatchObject({
harness: "remote",
backgroundJobs: true,
webResearch: true,
});
});

it("keeps a detached remote Planner's ownership two minutes unless PLANNER_ATTACH_GRACE_MS says otherwise", () => {
Expand Down
5 changes: 0 additions & 5 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,11 +289,6 @@ export function load(): Config {
throw new Error("AGENT_CONNECTION_MAX_SILENCE_MS must be at least AGENT_CONNECTION_TIMEOUT_MS");
}
let selection = harnessSelection();
if (selection.harness === "remote" && backgroundJobs) {
throw new Error(
"HARNESS=remote requires BACKGROUND_JOBS=off: summary and research workers have no model on the server",
);
}
let serverPort = port();
return {
...selection,
Expand Down
75 changes: 73 additions & 2 deletions apps/server/src/harness/remote/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import * as Store from "../../questions/store";
import * as Plan from "../../plan/service";
import * as Edit from "../../plan/edit";
import * as Room from "../../plan/room";
import { createPlannerAgent } from "../agents";
import { createPlannerAgent, createResearchAgent } from "../agents";
import { openWorkerSession } from "../../jobs/worker-session";
import { createHumanInput } from "../atomic/human-input";
import { openPlannerSession } from "../session";
import { untilUnpaused } from "../atomic/full";
Expand All @@ -43,7 +44,7 @@ import { createRemoteAdapter } from "./adapter";
import { admitAgent } from "./endpoint";
import { remotePlanner } from "./registry";

import type { AgentClient, AgentHandlers } from "@chopin/agent-link/client";
import type { AgentClient, AgentHandlers, TurnContext } from "@chopin/agent-link/client";
import type { ActiveOwnerBinding } from "../../agent/active-owner";
import type { RemotePlanner } from "./registry";

Expand Down Expand Up @@ -1774,3 +1775,73 @@ describe("Agent connection questions", () => {
await askingTurn("destroy");
});
});

describe("Agent connection background workers", () => {
it("runs a document's research worker in its attached client as an isolated session", async () => {
let starts: string[] = [];
let turns: TurnContext["message"][] = [];
let searched: unknown[] = [];
let { channel } = await setup({
start: message => void starts.push(message.mode),
async turn(context) {
turns.push(context.message);
context.emit({ type: "stream-start", modelId: "scripted" });
let search = await context.callHostTool("web_search", { query: "axum rate limiting" });
let evidence = {
findings: [`Searched: ${String(search.output)}`],
sources: [{ title: "tower-governor", url: "https://docs.rs/tower_governor" }],
};
context.emit({ type: "text-start", id: "result" });
context.emit({ type: "text-delta", id: "result", delta: JSON.stringify(evidence) });
context.emit({ type: "text-end", id: "result" });
finishTurn(context);
},
});
let agent = createResearchAgent(createRemoteAdapter());
let worker = await openWorkerSession(agent, {
channelId: channel.id,
token: () => MEMBER_TOKEN,
maxAiCredits: 1,
aborted: new Promise<never>(() => {}),
openSandbox: async () => sandboxSession,
});
cleanups.push(() => worker.close());

let result = await agent.generate({
session: worker.session,
prompt: JSON.stringify({ material: { query: "axum rate limiting" } }),
options: {
model: "",
instructions: "Research only public web evidence.",
webSearch: tool({
inputSchema: z.object({ query: z.string() }),
execute: async input => {
searched.push(input);
return "results";
},
}),
},
});

expect(starts).toEqual(["isolated"]);
expect(turns.map(turn => turn.tools.map(spec => spec.name))).toEqual([["web_search"]]);
expect(turns[0]!.responseFormat?.type).toBe("json");
expect(searched).toEqual([{ query: "axum rate limiting" }]);
expect(result.output).toEqual({
findings: ["Searched: results"],
sources: [{ title: "tower-governor", url: "https://docs.rs/tower_governor" }],
});
});

it("refuses a worker for a document with no attached Planner", async () => {
let { channel } = await setup();
let agent = createResearchAgent(createRemoteAdapter());
await expect(openWorkerSession(agent, {
channelId: channel.id,
token: () => MEMBER_TOKEN,
maxAiCredits: 1,
aborted: new Promise<never>(() => {}),
openSandbox: async () => sandboxSession,
})).rejects.toThrow("No Planner is attached to this document.");
});
});
1 change: 1 addition & 0 deletions apps/server/src/jobs/document-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ class HarnessSummaryEngine {
else abortSignal.addEventListener("abort", stop, { once: true });
});
let worker = await openWorkerSession(summaryAgent, {
channelId: execution.job.channelId,
token: () => credential.token,
maxAiCredits: MAX_AI_CREDITS,
aborted,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/jobs/research-brief.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ test("the default brief engine opens a worker within Copilot's session credit co
try {
let definition = researchBriefDefinition({ config: { agent: true, model: "fixture" } });
let result = await definition.execute({
job: { channelId: "channel" },
input: material,
signal: new AbortController().signal,
deadline: new Date(Date.now() + 60_000),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/jobs/research-brief.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ async function generate(
try {
if (!await credential.authorize()) throw new JobExecutionError("owner-unavailable");
let opened = await openWorkerSession(researchBriefAgent, {
channelId: execution.job.channelId,
token: () => credential.token,
maxAiCredits: MAX_AI_CREDITS,
aborted,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/jobs/research-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,7 @@ async function stage(
diagnose("opening-session");
if (publicWeb) await execution.progress("worker-session", "started");
worker = await openWorkerSession(agent, {
channelId: execution.job.channelId,
token: () => credential.token,
maxAiCredits: STAGE_AI_CREDITS,
aborted,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/jobs/version-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ class HarnessVersionSummaryEngine {
else abortSignal.addEventListener("abort", stop, { once: true });
});
let worker = await openWorkerSession(historySummaryAgent, {
channelId: execution.job.channelId,
token: () => credential.token,
maxAiCredits: MAX_AI_CREDITS,
aborted,
Expand Down
24 changes: 21 additions & 3 deletions apps/server/src/jobs/worker-session.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test";

import { registerCredential } from "../harness/harnesses";
import { remotePlanner } from "../harness/remote/registry";
import { openWorkerSession, type WorkerSandbox } from "./worker-session";

function fixture() {
Expand Down Expand Up @@ -41,6 +42,7 @@ describe("openWorkerSession", () => {
return session;
},
}, {
channelId: "channel",
token: () => "token",
maxAiCredits: 4,
aborted: never(),
Expand All @@ -49,9 +51,11 @@ describe("openWorkerSession", () => {

expect(worker.session).toBe(session);
expect(released(sessionIds[0]!)).toBe(false);
expect(remotePlanner(sessionIds[0]!)).toEqual({ channelId: "channel", isolated: true });
await worker.close();
expect(events).toEqual(["session destroyed", "sandbox destroyed"]);
expect(released(sessionIds[0]!)).toBe(true);
expect(remotePlanner(sessionIds[0]!)).toBeUndefined();
});

it("keeps closing after the session fails to destroy", async () => {
Expand All @@ -62,6 +66,7 @@ describe("openWorkerSession", () => {
return { destroy: async () => Promise.reject(new Error("session stuck")) };
},
}, {
channelId: "channel",
token: () => "token",
maxAiCredits: 4,
aborted: never(),
Expand All @@ -81,6 +86,7 @@ describe("openWorkerSession", () => {
throw new Error("harness unavailable");
},
}, {
channelId: "channel",
token: () => "token",
maxAiCredits: 4,
aborted: never(),
Expand All @@ -100,7 +106,13 @@ describe("openWorkerSession", () => {
sessionIds.push(options.sessionId);
return late.promise;
},
}, { token: () => "token", maxAiCredits: 4, aborted, openSandbox: async () => sandbox });
}, {
channelId: "channel",
token: () => "token",
maxAiCredits: 4,
aborted,
openSandbox: async () => sandbox,
});
await Bun.sleep(0);
abort(new Error("deadline"));

Expand All @@ -122,7 +134,13 @@ describe("openWorkerSession", () => {
created = true;
throw new Error("not reached");
},
}, { token: () => "token", maxAiCredits: 4, aborted, openSandbox: () => late.promise });
}, {
channelId: "channel",
token: () => "token",
maxAiCredits: 4,
aborted,
openSandbox: () => late.promise,
});
abort(new Error("cancelled"));

await expect(opening).rejects.toThrow("cancelled");
Expand All @@ -139,7 +157,7 @@ describe("openWorkerSession", () => {
received = options.sandboxSession;
return { destroy: async () => {} };
},
}, { token: () => "token", maxAiCredits: 4, aborted: never() });
}, { channelId: "channel", token: () => "token", maxAiCredits: 4, aborted: never() });

expect(received?.defaultWorkingDirectory).toBeString();
await worker.close();
Expand Down
17 changes: 13 additions & 4 deletions apps/server/src/jobs/worker-session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash";

import { registerCredential } from "../harness/harnesses";
import { registerRemotePlanner } from "../harness/remote/registry";

export type WorkerSandbox = Awaited<ReturnType<typeof createJustBashNetworkSandboxSession>>;
type Destroyable = { destroy(): PromiseLike<void> };
Expand All @@ -24,14 +25,17 @@ async function openAbortable<T extends Destroyable>(
}

/**
* Opens one bounded worker session: a fresh sandbox, a registered per-session
* credential, and the agent session. Aborting while either resource is still
* opening destroys it once it arrives. `close()` destroys the session, then the
* sandbox, then releases the credential, continuing past failures.
* Opens one bounded worker session for a document: a fresh sandbox, a registered
* per-session credential, and the agent session. The session is also registered as
* an isolated session of the document, so under `HARNESS=remote` it runs in the
* client attached as that document's Planner. Aborting while either resource is
* still opening destroys it once it arrives. `close()` destroys the session, then
* the sandbox, then releases the credential, continuing past failures.
*/
export async function openWorkerSession<Session extends Destroyable>(
agent: WorkerAgent<Session>,
options: {
channelId: string;
token: () => string | undefined;
maxAiCredits: number;
aborted: Promise<never>;
Expand All @@ -42,6 +46,10 @@ export async function openWorkerSession<Session extends Destroyable>(
let sandbox = await openAbortable(openSandbox(), options.aborted);
let sessionId = crypto.randomUUID();
let release = registerCredential(sessionId, options.token, options.maxAiCredits);
let unregister = registerRemotePlanner(sessionId, {
channelId: options.channelId,
isolated: true,
});
let session: Session | undefined;
let close = async () => {
try {
Expand All @@ -50,6 +58,7 @@ export async function openWorkerSession<Session extends Destroyable>(
try {
await sandbox.destroy();
} finally {
unregister();
release();
}
}
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2661,6 +2661,8 @@ jobRunner = new JobRunner({
return writerLease.held;
},
resolveActivePlanner: async job => {
// A remote document's workers run in its attached Planner client; without one, work waits.
if (plannerAttachments && !plannerAttachments.attached(job.channelId)) return undefined;
let binding = await ownerBindings!.resolve(job.channelId);
if (!binding) return undefined;
return {
Expand Down
17 changes: 12 additions & 5 deletions docs/hosted-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,9 @@ Under `HARNESS=remote` only attaching claims ownership. An `@chopin` message or
owner. Other Planner requests, such as sending a comment to the Planner or a
Build draft, claim nothing either: with a client attached they run on it, and
without one they fail as any request without a usable owner does, not with
`planner-not-attached`. Model-backed research is unavailable, because the remote
Planner requires `BACKGROUND_JOBS=off`; a research request claims no ownership
and is refused as not ready.
`planner-not-attached`. A research request claims no ownership either: its
workers run on the attached client as [isolated sessions](#remote-planner), and
without one the request waits until a client attaches.

An attachment lasts only as long as its owner. Signing that browser login out,
its expiry, or a repository writer's reset of the document's Planner closes the
Expand Down Expand Up @@ -503,7 +503,12 @@ them with `tools` before calling any, may not reuse a host tool's name, and
reports them as provider-executed `tool-call` and `tool-result` parts. An
undeclared tool fails the turn as a tool boundary failure. Background job
sessions start in `isolated` mode, where the client may offer no tools of its own
and ask nothing. A turn ends with `turn-end`. An abort waits briefly for the
and ask nothing. That includes every background worker: the research, research
brief, document description, and History summary workers each open an isolated
session on the client attached to their document, may call only the host tools
their stage offers (the research evidence worker's `web_search`), and return
their structured result as the turn's JSON. The job runner starts no worker for
a document with no attached client; its job stays pending until one attaches. A turn ends with `turn-end`. An abort waits briefly for the
client's `turn-end`, then ends the turn anyway. A turn that ends while one of
its host tools still runs, such as an `ask` waiting on Decisions, stops that tool
and withdraws the cards it opened. A connection that closes mid-turn ends the turn
Expand Down Expand Up @@ -540,7 +545,9 @@ GitHub App token, limited to the document's repository, which its own account
can already read. The attached client decides what the Planner writes to the
document during turns writers start, which is why attaching requires the push
or administration access every Planner owner needs. Summary and research workers
have no model in the server, so `HARNESS=remote` requires `BACKGROUND_JOBS=off`.
have no model in the server either; they run as isolated sessions on the same
client, so attaching also lends that client's model to the document's
background work.

## Permission checks

Expand Down
Loading
Loading