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
4 changes: 3 additions & 1 deletion apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ The library points TypeScript at `src` but runs from `dist`. So that a dev serve

The Ledger is the ordered sequence of accepted `ledger_commit` calls in the conversation's canonical history; it has no other storage. A commit submits one queue of `[route, payload]` entries: `entity/create`, `entity/update/<entity ID>`, `claim/create`, `obligation/create` and `reflection/create`. The host assigns record IDs in queue order (`e1`, `c1`, `o1`, `r1`, …) and stamps each commit with the user message it followed; entries reference existing records by ID and records in the same queue as `$index`. A claim supersedes earlier claims by reference and hides nothing. Obligations record checks the model owes, which a reflection citing the run or inspection discharges.

A running commit reads history, folds the accepted commits before its own call and decides from them alone, so an interrupted durable call re-runs to the same IDs. A commit counts only when its recorded output agrees with the IDs its input yields. Flue folds a response's steps into one message and may run a proposal's calls concurrently: a commit that runs while an earlier commit in the same response is unsettled is refused. Its receipt returns the IDs, how many user messages have passed, how many questions are open, the obligations still owed and notes on consequences to weigh.
The user comments on records from the Ledger tab. A batch of comments is a turn of its own: a Flue signal of type `brunch.ledger-comments` whose JSON body gives each comment's ID (`n1`, …), the record addresses it is on and the selected text. The fold holds the comments and anchors the commits after them to that turn, as it does a user message. Any entry resolves comments by naming their IDs in `resolves`; the fold marks each comment with the first record that resolved it.

A running commit reads history, folds the accepted commits before its own call and decides from them alone, so an interrupted durable call re-runs to the same IDs. A commit counts only when its recorded output agrees with the IDs its input yields. Flue folds a response's steps into one message and may run a proposal's calls concurrently: a commit that runs while an earlier commit in the same response is unsettled is refused. Its receipt returns the IDs, how many user turns have passed, how many questions are open, the obligations still owed, the comments still open and notes on consequences to weigh.

`ledger_compile` renders the folded Ledger's projection as Markdown, with record IDs and kind tokens inline; persona evidence (`ledger.md`, `ledger.json`) uses the same Markdown. The website's Ledger tab renders the projection as a document, without IDs, marking each record occurrence with its address so a canvas selection can highlight the records `traceLedger` reaches.

