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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,9 @@ server writes the instruction from the plan, rechecks write access as for
per document. An MCP
`invoke_planner` call posts its instruction as the caller's member message and
runs under the channel's existing Planner owner; only a caller with a live
browser login can claim an unowned channel.
browser login can claim an unowned channel. With `as: "system"`, an automatic
follow-up limited to the document's creator or its Planner's holder and to 10
an hour, it goes through `instruct()` and records only a system notice instead.

`Chat.Send.focus` names the child a member has open. The server resolves it
against storage and treats it as context, never as an authorization grant. A
Expand Down
20 changes: 18 additions & 2 deletions apps/server/src/chat/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ export type Waiting = Wire.Waiting & {
focus?: Focus;
/** The child holding `thread`, when the thread is not in this document. */
threadChannel?: string;
/** See `Instruction`. */
quiet?: boolean;
};

/** A request for implementation tasks, live while its turn is queued or running. */
Expand Down Expand Up @@ -169,6 +171,8 @@ export type Instruction = {
focus?: Focus;
/** The child holding `thread`, when the thread is not in this document. */
threadChannel?: string;
/** Kept out of the visible queue: its notice says why, and its text is for the Planner alone. */
quiet?: boolean;
};

/**
Expand Down Expand Up @@ -553,7 +557,7 @@ function responded(chat: Chat, server: Server<SocketData>, room: string, text: s
* anyone sent, so they stay off the wire entirely.
*/
function visible(chat: Chat): Wire.Waiting[] {
return chat.waiting.filter(item => !item.draft && !item.job).map((
return chat.waiting.filter(item => !item.draft && !item.job && !item.quiet).map((
{ handle, id, text, references },
) => ({
handle,
Expand Down Expand Up @@ -694,7 +698,9 @@ export function send(context: Room, ws: Socket, msg: Request<Wire.Send>): Promis
*
* The caller is attributed on the posted message, and the turn runs under the
* channel's Planner owner. A checkout matters only to the atomic harness, which
* verifies it before anything is posted and remembers it for the channel.
* verifies it before anything is posted and remembers it for the channel. With a
* `notice`, an automatic follow-up, Chat shows that notice instead of a message
* and the text goes to the Planner alone, as for any other instruction.
*/
export async function invoke(
context: Room,
Expand All @@ -704,6 +710,7 @@ export async function invoke(
focus?: Focus,
/** Called once, when the invoked turn ends or is dropped; never when this refuses. */
settled?: () => void,
notice?: string,
): Promise<
| "planner-unavailable"
| "planner-not-attached"
Expand Down Expand Up @@ -732,6 +739,15 @@ export async function invoke(
}
if (chat.closed) return "planner-unavailable" as const;
if (chat.busy && chat.waiting.length >= MAX_QUEUE) return "planner-queue-full" as const;
if (notice !== undefined) {
if (verified) rememberCheckout(room, verified);
await instruct(context, user.login, text, notice, {
quiet: true,
...(focus ? { focus } : {}),
...(settled ? { ended: () => settled() } : {}),
});
return undefined;
}
let entry: Wire.Entry = {
id: ulid(),
author: { kind: "member", handle: user.login },
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import { JobRunner } from "./jobs/runner";
import { researchAnswerDefinition, researchEvidenceDefinition } from "./jobs/research-workspace";
import { JobService } from "./jobs/service";
import { DocumentSummaryCoordinator } from "./jobs/summary-coordinator";
import { invokeRouted } from "./mcp/invoke";
import { invokeRouted, SystemTurns } from "./mcp/invoke";
import { liveClassifier } from "./tasks/live-gate";
import { LiveSyncCoordinator } from "./tasks/live-sync";
import { heuristicJudge, jevJudge, SpikeScout } from "./experiments/spike-scout";
Expand Down Expand Up @@ -174,6 +174,8 @@ let sessionCleanup: ReturnType<typeof setInterval> | undefined;
let cleaningSessions: Promise<void> | undefined;
let ownerBindings: ActiveOwnerBindings | undefined;
let plannerAttachments: PlannerAttachments | undefined;
/** Each document's recent automatic follow-ups through `invoke_planner`, for their hourly limit. */
let systemTurns = new SystemTurns();
/** Browser sockets by login, so a login that ends closes them before another broadcast. */
let sessionSockets = new SessionSockets(sessionId => hostedAuth.sessions.isLive(sessionId));
let jobRunner: JobRunner | undefined;
Expand Down Expand Up @@ -2475,6 +2477,8 @@ registerMcpRoutes(router, hostedAuth, {
deleting: id => deletingChannels.has(id) || archivingChannels.has(id),
transition: withDocumentTransition,
open: rootId => openRootChat(rootId, input.session?.session.id, input.repository),
plannerAccount: rootId => plannerAttachments?.holding(rootId)?.userId,
systemTurns,
}, input),
plannerNotAttached,
archiveChannel,
Expand Down
30 changes: 25 additions & 5 deletions apps/server/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,13 @@ export type RestoreDocument<Caller> = {
>;
};

export type InvokePlannerInput = { id: string; instruction: string; checkout?: string };
export type InvokePlannerInput = {
id: string;
instruction: string;
checkout?: string;
/** An automatic follow-up: Chat shows a system notice rather than the caller's message. */
as?: "system";
};

export type InvokePlannerError =
| "document-unavailable"
Expand All @@ -159,7 +165,9 @@ export type InvokePlannerError =
| "planner-owner-unavailable"
| "checkout-unverified"
| "planner-unavailable"
| "planner-queue-full";
| "planner-queue-full"
| "system-turn-forbidden"
| "system-turn-limited";

export type InvokePlanner<Caller> = {
invoke(caller: Caller, input: InvokePlannerInput): Promise<
Expand Down Expand Up @@ -524,7 +532,8 @@ export const TOOLS: Tool[] = [
+ "owner, and otherwise the call is refused. Where the Planner runs in an attached "
+ "client, a document with no attached Planner refuses the call with "
+ "planner-not-attached and a message saying how to attach one. Questions appear in "
+ "Decisions.",
+ 'Decisions. With as: "system", for an automatic follow-up, Chat shows a short '
+ "system notice instead of the caller's message.",
inputSchema: {
type: "object",
properties: {
Expand All @@ -547,6 +556,14 @@ export const TOOLS: Tool[] = [
+ "repository. Used only by HARNESS=atomic, which verifies its origin and "
+ "remembers it as the document's Planner working directory; ignored otherwise.",
},
as: {
type: "string",
enum: ["system"],
description: "An automatic follow-up nobody typed, such as a finished workflow run: "
+ "Chat shows a system notice naming the caller, and the instruction goes to the "
+ "Planner alone. Only the document's creator, or the account whose client holds "
+ "its Planner, may use it, at most 10 times per document per hour.",
},
},
required: ["id", "instruction"],
additionalProperties: false,
Expand Down Expand Up @@ -574,6 +591,8 @@ export const TOOLS: Tool[] = [
"checkout-unverified",
"planner-unavailable",
"planner-queue-full",
"system-turn-forbidden",
"system-turn-limited",
],
},
message: { type: "string" },
Expand Down Expand Up @@ -1100,19 +1119,20 @@ export function handler<Caller>(
if (tool.name === "invoke_planner" && options.invoke) {
let args = tool.arguments;
if (
Object.keys(args).some(key => !["id", "instruction", "checkout"].includes(key))
Object.keys(args).some(key => !["id", "instruction", "checkout", "as"].includes(key))
|| !isLocator(args.id)
|| typeof args.instruction !== "string" || !args.instruction.trim()
|| Buffer.byteLength(args.instruction) > MAX_MESSAGE_BYTES
|| (Object.hasOwn(args, "checkout")
&& (typeof args.checkout !== "string" || !isAbsolute(args.checkout)))
|| (Object.hasOwn(args, "as") && args.as !== "system")
) {
return notification
? undefined
: error(
call.id,
-32602,
"invoke_planner requires an id or URL, instruction, and optional absolute checkout",
"invoke_planner requires an id or URL, instruction, optional absolute checkout, and optional as: system",
);
}
let result = await options.invoke.invoke(caller, args as InvokePlannerInput);
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/mcp/hosted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,10 @@ describe("the hosted MCP adapter", () => {
expect(started[1]).toMatchObject({
session: { session: { id: session.id }, user: { id: caller.user.id } },
});
expect(Object.hasOwn(started[1]!, "system")).toBe(false);
refusal = undefined;
await adapter.invoke!.invoke(caller, { ...input, as: "system" });
expect(started[2]).toMatchObject({ instruction: input.instruction, system: true });
} finally {
await Service.close(opened.plan);
}
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/mcp/hosted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export type PlannerInvocation = {
instruction: string;
/** Unverified; only the atomic harness reads it. */
checkout?: string;
/** An automatic follow-up shown in Chat as a system notice rather than the caller's message. */
system?: boolean;
};

export type ImplementationPersistence = { lease(): Lease };
Expand Down Expand Up @@ -341,6 +343,7 @@ export function hosted(
session: await auth.sessions.forUser(caller.user.id),
instruction: input.instruction,
...(input.checkout === undefined ? {} : { checkout: input.checkout }),
...(input.as === "system" ? { system: true } : {}),
});
let url =
new URL(await documentUrl(channel, auth.storage.channels), auth.config.origin).href;
Expand Down
105 changes: 100 additions & 5 deletions apps/server/src/mcp/invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { afterEach, expect, test } from "bun:test";
import * as Chat from "../chat/service";
import { handler, TOOLS } from "../mcp";
import { rooted } from "../testing/rooted";
import { invokeRouted } from "./invoke";
import { invokeRouted, MAX_SYSTEM_TURNS_PER_HOUR, SystemTurns } from "./invoke";

let cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
Expand Down Expand Up @@ -82,6 +82,8 @@ test("invoke_planner validates strict arguments and counts instruction bytes wit
{ ...valid, checkout: null },
{ ...valid, checkout: "" },
{ ...valid, extra: true },
{ ...valid, as: "member" },
{ ...valid, as: true },
]
) {
let result = await call(mcp, "tools/call", { name: "invoke_planner", arguments: args });
Expand All @@ -93,6 +95,11 @@ test("invoke_planner validates strict arguments and counts instruction bytes wit
(await call(mcp, "tools/call", { name: "invoke_planner", arguments: exact })).result.isError,
).toBeUndefined();
expect(received).toEqual([exact]);
let system = { ...valid, as: "system" };
expect(
(await call(mcp, "tools/call", { name: "invoke_planner", arguments: system })).result.isError,
).toBeUndefined();
expect(received).toEqual([exact, system]);
});

test("invoke_planner exposes each refusal as an object code", async () => {
Expand All @@ -106,6 +113,8 @@ test("invoke_planner exposes each refusal as an object code", async () => {
"checkout-unverified",
"planner-unavailable",
"planner-queue-full",
"system-turn-forbidden",
"system-turn-limited",
] as const
) {
let mcp = endpoint({ invoke: async () => ({ kind: "refused", code }) });
Expand Down Expand Up @@ -137,7 +146,7 @@ test("invoke_planner carries a refusal's message beside its code", async () => {
});

/** A root and child, with `invokePlanner` routed as `main.ts` routes it. */
async function routed() {
async function routed(options: { plannerAccount?: string; now?: () => number } = {}) {
let fixture = await rooted({ root: "# Root\n\nRoot prose.\n", child: "# Child\n\nFinding.\n" });
cleanups.push(fixture.close);
let opened: string[] = [];
Expand All @@ -151,23 +160,109 @@ async function routed() {
if (rootId !== fixture.root.channel.id) throw new Error(`unexpected room ${rootId}`);
return { context: fixture.root.context, release: () => released++ };
},
plannerAccount: () => options.plannerAccount,
systemTurns: new SystemTurns(options.now),
};
let invoke = async (channelId: string, repositoryId = fixture.repository.id) => {
let invoke = async (
channelId: string,
repositoryId = fixture.repository.id,
as: {
user?: { id: string; login: string };
system?: boolean;
instruction?: string;
} = {},
) => {
let channel = (await fixture.storage.channels.get(channelId))!;
let user = as.user ?? fixture.user;
return invokeRouted(route, {
channel,
repository: {
...fixture.repository,
id: repositoryId,
permissions: { push: true, pull: true, admin: false },
},
user: { id: fixture.user.id, login: fixture.user.login, avatarUrl: "" },
instruction: "Summarize the open findings.",
user: { id: user.id, login: user.login, avatarUrl: "" },
instruction: as.instruction ?? "Summarize the open findings.",
...(as.system ? { system: true } : {}),
} as never);
};
return { ...fixture, invoke, opened, released: () => released };
}

const RIVAL = { id: "U_rival", login: "rival" };
const FOLLOW_UP = "A workflow run finished. Update the document from its results.";

test("a system invocation starts a Planner turn with its instruction behind a system notice", async () => {
let { invoke, root, turns } = await routed();
let system = { system: true, instruction: FOLLOW_UP };
expect(await invoke(root.channel.id, undefined, system)).toBeUndefined();
await turns.started();
expect(turns.prompts.at(-1)).toContain(FOLLOW_UP);
expect(root.plan.chat.entries).toEqual([
expect.objectContaining({
author: { kind: "system" },
text: "Automatic follow-up requested by @ana.",
}),
]);
expect(root.plan.chat.entries.some(entry => entry.text.includes(FOLLOW_UP))).toBe(false);
});

test("a system invocation behind a running turn waits out of the visible queue", async () => {
let { events, invoke, released, root, turns, user } = await routed();
expect(await Chat.invoke(root.context, user, "First")).toBeUndefined();
await turns.started(1);
expect(await invoke(root.channel.id, undefined, { system: true, instruction: FOLLOW_UP }))
.toBeUndefined();
let queues = events.filter(event => event.frame.kind === "chat:queue");
expect(JSON.stringify(queues)).not.toContain(FOLLOW_UP);

await turns.finish();
await turns.started(2);
expect(turns.prompts[1]).toContain(FOLLOW_UP);
expect(released()).toBe(0);
await turns.finish();
await root.plan.chat.running;
expect(released()).toBe(1);
});

test("only the document's creator or the account holding its Planner may start a system turn", async () => {
let refused = await routed();
let system = { user: RIVAL, system: true, instruction: FOLLOW_UP };
expect(await refused.invoke(refused.root.channel.id, undefined, system))
.toBe("system-turn-forbidden");
expect(await refused.invoke(refused.child.channel.id, undefined, system))
.toBe("system-turn-forbidden");
expect(refused.root.plan.chat.entries).toEqual([]);
expect(refused.opened).toEqual([]);

let holder = await routed({ plannerAccount: RIVAL.id });
expect(await holder.invoke(holder.root.channel.id, undefined, system)).toBeUndefined();
await holder.turns.started();
expect(holder.root.plan.chat.entries.at(-1)).toMatchObject({
author: { kind: "system" },
text: "Automatic follow-up requested by @rival.",
});
});

test("system turns are limited per document per hour", async () => {
let now = 0;
let { invoke, root, turns } = await routed({ now: () => now });
let system = { system: true, instruction: FOLLOW_UP };
for (let i = 0; i < MAX_SYSTEM_TURNS_PER_HOUR; i++) {
expect(await invoke(root.channel.id, undefined, system)).toBeUndefined();
}
let notices = root.plan.chat.entries.length;
expect(await invoke(root.channel.id, undefined, system)).toBe("system-turn-limited");
expect(root.plan.chat.entries).toHaveLength(notices);
// An ordinary instruction is not counted against the limit.
expect(await invoke(root.channel.id)).toBeUndefined();

now += 60 * 60_000;
turns.finishAll();
await root.plan.chat.running;
expect(await invoke(root.channel.id, undefined, system)).toBeUndefined();
});

test("invoke_planner on a child posts to the root's Chat with the child as focus", async () => {
let { child, invoke, opened, root, turns } = await routed();
expect(await invoke(child.channel.id)).toBeUndefined();
Expand Down
Loading
Loading