Expand Down
13 changes: 7 additions & 6 deletions apps/brunch-agent/evals/petrinaut/persona/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The agent runs in a sibling Herdr pane when the launcher runs inside Herdr (`HER
Each run writes `<run>/bin/persona`, a helper bound to that run's bridge socket:

```sh
persona open # sends the case's opening; prints it and Brunch's reply
persona say "We lose about a day a week to expediting." # prints Brunch's reply
persona say <<'EOF'
A longer message, typed as one chat turn.
Expand All @@ -42,15 +43,15 @@ persona end "Goal reached" # ends the conversation and closes the run
persona rpc '{"type":"get_state"}' # one raw record in, JSONL records out
```

`say` blocks until Brunch has completely finished, including browser-tool continuations; a turn can take several minutes. Interrupting the command (or a shell-tool timeout) stops Brunch's turn. Errors go to stderr with exit status 1, and the persona is told never to resend a failed message: it may already have been admitted.
The persona starts the conversation itself with `open`, which sends the case's opening exactly as written, so the opening exchange is in its own context and the time before its second message is its own. The bridge refuses `say` until `open` has been admitted, and refuses `open` after that and on resume. `open` and `say` block until Brunch has completely finished, including browser-tool continuations; a turn can take several minutes. Interrupting the command (or a shell-tool timeout) stops Brunch's turn. Errors go to stderr with exit status 1, and the persona is told never to resend a failed message: it may already have been admitted.

The helper speaks a Pi-style JSONL protocol to the bridge. Commands are `{ "id"?, "type", ... }` records: `prompt` (with `message`), `get_state`, `get_transcript` and `end` (with optional `reason`). Each command gets one `response` record with `success` and `data` or `error`. An accepted `prompt` is followed by `turn_start`, `assistant_message` and `turn_settled` events (`replied`, `failed` or `aborted`). Records are framed on LF only. New interaction types, such as structured answers to a question card, are added as new command and event types.
The helper speaks a Pi-style JSONL protocol to the bridge. Commands are `{ "id"?, "type", ... }` records: `open`, `prompt` (with `message`), `get_state`, `get_transcript` and `end` (with optional `reason`). Each command gets one `response` record with `success` and `data` or `error`. An accepted `open` or `prompt` is followed by `turn_start`, `assistant_message` and `turn_settled` events (`replied`, `failed` or `aborted`). Records are framed on LF only. New interaction types, such as structured answers to a question card, are added as new command and event types.

## Stopping

The persona stops by its own rule: when the person has what they came for, when the conversation is going nowhere, or when they run out of patience. It then runs `persona end` and tells you why it stopped. You, or an agent supervising the run, can force a stop at any point, for example after a chosen number of turns, with `<run>/bin/persona end`, or with Ctrl-C in the launcher when the agent runs in its own pane.

**Persona runs have no automatic accounting cutoff.** The launcher disables the campaign accounting wrapper for Brunch even if `BRUNCH_STEP_A_ACCOUNTING` was inherited. `--max-messages <n>` stops the run once Brunch has replied to the persona's nth message; the case's opening is not counted, and the run packet is written as on any other stop. A live-provider run requires explicit model and spend authorization; the existence of this command grants none.
**Persona runs have no automatic accounting cutoff.** The launcher disables the campaign accounting wrapper for Brunch even if `BRUNCH_STEP_A_ACCOUNTING` was inherited. `--max-messages <n>` stops the run once Brunch has replied to the persona's nth message; the case's opening is not counted, and the run packet is written as on any other stop. With `--hold`, the persona stopping, whether at that limit, by `persona end` or by its agent exiting, closes nothing: the bridge refuses any further persona messages and you carry on in the same Chrome window and conversation. Ctrl-C in the launcher (once a foreground agent has exited) or closing Chrome then stops the run as usual. A live-provider run requires explicit model and spend authorization; the existence of this command grants none.

## Guidance provenance

Expand All @@ -74,9 +75,9 @@ Other files in the directory are not loaded. A case may keep a `key.md` beside t
The [launcher](launch.ts):

1. Starts its own local Brunch/Petrinaut services with a fresh run-local SQLite database. Occupied ports refuse rather than reuse unverified services.
2. Opens a separate, headed Chrome window on the real Petrinaut AI panel. On an interactive terminal it prints the URL and profile and **waits for Enter before sending the public opening**, so you can start screen recording. `--skip-recording-pause` skips the wait; it is skipped automatically when stdin is not a terminal, for example when an agent runs the launcher.
3. Sends the opening and waits through browser-tool continuations for the completed reply. The default route is `/` with no preloaded net; `--initial-net` is optional staging, not a from-scratch run.
4. Opens the browser bridge, writes `bin/persona` and the private `persona-brief.md` (role, command guide, any objective, the opening exchange and the situation pack), then starts the agent.
2. Opens a separate, headed Chrome window on the real Petrinaut AI panel. On an interactive terminal it prints the URL and profile and **waits for Enter before starting the agent**, so you can start screen recording. `--skip-recording-pause` skips the wait; it is skipped automatically when stdin is not a terminal, for example when an agent runs the launcher. The default route is `/` with no preloaded net; `--initial-net` is optional staging, not a from-scratch run.
3. Opens the browser bridge, writes `bin/persona` and the private `persona-brief.md` (role, command guide, any objective, the opening message to send and the situation pack), then starts the agent.
4. The agent's `persona open` sends the opening, and the launcher waits through browser-tool continuations for the completed reply.
5. Types each `say` message into the ordinary composer, waits until every admitted continuation settles, and returns the reply. This uses selectors, not screenshot-based AI computer use.
6. Clicks **Allow** on each destructive-edit approval panel (any `remove*` net tool) during a turn, because the persona sees only reply prose and cannot answer it; an unanswered panel holds the turn indefinitely. Each allowed edit is recorded in `bridge-log.jsonl`. It clicks Allow rather than Always allow so every request is recorded.

Expand Down
85 changes: 83 additions & 2 deletions apps/brunch-agent/evals/petrinaut/persona/browser-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ afterEach(async () => {

const openBridge = async (handlers: Partial<PersonaBridgeHandlers>) => {
const bridge = await openPersonaBrowserBridge({
// Most tests talk to an open conversation, as on resume.
opening: undefined,
prompt: async (_message, turn) => {
turn.admitted();
return { text: "Tell me more.", submissionIds: ["sub_1"] };
Expand Down Expand Up @@ -70,6 +72,82 @@ test("an admitted prompt yields its response, then the turn's events through set
});
});

test("open sends the case's opening once, and say waits for it", async () => {
const prompt = vi.fn<PersonaBridgeHandlers["prompt"]>(
async (_message, turn) => {
turn.admitted();
return { text: "Tell me more.", submissionIds: ["sub_1"] };
},
);
const bridge = await openBridge({ opening: "Hello, I run a desk.", prompt });
expect(
await sendPersonaCommand(bridge.socketPath, {
type: "prompt",
message: "Hi.",
}),
).toEqual([
expect.objectContaining({
success: false,
error:
"Start the conversation with `open`, which sends your opening message.",
}),
]);
expect(
await sendPersonaCommand(bridge.socketPath, { id: "o", type: "open" }),
).toEqual([
{ id: "o", type: "response", command: "open", success: true },
{ type: "turn_start", message: "Hello, I run a desk." },
{ type: "assistant_message", text: "Tell me more." },
{ type: "turn_settled", outcome: "replied", submissionIds: ["sub_1"] },
]);
expect(prompt).toHaveBeenLastCalledWith(
"Hello, I run a desk.",
expect.objectContaining({ opening: true }),
);
expect(await sendPersonaCommand(bridge.socketPath, { type: "open" })).toEqual(
[
expect.objectContaining({
success: false,
error: "The conversation is already open; reply with `say`.",
}),
],
);
await sendPersonaCommand(bridge.socketPath, {
type: "prompt",
message: "Six agents.",
});
expect(prompt).toHaveBeenLastCalledWith(
"Six agents.",
expect.objectContaining({ opening: false }),
);
});

test("an opening refused before admission may be retried; once admitted it is never resent", async () => {
let attempts = 0;
const bridge = await openBridge({
opening: "Hello.",
prompt: async (_message, turn) => {
attempts += 1;
if (attempts === 1) throw new Error("Composer has an unsent draft");
turn.admitted();
throw new Error("Brunch turn failed");
},
});
expect(await sendPersonaCommand(bridge.socketPath, { type: "open" })).toEqual(
[expect.objectContaining({ success: false })],
);
expect(
(await sendPersonaCommand(bridge.socketPath, { type: "open" })).at(-1),
).toMatchObject({ type: "turn_settled", outcome: "failed" });
expect(await sendPersonaCommand(bridge.socketPath, { type: "open" })).toEqual(
[
expect.objectContaining({
error: "The conversation is already open; reply with `say`.",
}),
],
);
});

test("a failure before admission is a failed response; after admission it settles the turn", async () => {
const early = await openBridge({
prompt: async () => {
Expand Down Expand Up @@ -197,12 +275,15 @@ test("end is acknowledged before the launcher is told to stop", async () => {
test("the generated helper reads like an ordinary CLI over the bridge", async () => {
const run = await mkdtemp(join(tmpdir(), "TEST-persona-helper-"));
cleanup.push(() => rm(run, { recursive: true }));
const bridge = await openBridge({});
const bridge = await openBridge({ opening: "Hello." });
const helper = await writePersonaHelper(run, bridge.socketPath);
const persona = (...args: string[]) =>
promisify(execFile)(helper, args, { env: process.env });

expect((await persona("say", "Hello.")).stdout).toBe("Tell me more.\n");
expect((await persona("open")).stdout).toBe(
"You: Hello.\n\nBrunch: Tell me more.\n",
);
expect((await persona("say", "Go on.")).stdout).toBe("Tell me more.\n");
expect((await persona("transcript")).stdout).toBe(
"You: Hello.\n\nBrunch: Tell me more.\n",
);
Expand Down
49 changes: 44 additions & 5 deletions apps/brunch-agent/evals/petrinaut/persona/browser-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,19 @@ export type PersonaBrowserReply = {
};

export type PersonaBridgeHandlers = {
/**
* The case's opening, which `open` sends as the persona's first message;
* undefined when the conversation already exists, as on resume.
*/
opening: string | undefined;
/** Submit through the real composer; call `admitted` once Brunch accepts it. */
prompt(
message: string,
turn: { readonly signal: AbortSignal; readonly admitted: () => void },
turn: {
readonly signal: AbortSignal;
readonly admitted: () => void;
readonly opening: boolean;
},
): Promise<PersonaBrowserReply>;
getState(): Record<string, unknown>;
getTranscript(): Promise<readonly PersonaTranscriptEntry[]>;
Expand All @@ -52,6 +61,8 @@ export const openPersonaBrowserBridge = async (
const tasks = new Set<Promise<void>>();
let active: AbortController | undefined;
let completedTurns = 0;
// Set once the opening is admitted: an admitted message is never resent.
let opened = handlers.opening === undefined;

const send = (socket: Socket, record: PersonaRecord) => {
if (!socket.destroyed) socket.write(encodePersonaRecord(record));
Expand Down Expand Up @@ -79,25 +90,48 @@ export const openPersonaBrowserBridge = async (

const prompt = async (
socket: Socket,
command: Extract<PersonaCommand, { type: "prompt" }>,
command: Extract<PersonaCommand, { type: "open" | "prompt" }>,
) => {
if (active) {
send(socket, fail(command, "A persona turn is already active"));
return;
}
const opening = command.type === "open";
// `open` only before the conversation exists, `prompt` only after.
const message = opening
? opened
? undefined
: handlers.opening
: opened
? command.message
: undefined;
if (message === undefined) {
send(
socket,
fail(
command,
opening
? "The conversation is already open; reply with `say`."
: "Start the conversation with `open`, which sends your opening message.",
),
);
return;
}
const controller = new AbortController();
active = controller;
const abortOnClose = () => controller.abort();
socket.once("close", abortOnClose);
const turn = { admitted: false };
try {
const reply = await handlers.prompt(command.message, {
const reply = await handlers.prompt(message, {
signal: controller.signal,
opening,
admitted: () => {
if (turn.admitted) return;
turn.admitted = true;
opened = true;
send(socket, succeed(command));
broadcast({ type: "turn_start", message: command.message });
broadcast({ type: "turn_start", message });
},
});
// The sender waits for the admission response before reading events.
Expand Down Expand Up @@ -128,6 +162,7 @@ export const openPersonaBrowserBridge = async (

const dispatch = async (socket: Socket, command: PersonaCommand) => {
switch (command.type) {
case "open":
case "prompt":
return prompt(socket, command);
case "get_state":
Expand Down Expand Up @@ -243,7 +278,11 @@ export const sendPersonaCommand = (
if (record.type === "response") {
if (record.id !== id) return;
records.push(record);
if (!record.success || command.type !== "prompt") finish();
if (
!record.success ||
(command.type !== "prompt" && command.type !== "open")
)
finish();
else accepted = true;
return;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/brunch-agent/evals/petrinaut/persona/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ test.each([false, true])(
await appendAdmittedUtterance(run, {
source: "opening",
message: "Hello.",
submissionId: "sub_TEST_opening",
personaAgent: { agent: "pi" },
});
await appendAdmittedUtterance(run, {
source: "persona",
Expand Down
Loading
Loading