From e3c15c9ec38fb3017aed0401368d2924f29f53ed Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Thu, 22 Oct 2026 16:12:44 +0200 Subject: [PATCH 01/21] Keep Petrinaut's diagnostics context out of Brunch's user turns Petrinaut's assistant panel appends a reserved diagnostics message after client tool results. Brunch's adapter packed it into the human's turn, so the model and the stored history read host diagnostics as if the user had written them. Brunch checks diagnostics through its own getNetCompilationErrors tool, so the adapter now drops the reserved message. The contextual envelope stays for per-submission host context, such as interview length and custom words, and carries no diagnostics. Making the injection each host's choice is FE-1991. --- .../submitted-user-message.test.ts | 84 +++++++------------ .../submitted-user-message.ts | 48 +++-------- 2 files changed, 41 insertions(+), 91 deletions(-) diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.test.ts index 6eb75284071..0e4bbd0a266 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.test.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.test.ts @@ -7,88 +7,66 @@ import { brunchSubmittedUserMessage } from "./submitted-user-message"; import type { UIMessage } from "ai"; -const text = (id: string, value: string): UIMessage => ({ +const message = ( + id: string, + value: string, + role: UIMessage["role"] = "user", +): UIMessage => ({ id, - role: "user", + role, parts: [{ type: "text", text: value }], }); describe("brunchSubmittedUserMessage", () => { test("admits an ordinary turn unchanged", () => { - expect(brunchSubmittedUserMessage([text("user-1", "Hello.")])).toEqual({ + expect(brunchSubmittedUserMessage([message("user-1", "Hello.")])).toEqual({ messageId: "user-1", body: "Hello.", }); }); - test("carries reserved diagnostics on the correlated user turn", () => { - const context = "Petrinaut diagnostics context only; one current error."; + test("leaves Petrinaut's diagnostics context out of the human's turn", () => { expect( brunchSubmittedUserMessage([ - text("user-with-context", "Repair the model."), - text(petrinautDiagnosticsContextMessageId, context), + message("user-1", "Repair the model."), + message(petrinautDiagnosticsContextMessageId, "One current error."), ]), - ).toEqual({ - messageId: "user-with-context", - body: petrinautContextualUserMessageBody({ - userText: "Repair the model.", - diagnosticsContext: context, - }), - }); + ).toEqual({ messageId: "user-1", body: "Repair the model." }); }); - test("carries submission context alongside any diagnostics", () => { + test("carries submission context without any diagnostics", () => { const submissionContext = { hostKey: { level: 2 } }; - const context = "Petrinaut diagnostics context only; one current error."; - expect( - brunchSubmittedUserMessage([text("user-1", "Four agents")], { - ...submissionContext, - }), - ).toEqual({ + const expected = { messageId: "user-1", body: petrinautContextualUserMessageBody({ - userText: "Four agents", + userText: "Repair the model.", diagnosticsContext: "", submissionContext, }), - }); + }; + expect( + brunchSubmittedUserMessage([message("user-1", "Repair the model.")], { + ...submissionContext, + }), + ).toEqual(expected); expect( brunchSubmittedUserMessage( [ - text("user-1", "Repair the model."), - text(petrinautDiagnosticsContextMessageId, context), + message("user-1", "Repair the model."), + message(petrinautDiagnosticsContextMessageId, "One current error."), ], submissionContext, ), - ).toEqual({ - messageId: "user-1", - body: petrinautContextualUserMessageBody({ - userText: "Repair the model.", - diagnosticsContext: context, - submissionContext, - }), - }); + ).toEqual(expected); }); - test.each([ - [ - "duplicate", - [ - text("user-1", "Repair."), - text(petrinautDiagnosticsContextMessageId, "a"), - text(petrinautDiagnosticsContextMessageId, "b"), - ], - "The submission has duplicate diagnostics context.", - ], - [ - "stale", - [ - text(petrinautDiagnosticsContextMessageId, "a"), - text("user-1", "Repair."), - ], - "The submission has invalid or stale diagnostics context.", - ], - ])("refuses %s diagnostics context", (_label, messages, error) => { - expect(() => brunchSubmittedUserMessage(messages)).toThrow(error); + test("refuses a continuation that carries only diagnostics context", () => { + expect(() => + brunchSubmittedUserMessage([ + message("user-1", "Repair the model."), + message("assistant-1", "Changed the kernel.", "assistant"), + message(petrinautDiagnosticsContextMessageId, "One current error."), + ]), + ).toThrow("The submitted user message has no text."); }); }); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.ts index b90827cd0c0..6d845024c8d 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.ts @@ -1,7 +1,4 @@ -import { - CLIENT_TOOL_RESULT_CONTEXT_MAX_LENGTH, - petrinautContextualUserMessageBody, -} from "@hashintel/brunch-agent"; +import { petrinautContextualUserMessageBody } from "@hashintel/brunch-agent"; import { petrinautDiagnosticsContextMessageId } from "@hashintel/petrinaut-core"; import { finalUserMessage } from "@local/flue-aisdk-transport"; @@ -9,52 +6,27 @@ import type { SubmissionContext } from "@hashintel/brunch-agent"; import type { SubmittedUserMessage } from "@local/flue-aisdk-transport"; import type { UIMessage } from "ai"; -const submittedDiagnosticsContext = ( - messages: readonly UIMessage[], -): string | undefined => { - const [message, ...duplicates] = messages.filter( - ({ id }) => id === petrinautDiagnosticsContextMessageId, - ); - if (message === undefined) return undefined; - if (duplicates.length > 0) { - throw new Error("The submission has duplicate diagnostics context."); - } - const part = message.parts[0]; - if ( - message !== messages.at(-1) || - message.role !== "user" || - message.parts.length !== 1 || - part?.type !== "text" || - part.text.length === 0 || - Array.from(part.text).length > CLIENT_TOOL_RESULT_CONTEXT_MAX_LENGTH - ) { - throw new Error("The submission has invalid or stale diagnostics context."); - } - return part.text; -}; - /** - * Admit the human's turn, framing any trailing diagnostics context and this - * submission's host context into its body so the server can separate human - * evidence from host data. + * Admit the human's turn as they wrote it, framing this submission's host + * context into its body so the server can separate human evidence from host + * data. Petrinaut's assistant appends a reserved diagnostics message after + * client tool results; Brunch reads diagnostics through its own tools, so the + * turn never carries it. */ export const brunchSubmittedUserMessage = ( messages: readonly UIMessage[], submissionContext?: SubmissionContext, ): SubmittedUserMessage => { - const diagnosticsContext = submittedDiagnosticsContext(messages); - if (diagnosticsContext === undefined && submissionContext === undefined) { - return finalUserMessage(messages); - } const userMessage = finalUserMessage( - diagnosticsContext === undefined ? messages : messages.slice(0, -1), + messages.filter(({ id }) => id !== petrinautDiagnosticsContextMessageId), ); + if (submissionContext === undefined) return userMessage; return { messageId: userMessage.messageId, body: petrinautContextualUserMessageBody({ userText: userMessage.body, - diagnosticsContext: diagnosticsContext ?? "", - ...(submissionContext === undefined ? {} : { submissionContext }), + diagnosticsContext: "", + submissionContext, }), }; }; From 7e8e0ae76a81f725281ae4228210ffebbdab4912 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Thu, 15 Oct 2026 15:04:25 +0200 Subject: [PATCH 02/21] Answer query_basis from the reflections that name a net element traceLedger walks back from net elements or record addresses to the reflections, obligations, claims and entities behind them, and the turns those records came from. query_basis now answers from that trace instead of listing every record committed in the same turn as a change, and reports a traced claim a later claim replaced. Each turn now carries the opening of the user's words instead of an empty excerpt. --- .../docs/petrinaut/ARCHITECTURE.md | 2 +- .../petrinaut/tools/query-basis.test.ts | 274 +++++++++++------- .../src/agents/petrinaut/tools/query-basis.ts | 144 +++++---- .../shared/conversation/net-changes.test.ts | 5 +- .../src/shared/conversation/net-changes.ts | 2 +- libs/@hashintel/brunch-agent/src/ledger.ts | 11 +- .../brunch-agent/src/ledger/commits.ts | 60 ++-- .../brunch-agent/src/ledger/trace.test.ts | 197 +++++++++++++ .../brunch-agent/src/ledger/trace.ts | 133 +++++++++ 9 files changed, 616 insertions(+), 212 deletions(-) create mode 100644 libs/@hashintel/brunch-agent/src/ledger/trace.test.ts create mode 100644 libs/@hashintel/brunch-agent/src/ledger/trace.ts diff --git a/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md b/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md index 2ac3a049442..4136dd6c8ba 100644 --- a/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md +++ b/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md @@ -45,7 +45,7 @@ Flue's persisted conversation is canonical history. In a document-bound conversa Before each provider request the context projection records which net reads and settled changes the model's context holds. A document change is issued only against the revision of the latest of those; with none left, for example after compaction, it is refused before reaching the browser. The browser accepts the change only if its document has moved since that revision through Brunch's own settled changes, following their before-to-after chain, so a hand edit refuses it as stale. The projection strips the host envelope from browser results and shows the latest change's `readBack` as `netAfterChanges` unless a later net read supersedes it. It does not change canonical conversation history, public history, call identity or ordering. -The server credits an applied canonical call with its settled `after` revision; it does not re-hash or replay browser-produced evidence. `src/shared/conversation/net-changes.ts` projects those calls in history order. `query_basis` looks up a named element in the latest net result that shows the net, a read or a change carrying its `readBack` (`src/shared/conversation/net-view.ts` holds that rule for the projection, net freshness and `query_basis` alike), and lists applied calls associated by element ID, each with the Ledger revision current at the call and the content of the records committed in its turn, before or after the call, since a change's reflections are committed after it. These temporal associations do not prove a semantic basis. A reviewed experiment draft requires an earlier complete net read; the browser prepares against the current document revision. Canonical `createExperiment` remains Petrinaut-owned and does not mutate the document. +The server credits an applied canonical call with its settled `after` revision; it does not re-hash or replay browser-produced evidence. `src/shared/conversation/net-changes.ts` projects those calls in history order. `query_basis` looks up a named element in the latest net result that shows the net, a read or a change carrying its `readBack` (`src/shared/conversation/net-view.ts` holds that rule for the projection, net freshness and `query_basis` alike), and returns its recorded basis: `traceLedger` in `@hashintel/brunch-agent` walks back from the reflections that name the element to the claims, entities and discharged obligations they rest on, and to the turns those records came from, each turn with the opening of the user's words. A traced claim a later claim replaced is reported with its replacement. An element no reflection names has no recorded basis, and the tool says so instead of offering records that merely share its turn. It also lists the applied calls associated by element ID, each with the Ledger revision current at the call. A reviewed experiment draft requires an earlier complete net read; the browser prepares against the current document revision. Canonical `createExperiment` remains Petrinaut-owned and does not mutate the document. ## Runtime and persistence diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.test.ts b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.test.ts index 02661996c6a..657c59d8bce 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.test.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.test.ts @@ -112,71 +112,55 @@ const snapshot = { ], } as FlueConversationSnapshot; -test("query_basis attributes a net change to the Ledger revision and records", () => { +const [user, assistant] = snapshot.messages; +const [commit, add, read] = assistant?.parts ?? []; +const committed = ( + toolCallId: string, + revision: number, + entries: unknown[], + ids: string[], +) => ({ + type: "dynamic-tool", + toolName: "ledger_commit", + toolCallId, + state: "output-available", + input: { entries }, + output: { status: "recorded", commitId: toolCallId, revision, ids }, +}); +const queueReflection = committed( + "ledger-2", + 2, + [ + [ + "reflection/create", + { + text: "The Queue place holds waiting cases.", + netElements: [{ kind: "place", id: "queue" }], + claims: ["c1"], + }, + ], + ], + ["r1"], +); + +test("query_basis reports no recorded basis for an element no reflection names", () => { expect( queryBasis({ snapshot, browser, query: { kind: "place", name: "Queue" } }), ).toMatchObject({ disposition: "basis-absent", target: { id: "queue" }, + basis: undefined, changes: [ { toolCallId: "add-queue", petrinautRevisionId: "revision-2", ledgerRevision: 1, - recordsCommittedThisTurn: [ - { - id: "e1", - type: "entity", - name: "The queue", - kind: "location", - origin: "stated", - status: "confirmed", - }, - { - id: "c1", - type: "claim", - text: "Cases wait in a queue.", - entities: ["e1"], - origin: "stated", - status: "confirmed", - }, - ], }, ], }); }); -test("query_basis returns a change's reflection committed after it in the same turn, but not a later turn's records", () => { - const [user, assistant] = snapshot.messages; - const [commit, add, read] = assistant?.parts ?? []; - const committed = ( - toolCallId: string, - revision: number, - entries: unknown[], - ids: string[], - ) => ({ - type: "dynamic-tool", - toolName: "ledger_commit", - toolCallId, - state: "output-available", - input: { entries }, - output: { status: "recorded", commitId: toolCallId, revision, ids }, - }); - const reflection = committed( - "ledger-2", - 2, - [ - [ - "reflection/create", - { - text: "The Queue place holds waiting cases.", - netElements: [{ kind: "place", id: "queue" }], - claims: ["c1"], - }, - ], - ], - ["r1"], - ); +test("query_basis returns the reflection naming an element, what it rests on, and its turns", () => { const laterTurn = committed( "ledger-3", 3, @@ -197,84 +181,160 @@ test("query_basis returns a change's reflection committed after it in the same t ...snapshot, messages: [ user, - { ...assistant, parts: [commit, add, reflection, read] }, + { ...assistant, parts: [commit, add, queueReflection, read] }, { ...user, id: "user-2" }, { ...assistant, id: "assistant-2", parts: [laterTurn] }, ], } as FlueConversationSnapshot; - const [change] = queryBasis({ + const result = queryBasis({ snapshot: history, browser, query: { kind: "place", name: "Queue" }, - }).changes; - // The revision is the Ledger's at the call; the records span the call's turn. - expect(change).toMatchObject({ toolCallId: "add-queue", ledgerRevision: 1 }); - expect(change?.recordsCommittedThisTurn.map(({ id }) => id)).toEqual([ - "e1", - "c1", - "r1", - ]); + }); + expect(result).toMatchObject({ + disposition: "basis-declared", + changes: [{ toolCallId: "add-queue", ledgerRevision: 1 }], + }); + // A later claim about the same entity is not part of the basis. + expect(result.basis).toEqual({ + reflections: [ + { + id: "r1", + text: "The Queue place holds waiting cases.", + netElements: [{ kind: "place", id: "queue" }], + claims: ["c1"], + turn: "user-1", + }, + ], + obligations: [], + claims: [ + { + id: "c1", + text: "Cases wait in a queue.", + entities: ["e1"], + origin: "stated", + status: "confirmed", + turn: "user-1", + }, + ], + entities: [ + { + id: "e1", + name: "The queue", + kind: "location", + origin: "stated", + status: "confirmed", + turn: "user-1", + }, + ], + superseded: {}, + turns: [{ id: "user-1", excerpt: "Cases wait in a queue." }], + }); }); -test("query_basis returns a reflection committed this turn with its text and net elements", () => { - const [user, assistant] = snapshot.messages; - const [commit, ...calls] = assistant?.parts ?? []; - const reflectionCommit = { - type: "dynamic-tool", - toolName: "ledger_commit", - toolCallId: "ledger-2", - state: "output-available", - input: { - entries: [ - [ - "reflection/create", - { - text: "The Queue place holds waiting cases.", - netElements: [{ kind: "place", id: "queue" }], - claims: ["c1"], - }, - ], +test("query_basis marks a claim the basis rests on that a later claim replaced", () => { + const correction = committed( + "ledger-3", + 3, + [ + [ + "claim/create", + { + text: "Cases wait in two queues.", + entities: ["e1"], + origin: "stated", + status: "confirmed", + supersedes: ["c1"], + }, ], - }, - output: { - status: "recorded", - commitId: "ledger-2", - revision: 2, - ids: ["r1"], - }, - }; - const withReflection = { + ], + ["c2"], + ); + const history = { ...snapshot, messages: [ user, - { ...assistant, parts: [commit, reflectionCommit, ...calls] }, + { ...assistant, parts: [commit, add, queueReflection, read] }, + { ...user, id: "user-2" }, + { ...assistant, id: "assistant-2", parts: [correction] }, ], } as FlueConversationSnapshot; - const [change] = queryBasis({ - snapshot: withReflection, - browser, - query: { kind: "place", name: "Queue" }, - }).changes; - expect(change).toMatchObject({ toolCallId: "add-queue", ledgerRevision: 2 }); - expect(change?.recordsCommittedThisTurn.map(({ id }) => id)).toEqual([ - "e1", - "c1", - "r1", - ]); - expect(change?.recordsCommittedThisTurn.at(-1)).toEqual({ - id: "r1", - type: "reflection", - text: "The Queue place holds waiting cases.", - netElements: [{ kind: "place", id: "queue" }], - claims: ["c1"], + expect( + queryBasis({ + snapshot: history, + browser, + query: { kind: "place", name: "Queue" }, + }).basis?.superseded, + ).toEqual({ c1: "c2" }); +}); + +test("query_basis matches a reflection that names an arc by Petrinaut's generated arc ID", () => { + const [queue] = definition.places; + const withArc = { + ...definition, + places: [queue, { ...queue, id: "served", name: "Served" }], + transitions: [ + { + id: "serve", + name: "Serve", + inputArcs: [{ placeId: "queue", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "served", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + }; + const readArc = { + ...read, + output: { + brunchBrowserResult: true, + output: { title: "Queue", definition: withArc }, + metadata: { documentRevision: { before: "revision-2" } }, + }, + }; + const arcReflection = committed( + "ledger-2", + 2, + [ + [ + "reflection/create", + { + text: "Serving takes the oldest waiting case.", + netElements: [{ kind: "arc", id: "$A_place:queue___serve" }], + claims: ["c1"], + }, + ], + ], + ["r1"], + ); + const history = { + ...snapshot, + messages: [user, { ...assistant, parts: [commit, arcReflection, readArc] }], + } as FlueConversationSnapshot; + + expect( + queryBasis({ + snapshot: history, + browser, + query: { + kind: "arc", + transitionId: "serve", + arcDirection: "input", + placeId: "queue", + }, + }), + ).toMatchObject({ + disposition: "basis-declared", + basis: { reflections: [{ id: "r1" }] }, }); }); test("query_basis finds an element inside a subnet and the call that added it there", () => { - const [user, assistant] = snapshot.messages; - const [commit, add, read] = assistant?.parts ?? []; const [queue] = definition.places; const intoSubnet = { ...add, diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts index 916b77be639..7b1b0d3164a 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts @@ -5,10 +5,12 @@ import { brunchTools, foldCommits, netElementKinds, + traceLedger, } from "@hashintel/brunch-agent"; import { callsForElement, + generatedArcId, isAppliedChange, latestNetDefinition, namesId, @@ -18,11 +20,15 @@ import { } from "../../../shared/conversation/net-changes.ts"; import type { FlueConversationSnapshot } from "@flue/sdk"; -import type { BrowserContext, LedgerState } from "@hashintel/brunch-agent"; +import type { + BrowserContext, + LedgerTrace, + NetElementAddress, +} from "@hashintel/brunch-agent"; import type { SDCPN } from "@hashintel/petrinaut-core"; export const queryBasisToolDescription = - "Use when the USER asks why a visible net element exists, instead of answering from memory. Finds the element in the latest net read, or in the net read back after a later change, and lists the canonical calls that changed it, each with its settled document revision, the Ledger revision current at the call, and the Ledger records committed in the same turn, before or after the call, each with its ID, record type and content, including the net element addresses a reflection concerns. These are chronological associations, not semantic justification: say so, and say plainly when no call is associated. A stale-read disposition means the net changed after the latest net view, a read or a change's read-back, without a later read-back: read it again, then query."; + "Use when the USER asks why a visible net element exists or is modelled as it is, instead of answering from memory. Finds the element in the latest net read, or in the net read back after a later change, and returns its recorded basis: the reflections that name the element and, behind them, the claims, entities and discharged obligations they rest on, each with its ID, and the conversation turns those records came from, each with its ID and the opening of the USER's words. Answer from that basis and cite its record IDs and turns. A claim under `superseded` was later replaced: say so, and give its replacement. It also lists the canonical calls that changed the element, with their document and Ledger revisions. A basis-absent disposition means no reflection names the element: say plainly that its basis was not recorded rather than reconstructing one. A stale-read disposition means the net changed after the latest net view, a read or a change's read-back, without a later read-back: read it again, then query."; const elementSchema = v.object({ selector: v.pipe( @@ -117,67 +123,44 @@ const netElements = ( }); }; -/** A record as the model reads it: its ID and type with its fields, without the turn anchor. */ -const recordContent = < - Type extends string, - Fields extends { address: string; turn: string }, ->( - type: Type, - { address, turn: _turn, ...fields }: Fields, -) => ({ id: address, type, ...fields }); +/** A record as the model reads it: its ID and fields, with the turn it was recorded in. */ +const recordContent = ({ + address, + ...fields +}: Fields) => ({ id: address, ...fields }); -const findRecord = (state: LedgerState, id: string) => { - const entity = state.entities.find(({ address }) => address === id); - if (entity) return recordContent("entity", entity); - const claim = state.claims.find(({ address }) => address === id); - if (claim) return recordContent("claim", claim); - const reflection = state.reflections.find(({ address }) => address === id); - if (reflection) return recordContent("reflection", reflection); - const obligation = state.obligations.find(({ address }) => address === id); - if (obligation) return recordContent("obligation", obligation); - return undefined; -}; +/** The trace as the model reads it: record IDs in place of addresses. */ +const basisContent = (trace: LedgerTrace) => ({ + reflections: trace.reflections.map(recordContent), + obligations: trace.obligations.map(recordContent), + claims: trace.claims.map(recordContent), + entities: trace.entities.map(recordContent), + superseded: trace.superseded, + turns: trace.turns, +}); -const isUserMessage = (message: FlueConversationSnapshot["messages"][number]) => - message.role === "user" && message.purpose === "user"; +/** Whether a reflection's net element address names the target, as the document or the model wrote it. */ +const namesTarget = + (kind: Selector["kind"], target: { id: string; arc?: ArcElement }) => + (address: NetElementAddress) => + address.kind === kind && + (namesId(address.id, target.id) || + (target.arc !== undefined && + namesId(address.id, generatedArcId(target.arc)))); -/** - * The Ledger revision current when a call was made, and every record its turn - * committed, before or after the call: the guidance commits a change's - * reflections after the change, in the same turn. - */ -const ledgerAtCall = (snapshot: FlueConversationSnapshot, call: NetCall) => { +/** The Ledger revision current when a call was made. */ +const ledgerRevisionAt = ( + snapshot: FlueConversationSnapshot, + call: NetCall, +) => { const message = snapshot.messages[call.messageIndex]; if (!message) return undefined; - const { revision } = foldCommits({ + return foldCommits({ messages: [ ...snapshot.messages.slice(0, call.messageIndex), { ...message, parts: message.parts.slice(0, call.partIndex) }, ], - }); - const turnId = snapshot.messages - .slice(0, call.messageIndex) - .findLast(isUserMessage)?.id; - const nextTurn = snapshot.messages.findIndex( - (entry, index) => index > call.messageIndex && isUserMessage(entry), - ); - const { state, commits } = foldCommits({ - messages: snapshot.messages.slice( - 0, - nextTurn === -1 ? undefined : nextTurn, - ), - }); - const idsThisTurn = new Set( - commits - .filter(({ afterMessageId }) => afterMessageId === turnId) - .flatMap(({ ids }) => ids), - ); - return { - revision, - recordsThisTurn: [...idsThisTurn].flatMap( - (id) => findRecord(state, id) ?? [], - ), - }; + }).revision; }; export const queryBasis = (input: { @@ -206,6 +189,7 @@ export const queryBasis = (input: { "The net changed after the latest net view without a subsequent read-back; read it again, then query.", target: undefined, readToolCallId: view.toolCallId, + basis: undefined, changes: [], }; const candidates = latest @@ -222,30 +206,42 @@ export const queryBasis = (input: { ) : []; const target = candidates.length === 1 ? candidates.at(0) : undefined; - const changes = target - ? callsForElement(input.snapshot, { - kind: input.query.kind, - id: target.id, - ...(target.arc === undefined ? {} : { arc: target.arc }), - }).map((call) => { - const ledger = ledgerAtCall(input.snapshot, call); - return { - toolCallId: call.toolCallId, - operation: call.toolName, - petrinautRevisionId: call.revisionAfter, - ledgerRevision: ledger?.revision, - recordsCommittedThisTurn: ledger?.recordsThisTurn ?? [], - }; - }) - : []; + if (!target) + return { + binding: input.browser.binding, + disposition: "not-found" as const, + reason: + "The named element is absent or ambiguous in the latest net view.", + target, + readToolCallId: latest?.call.toolCallId, + basis: undefined, + changes: [], + }; + const changes = callsForElement(input.snapshot, { + kind: input.query.kind, + id: target.id, + ...(target.arc === undefined ? {} : { arc: target.arc }), + }).map((call) => ({ + toolCallId: call.toolCallId, + operation: call.toolName, + petrinautRevisionId: call.revisionAfter, + ledgerRevision: ledgerRevisionAt(input.snapshot, call), + })); + const trace = traceLedger(foldCommits(input.snapshot).state, { + netElement: namesTarget(input.query.kind, target), + }); + const declared = trace.reflections.length > 0; return { binding: input.browser.binding, - disposition: target ? ("basis-absent" as const) : ("not-found" as const), - reason: target - ? "No declared basis exists; these are chronological call associations, not semantic justification." - : "The named element is absent or ambiguous in the latest net view.", + disposition: declared + ? ("basis-declared" as const) + : ("basis-absent" as const), + reason: declared + ? "Reflections name this element; the basis is what they rest on." + : "No reflection names this element, so its basis was not recorded.", target, readToolCallId: latest?.call.toolCallId, + basis: declared ? basisContent(trace) : undefined, changes, }; }; diff --git a/apps/brunch-agent/src/shared/conversation/net-changes.test.ts b/apps/brunch-agent/src/shared/conversation/net-changes.test.ts index 02e21fb766e..4815fe0a88d 100644 --- a/apps/brunch-agent/src/shared/conversation/net-changes.test.ts +++ b/apps/brunch-agent/src/shared/conversation/net-changes.test.ts @@ -154,7 +154,7 @@ test("credits only canonical applied calls with an after revision, in history or ); }); -test("query_basis locates the latest read element and relates the applied call to the Ledger revision and the records its turn committed", () => { +test("query_basis locates the latest read element and relates the applied call to the Ledger revision", () => { expect( queryBasis({ snapshot, @@ -170,7 +170,6 @@ test("query_basis locates the latest read element and relates the applied call t toolCallId: "add-queue", petrinautRevisionId: "revision-2", ledgerRevision: 1, - recordsCommittedThisTurn: [{ id: "e1", type: "entity" }], }, ], }); @@ -254,7 +253,6 @@ test("a refused Ledger commit adds nothing to the revision or records a call is }).changes[0], ).toMatchObject({ ledgerRevision: 1, - recordsCommittedThisTurn: [{ id: "e1", type: "entity" }], }); }); @@ -357,7 +355,6 @@ test("query_basis matches a root arc to canonical addArc by transition, directio operation: "addArc", petrinautRevisionId: "revision-3", ledgerRevision: 1, - recordsCommittedThisTurn: [{ id: "e1", type: "entity" }], }, ], }); diff --git a/apps/brunch-agent/src/shared/conversation/net-changes.ts b/apps/brunch-agent/src/shared/conversation/net-changes.ts index 82b2ecc56ba..320fa57342f 100644 --- a/apps/brunch-agent/src/shared/conversation/net-changes.ts +++ b/apps/brunch-agent/src/shared/conversation/net-changes.ts @@ -124,7 +124,7 @@ const hasId = (value: unknown, id: string, underIdKey = false): boolean => { }; /** The ID Petrinaut generates for an arc, which `deleteItemsByIds` uses. */ -const generatedArcId = (arc: ArcElement): string => { +export const generatedArcId = (arc: ArcElement): string => { const place = getArcEndpointKey(placeArcEndpoint(arc.placeId)); return arc.arcDirection === "input" ? generateArcId({ inputId: place, outputId: arc.transitionId }) diff --git a/libs/@hashintel/brunch-agent/src/ledger.ts b/libs/@hashintel/brunch-agent/src/ledger.ts index 76176373829..2eb6f9ffb9b 100644 --- a/libs/@hashintel/brunch-agent/src/ledger.ts +++ b/libs/@hashintel/brunch-agent/src/ledger.ts @@ -18,12 +18,21 @@ export { type LedgerHistoryMessage, } from "./ledger/commits"; export { vObligation } from "./ledger/construction/obligations"; -export { vReflection } from "./ledger/construction/reflections"; export { vClaim } from "./ledger/elicitation/claims"; export { entityKindStages, vEntity, vEntityKind, } from "./ledger/elicitation/entities"; +export { + vNetElementAddress, + vReflection, + type NetElementAddress, +} from "./ledger/construction/reflections"; export { vOrigin, vStatus } from "./ledger/shared/epistemics"; +export { + traceLedger, + type LedgerTrace, + type LedgerTraceSeeds, +} from "./ledger/trace"; export * from "./ledger/projection"; diff --git a/libs/@hashintel/brunch-agent/src/ledger/commits.ts b/libs/@hashintel/brunch-agent/src/ledger/commits.ts index dbceee087b5..4adf0d1b942 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/commits.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/commits.ts @@ -10,6 +10,7 @@ import type { LedgerState, ObligationRecord, ReflectionRecord, + Turn, } from "./projection"; /** Structural view of a conversation history; Flue and AI SDK messages both fit. */ @@ -43,7 +44,8 @@ interface LedgerCall { * can still be running. */ readonly followsUnsettledCall: boolean; - readonly afterMessageId?: string; + /** The user message the call followed, which anchors its records' turn. */ + readonly turn?: Turn; } const isSettled = (part: ToolPart) => part.state.startsWith("output-"); @@ -51,12 +53,32 @@ const isSettled = (part: ToolPart) => part.state.startsWith("output-"); const isUserMessage = (message: LedgerHistoryMessage) => message.role === "user" && (message.purpose ?? "user") === "user"; +const textPartSchema = v.object({ type: v.literal("text"), text: v.string() }); + +const turnExcerptLength = 240; + +/** The opening of a user message's text, enough to recognise the turn by. */ +const turnExcerpt = (message: LedgerHistoryMessage): string => { + const text = message.parts + .flatMap((part) => { + const parsed = v.safeParse(textPartSchema, part); + return parsed.success ? [parsed.output.text] : []; + }) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + const characters = Array.from(text); + return characters.length > turnExcerptLength + ? `${characters.slice(0, turnExcerptLength - 1).join("")}…` + : text; +}; + /** Every `ledger_commit` call in canonical order, whatever its state. */ const ledgerCalls = (history: LedgerHistory): LedgerCall[] => { - let afterMessageId: string | undefined; + let turn: Turn | undefined; return history.messages.flatMap((message) => { if (isUserMessage(message)) { - afterMessageId = message.id; + turn = { id: message.id, excerpt: turnExcerpt(message) }; return []; } if (message.role !== "assistant") return []; @@ -71,9 +93,9 @@ const ledgerCalls = (history: LedgerHistory): LedgerCall[] => { const followsUnsettledCall = parts .slice(0, index) .some((earlier) => !isSettled(earlier)); - return afterMessageId === undefined + return turn === undefined ? { part, followsUnsettledCall } - : { part, followsUnsettledCall, afterMessageId }; + : { part, followsUnsettledCall, turn }; }); }); }; @@ -234,9 +256,10 @@ type Derivation = const deriveBatch = ( state: LedgerState, entries: LedgerAppend["entries"], - turn: string, + anchor: Turn | undefined, reserved: AddressCounters, ): Derivation => { + const turn = anchor?.id ?? ""; const counters = { ...countersOf(state, reserved) }; const addressed = entries.map((entry) => { const [route] = entry; @@ -387,8 +410,9 @@ const deriveBatch = ( } const turns = - turn !== "" && !state.turns.some((existing) => existing.id === turn) - ? [...state.turns, { id: turn, excerpt: "" }] + anchor !== undefined && + !state.turns.some((existing) => existing.id === anchor.id) + ? [...state.turns, anchor] : state.turns; return { @@ -438,12 +462,7 @@ const foldWithReservations = ( continue; const input = v.safeParse(vLedgerAppend, call.part.input); const derived = input.success - ? deriveBatch( - state, - input.output.entries, - call.afterMessageId ?? "", - reserved, - ) + ? deriveBatch(state, input.output.entries, call.turn, reserved) : undefined; const derivedIds = derived === undefined || "refusal" in derived ? undefined : derived.ids; @@ -462,9 +481,7 @@ const foldWithReservations = ( commits.push({ commitId: call.part.toolCallId, revision: commits.length + 1, - ...(call.afterMessageId === undefined - ? {} - : { afterMessageId: call.afterMessageId }), + ...(call.turn === undefined ? {} : { afterMessageId: call.turn.id }), ids: derived.ids, }); state = derived.state; @@ -527,12 +544,7 @@ export const prepareAppend = (call: { message: unshownClaim, revision, }; - const derived = deriveBatch( - state, - call.entries, - own.afterMessageId ?? "", - reserved, - ); + const derived = deriveBatch(state, call.entries, own.turn, reserved); if ("refusal" in derived) return { status: "refused", @@ -551,7 +563,7 @@ export const prepareAppend = (call: { commitId: call.toolCallId, revision: revision + 1, ids: derived.ids, - exchanges: exchangesThrough(call.history, own.afterMessageId), + exchanges: exchangesThrough(call.history, own.turn?.id), openQuestions: projectLedger(derived.state).questions.length, ...(owed.length > 0 ? { owed } : {}), ...(notes.length > 0 ? { notes } : {}), diff --git a/libs/@hashintel/brunch-agent/src/ledger/trace.test.ts b/libs/@hashintel/brunch-agent/src/ledger/trace.test.ts new file mode 100644 index 00000000000..728ed2262a4 --- /dev/null +++ b/libs/@hashintel/brunch-agent/src/ledger/trace.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from "vitest"; + +import { foldCommits } from "./commits"; +import { traceLedger } from "./trace"; + +import type { LedgerState } from "./projection/records"; + +const ledger: LedgerState = { + title: "Ledger", + turns: [ + { id: "turn-1", excerpt: "Cases wait in one queue." }, + { id: "turn-2", excerpt: "Two clerks work the queue." }, + { id: "turn-3", excerpt: "Actually there are three clerks." }, + { id: "turn-4", excerpt: "Go ahead and build it." }, + ], + entities: [ + { + address: "e1", + name: "The queue", + kind: "location", + origin: "stated", + status: "confirmed", + turn: "turn-1", + }, + { + address: "e2", + name: "Clerks", + kind: "resource", + origin: "stated", + status: "confirmed", + turn: "turn-2", + }, + { + address: "e3", + name: "Opening hours", + kind: "horizon", + origin: "stated", + status: "confirmed", + turn: "turn-2", + }, + ], + claims: [ + { + address: "c1", + text: "Cases wait in one queue, first come first served.", + entities: ["e1"], + origin: "stated", + status: "confirmed", + turn: "turn-1", + }, + { + address: "c2", + text: "Two clerks work the queue.", + entities: ["e2", "e1"], + origin: "stated", + status: "confirmed", + turn: "turn-2", + }, + { + address: "c3", + text: "Three clerks work the queue.", + entities: ["e2", "e1"], + origin: "stated", + status: "confirmed", + supersedes: ["c2"], + turn: "turn-3", + }, + ], + obligations: [ + { + address: "o1", + text: "Check the clerk count against a run.", + claims: ["c2"], + turn: "turn-4", + }, + ], + reflections: [ + { + address: "r1", + text: "The Queue place holds waiting cases.", + netElements: [{ kind: "place", id: "queue" }], + claims: ["c1"], + turn: "turn-4", + }, + { + address: "r2", + text: "Clerks are tokens in the Clerks place; the run used two.", + netElements: [ + { kind: "place", id: "clerks" }, + { kind: "transition", id: "serve" }, + ], + discharges: ["o1"], + turn: "turn-4", + }, + ], +}; + +const addresses = (records: readonly { address: string }[]) => + records.map(({ address }) => address); + +describe("traceLedger", () => { + test("walks from a net element to the reflections naming it and back to its turns", () => { + const trace = traceLedger(ledger, { + netElement: ({ kind, id }) => kind === "transition" && id === "serve", + }); + expect(addresses(trace.reflections)).toEqual(["r2"]); + expect(addresses(trace.obligations)).toEqual(["o1"]); + expect(addresses(trace.claims)).toEqual(["c2"]); + expect(addresses(trace.entities)).toEqual(["e1", "e2"]); + expect(trace.turns.map(({ id }) => id)).toEqual([ + "turn-1", + "turn-2", + "turn-4", + ]); + }); + + test("flags a traced claim a later claim replaced", () => { + const trace = traceLedger(ledger, { + netElement: ({ id }) => id === "clerks", + }); + expect(trace.superseded).toEqual({ c2: "c3" }); + }); + + test("starts from record addresses without pulling in every claim about an entity", () => { + const trace = traceLedger(ledger, { records: ["e2", "c1", "c9"] }); + expect(addresses(trace.claims)).toEqual(["c1"]); + expect(addresses(trace.entities)).toEqual(["e1", "e2"]); + expect(trace.reflections).toEqual([]); + expect(trace.missing).toEqual(["c9"]); + }); + + test("traces nothing for a net element no reflection names", () => { + const trace = traceLedger(ledger, { netElement: ({ id }) => id === "x" }); + expect(trace).toEqual({ + reflections: [], + obligations: [], + claims: [], + entities: [], + superseded: {}, + turns: [], + missing: [], + }); + }); +}); + +describe("turn excerpts", () => { + const commit = (toolCallId: string, entries: unknown[], ids: string[]) => ({ + id: `assistant-${toolCallId}`, + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolName: "ledger_commit", + toolCallId, + state: "output-available", + input: { entries }, + output: { status: "recorded", commitId: toolCallId, revision: 1, ids }, + }, + ], + }); + const entity = [ + "entity/create", + { name: "Queue", kind: "location", origin: "stated", status: "confirmed" }, + ]; + + test("anchor each turn with the opening of the user's words", () => { + const { state } = foldCommits({ + messages: [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Cases wait\nin a queue." }], + }, + commit("ledger-1", [entity], ["e1"]), + ], + }); + expect(state.turns).toEqual([ + { id: "user-1", excerpt: "Cases wait in a queue." }, + ]); + }); + + test("shorten a long message to a bounded excerpt", () => { + const { state } = foldCommits({ + messages: [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "word ".repeat(100) }], + }, + commit("ledger-1", [entity], ["e1"]), + ], + }); + const excerpt = state.turns[0]?.excerpt ?? ""; + expect(Array.from(excerpt)).toHaveLength(240); + expect(excerpt.endsWith("…")).toBe(true); + }); +}); diff --git a/libs/@hashintel/brunch-agent/src/ledger/trace.ts b/libs/@hashintel/brunch-agent/src/ledger/trace.ts new file mode 100644 index 00000000000..8942f3c3679 --- /dev/null +++ b/libs/@hashintel/brunch-agent/src/ledger/trace.ts @@ -0,0 +1,133 @@ +import type { NetElementAddress } from "./construction/reflections"; +import type { + ClaimRecord, + EntityRecord, + LedgerState, + ObligationRecord, + ReflectionRecord, + Turn, +} from "./projection/records"; + +/** Where a trace starts: net elements reflections name, Ledger records, or both. */ +export interface LedgerTraceSeeds { + /** + * Whether a net element address a reflection names is one sought. A + * predicate, so the caller decides how written IDs match the document's. + */ + readonly netElement?: (address: NetElementAddress) => boolean; + /** Record addresses to start from, such as `c4` or `e2`. */ + readonly records?: readonly string[]; +} + +/** The records behind the seeds, each list in Ledger order, and the turns they came from. */ +export interface LedgerTrace { + readonly reflections: readonly ReflectionRecord[]; + readonly obligations: readonly ObligationRecord[]; + readonly claims: readonly ClaimRecord[]; + readonly entities: readonly EntityRecord[]; + /** Each traced claim a later claim replaced, with the address of its replacement. */ + readonly superseded: Readonly>; + /** The turns the traced records were recorded in, in conversation order. */ + readonly turns: readonly Turn[]; + /** Seed addresses the Ledger does not hold. */ + readonly missing: readonly string[]; +} + +/** + * Walk back from the seeds to the records they rest on: a net element to the + * reflections that name it; a reflection to its claims, entities and the + * obligations it discharges; an obligation to its claims and entities; a claim + * to its entities. Every record reached contributes its turn. The walk never + * runs forward, so an entity does not pull in every claim about it. + */ +export const traceLedger = ( + state: LedgerState, + seeds: LedgerTraceSeeds, +): LedgerTrace => { + const reached = new Set(); + const pending: string[] = []; + const reach = (addresses: readonly string[] | undefined) => { + for (const address of addresses ?? []) + if (!reached.has(address)) { + reached.add(address); + pending.push(address); + } + }; + + const reflections = new Map( + state.reflections.map((record) => [record.address, record]), + ); + const obligations = new Map( + state.obligations.map((record) => [record.address, record]), + ); + const claims = new Map( + state.claims.map((record) => [record.address, record]), + ); + const entities = new Map( + state.entities.map((record) => [record.address, record]), + ); + const holds = (address: string) => + reflections.has(address) || + obligations.has(address) || + claims.has(address) || + entities.has(address); + + const { netElement } = seeds; + if (netElement) + reach( + state.reflections + .filter((record) => record.netElements?.some(netElement)) + .map(({ address }) => address), + ); + reach(seeds.records); + + for ( + let address = pending.shift(); + address !== undefined; + address = pending.shift() + ) { + const reflection = reflections.get(address); + if (reflection) { + reach(reflection.discharges); + reach(reflection.claims); + reach(reflection.entities); + } + const obligation = obligations.get(address); + if (obligation) { + reach(obligation.claims); + reach(obligation.entities); + } + reach(claims.get(address)?.entities); + } + + const inOrder = ( + records: readonly LedgerRecord[], + ) => records.filter(({ address }) => reached.has(address)); + const traced = { + reflections: inOrder(state.reflections), + obligations: inOrder(state.obligations), + claims: inOrder(state.claims), + entities: inOrder(state.entities), + }; + + const superseded: Record = {}; + for (const claim of state.claims) + for (const earlier of claim.supersedes ?? []) + if (reached.has(earlier)) superseded[earlier] = claim.address; + + const turnIds = new Set( + [ + ...traced.reflections, + ...traced.obligations, + ...traced.claims, + ...traced.entities, + ].map(({ turn }) => turn), + ); + + return { + ...traced, + superseded, + turns: state.turns.filter(({ id }) => turnIds.has(id)), + missing: (seeds.records ?? []).filter((address) => !holds(address)), + }; +}; From 9a787fb564b479fe541bfb191741a5ba58efac15 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Thu, 15 Oct 2026 16:14:19 +0200 Subject: [PATCH 03/21] Read the Ledger tab as a document that explains the canvas selection The Ledger tab renders the projection as React instead of Markdown, marking every record occurrence with its address. Selecting net elements shows the reflections that name them, the claims they rest on and the turns they came from, and highlights those records. Reflections can be shown under their entities, each able to select the net elements it names. namesId moves to @hashintel/brunch-agent so the website matches written IDs the way query_basis does. --- .../docs/petrinaut/ARCHITECTURE.md | 2 +- .../src/agents/petrinaut/tools/query-basis.ts | 2 +- .../src/shared/conversation/net-changes.ts | 14 +- apps/petrinaut-website/package.json | 1 - .../brunch/ledger/brunch-ledger-canvas.ts | 45 +++ .../brunch/ledger/brunch-ledger-document.tsx | 296 ++++++++++++++++++ .../brunch/ledger/brunch-ledger-history.ts | 14 +- .../brunch/ledger/brunch-ledger-pane.test.tsx | 160 ++++++++-- .../brunch/ledger/brunch-ledger-pane.tsx | 147 ++++++--- .../brunch-ledger-tab.in-editor.test.tsx | 157 ++++++++++ .../brunch/ledger/brunch-ledger-tab.tsx | 42 +++ .../plugins/brunch/use-brunch-assistant.tsx | 4 +- libs/@hashintel/brunch-agent/src/index.ts | 5 + .../src/ledger/projection.test.ts | 13 + .../brunch-agent/src/ledger/projection.ts | 4 +- .../src/ledger/projection/project-ledger.ts | 35 ++- .../src/petrinaut/net-element-ids.test.ts | 32 ++ .../src/petrinaut/net-element-ids.ts | 45 +++ .../@hashintel/petrinaut/docs/ai-assistant.md | 2 + yarn.lock | 1 - 20 files changed, 921 insertions(+), 100 deletions(-) create mode 100644 apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-canvas.ts create mode 100644 apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx create mode 100644 apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.in-editor.test.tsx create mode 100644 apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.tsx create mode 100644 libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.test.ts create mode 100644 libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts diff --git a/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md b/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md index 4136dd6c8ba..460a3bb6e5e 100644 --- a/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md +++ b/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md @@ -33,7 +33,7 @@ The Ledger is the ordered sequence of accepted `ledger_commit` calls in the conv 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. -`ledger_compile` renders the folded Ledger in the agent skin, which carries record IDs and kind tokens inline. The website's Ledger tab renders the same projection in the user skin, and persona evidence (`ledger.md`, `ledger.json`) uses the agent skin. +`ledger_compile` renders the folded Ledger in the agent skin, which carries record IDs and kind tokens inline. The website's Ledger tab renders the same projection as a document with the user skin's wording, marking each record occurrence with its address so a canvas selection can highlight the records `traceLedger` reaches, and persona evidence (`ledger.md`, `ledger.json`) uses the agent skin. ## Browser and conversation boundary diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts index 7b1b0d3164a..90d8d607adf 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts @@ -4,6 +4,7 @@ import * as v from "valibot"; import { brunchTools, foldCommits, + namesId, netElementKinds, traceLedger, } from "@hashintel/brunch-agent"; @@ -13,7 +14,6 @@ import { generatedArcId, isAppliedChange, latestNetDefinition, - namesId, netCalls, type ArcElement, type NetCall, diff --git a/apps/brunch-agent/src/shared/conversation/net-changes.ts b/apps/brunch-agent/src/shared/conversation/net-changes.ts index 320fa57342f..42fce8e1443 100644 --- a/apps/brunch-agent/src/shared/conversation/net-changes.ts +++ b/apps/brunch-agent/src/shared/conversation/net-changes.ts @@ -1,15 +1,14 @@ import { type NetElementKind, + namesId, netReaderLevelOf, parseClientToolResultMetadata, petrinautToolTargets, } from "@hashintel/brunch-agent"; import { - canonicalizeArcId, generateArcId, getArcEndpointKey, placeArcEndpoint, - toPetrinautId, } from "@hashintel/petrinaut-core"; import { petrinautAiTools, @@ -98,17 +97,6 @@ export const isAppliedChange = (call: NetCall): boolean => call.output.applied === false ); -/** - * Whether `written` names `id`: as written, or as the converted id the - * document stores for an id the model invented. Flue keeps the model's raw - * arguments, while ids read from the document are converted. - */ -export const namesId = (written: unknown, id: string): boolean => - typeof written === "string" && - (written === id || - toPetrinautId(written) === id || - canonicalizeArcId(written) === id); - /** Only values under an id key (`id` or `...Id`) are converted; names and code compare exactly. */ const hasId = (value: unknown, id: string, underIdKey = false): boolean => { if (underIdKey ? namesId(value, id) : value === id) return true; diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index 0e4a14c916b..02c0ab9191a 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -37,7 +37,6 @@ "react-dom": "19.2.6", "react-icons": "5.5.0", "react-markdown": "10.1.0", - "remark-gfm": "4.0.1", "zod": "4.4.3" }, "devDependencies": { diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-canvas.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-canvas.ts new file mode 100644 index 00000000000..7465122ddc8 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-canvas.ts @@ -0,0 +1,45 @@ +import { + namesSelectionItem, + netElementSelectionItem, + traceLedger, + type LedgerState, + type LedgerTrace, + type ReflectionRecord, +} from "@hashintel/brunch-agent"; + +import type { SelectionItem } from "@hashintel/petrinaut-core"; + +/** The Ledger behind the editor's selection; absent while nothing is selected. */ +export const traceCanvasSelection = ( + ledger: LedgerState, + selection: readonly SelectionItem[], +): LedgerTrace | undefined => + selection.length === 0 + ? undefined + : traceLedger(ledger, { + netElement: (address) => + selection.some((item) => namesSelectionItem(address, item)), + }); + +/** Every record address a trace reached, for marking them in the document. */ +export const tracedAddresses = ( + trace: LedgerTrace | undefined, +): ReadonlySet => + new Set( + trace === undefined + ? [] + : [ + ...trace.reflections, + ...trace.obligations, + ...trace.claims, + ...trace.entities, + ].map(({ address }) => address), + ); + +/** The editor items a reflection names, for selecting them on the canvas. */ +export const reflectionSelection = ( + reflection: ReflectionRecord, +): SelectionItem[] => + (reflection.netElements ?? []).flatMap( + (address) => netElementSelectionItem(address) ?? [], + ); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx new file mode 100644 index 00000000000..919a1b6d2de --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx @@ -0,0 +1,296 @@ +import { Button } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { reflectionSelection } from "./brunch-ledger-canvas"; + +import type { + LedgerProjection, + ProjectedClaim, + ProjectedEntity, + ProjectedObligation, + ReflectionRecord, +} from "@hashintel/brunch-agent"; +import type { SelectionItem } from "@hashintel/petrinaut-core"; +import type { ReactNode } from "react"; + +const documentStyle = css({ + fontSize: "sm", + lineHeight: "[1.65]", + overflowWrap: "anywhere", + "& :is(h1, h2, h3)": { + fontWeight: "semibold", + lineHeight: "[1.3]", + marginTop: "6", + marginBottom: "3", + }, + "& h1": { fontSize: "xl", marginTop: "0" }, + "& h2": { fontSize: "lg" }, + "& ul": { listStyleType: "disc", paddingLeft: "5", marginBottom: "3" }, + "& ul ul": { marginBottom: "0" }, +}); + +const recordStyle = css({ + borderRadius: "sm", + "&[data-traced]": { backgroundColor: "blue.s20" }, +}); + +const qualifierStyle = css({ color: "neutral.s90" }); + +const reflectionStyle = css({ color: "neutral.s100", fontStyle: "italic" }); + +/** What the document shows besides the records themselves. */ +export interface LedgerDocumentOptions { + /** Record addresses to mark, such as those behind the canvas selection. */ + readonly traced: ReadonlySet; + /** Whether to show the agent's reflections under the entities they concern. */ + readonly showReflections: boolean; + /** Select a reflection's net elements on the canvas; absent where there is no canvas. */ + readonly onSelectNetElements?: (items: SelectionItem[]) => void; +} + +/** + * One record where it appears. A claim can appear under several entities, + * so the same address can mark more than one block. + */ +const RecordMark = ({ + address, + options, + children, +}: { + address: string; + options: LedgerDocumentOptions; + children: ReactNode; +}) => ( + + {children} + +); + +const qualifiers = (...parts: string[]) => ( + — {parts.join(" · ")} +); + +const ClaimItem = ({ + claim: { record, history }, + options, +}: { + claim: ProjectedClaim; + options: LedgerDocumentOptions; +}) => ( +
  • + + {record.text} + {qualifiers(record.origin, record.status)} + + {history.length > 0 && ( +
      + {history.map((ancestor) => ( +
    • + + {ancestor.text} + {qualifiers("superseded")} + +
    • + ))} +
    + )} +
  • +); + +const ReflectionItem = ({ + reflection, + options, +}: { + reflection: ReflectionRecord; + options: LedgerDocumentOptions; +}) => { + const items = reflectionSelection(reflection); + const { onSelectNetElements } = options; + return ( +
  • + + {reflection.text} + + {onSelectNetElements && items.length > 0 && ( + <> + {" "} + + + )} +
  • + ); +}; + +const EntityItem = ({ + entity: { record, claims, reflections }, + options, + withKind = false, +}: { + entity: ProjectedEntity; + options: LedgerDocumentOptions; + withKind?: boolean; +}) => { + const shownReflections = options.showReflections ? reflections : []; + return ( +
  • + + {record.name} + {withKind + ? qualifiers(record.kind, record.origin, record.status) + : qualifiers(record.origin, record.status)} + + {claims.length + shownReflections.length > 0 && ( +
      + {claims.map((claim) => ( + + ))} + {shownReflections.map((reflection) => ( + + ))} +
    + )} +
  • + ); +}; + +const ObligationItem = ({ + obligation: { record, concerns }, + options, + children, +}: { + obligation: ProjectedObligation; + options: LedgerDocumentOptions; + children?: ReactNode; +}) => ( +
  • + + {record.text} + {concerns.length > 0 && qualifiers(concerns.join(", "))} + + {children} +
  • +); + +/** + * The Ledger read as a document: the user skin's sections and wording, with + * every record occurrence marked by its address so a selection or a comment + * can resolve to the records it touches. + */ +export const BrunchLedgerDocument = ({ + projection, + options, +}: { + projection: LedgerProjection; + options: LedgerDocumentOptions; +}) => ( +
    +

    {projection.title}

    + {projection.sections.map((section) => ( +
    +

    {section.title}

    + {section.kinds.map((group) => ( +
    +

    {group.label}

    +
      + {group.entities.map((entity) => ( + + ))} +
    +
    + ))} +
    + ))} + {projection.questions.length > 0 && ( +
    +

    Open questions

    +
      + {projection.questions.map(({ claim, prompt, context }) => ( +
    • + + {prompt} + +
        +
      • {context}
      • +
      +
    • + ))} +
    +
    + )} + {projection.owed.length > 0 && ( +
    +

    Checks still owed

    +
      + {projection.owed.map((obligation) => ( + + ))} +
    +
    + )} + {projection.met.length > 0 && ( +
    +

    Checks met

    +
      + {projection.met.map((obligation) => ( + +
        + {obligation.dischargedBy.map((reflection) => ( + + ))} +
      +
      + ))} +
    +
    + )} + {projection.excluded.length > 0 && ( +
    +

    Excluded from the model

    +
      + {projection.excluded.map((entity) => ( + + ))} +
    +
    + )} +
    +); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-history.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-history.ts index 9ecfd20c767..169ffbe01f5 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-history.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-history.ts @@ -1,9 +1,8 @@ import { foldCommits, type LedgerHistoryMessage, + type LedgerState, parseClientToolResultMetadata, - projectLedger, - renderLedgerMarkdown, } from "@hashintel/brunch-agent"; export type BrunchLedgerHistoryMessage = LedgerHistoryMessage; @@ -11,8 +10,8 @@ export type BrunchLedgerHistoryMessage = LedgerHistoryMessage; export type BrunchLedgerHistory = { /** Accepted commits and settled document changes, for the tab's activity badge. */ readonly activityIdentities: readonly string[]; - /** The compiled Ledger; absent before the first commit. */ - readonly markdown: string | undefined; + /** The folded Ledger; absent before the first commit. */ + readonly ledger: LedgerState | undefined; }; const settledDocumentChanges = ( @@ -38,7 +37,7 @@ const settledDocumentChanges = ( : [], ); -/** Folds only accepted commits, rendered in the user skin; refused, failed, pending and unbound calls never appear. */ +/** Folds only accepted commits; refused, failed, pending and unbound calls never appear. */ export const foldBrunchLedgerHistory = ( messages: readonly BrunchLedgerHistoryMessage[], ): BrunchLedgerHistory => { @@ -48,9 +47,6 @@ export const foldBrunchLedgerHistory = ( ...commits.map(({ commitId }) => commitId), ...settledDocumentChanges(messages), ], - markdown: - commits.length > 0 - ? renderLedgerMarkdown(projectLedger(state), "user") - : undefined, + ledger: commits.length > 0 ? state : undefined, }; }; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx index ece62b7911d..67fadb9eaf6 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx @@ -1,24 +1,35 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { expect, test } from "vitest"; +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; + +import { toPetrinautId } from "@hashintel/petrinaut-core"; import { foldBrunchLedgerHistory } from "./brunch-ledger-history"; import { BrunchLedgerPane } from "./brunch-ledger-pane"; -const entries = [ - [ - "entity/create", - { - name: "Hillcrest service reservoir", - kind: "resource", - origin: "stated", - status: "confirmed", - }, - ], +afterEach(cleanup); + +const reservoir = [ + "entity/create", + { + name: "Hillcrest service reservoir", + kind: "resource", + origin: "stated", + status: "confirmed", + }, ]; const commit = ( toolCallId: string, overrides: Record = {}, + entries: unknown[] = [reservoir], + ids: string[] = ["e1"], ) => ({ type: "dynamic-tool", toolName: "ledger_commit", @@ -29,7 +40,7 @@ const commit = ( status: "recorded", commitId: toolCallId, revision: 1, - ids: ["e1"], + ids, }, ...overrides, }); @@ -41,11 +52,56 @@ const assistant = (...parts: unknown[]) => ({ parts, }); -test("renders the Ledger from accepted commits in the user skin", () => { - const messages = [assistant(commit("c1"))]; - const html = renderToStaticMarkup(); - expect(html).toContain("Hillcrest service reservoir"); - expect(html).not.toContain("e1"); +const user = (id: string, text: string) => ({ + id, + role: "user", + purpose: "user", + parts: [{ type: "text", text }], +}); + +/** One turn whose commit records the reservoir, a claim about it and a reflection naming the Reservoir place. */ +const reflected = [ + user("user-1", "The reservoir holds a day of supply."), + assistant( + commit( + "ledger-1", + {}, + [ + reservoir, + [ + "claim/create", + { + text: "The reservoir holds a day of supply.", + entities: ["$0"], + origin: "stated", + status: "confirmed", + }, + ], + [ + "reflection/create", + { + text: "The Reservoir place stands in for the stored volume.", + netElements: [{ kind: "place", id: "reservoir" }], + claims: ["$1"], + }, + ], + ], + ["e1", "c1", "r1"], + ), + ), +]; + +const reservoirPlace = { + type: "place", + id: toPetrinautId("reservoir"), +} as const; + +test("renders accepted commits as a document whose records carry their addresses, not as visible IDs", () => { + render(); + const document = screen.getByTestId("brunch-ledger-document"); + expect(document.textContent).toContain("Hillcrest service reservoir"); + expect(document.textContent).not.toContain("e1"); + expect(document.querySelector('[data-ledger-address="e1"]')).not.toBeNull(); }); test("never shows refused, failed, pending or unbound commits", () => { @@ -70,11 +126,14 @@ test("never shows refused, failed, pending or unbound commits", () => { }, }), ]) { - const html = renderToStaticMarkup( + const { container, unmount } = render( , ); - expect(html).toContain("Nothing is recorded in the Ledger yet."); - expect(html).not.toContain("Hillcrest service reservoir"); + expect(container.textContent).toContain( + "Nothing is recorded in the Ledger yet.", + ); + expect(container.textContent).not.toContain("Hillcrest service reservoir"); + unmount(); } }); @@ -107,3 +166,62 @@ test("counts accepted commits and settled document changes as activity", () => { ]).activityIdentities, ).toEqual(["c1", "canonical-call"]); }); + +test("explains a selected net element from the reflection naming it, and marks the records behind it", () => { + render( + , + ); + const basis = screen.getByRole("complementary", { + name: "Basis of the selection", + }); + expect(basis.textContent).toContain( + "The Reservoir place stands in for the stored volume.", + ); + expect(basis.textContent).toContain("The reservoir holds a day of supply."); + expect(basis.textContent).toContain("“The reservoir holds a day of supply.”"); + const document = screen.getByTestId("brunch-ledger-document"); + for (const address of ["e1", "c1"]) + expect( + document + .querySelector(`[data-ledger-address="${address}"]`) + ?.hasAttribute("data-traced"), + ).toBe(true); +}); + +test("says no reasoning was recorded for a selected element no reflection names", () => { + render( + , + ); + expect( + screen.getByRole("complementary", { name: "Basis of the selection" }) + .textContent, + ).toContain("Brunch recorded no reasoning for the selected net elements."); + expect( + screen.getByTestId("brunch-ledger-document").querySelector("[data-traced]"), + ).toBeNull(); +}); + +test("shows reflections under their entities only when switched on, and selects their net elements", async () => { + const onSelectNetElements = vi.fn(); + render( + , + ); + const document = screen.getByTestId("brunch-ledger-document"); + expect(document.querySelector('[data-ledger-address="r1"]')).toBeNull(); + + fireEvent.click( + screen.getByRole("checkbox", { name: "Show Brunch’s reflections" }), + ); + await waitFor(() => + expect(document.querySelector('[data-ledger-address="r1"]')).not.toBeNull(), + ); + + fireEvent.click(screen.getByRole("button", { name: "Show in net" })); + expect(onSelectNetElements).toHaveBeenCalledWith([reservoirPlace]); +}); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx index bf9188c8196..78d30ee2789 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx @@ -1,54 +1,99 @@ -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; +import { useId, useState } from "react"; +import { projectLedger, type LedgerTrace } from "@hashintel/brunch-agent"; +import { Toggle } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; +import { traceCanvasSelection, tracedAddresses } from "./brunch-ledger-canvas"; +import { BrunchLedgerDocument } from "./brunch-ledger-document"; import { foldBrunchLedgerHistory, type BrunchLedgerHistoryMessage, } from "./brunch-ledger-history"; -const documentStyle = css({ - fontSize: "sm", - lineHeight: "[1.65]", - overflowWrap: "anywhere", - overflowX: "auto", - "& :is(h1, h2, h3, h4)": { - fontWeight: "semibold", - lineHeight: "[1.3]", - marginTop: "6", - marginBottom: "3", - }, - "& h1": { fontSize: "xl", marginTop: "0" }, - "& h2": { fontSize: "lg" }, - "& :is(p, ul, ol, table, pre, blockquote)": { marginBottom: "3" }, - "& :is(ul, ol)": { paddingLeft: "5" }, - "& ul": { listStyleType: "disc" }, - "& ol": { listStyleType: "decimal" }, - "& table": { borderCollapse: "collapse", fontSize: "xs" }, - "& :is(th, td)": { - padding: "2", - borderWidth: "thin", - borderColor: "neutral.s35", - textAlign: "left", - }, - "& th": { backgroundColor: "neutral.s20" }, - "& pre": { overflowX: "auto" }, - "& a": { color: "blue.s100", textDecoration: "underline" }, -}); +import type { SelectionItem } from "@hashintel/petrinaut-core"; + const noticeStyle = css({ color: "neutral.s90", fontSize: "xs", marginBottom: "3", }); -/** The Ledger compiled from conversation history, rendered in the user skin. */ +const basisStyle = css({ + fontSize: "sm", + lineHeight: "[1.55]", + borderWidth: "thin", + borderColor: "blue.s50", + borderRadius: "md", + padding: "3", + marginBottom: "4", + "& h2": { fontWeight: "semibold", marginBottom: "2" }, + "& h3": { fontWeight: "medium", marginTop: "2" }, + "& ul": { listStyleType: "disc", paddingLeft: "5" }, + "& blockquote": { color: "neutral.s100", fontStyle: "italic" }, +}); + +/** Why the selected net elements are modelled as they are, from the Ledger. */ +const SelectionBasis = ({ trace }: { trace: LedgerTrace }) => { + if (trace.reflections.length === 0) + return ( + + ); + return ( + + ); +}; + +/** + * The Ledger compiled from conversation history, read as a document. With a + * canvas selection, it shows why the selection is modelled as it is and marks + * the records behind it. + */ export const BrunchLedgerPane = ({ messages, + selection = [], + onSelectNetElements, }: { messages: readonly BrunchLedgerHistoryMessage[]; + /** The editor's selection, when the pane sits beside a canvas. */ + selection?: readonly SelectionItem[]; + onSelectNetElements?: (items: SelectionItem[]) => void; }) => { - const { markdown } = foldBrunchLedgerHistory(messages); + const id = useId(); + const [showReflections, setShowReflections] = useState(false); + const { ledger } = foldBrunchLedgerHistory(messages); + const trace = ledger && traceCanvasSelection(ledger, selection); return (
    - {markdown === undefined ? ( + {ledger === undefined ? (

    Nothing is recorded in the Ledger yet.

    ) : ( -
    - +
    - {markdown} - -
    + + Show Brunch’s reflections + + {trace && } + + )}
    ); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.in-editor.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.in-editor.test.tsx new file mode 100644 index 00000000000..39ef010450b --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.in-editor.test.tsx @@ -0,0 +1,157 @@ +/** @vitest-environment jsdom */ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, beforeAll, expect, test, vi } from "vitest"; + +import { createJsonDocHandle, toPetrinautId } from "@hashintel/petrinaut-core"; +import { Petrinaut } from "@hashintel/petrinaut/ui"; + +import { + InProcessLspWorker, + NoopResizeObserver, +} from "../../../shared/petrinaut-jsdom"; +import { brunchPlugin, BrunchAssistantContext } from "../editor-api-plugin"; +import { BrunchLedgerTab } from "./brunch-ledger-tab"; + +import type { PetrinautAiAssistant } from "../chat/petrinaut"; +import type { UIMessageChunk } from "ai"; + +await vi.hoisted(async () => { + const { installPetrinautDomShims } = + await import("../../../shared/petrinaut-jsdom"); + installPetrinautDomShims(); +}); + +beforeAll(async () => { + // Settle the editor's import and theme setup before mounting. + const monaco = await import("monaco-editor"); + monaco.editor.setTheme("vs"); +}, 30_000); + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +const reservoirId = toPetrinautId("reservoir"); + +/** One accepted commit: the reservoir and a reflection naming its place by the id Brunch wrote. */ +const messages = [ + { + id: "user-1", + role: "user", + purpose: "user", + parts: [{ type: "text", text: "The reservoir holds a day of supply." }], + }, + { + id: "assistant-1", + role: "assistant", + purpose: "assistant", + parts: [ + { + type: "dynamic-tool", + toolName: "ledger_commit", + toolCallId: "ledger-1", + state: "output-available", + input: { + entries: [ + [ + "entity/create", + { + name: "Hillcrest service reservoir", + kind: "resource", + origin: "stated", + status: "confirmed", + }, + ], + [ + "reflection/create", + { + text: "The Reservoir place stands in for the stored volume.", + netElements: [{ kind: "place", id: "reservoir" }], + entities: ["$0"], + }, + ], + ], + }, + output: { + status: "recorded", + commitId: "ledger-1", + revision: 1, + ids: ["e1", "r1"], + }, + }, + ], + }, +]; + +test("a reflection's net elements become the editor's selection, which the Ledger then explains", async () => { + vi.stubGlobal("ResizeObserver", NoopResizeObserver); + vi.stubGlobal("Worker", InProcessLspWorker); + Element.prototype.scrollIntoView = vi.fn(); + const aiAssistant: PetrinautAiAssistant = { + messages: [], + transport: { + reconnectToStream: () => Promise.resolve(null), + sendMessages: () => Promise.resolve(new ReadableStream()), + }, + additionalTab: { + label: "Ledger", + content: , + }, + }; + render( + + + , + ); + await act(async () => + fireEvent.click( + await screen.findByRole("button", { name: "Show AI assistant" }), + ), + ); + await act(async () => + fireEvent.click(await screen.findByRole("tab", { name: /Ledger/u })), + ); + expect( + screen.queryByRole("complementary", { name: "Basis of the selection" }), + ).toBeNull(); + + const reflectionsSwitch = await screen.findByRole("checkbox", { + name: "Show Brunch’s reflections", + }); + await act(async () => fireEvent.click(reflectionsSwitch)); + await act(async () => + fireEvent.click(await screen.findByRole("button", { name: "Show in net" })), + ); + + const basis = await screen.findByRole("complementary", { + name: "Basis of the selection", + }); + expect(basis.textContent).toContain( + "The Reservoir place stands in for the stored volume.", + ); +}); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.tsx new file mode 100644 index 00000000000..ca1a5ac9434 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.tsx @@ -0,0 +1,42 @@ +import { useStore } from "@hashintel/petrinaut/ui/plugin"; + +import { + WithBrunchEditorApi, + type BrunchEditorApi, +} from "../editor-api-plugin"; +import { BrunchLedgerPane } from "./brunch-ledger-pane"; + +import type { BrunchLedgerHistoryMessage } from "./brunch-ledger-history"; + +const LedgerBesideCanvas = ({ + api, + messages, +}: { + api: BrunchEditorApi; + messages: readonly BrunchLedgerHistoryMessage[]; +}) => { + const { selection } = useStore(api.editor.navigation); + return ( + + api.editor.navigate((current) => ({ ...current, selection: items })) + } + /> + ); +}; + +/** + * The Ledger tab: reads the editor's selection through Brunch's editor plugin + * so the Ledger explains it, and sets it from a reflection's net elements. + */ +export const BrunchLedgerTab = ({ + messages, +}: { + messages: readonly BrunchLedgerHistoryMessage[]; +}) => ( + + {(api) => } + +); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx index a2ea327c7db..31fab132ea5 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx @@ -51,7 +51,7 @@ import { type InterviewBudgetLevelChange, } from "./interview-budget/interview-budget-level-changes"; import { foldBrunchLedgerHistory } from "./ledger/brunch-ledger-history"; -import { BrunchLedgerPane } from "./ledger/brunch-ledger-pane"; +import { BrunchLedgerTab } from "./ledger/brunch-ledger-tab"; import { brunchPetrinautClientToolNames, canonicalPetrinautClientToolNames, @@ -599,7 +599,7 @@ export const useBrunchAssistant = ({ label: "Ledger", activityIdentities, content: ( - ), diff --git a/libs/@hashintel/brunch-agent/src/index.ts b/libs/@hashintel/brunch-agent/src/index.ts index 36ee29d0f30..727ed96faf6 100644 --- a/libs/@hashintel/brunch-agent/src/index.ts +++ b/libs/@hashintel/brunch-agent/src/index.ts @@ -38,6 +38,11 @@ export { projectNetDefinition, type NetReaderLevel, } from "./petrinaut/net-readers"; +export { + namesId, + namesSelectionItem, + netElementSelectionItem, +} from "./petrinaut/net-element-ids"; export { browserToolMutatesDocument, netElementKinds, diff --git a/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts b/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts index 44abf85fa20..6a4ffe70a75 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts @@ -94,3 +94,16 @@ test("a question that names no entity drops its concerns clause", () => { ); expect(rendered).not.toContain("Concerns: ."); }); + +test("each entity carries the reflections naming it or one of its current claims", () => { + const reflectionsOf = (address: string) => + projection.sections + .flatMap(({ kinds }) => kinds) + .flatMap(({ entities }) => entities) + .find(({ record }) => record.address === address) + ?.reflections.map((reflection) => reflection.address); + // e9 is named directly; e6 only through claim c8, which r2 addresses. + expect(reflectionsOf("e9")).toEqual(["r1"]); + expect(reflectionsOf("e6")).toEqual(["r2"]); + expect(user).not.toContain(supportDeskLedger.reflections[0]?.text); +}); diff --git a/libs/@hashintel/brunch-agent/src/ledger/projection.ts b/libs/@hashintel/brunch-agent/src/ledger/projection.ts index ba8a6457871..fcfb7dceba5 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/projection.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection.ts @@ -2,8 +2,8 @@ * The Ledger's projection: committed records folded into a sectioned * structure and rendered to markdown in two skins over one shape. The agent * skin carries record addresses and kind tokens inline; the user skin is clean - * prose. `ledger_compile` renders the agent skin, the website's Ledger tab the - * user skin. + * prose. `ledger_compile` renders the agent skin; the website's Ledger tab + * renders the projection itself, in the user skin's wording. */ export type { diff --git a/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts b/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts index 05cecc1b2d2..f979e3207c4 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts @@ -32,6 +32,12 @@ export interface ProjectedClaim { export interface ProjectedEntity { record: EntityRecord; claims: ProjectedClaim[]; + /** + * Reflections on how the net represents this entity: those naming it or one + * of its current claims, in recording order. Renderers choose whether to + * show them; the Markdown skins do not. + */ + reflections: ReflectionRecord[]; } export interface ProjectedKindGroup { @@ -113,8 +119,8 @@ const addressNumber = (address: string) => Number(address.slice(1)); /** * The deterministic fold from committed records to the sectioned view - * structure. Reflections are construction-mode records and deliberately stay - * out of this account view. + * structure. Reflections are construction-mode records: each entity carries + * the ones about it, and renderers decide whether to show them. */ export const projectLedger = (ledger: LedgerState): LedgerProjection => { const supersededBy = new Map(); @@ -144,12 +150,25 @@ export const projectLedger = (ledger: LedgerState): LedgerProjection => { return ancestors; }; - const projectEntity = (record: EntityRecord): ProjectedEntity => ({ - record, - claims: currentClaims - .filter((claim) => claim.entities.includes(record.address)) - .map((claim) => ({ record: claim, history: historyOf(claim) })), - }); + const projectEntity = (record: EntityRecord): ProjectedEntity => { + const claims = currentClaims.filter((claim) => + claim.entities.includes(record.address), + ); + const claimAddresses = new Set(claims.map(({ address }) => address)); + return { + record, + claims: claims.map((claim) => ({ + record: claim, + history: historyOf(claim), + })), + reflections: ledger.reflections.filter( + (reflection) => + reflection.entities?.includes(record.address) === true || + reflection.claims?.some((claim) => claimAddresses.has(claim)) === + true, + ), + }; + }; const included = ledger.entities.filter( (entity) => entity.status !== "out-of-scope", diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.test.ts b/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.test.ts new file mode 100644 index 00000000000..8bbe07ca7ff --- /dev/null +++ b/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "vitest"; + +import { toPetrinautId } from "@hashintel/petrinaut-core"; + +import { namesSelectionItem, netElementSelectionItem } from "./net-element-ids"; + +test("a written net element ID becomes the selection the document stores", () => { + expect(netElementSelectionItem({ kind: "place", id: "queue" })).toEqual({ + type: "place", + id: toPetrinautId("queue"), + }); + const stored = toPetrinautId("queue"); + expect(netElementSelectionItem({ kind: "place", id: stored })).toEqual({ + type: "place", + id: stored, + }); +}); + +test("kinds the editor cannot select have no selection", () => { + expect(netElementSelectionItem({ kind: "metric", id: "wait" })).toBe( + undefined, + ); +}); + +test("an address names a selected item of its kind by written or stored ID", () => { + const item = { type: "place", id: toPetrinautId("queue") } as const; + expect(namesSelectionItem({ kind: "place", id: "queue" }, item)).toBe(true); + expect(namesSelectionItem({ kind: "transition", id: "queue" }, item)).toBe( + false, + ); + expect(namesSelectionItem({ kind: "place", id: "other" }, item)).toBe(false); +}); diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts b/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts new file mode 100644 index 00000000000..6c46b01c883 --- /dev/null +++ b/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts @@ -0,0 +1,45 @@ +import { canonicalizeArcId, toPetrinautId } from "@hashintel/petrinaut-core"; +import { + selectionItemTypes, + type SelectionItem, + type SelectionItemType, +} from "@hashintel/petrinaut-core/selection"; + +import type { NetElementAddress } from "../ledger/construction/reflections"; + +/** + * Whether `written` names `id`: as written, or as the converted id the + * document stores for an id the model invented. Flue keeps the model's raw + * arguments, while ids read from the document are converted. + */ +export const namesId = (written: unknown, id: string): boolean => + typeof written === "string" && + (written === id || + toPetrinautId(written) === id || + canonicalizeArcId(written) === id); + +const selectable: ReadonlySet = new Set(selectionItemTypes); + +const isSelectionItemType = (kind: string): kind is SelectionItemType => + selectable.has(kind); + +/** + * The editor selection a net element address stands for, with its ID as the + * document stores it; absent for kinds the editor cannot select. + */ +export const netElementSelectionItem = ({ + kind, + id, +}: NetElementAddress): SelectionItem | undefined => + isSelectionItemType(kind) + ? { + type: kind, + id: kind === "arc" ? canonicalizeArcId(id) : toPetrinautId(id), + } + : undefined; + +/** Whether a net element address names a selected editor item. */ +export const namesSelectionItem = ( + address: NetElementAddress, + item: SelectionItem, +): boolean => address.kind === item.type && namesId(address.id, item.id); diff --git a/libs/@hashintel/petrinaut/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md index 9cef738cc61..81b013a7b63 100644 --- a/libs/@hashintel/petrinaut/docs/ai-assistant.md +++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md @@ -97,6 +97,8 @@ In Brunch construction conversations, the **Ledger** tab shows what Brunch has r Ledger groups the parts of your process by section and kind, each with its origin and status and its current claims beneath; superseded claims are struck through. Open questions, checks still owed, checks met with the reflection that met each, and anything excluded from the model follow. Internal record IDs are not shown. +Select something on the canvas and Ledger explains it: the reflections in which Brunch said why that element is modelled as it is, the claims they rest on, and the opening of your messages those came from. The records behind the selection are highlighted in the Ledger. When no reflection names the selected element, Ledger says that Brunch recorded no reasoning for it. **Show Brunch's reflections** lists each reflection under the parts of your process it concerns, with **Show in net** to select the elements it names. + Each unseen commit or net change by Brunch adds to Ledger's badge while Chat is selected or the panel is closed. You can read Ledger while continuing to type in the same composer, then switch to Chat to inspect the reply; if a response completes, errors, or is stopped while Ledger is visible, Chat receives an activity dot. Closing and reopening the assistant retains the selected tab for that mounted conversation. In Chat, each commit appears as **Recorded in the Ledger**. A commit that cannot be recorded, for example because it refers to an entry that does not exist, records nothing and appears as **Ledger commit needs correction**; expand it to read the reason. Brunch is asked to repair a refused commit before moving on. The Ledger has no revisions, so it shows no warnings about newer or older versions. diff --git a/yarn.lock b/yarn.lock index 8069c1b44e6..f30907bd2df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -963,7 +963,6 @@ __metadata: react-dom: "npm:19.2.6" react-icons: "npm:5.5.0" react-markdown: "npm:10.1.0" - remark-gfm: "npm:4.0.1" sharp: "npm:0.35.5" vite: "catalog:" vitest: "catalog:" From c6aeb0310aa7a2f4f95b63fefe2c4b90d5c27636 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Thu, 15 Oct 2026 17:06:03 +0200 Subject: [PATCH 04/21] Render Ledger Markdown only for the model, and badge the Ledger tab only for commits The Ledger tab renders the projection as a document, so the Markdown renderer's user skin had no caller left; renderLedgerMarkdown now always carries addresses and kind tokens. The tab's badge counted net changes too, which lit it up while the tab still had nothing to show; it now counts accepted commits only. --- .../docs/petrinaut/ARCHITECTURE.md | 2 +- .../petrinaut/persona/ledger-evidence.ts | 2 +- .../evals/petrinaut/turn-probe.ts | 4 +- .../petrinaut/tools/ledger-kind-enums.test.ts | 6 +- .../src/agents/petrinaut/tools/ledger.ts | 4 +- .../brunch/ledger/brunch-ledger-history.ts | 31 +----- .../brunch/ledger/brunch-ledger-pane.test.tsx | 4 +- .../__snapshots__/projection.test.ts.snap | 105 +----------------- .../src/ledger/projection.test.ts | 54 ++++----- .../brunch-agent/src/ledger/projection.ts | 12 +- .../src/ledger/projection/project-ledger.ts | 2 +- .../projection/render-ledger-markdown.ts | 84 ++++++-------- .../@hashintel/petrinaut/docs/ai-assistant.md | 2 +- 13 files changed, 73 insertions(+), 239 deletions(-) diff --git a/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md b/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md index 460a3bb6e5e..3a6626375d1 100644 --- a/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md +++ b/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md @@ -33,7 +33,7 @@ The Ledger is the ordered sequence of accepted `ledger_commit` calls in the conv 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. -`ledger_compile` renders the folded Ledger in the agent skin, which carries record IDs and kind tokens inline. The website's Ledger tab renders the same projection as a document with the user skin's wording, marking each record occurrence with its address so a canvas selection can highlight the records `traceLedger` reaches, and persona evidence (`ledger.md`, `ledger.json`) uses the agent skin. +`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. ## Browser and conversation boundary diff --git a/apps/brunch-agent/evals/petrinaut/persona/ledger-evidence.ts b/apps/brunch-agent/evals/petrinaut/persona/ledger-evidence.ts index ba755b83846..254395a5db6 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/ledger-evidence.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/ledger-evidence.ts @@ -17,6 +17,6 @@ export const ledgerEvidence = (history: LedgerHistory): LedgerEvidence => { const { state, commits } = foldCommits(history); return { commits, - markdown: renderLedgerMarkdown(projectLedger(state), "agent"), + markdown: renderLedgerMarkdown(projectLedger(state)), }; }; diff --git a/apps/brunch-agent/evals/petrinaut/turn-probe.ts b/apps/brunch-agent/evals/petrinaut/turn-probe.ts index 4330be8cde3..a86c5435f8e 100644 --- a/apps/brunch-agent/evals/petrinaut/turn-probe.ts +++ b/apps/brunch-agent/evals/petrinaut/turn-probe.ts @@ -116,9 +116,7 @@ try { `ledger: ${commits.length} accepted commits, ${state.entities.length} entities, ${state.claims.length} claims, ${state.obligations.length} obligations`, ); report(`\n--- reply ---\n${reply}`); - report( - `\n--- ledger (agent skin) ---\n${renderLedgerMarkdown(projectLedger(state), "agent")}`, - ); + report(`\n--- ledger ---\n${renderLedgerMarkdown(projectLedger(state))}`); report(`\nhistory: ${join(directory, "history.json")}`); } finally { await application.stop(); diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/ledger-kind-enums.test.ts b/apps/brunch-agent/src/agents/petrinaut/tools/ledger-kind-enums.test.ts index 38569be2f65..fc98e958b4e 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/ledger-kind-enums.test.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/ledger-kind-enums.test.ts @@ -574,7 +574,7 @@ test("an entity update keeps the turn the entity was created in", () => { ]); }); -test("ledger_compile renders the committed state as the agent-skin map", async () => { +test("ledger_compile renders the committed state as the Markdown map", async () => { const history = settledHistory([ toBatch([ [ @@ -968,7 +968,6 @@ test("an obligation stays owed until a reflection discharges it", async () => { expect( renderLedgerMarkdown( projectLedger(foldCommits(settledHistory([owing])).state), - "agent", ), ).toContain( "## Checks still owed\n\n- A run at the current settings reproduces the 2 July overflow. \u2014 The 2 July overflow `o1`", @@ -1008,7 +1007,6 @@ test("an obligation stays owed until a reflection discharges it", async () => { expect( renderLedgerMarkdown( projectLedger(foldCommits(settledHistory([owing, discharging])).state), - "agent", ), ).not.toContain("Checks still owed"); }); @@ -1158,7 +1156,7 @@ describe.skipIf(!live).concurrent("kind enums with a live model", () => { const ledger = prior.length === 0 ? "There are no existing entities or claims, so use only create routes." - : `The ledger already holds the records below; reference them by their IDs and use create routes for new records.\n\n${renderLedgerMarkdown(projectLedger(foldCommits(settledHistory(prior)).state), "agent")}\n\n`; + : `The ledger already holds the records below; reference them by their IDs and use create routes for new records.\n\n${renderLedgerMarkdown(projectLedger(foldCommits(settledHistory(prior)).state))}\n\n`; const models = createModels(); models.setProvider(anthropicProvider()); models.setProvider(openaiProviderWithAddedModels()); diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/ledger.ts b/apps/brunch-agent/src/agents/petrinaut/tools/ledger.ts index acb4933de3d..9bb079d83b2 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/ledger.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/ledger.ts @@ -38,7 +38,7 @@ export const createLedgerCommitTool = ( }, }); -/** `ledger_compile`: the committed Ledger rendered as the agent-skin map. */ +/** `ledger_compile`: the committed Ledger rendered as the Markdown map. */ export const createLedgerCompileTool = ( readHistory: () => Promise, ) => @@ -57,7 +57,7 @@ export const createLedgerCompileTool = ( output: { revision, rejected: [...rejected], - map: renderLedgerMarkdown(projectLedger(state), "agent"), + map: renderLedgerMarkdown(projectLedger(state)), }, terminate: false, }; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-history.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-history.ts index 169ffbe01f5..70bfeae2417 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-history.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-history.ts @@ -2,51 +2,24 @@ import { foldCommits, type LedgerHistoryMessage, type LedgerState, - parseClientToolResultMetadata, } from "@hashintel/brunch-agent"; export type BrunchLedgerHistoryMessage = LedgerHistoryMessage; export type BrunchLedgerHistory = { - /** Accepted commits and settled document changes, for the tab's activity badge. */ + /** Accepted commits, for the tab's activity badge: each is something new to read. */ readonly activityIdentities: readonly string[]; /** The folded Ledger; absent before the first commit. */ readonly ledger: LedgerState | undefined; }; -const settledDocumentChanges = ( - messages: readonly BrunchLedgerHistoryMessage[], -): string[] => - messages.flatMap((message) => - message.role === "assistant" - ? message.parts.flatMap((part) => { - if (typeof part !== "object" || part === null) return []; - const candidate = part as Record; - const output = candidate.output as Record | null; - return candidate.type === "dynamic-tool" && - candidate.state === "output-available" && - typeof candidate.toolCallId === "string" && - typeof output === "object" && - output !== null && - output.brunchBrowserResult === true && - parseClientToolResultMetadata(output.metadata)?.documentRevision - .after !== undefined - ? [candidate.toolCallId] - : []; - }) - : [], - ); - /** Folds only accepted commits; refused, failed, pending and unbound calls never appear. */ export const foldBrunchLedgerHistory = ( messages: readonly BrunchLedgerHistoryMessage[], ): BrunchLedgerHistory => { const { commits, state } = foldCommits({ messages }); return { - activityIdentities: [ - ...commits.map(({ commitId }) => commitId), - ...settledDocumentChanges(messages), - ], + activityIdentities: commits.map(({ commitId }) => commitId), ledger: commits.length > 0 ? state : undefined, }; }; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx index 67fadb9eaf6..1813cc40e69 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx @@ -137,7 +137,7 @@ test("never shows refused, failed, pending or unbound commits", () => { } }); -test("counts accepted commits and settled document changes as activity", () => { +test("counts accepted commits as activity, not net changes or reads", () => { const settledChange = { type: "dynamic-tool", toolName: "addPlace", @@ -164,7 +164,7 @@ test("counts accepted commits and settled document changes as activity", () => { foldBrunchLedgerHistory([ assistant(commit("c1"), settledChange, unchangedRead), ]).activityIdentities, - ).toEqual(["c1", "canonical-call"]); + ).toEqual(["c1"]); }); test("explains a selected net element from the reflection naming it, and marks the records behind it", () => { diff --git a/libs/@hashintel/brunch-agent/src/ledger/__snapshots__/projection.test.ts.snap b/libs/@hashintel/brunch-agent/src/ledger/__snapshots__/projection.test.ts.snap index 7d6d47e15df..53e2bc1fb4d 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/__snapshots__/projection.test.ts.snap +++ b/libs/@hashintel/brunch-agent/src/ledger/__snapshots__/projection.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`the worked example renders the approved markdown structure > agent skin 1`] = ` +exports[`the worked example renders the approved markdown structure 1`] = ` "# Brightwater support desk — peak staffing ## Framing @@ -102,106 +102,3 @@ exports[`the worked example renders the approved markdown structure > agent skin - **Cost of an agent-hour** — metric · stated · out-of-scope \`e13\` - Cost stays out of the comparison; the decision is made on waiting time within two to eight agents. — stated · confirmed \`c12\`" `; - -exports[`the worked example renders the approved markdown structure > user skin 1`] = ` -"# Brightwater support desk — peak staffing - -## Framing - -### Purpose - -- **Decide peak staffing** — stated · confirmed - -## Scope - -### Time spans - -- **Weekday morning peak** — stated · confirmed - - The measure is average waiting time from joining the queue to answer, over the peak window, reported weekly. — stated · confirmed - - Six agents are on today's weekday morning peak block. — stated · confirmed - - The phone-system export shows about 50 calls an hour in the peak, arriving unevenly; off-peak is closer to 15. — evidenced · confirmed - - ~~About 45 calls an hour arrive during the peak.~~ — superseded - - Is there a pattern inside the two-hour block beyond “it builds after ten”? — stated · open - -## Inputs - -### Levers - -- **Agents on the block** — stated · confirmed - - Staffing is a whole number of agents from two to eight; eight seats and licences is the cap, two is the floor the team leads will accept. — stated · confirmed - - Is the current six one agent too many on the peak block? — stated · conflicted - -### Limits - -- **Seat and licence cap** — stated · confirmed - - Staffing is a whole number of agents from two to eight; eight seats and licences is the cap, two is the floor the team leads will accept. — stated · confirmed - -## The system - -### Items - -- **Callers** — stated · confirmed - - The phone-system export shows about 50 calls an hour in the peak, arriving unevenly; off-peak is closer to 15. — evidenced · confirmed - - ~~About 45 calls an hour arrive during the peak.~~ — superseded - - Calls come in at random around the rate and handle times vary around six minutes — the team's working assumption, not something measured. — assumed · tentative - - Is there a pattern inside the two-hour block beyond “it builds after ten”? — stated · open - -### Locations - -- **The queue** — stated · confirmed - - Callers start hanging up after about eight to ten minutes on hold; the export shows when they left, not why. — stated · tentative - -### Resources - -- **Agents on the phones** — stated · confirmed - - Six agents are on today's weekday morning peak block. — stated · confirmed - - Is the current six one agent too many on the peak block? — stated · conflicted - -### Activities - -- **Call handling** — stated · confirmed - - Average handle time is about six minutes with plenty of spread: under two minutes for bill queries, past fifteen for complaints. — evidenced · confirmed - - Calls come in at random around the rate and handle times vary around six minutes — the team's working assumption, not something measured. — assumed · tentative - -### Events - -- **Caller abandonment** — stated · tentative - - Callers start hanging up after about eight to ten minutes on hold; the export shows when they left, not why. — stated · tentative - -## Outputs - -### Measures - -- **Average waiting time** — stated · confirmed - - The measure is average waiting time from joining the queue to answer, over the peak window, reported weekly. — stated · confirmed - -### Aims - -- **Keep peak waiting time low** — stated · confirmed - -### Thresholds - -- **The ten-minute promise** — stated · confirmed - - No caller waits more than ten minutes — a hard rule, not a preference to trade off. — stated · confirmed - -## Open questions - -- Is the current six one agent too many on the peak block? - - Accounts disagree and nobody has said which holds. Concerns: Agents on the block, Agents on the phones. -- Is there a pattern inside the two-hour block beyond “it builds after ten”? - - It matters and nobody knows yet. Concerns: Weekday morning peak, Callers. - -## Checks still owed - -- A run with today's six agents shows the hold times past eight minutes that callers hang up after. — Weekday morning peak, Caller abandonment - -## Checks met - -- The ten-minute promise is reported by a metric a run can breach, not prevented by the net. — The ten-minute promise - - The ten-minute promise is tracked as a maximum-wait metric, not enforced by the net: a run reports breaches, it cannot prevent them. - -## Excluded from the model - -- **Cost of an agent-hour** — metric · stated · out-of-scope - - Cost stays out of the comparison; the decision is made on waiting time within two to eight agents. — stated · confirmed" -`; diff --git a/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts b/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts index 6a4ffe70a75..dbc0604bef6 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts @@ -4,71 +4,66 @@ import { projectLedger, renderLedgerMarkdown } from "./projection"; import { supportDeskLedger } from "./projection/fixtures/support-desk-ledger"; const projection = projectLedger(supportDeskLedger); -const user = renderLedgerMarkdown(projection, "user"); -const agent = renderLedgerMarkdown(projection, "agent"); +const markdown = renderLedgerMarkdown(projection); test("the worked example renders the approved markdown structure", () => { - expect(user).toMatchSnapshot("user skin"); - expect(agent).toMatchSnapshot("agent skin"); + expect(markdown).toMatchSnapshot(); }); -test("kind headings carry user labels; addresses stay out of the user skin", () => { - expect(user).toMatch(/^### Purpose$/mu); - expect(user).toMatch(/^### Aims$/mu); - expect(user).not.toContain("`purpose`"); - expect(user).not.toContain("`e1`"); - expect(agent).toContain("### Purpose `purpose`"); - expect(agent).toContain("`c5`"); +test("kind headings carry the label and the kind token; records carry their addresses", () => { + expect(markdown).toContain("### Purpose `purpose`"); + expect(markdown).toContain("### Aims `direction`"); + expect(markdown).toContain("`c5`"); }); test("empty kinds and clusters render no heading", () => { - expect(user).not.toContain("### Targets"); - expect(user).not.toContain("### People and devices"); + expect(markdown).not.toContain("### Targets"); + expect(markdown).not.toContain("### People and devices"); }); test("superseded claims collapse under their successor, struck through", () => { - expect(user).toContain( + expect(markdown).toContain( "~~About 45 calls an hour arrive during the peak.~~ \u2014 superseded", ); - const horizons = user.slice(user.indexOf("### Time spans")); + const horizons = markdown.slice(markdown.indexOf("### Time spans")); expect( horizons.indexOf("The phone-system export shows about 50 calls"), ).toBeLessThan(horizons.indexOf("~~About 45 calls")); }); test("open and conflicted claims list as plain-markdown questions", () => { - const openQuestions = user.slice( - user.indexOf("## Open questions"), - user.indexOf("## Checks still owed"), + const openQuestions = markdown.slice( + markdown.indexOf("## Open questions"), + markdown.indexOf("## Checks still owed"), ); expect(openQuestions).toContain( - "- Is the current six one agent too many on the peak block?\n - Accounts disagree", + "- Is the current six one agent too many on the peak block? `c10`\n - Accounts disagree", ); expect(openQuestions).toContain( "- Is there a pattern inside the two-hour block", ); expect(openQuestions).not.toContain(":::"); expect(openQuestions).not.toContain("- [ ]"); - expect(agent).toContain( - "- Is the current six one agent too many on the peak block? `c10`", - ); }); test("out-of-scope entities leave their kind section for the excluded list", () => { - const measuresStart = user.indexOf("### Measures"); + const measuresStart = markdown.indexOf("### Measures"); expect(measuresStart).toBeGreaterThan(-1); - const metrics = user.slice( + const metrics = markdown.slice( measuresStart, - user.indexOf("\n#", measuresStart + 1), + markdown.indexOf("\n#", measuresStart + 1), ); expect(metrics).not.toContain("Cost of an agent-hour"); - const excluded = user.slice(user.indexOf("## Excluded from the model")); + const excluded = markdown.slice( + markdown.indexOf("## Excluded from the model"), + ); expect(excluded).toContain("**Cost of an agent-hour**"); expect(excluded).toContain("metric \u00b7"); }); test("a multi-entity claim repeats under each entity it references", () => { - const matches = user.match(/Staffing is a whole number of agents/gu) ?? []; + const matches = + markdown.match(/Staffing is a whole number of agents/gu) ?? []; expect(matches.length).toBe(2); }); @@ -87,10 +82,9 @@ test("a question that names no entity drops its concerns clause", () => { }, ], }), - "user", ); expect(rendered).toContain( - "- Who owns the overnight queue?\n - It matters and nobody knows yet.\n", + "- Who owns the overnight queue? `c99`\n - It matters and nobody knows yet.\n", ); expect(rendered).not.toContain("Concerns: ."); }); @@ -105,5 +99,5 @@ test("each entity carries the reflections naming it or one of its current claims // e9 is named directly; e6 only through claim c8, which r2 addresses. expect(reflectionsOf("e9")).toEqual(["r1"]); expect(reflectionsOf("e6")).toEqual(["r2"]); - expect(user).not.toContain(supportDeskLedger.reflections[0]?.text); + expect(markdown).not.toContain(supportDeskLedger.reflections[0]?.text); }); diff --git a/libs/@hashintel/brunch-agent/src/ledger/projection.ts b/libs/@hashintel/brunch-agent/src/ledger/projection.ts index fcfb7dceba5..b1774ccc614 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/projection.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection.ts @@ -1,9 +1,8 @@ /** * The Ledger's projection: committed records folded into a sectioned - * structure and rendered to markdown in two skins over one shape. The agent - * skin carries record addresses and kind tokens inline; the user skin is clean - * prose. `ledger_compile` renders the agent skin; the website's Ledger tab - * renders the projection itself, in the user skin's wording. + * structure. `ledger_compile` renders it as Markdown for the model, with + * record addresses and kind tokens inline; the website's Ledger tab renders + * it as a document. */ export type { @@ -26,7 +25,4 @@ export { type ProjectedQuestion, type ProjectedSection, } from "./projection/project-ledger"; -export { - renderLedgerMarkdown, - type Skin, -} from "./projection/render-ledger-markdown"; +export { renderLedgerMarkdown } from "./projection/render-ledger-markdown"; diff --git a/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts b/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts index f979e3207c4..9c1342da4d8 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts @@ -35,7 +35,7 @@ export interface ProjectedEntity { /** * Reflections on how the net represents this entity: those naming it or one * of its current claims, in recording order. Renderers choose whether to - * show them; the Markdown skins do not. + * show them; the model's Markdown does not. */ reflections: ReflectionRecord[]; } diff --git a/libs/@hashintel/brunch-agent/src/ledger/projection/render-ledger-markdown.ts b/libs/@hashintel/brunch-agent/src/ledger/projection/render-ledger-markdown.ts index 77ca1ee7b28..e84a4b024ca 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/projection/render-ledger-markdown.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection/render-ledger-markdown.ts @@ -8,89 +8,72 @@ import type { ProjectedQuestion, } from "./project-ledger"; -/** - * One structure, two skins: the user skin is clean prose; the agent skin - * carries record addresses and canonical kind tokens inline, so freeform - * references resolve identically for both parties. - */ -export type Skin = "user" | "agent"; - type ListItems = (string | ListItems)[]; -const tag = (skin: Skin, address: string) => - skin === "agent" ? ` ${code(address)}` : ""; +const tag = (address: string) => ` ${code(address)}`; -const claimLine = (skin: Skin, { record, history }: ProjectedClaim) => { - const line = `${record.text} \u2014 ${record.origin} \u00b7 ${record.status}${tag(skin, record.address)}`; +const claimLine = ({ record, history }: ProjectedClaim) => { + const line = `${record.text} \u2014 ${record.origin} \u00b7 ${record.status}${tag(record.address)}`; if (history.length === 0) return [line]; return [ line, history.map( (ancestor) => - `${strikethrough(ancestor.text)} \u2014 superseded${tag(skin, ancestor.address)}`, + `${strikethrough(ancestor.text)} \u2014 superseded${tag(ancestor.address)}`, ), ]; }; -const entityItems = ( - skin: Skin, - entity: ProjectedEntity, - withKind = false, -): ListItems => { +const entityItems = (entity: ProjectedEntity, withKind = false): ListItems => { const { record, claims } = entity; const kindNote = withKind ? `${record.kind} \u00b7 ` : ""; - const heading = `**${record.name}** \u2014 ${kindNote}${record.origin} \u00b7 ${record.status}${tag(skin, record.address)}`; + const heading = `**${record.name}** \u2014 ${kindNote}${record.origin} \u00b7 ${record.status}${tag(record.address)}`; if (claims.length === 0) return [heading]; - return [heading, claims.flatMap((claim) => claimLine(skin, claim))]; + return [heading, claims.flatMap((claim) => claimLine(claim))]; }; -const questionItems = ( - skin: Skin, - { claim, prompt, context }: ProjectedQuestion, -): ListItems => [`${prompt}${tag(skin, claim.address)}`, [context]]; +const questionItems = ({ + claim, + prompt, + context, +}: ProjectedQuestion): ListItems => [ + `${prompt}${tag(claim.address)}`, + [context], +]; -const obligationLine = ( - skin: Skin, - { record, concerns }: ProjectedObligation, -) => { +const obligationLine = ({ record, concerns }: ProjectedObligation) => { const about = concerns.length > 0 ? ` \u2014 ${concerns.join(", ")}` : ""; const tests = - skin === "agent" && (record.claims?.length ?? 0) > 0 + (record.claims?.length ?? 0) > 0 ? ` \u00b7 tests ${(record.claims ?? []).map((claim) => code(claim)).join(", ")}` : ""; - return `${record.text}${about}${tests}${tag(skin, record.address)}`; + return `${record.text}${about}${tests}${tag(record.address)}`; }; -export const renderLedgerMarkdown = ( - projection: LedgerProjection, - skin: Skin, -): string => { +/** + * The Ledger as Markdown for the model: the projection's structure with record + * addresses and canonical kind tokens inline, so freeform references resolve + * to records. + */ +export const renderLedgerMarkdown = (projection: LedgerProjection): string => { const blocks: string[] = [h1(projection.title)]; for (const section of projection.sections) { blocks.push(h2(section.title)); for (const group of section.kinds) { - // The canonical kind token is agent-facing only; users see the label. - const kindTag = skin === "agent" ? ` ${code(group.kind)}` : ""; - blocks.push(h3(`${group.label}${kindTag}`)); - blocks.push( - ul(group.entities.flatMap((entity) => entityItems(skin, entity))), - ); + blocks.push(h3(`${group.label} ${code(group.kind)}`)); + blocks.push(ul(group.entities.flatMap((entity) => entityItems(entity)))); } } if (projection.questions.length > 0) { blocks.push(h2("Open questions")); blocks.push( - ul( - projection.questions.flatMap((question) => - questionItems(skin, question), - ), - ), + ul(projection.questions.flatMap((question) => questionItems(question))), ); } if (projection.owed.length > 0) { blocks.push(h2("Checks still owed")); blocks.push( - ul(projection.owed.map((obligation) => obligationLine(skin, obligation))), + ul(projection.owed.map((obligation) => obligationLine(obligation))), ); } if (projection.met.length > 0) { @@ -98,10 +81,9 @@ export const renderLedgerMarkdown = ( blocks.push( ul( projection.met.flatMap((obligation) => [ - obligationLine(skin, obligation), + obligationLine(obligation), obligation.dischargedBy.map( - (reflection) => - `${reflection.text}${tag(skin, reflection.address)}`, + (reflection) => `${reflection.text}${tag(reflection.address)}`, ), ]), ), @@ -110,11 +92,7 @@ export const renderLedgerMarkdown = ( if (projection.excluded.length > 0) { blocks.push(h2("Excluded from the model")); blocks.push( - ul( - projection.excluded.flatMap((entity) => - entityItems(skin, entity, true), - ), - ), + ul(projection.excluded.flatMap((entity) => entityItems(entity, true))), ); } return blocks.join("\n\n"); diff --git a/libs/@hashintel/petrinaut/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md index 81b013a7b63..b9fe1a04ec7 100644 --- a/libs/@hashintel/petrinaut/docs/ai-assistant.md +++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md @@ -99,7 +99,7 @@ Ledger groups the parts of your process by section and kind, each with its origi Select something on the canvas and Ledger explains it: the reflections in which Brunch said why that element is modelled as it is, the claims they rest on, and the opening of your messages those came from. The records behind the selection are highlighted in the Ledger. When no reflection names the selected element, Ledger says that Brunch recorded no reasoning for it. **Show Brunch's reflections** lists each reflection under the parts of your process it concerns, with **Show in net** to select the elements it names. -Each unseen commit or net change by Brunch adds to Ledger's badge while Chat is selected or the panel is closed. You can read Ledger while continuing to type in the same composer, then switch to Chat to inspect the reply; if a response completes, errors, or is stopped while Ledger is visible, Chat receives an activity dot. Closing and reopening the assistant retains the selected tab for that mounted conversation. +Each commit to the Ledger made while Chat is selected or the panel is closed adds to Ledger's badge. Net changes do not, since they add nothing to read there. You can read Ledger while continuing to type in the same composer, then switch to Chat to inspect the reply; if a response completes, errors, or is stopped while Ledger is visible, Chat receives an activity dot. Closing and reopening the assistant retains the selected tab for that mounted conversation. In Chat, each commit appears as **Recorded in the Ledger**. A commit that cannot be recorded, for example because it refers to an entry that does not exist, records nothing and appears as **Ledger commit needs correction**; expand it to read the reason. Brunch is asked to repair a refused commit before moving on. The Ledger has no revisions, so it shows no warnings about newer or older versions. From f694bbea9a3dda705f29fcdbee586435901319f5 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 16 Oct 2026 09:13:53 +0200 Subject: [PATCH 05/21] Make the Ledger's trace of the canvas selection stand out Traced records get a stronger fill, a ring and medium weight; the rest of the document recedes while a selection is traced, and the first traced record scrolls into view. --- .../brunch/ledger/brunch-ledger-document.tsx | 206 ++++++++++-------- .../brunch/ledger/brunch-ledger-pane.test.tsx | 6 + .../brunch/ledger/brunch-ledger-pane.tsx | 1 + 3 files changed, 124 insertions(+), 89 deletions(-) diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx index 919a1b6d2de..dbaa1fee7da 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef } from "react"; + import { Button } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; @@ -27,11 +29,22 @@ const documentStyle = css({ "& h2": { fontSize: "lg" }, "& ul": { listStyleType: "disc", paddingLeft: "5", marginBottom: "3" }, "& ul ul": { marginBottom: "0" }, + // While a selection is traced, records outside the trace recede. + "&[data-tracing] [data-ledger-address]:not([data-traced])": { + opacity: "[0.4]", + }, }); const recordStyle = css({ borderRadius: "sm", - "&[data-traced]": { backgroundColor: "blue.s20" }, + transition: "[background-color 150ms, box-shadow 150ms, opacity 150ms]", + "&[data-traced]": { + backgroundColor: "blue.s40", + boxShadow: "[0 0 0 2px {colors.blue.s70}]", + boxDecorationBreak: "clone", + paddingX: "0.5", + fontWeight: "medium", + }, }); const qualifierStyle = css({ color: "neutral.s90" }); @@ -200,97 +213,112 @@ export const BrunchLedgerDocument = ({ }: { projection: LedgerProjection; options: LedgerDocumentOptions; -}) => ( -
    -

    {projection.title}

    - {projection.sections.map((section) => ( -
    -

    {section.title}

    - {section.kinds.map((group) => ( -
    -

    {group.label}

    -
      - {group.entities.map((entity) => ( - - ))} -
    -
    - ))} -
    - ))} - {projection.questions.length > 0 && ( -
    -

    Open questions

    -
      - {projection.questions.map(({ claim, prompt, context }) => ( -
    • - - {prompt} - -
        -
      • {context}
      • -
      -
    • - ))} -
    -
    - )} - {projection.owed.length > 0 && ( -
    -

    Checks still owed

    -
      - {projection.owed.map((obligation) => ( - - ))} -
    -
    - )} - {projection.met.length > 0 && ( -
    -

    Checks met

    -
      - {projection.met.map((obligation) => ( - +}) => { + const articleRef = useRef(null); + const tracedKey = [...options.traced].join(" "); + useEffect(() => { + if (tracedKey === "") return; + articleRef.current + ?.querySelector("[data-traced]") + ?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }, [tracedKey]); + return ( +
      0 ? "" : undefined} + > +

      {projection.title}

      + {projection.sections.map((section) => ( +
      +

      {section.title}

      + {section.kinds.map((group) => ( +
      +

      {group.label}

        - {obligation.dischargedBy.map((reflection) => ( - ( + ))}
      - - ))} -
    -
    - )} - {projection.excluded.length > 0 && ( -
    -

    Excluded from the model

    -
      - {projection.excluded.map((entity) => ( - +
    ))} - - - )} -
    -); + + ))} + {projection.questions.length > 0 && ( +
    +

    Open questions

    +
      + {projection.questions.map(({ claim, prompt, context }) => ( +
    • + + {prompt} + +
        +
      • {context}
      • +
      +
    • + ))} +
    +
    + )} + {projection.owed.length > 0 && ( +
    +

    Checks still owed

    +
      + {projection.owed.map((obligation) => ( + + ))} +
    +
    + )} + {projection.met.length > 0 && ( +
    +

    Checks met

    +
      + {projection.met.map((obligation) => ( + +
        + {obligation.dischargedBy.map((reflection) => ( + + ))} +
      +
      + ))} +
    +
    + )} + {projection.excluded.length > 0 && ( +
    +

    Excluded from the model

    +
      + {projection.excluded.map((entity) => ( + + ))} +
    +
    + )} + + ); +}; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx index 1813cc40e69..679533c8842 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx @@ -13,6 +13,10 @@ import { toPetrinautId } from "@hashintel/petrinaut-core"; import { foldBrunchLedgerHistory } from "./brunch-ledger-history"; import { BrunchLedgerPane } from "./brunch-ledger-pane"; +// jsdom does not implement scrolling. +const scrollIntoView = vi.fn(); +Element.prototype.scrollIntoView = scrollIntoView; + afterEach(cleanup); const reservoir = [ @@ -180,6 +184,8 @@ test("explains a selected net element from the reflection naming it, and marks t expect(basis.textContent).toContain("The reservoir holds a day of supply."); expect(basis.textContent).toContain("“The reservoir holds a day of supply.”"); const document = screen.getByTestId("brunch-ledger-document"); + expect(document.hasAttribute("data-tracing")).toBe(true); + expect(scrollIntoView).toHaveBeenCalled(); for (const address of ["e1", "c1"]) expect( document diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx index 78d30ee2789..fdec3141530 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx @@ -24,6 +24,7 @@ const basisStyle = css({ lineHeight: "[1.55]", borderWidth: "thin", borderColor: "blue.s50", + backgroundColor: "blue.s05", borderRadius: "md", padding: "3", marginBottom: "4", From aef06f9f48eaa4a5da59531b62a76809bbb1bd3c Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 16 Oct 2026 16:50:23 +0200 Subject: [PATCH 06/21] Give net element id matching one home --- .../src/agents/petrinaut/tools/query-basis.ts | 4 +- .../src/shared/conversation/net-changes.ts | 21 +------- .../brunch/ledger/brunch-ledger-document.tsx | 2 +- .../brunch/ledger/brunch-ledger-pane.tsx | 5 +- ...r-canvas.ts => brunch-ledger-selection.ts} | 0 libs/@hashintel/brunch-agent/src/index.ts | 4 +- libs/@hashintel/brunch-agent/src/ledger.ts | 4 ++ .../net-element-selection.test.ts} | 5 +- .../construction/net-element-selection.ts | 36 +++++++++++++ .../src/petrinaut/net-element-ids.ts | 50 +++++++------------ 10 files changed, 74 insertions(+), 57 deletions(-) rename apps/petrinaut-website/src/main/app/plugins/brunch/ledger/{brunch-ledger-canvas.ts => brunch-ledger-selection.ts} (100%) rename libs/@hashintel/brunch-agent/src/{petrinaut/net-element-ids.test.ts => ledger/construction/net-element-selection.test.ts} (91%) create mode 100644 libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.ts diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts index 90d8d607adf..a6e44d9253b 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts @@ -4,6 +4,7 @@ import * as v from "valibot"; import { brunchTools, foldCommits, + generatedArcId, namesId, netElementKinds, traceLedger, @@ -11,16 +12,15 @@ import { import { callsForElement, - generatedArcId, isAppliedChange, latestNetDefinition, netCalls, - type ArcElement, type NetCall, } from "../../../shared/conversation/net-changes.ts"; import type { FlueConversationSnapshot } from "@flue/sdk"; import type { + ArcElement, BrowserContext, LedgerTrace, NetElementAddress, diff --git a/apps/brunch-agent/src/shared/conversation/net-changes.ts b/apps/brunch-agent/src/shared/conversation/net-changes.ts index 42fce8e1443..b4b18f2ebab 100644 --- a/apps/brunch-agent/src/shared/conversation/net-changes.ts +++ b/apps/brunch-agent/src/shared/conversation/net-changes.ts @@ -1,15 +1,12 @@ import { + type ArcElement, type NetElementKind, + generatedArcId, namesId, netReaderLevelOf, parseClientToolResultMetadata, petrinautToolTargets, } from "@hashintel/brunch-agent"; -import { - generateArcId, - getArcEndpointKey, - placeArcEndpoint, -} from "@hashintel/petrinaut-core"; import { petrinautAiTools, getLatestNetDefinitionToolName, @@ -20,12 +17,6 @@ import { netResultView } from "./net-view.ts"; import type { FlueConversationSnapshot } from "@flue/sdk"; import type { SDCPN } from "@hashintel/petrinaut-core"; -export interface ArcElement { - readonly transitionId: string; - readonly arcDirection: "input" | "output"; - readonly placeId: string; -} - export interface NetElement { readonly kind: NetElementKind; readonly id: string; @@ -111,14 +102,6 @@ const hasId = (value: unknown, id: string, underIdKey = false): boolean => { ); }; -/** The ID Petrinaut generates for an arc, which `deleteItemsByIds` uses. */ -export const generatedArcId = (arc: ArcElement): string => { - const place = getArcEndpointKey(placeArcEndpoint(arc.placeId)); - return arc.arcDirection === "input" - ? generateArcId({ inputId: place, outputId: arc.transitionId }) - : generateArcId({ inputId: arc.transitionId, outputId: place }); -}; - /** Arcs have no standalone ID: the arc actions identify their transition, direction and endpoint, and a batch deletion uses the generated arc ID. */ const matchesArc = (call: NetCall, arc: ArcElement): boolean => { if (call.toolName === "deleteItemsByIds") diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx index dbaa1fee7da..db0d2a4895c 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from "react"; import { Button } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; -import { reflectionSelection } from "./brunch-ledger-canvas"; +import { reflectionSelection } from "./brunch-ledger-selection"; import type { LedgerProjection, diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx index fdec3141530..aa1e2dbc488 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx @@ -4,12 +4,15 @@ import { projectLedger, type LedgerTrace } from "@hashintel/brunch-agent"; import { Toggle } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; -import { traceCanvasSelection, tracedAddresses } from "./brunch-ledger-canvas"; import { BrunchLedgerDocument } from "./brunch-ledger-document"; import { foldBrunchLedgerHistory, type BrunchLedgerHistoryMessage, } from "./brunch-ledger-history"; +import { + traceCanvasSelection, + tracedAddresses, +} from "./brunch-ledger-selection"; import type { SelectionItem } from "@hashintel/petrinaut-core"; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-canvas.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-selection.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-canvas.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-selection.ts diff --git a/libs/@hashintel/brunch-agent/src/index.ts b/libs/@hashintel/brunch-agent/src/index.ts index 727ed96faf6..634e330b274 100644 --- a/libs/@hashintel/brunch-agent/src/index.ts +++ b/libs/@hashintel/brunch-agent/src/index.ts @@ -39,9 +39,9 @@ export { type NetReaderLevel, } from "./petrinaut/net-readers"; export { + generatedArcId, namesId, - namesSelectionItem, - netElementSelectionItem, + type ArcElement, } from "./petrinaut/net-element-ids"; export { browserToolMutatesDocument, diff --git a/libs/@hashintel/brunch-agent/src/ledger.ts b/libs/@hashintel/brunch-agent/src/ledger.ts index 2eb6f9ffb9b..2711216365c 100644 --- a/libs/@hashintel/brunch-agent/src/ledger.ts +++ b/libs/@hashintel/brunch-agent/src/ledger.ts @@ -29,6 +29,10 @@ export { vReflection, type NetElementAddress, } from "./ledger/construction/reflections"; +export { + namesSelectionItem, + netElementSelectionItem, +} from "./ledger/construction/net-element-selection"; export { vOrigin, vStatus } from "./ledger/shared/epistemics"; export { traceLedger, diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.test.ts b/libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.test.ts similarity index 91% rename from libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.test.ts rename to libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.test.ts index 8bbe07ca7ff..be8afde084d 100644 --- a/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.test.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.test.ts @@ -2,7 +2,10 @@ import { expect, test } from "vitest"; import { toPetrinautId } from "@hashintel/petrinaut-core"; -import { namesSelectionItem, netElementSelectionItem } from "./net-element-ids"; +import { + namesSelectionItem, + netElementSelectionItem, +} from "./net-element-selection"; test("a written net element ID becomes the selection the document stores", () => { expect(netElementSelectionItem({ kind: "place", id: "queue" })).toEqual({ diff --git a/libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.ts b/libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.ts new file mode 100644 index 00000000000..69e43905571 --- /dev/null +++ b/libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.ts @@ -0,0 +1,36 @@ +import { canonicalizeArcId, toPetrinautId } from "@hashintel/petrinaut-core"; +import { + selectionItemTypes, + type SelectionItem, + type SelectionItemType, +} from "@hashintel/petrinaut-core/selection"; + +import { namesId } from "../../petrinaut/net-element-ids"; + +import type { NetElementAddress } from "./reflections"; + +const selectable: ReadonlySet = new Set(selectionItemTypes); + +const isSelectionItemType = (kind: string): kind is SelectionItemType => + selectable.has(kind); + +/** + * The editor selection a net element address stands for, with its ID as the + * document stores it; absent for kinds the editor cannot select. + */ +export const netElementSelectionItem = ({ + kind, + id, +}: NetElementAddress): SelectionItem | undefined => + isSelectionItemType(kind) + ? { + type: kind, + id: kind === "arc" ? canonicalizeArcId(id) : toPetrinautId(id), + } + : undefined; + +/** Whether a net element address names a selected editor item. */ +export const namesSelectionItem = ( + address: NetElementAddress, + item: SelectionItem, +): boolean => address.kind === item.type && namesId(address.id, item.id); diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts b/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts index 6c46b01c883..f5c16bc8747 100644 --- a/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts +++ b/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts @@ -1,11 +1,10 @@ -import { canonicalizeArcId, toPetrinautId } from "@hashintel/petrinaut-core"; import { - selectionItemTypes, - type SelectionItem, - type SelectionItemType, -} from "@hashintel/petrinaut-core/selection"; - -import type { NetElementAddress } from "../ledger/construction/reflections"; + canonicalizeArcId, + generateArcId, + getArcEndpointKey, + placeArcEndpoint, + toPetrinautId, +} from "@hashintel/petrinaut-core"; /** * Whether `written` names `id`: as written, or as the converted id the @@ -18,28 +17,17 @@ export const namesId = (written: unknown, id: string): boolean => toPetrinautId(written) === id || canonicalizeArcId(written) === id); -const selectable: ReadonlySet = new Set(selectionItemTypes); - -const isSelectionItemType = (kind: string): kind is SelectionItemType => - selectable.has(kind); - -/** - * The editor selection a net element address stands for, with its ID as the - * document stores it; absent for kinds the editor cannot select. - */ -export const netElementSelectionItem = ({ - kind, - id, -}: NetElementAddress): SelectionItem | undefined => - isSelectionItemType(kind) - ? { - type: kind, - id: kind === "arc" ? canonicalizeArcId(id) : toPetrinautId(id), - } - : undefined; +/** An arc by its endpoints, as the arc actions identify it. */ +export interface ArcElement { + readonly transitionId: string; + readonly arcDirection: "input" | "output"; + readonly placeId: string; +} -/** Whether a net element address names a selected editor item. */ -export const namesSelectionItem = ( - address: NetElementAddress, - item: SelectionItem, -): boolean => address.kind === item.type && namesId(address.id, item.id); +/** The ID Petrinaut generates for an arc, which `deleteItemsByIds` uses. */ +export const generatedArcId = (arc: ArcElement): string => { + const place = getArcEndpointKey(placeArcEndpoint(arc.placeId)); + return arc.arcDirection === "input" + ? generateArcId({ inputId: place, outputId: arc.transitionId }) + : generateArcId({ inputId: arc.transitionId, outputId: place }); +}; From a9ba14a1abb3b3628dd922bc251038ecf6a75b3b Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 16 Oct 2026 17:45:47 +0200 Subject: [PATCH 07/21] Keep in the Brunch library only what the server and browser must agree on --- .../run-packet/read-run-directory.ts | 4 ++- .../brunch/ledger/brunch-ledger-history.ts | 4 +-- .../brunch/ledger/brunch-ledger-pane.test.tsx | 6 ++-- .../brunch/ledger/brunch-ledger-pane.tsx | 13 +++---- .../ledger/brunch-ledger-selection.test.ts | 2 +- .../brunch/ledger/brunch-ledger-selection.ts | 32 +++++++++++++++-- .../brunch-ledger-tab.in-editor.test.tsx | 3 +- .../brunch/ledger/brunch-ledger-tab.tsx | 6 ++-- libs/@hashintel/brunch-agent/README.md | 4 ++- libs/@hashintel/brunch-agent/src/index.ts | 1 + libs/@hashintel/brunch-agent/src/ledger.ts | 4 --- .../brunch-agent/src/ledger/commits.ts | 18 +++++----- .../construction/net-element-selection.ts | 36 ------------------- .../brunch-agent/src/ledger/trace.test.ts | 2 +- .../src/petrinaut/net-element-ids.test.ts | 22 ++++++++++++ .../src/petrinaut/net-element-ids.ts | 11 ++++++ 16 files changed, 98 insertions(+), 70 deletions(-) rename libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.test.ts => apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-selection.test.ts (96%) delete mode 100644 libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.ts create mode 100644 libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.test.ts diff --git a/apps/brunch-agent/evals/petrinaut/run-packet/read-run-directory.ts b/apps/brunch-agent/evals/petrinaut/run-packet/read-run-directory.ts index 0f761e2bfe2..f939f3196b8 100644 --- a/apps/brunch-agent/evals/petrinaut/run-packet/read-run-directory.ts +++ b/apps/brunch-agent/evals/petrinaut/run-packet/read-run-directory.ts @@ -152,7 +152,9 @@ const finalNetOf = (net: unknown): FinalNetSummary | null => { const isHistoryMessage = (message: unknown): message is LedgerHistoryMessage => isRecord(message) && typeof message.id === "string" && - typeof message.role === "string" && + (message.role === "user" || + message.role === "assistant" || + message.role === "system") && Array.isArray(message.parts); const finalLedgerOf = (history: unknown): FinalLedgerSummary | null => { diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-history.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-history.ts index 70bfeae2417..ac9fa32cb93 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-history.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-history.ts @@ -4,8 +4,6 @@ import { type LedgerState, } from "@hashintel/brunch-agent"; -export type BrunchLedgerHistoryMessage = LedgerHistoryMessage; - export type BrunchLedgerHistory = { /** Accepted commits, for the tab's activity badge: each is something new to read. */ readonly activityIdentities: readonly string[]; @@ -15,7 +13,7 @@ export type BrunchLedgerHistory = { /** Folds only accepted commits; refused, failed, pending and unbound calls never appear. */ export const foldBrunchLedgerHistory = ( - messages: readonly BrunchLedgerHistoryMessage[], + messages: readonly LedgerHistoryMessage[], ): BrunchLedgerHistory => { const { commits, state } = foldCommits({ messages }); return { diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx index 679533c8842..9d68f9f916b 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.test.tsx @@ -13,6 +13,8 @@ import { toPetrinautId } from "@hashintel/petrinaut-core"; import { foldBrunchLedgerHistory } from "./brunch-ledger-history"; import { BrunchLedgerPane } from "./brunch-ledger-pane"; +import type { LedgerHistoryMessage } from "@hashintel/brunch-agent"; + // jsdom does not implement scrolling. const scrollIntoView = vi.fn(); Element.prototype.scrollIntoView = scrollIntoView; @@ -49,14 +51,14 @@ const commit = ( ...overrides, }); -const assistant = (...parts: unknown[]) => ({ +const assistant = (...parts: unknown[]): LedgerHistoryMessage => ({ id: "assistant", role: "assistant", purpose: "assistant", parts, }); -const user = (id: string, text: string) => ({ +const user = (id: string, text: string): LedgerHistoryMessage => ({ id, role: "user", purpose: "user", diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx index aa1e2dbc488..79704a47149 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-pane.tsx @@ -1,14 +1,15 @@ import { useId, useState } from "react"; -import { projectLedger, type LedgerTrace } from "@hashintel/brunch-agent"; +import { + projectLedger, + type LedgerHistoryMessage, + type LedgerTrace, +} from "@hashintel/brunch-agent"; import { Toggle } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { BrunchLedgerDocument } from "./brunch-ledger-document"; -import { - foldBrunchLedgerHistory, - type BrunchLedgerHistoryMessage, -} from "./brunch-ledger-history"; +import { foldBrunchLedgerHistory } from "./brunch-ledger-history"; import { traceCanvasSelection, tracedAddresses, @@ -89,7 +90,7 @@ export const BrunchLedgerPane = ({ selection = [], onSelectNetElements, }: { - messages: readonly BrunchLedgerHistoryMessage[]; + messages: readonly LedgerHistoryMessage[]; /** The editor's selection, when the pane sits beside a canvas. */ selection?: readonly SelectionItem[]; onSelectNetElements?: (items: SelectionItem[]) => void; diff --git a/libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-selection.test.ts similarity index 96% rename from libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-selection.test.ts index be8afde084d..4bd8f3caa39 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.test.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-selection.test.ts @@ -5,7 +5,7 @@ import { toPetrinautId } from "@hashintel/petrinaut-core"; import { namesSelectionItem, netElementSelectionItem, -} from "./net-element-selection"; +} from "./brunch-ledger-selection"; test("a written net element ID becomes the selection the document stores", () => { expect(netElementSelectionItem({ kind: "place", id: "queue" })).toEqual({ diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-selection.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-selection.ts index 7465122ddc8..29cf6a98851 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-selection.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-selection.ts @@ -1,13 +1,39 @@ import { - namesSelectionItem, - netElementSelectionItem, + namesId, + storedNetElementId, traceLedger, type LedgerState, type LedgerTrace, + type NetElementAddress, type ReflectionRecord, } from "@hashintel/brunch-agent"; +import { + selectionItemTypes, + type SelectionItem, + type SelectionItemType, +} from "@hashintel/petrinaut-core/selection"; + +const selectable: ReadonlySet = new Set(selectionItemTypes); + +const isSelectionItemType = (kind: string): kind is SelectionItemType => + selectable.has(kind); + +/** + * The editor selection a net element address stands for, with its ID as the + * document stores it; absent for kinds the editor cannot select. + */ +export const netElementSelectionItem = ( + address: NetElementAddress, +): SelectionItem | undefined => + isSelectionItemType(address.kind) + ? { type: address.kind, id: storedNetElementId(address) } + : undefined; -import type { SelectionItem } from "@hashintel/petrinaut-core"; +/** Whether a net element address names a selected editor item. */ +export const namesSelectionItem = ( + address: NetElementAddress, + item: SelectionItem, +): boolean => address.kind === item.type && namesId(address.id, item.id); /** The Ledger behind the editor's selection; absent while nothing is selected. */ export const traceCanvasSelection = ( diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.in-editor.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.in-editor.test.tsx index 39ef010450b..8620ea1238b 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.in-editor.test.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.in-editor.test.tsx @@ -19,6 +19,7 @@ import { brunchPlugin, BrunchAssistantContext } from "../editor-api-plugin"; import { BrunchLedgerTab } from "./brunch-ledger-tab"; import type { PetrinautAiAssistant } from "../chat/petrinaut"; +import type { LedgerHistoryMessage } from "@hashintel/brunch-agent"; import type { UIMessageChunk } from "ai"; await vi.hoisted(async () => { @@ -41,7 +42,7 @@ afterEach(() => { const reservoirId = toPetrinautId("reservoir"); /** One accepted commit: the reservoir and a reflection naming its place by the id Brunch wrote. */ -const messages = [ +const messages: LedgerHistoryMessage[] = [ { id: "user-1", role: "user", diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.tsx index ca1a5ac9434..bf94fbf0ac5 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-tab.tsx @@ -6,14 +6,14 @@ import { } from "../editor-api-plugin"; import { BrunchLedgerPane } from "./brunch-ledger-pane"; -import type { BrunchLedgerHistoryMessage } from "./brunch-ledger-history"; +import type { LedgerHistoryMessage } from "@hashintel/brunch-agent"; const LedgerBesideCanvas = ({ api, messages, }: { api: BrunchEditorApi; - messages: readonly BrunchLedgerHistoryMessage[]; + messages: readonly LedgerHistoryMessage[]; }) => { const { selection } = useStore(api.editor.navigation); return ( @@ -34,7 +34,7 @@ const LedgerBesideCanvas = ({ export const BrunchLedgerTab = ({ messages, }: { - messages: readonly BrunchLedgerHistoryMessage[]; + messages: readonly LedgerHistoryMessage[]; }) => ( {(api) => } diff --git a/libs/@hashintel/brunch-agent/README.md b/libs/@hashintel/brunch-agent/README.md index 57c61b046e9..05e2b680a57 100644 --- a/libs/@hashintel/brunch-agent/README.md +++ b/libs/@hashintel/brunch-agent/README.md @@ -1,6 +1,8 @@ # `@hashintel/brunch-agent` -What the Brunch agent app (`apps/brunch-agent`) and the Petrinaut website (`apps/petrinaut-website`) share: Brunch's named constants, conversation identity, the browser-tool envelope and message metadata, the Petrinaut tool contracts, contextual user messages with their submission context, the interview allowance and custom-word spellings, and the Ledger with its projection. It is browser-safe; it never imports the Flue runtime. Code that only one app needs belongs in that app. Besides its main entry, two leaf entries, `@hashintel/brunch-agent/interview-budget` and `@hashintel/brunch-agent/words`, export the interview allowance and the custom-word spelling bounds alone, for the website code its Voice server functions share, whose `nodenext` type check cannot read the main entry's source. +What the Brunch agent app (`apps/brunch-agent`) and the Petrinaut website (`apps/petrinaut-website`) must agree on: Brunch's named constants, conversation identity, the browser-tool envelope and message metadata, the Petrinaut tool contracts, net element identity, contextual user messages with their submission context, the interview allowance and custom-word spellings, and the Ledger with its projection. It is browser-safe; it never imports the Flue runtime. Besides its main entry, two leaf entries, `@hashintel/brunch-agent/interview-budget` and `@hashintel/brunch-agent/words`, export the interview allowance and the custom-word spelling bounds alone, for the website code its Voice server functions share, whose `nodenext` type check cannot read the main entry's source. + +Code belongs here when the server and the browser must agree on it: a contract between them, a derivation from conversation history that both compute, or how an element's ID is recognised. It belongs here even when only one side calls it today. Code only one side runs, with nothing to agree on, belongs in that app. Types derive from their owner here rather than being restated in either app. The agents themselves, their guidance, Flue tools and evaluations live in [`apps/brunch-agent`](../../../apps/brunch-agent/README.md). The Petrinaut agent's architecture, including the Ledger and the browser boundary, is in [`apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md`](../../../apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md). diff --git a/libs/@hashintel/brunch-agent/src/index.ts b/libs/@hashintel/brunch-agent/src/index.ts index 634e330b274..75663a1a873 100644 --- a/libs/@hashintel/brunch-agent/src/index.ts +++ b/libs/@hashintel/brunch-agent/src/index.ts @@ -41,6 +41,7 @@ export { export { generatedArcId, namesId, + storedNetElementId, type ArcElement, } from "./petrinaut/net-element-ids"; export { diff --git a/libs/@hashintel/brunch-agent/src/ledger.ts b/libs/@hashintel/brunch-agent/src/ledger.ts index 2711216365c..2eb6f9ffb9b 100644 --- a/libs/@hashintel/brunch-agent/src/ledger.ts +++ b/libs/@hashintel/brunch-agent/src/ledger.ts @@ -29,10 +29,6 @@ export { vReflection, type NetElementAddress, } from "./ledger/construction/reflections"; -export { - namesSelectionItem, - netElementSelectionItem, -} from "./ledger/construction/net-element-selection"; export { vOrigin, vStatus } from "./ledger/shared/epistemics"; export { traceLedger, diff --git a/libs/@hashintel/brunch-agent/src/ledger/commits.ts b/libs/@hashintel/brunch-agent/src/ledger/commits.ts index 4adf0d1b942..0ff796b455c 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/commits.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/commits.ts @@ -12,14 +12,16 @@ import type { ReflectionRecord, Turn, } from "./projection"; - -/** Structural view of a conversation history; Flue and AI SDK messages both fit. */ -export interface LedgerHistoryMessage { - readonly id: string; - readonly role: string; - readonly purpose?: string; - readonly parts: readonly unknown[]; -} +import type { FlueConversationMessage } from "@flue/sdk"; + +/** A Flue conversation message as the fold reads it: parts stay `unknown` because the fold parses them. */ +export type LedgerHistoryMessage = Pick< + FlueConversationMessage, + "id" | "role" +> & + Partial> & { + readonly parts: readonly unknown[]; + }; export interface LedgerHistory { readonly messages: readonly LedgerHistoryMessage[]; diff --git a/libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.ts b/libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.ts deleted file mode 100644 index 69e43905571..00000000000 --- a/libs/@hashintel/brunch-agent/src/ledger/construction/net-element-selection.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { canonicalizeArcId, toPetrinautId } from "@hashintel/petrinaut-core"; -import { - selectionItemTypes, - type SelectionItem, - type SelectionItemType, -} from "@hashintel/petrinaut-core/selection"; - -import { namesId } from "../../petrinaut/net-element-ids"; - -import type { NetElementAddress } from "./reflections"; - -const selectable: ReadonlySet = new Set(selectionItemTypes); - -const isSelectionItemType = (kind: string): kind is SelectionItemType => - selectable.has(kind); - -/** - * The editor selection a net element address stands for, with its ID as the - * document stores it; absent for kinds the editor cannot select. - */ -export const netElementSelectionItem = ({ - kind, - id, -}: NetElementAddress): SelectionItem | undefined => - isSelectionItemType(kind) - ? { - type: kind, - id: kind === "arc" ? canonicalizeArcId(id) : toPetrinautId(id), - } - : undefined; - -/** Whether a net element address names a selected editor item. */ -export const namesSelectionItem = ( - address: NetElementAddress, - item: SelectionItem, -): boolean => address.kind === item.type && namesId(address.id, item.id); diff --git a/libs/@hashintel/brunch-agent/src/ledger/trace.test.ts b/libs/@hashintel/brunch-agent/src/ledger/trace.test.ts index 728ed2262a4..a9b7b3c7595 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/trace.test.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/trace.test.ts @@ -146,7 +146,7 @@ describe("traceLedger", () => { describe("turn excerpts", () => { const commit = (toolCallId: string, entries: unknown[], ids: string[]) => ({ id: `assistant-${toolCallId}`, - role: "assistant", + role: "assistant" as const, parts: [ { type: "dynamic-tool", diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.test.ts b/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.test.ts new file mode 100644 index 00000000000..e749558ada1 --- /dev/null +++ b/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; + +import { canonicalizeArcId, toPetrinautId } from "@hashintel/petrinaut-core"; + +import { namesId, storedNetElementId } from "./net-element-ids"; + +test("a written net element ID becomes the ID the document stores", () => { + expect(storedNetElementId({ kind: "place", id: "queue" })).toBe( + toPetrinautId("queue"), + ); + const stored = toPetrinautId("queue"); + expect(storedNetElementId({ kind: "place", id: stored })).toBe(stored); + expect(storedNetElementId({ kind: "arc", id: "a" })).toBe( + canonicalizeArcId("a"), + ); +}); + +test("a written ID names its stored ID, and nothing else", () => { + expect(namesId("queue", toPetrinautId("queue"))).toBe(true); + expect(namesId("other", toPetrinautId("queue"))).toBe(false); + expect(namesId(undefined, "queue")).toBe(false); +}); diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts b/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts index f5c16bc8747..edffa9dd048 100644 --- a/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts +++ b/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts @@ -6,6 +6,8 @@ import { toPetrinautId, } from "@hashintel/petrinaut-core"; +import type { NetElementKind } from "./petrinaut-tool-effects"; + /** * Whether `written` names `id`: as written, or as the converted id the * document stores for an id the model invented. Flue keeps the model's raw @@ -17,6 +19,15 @@ export const namesId = (written: unknown, id: string): boolean => toPetrinautId(written) === id || canonicalizeArcId(written) === id); +/** The ID the document stores for a net element the model may have written by its own ID. */ +export const storedNetElementId = ({ + kind, + id, +}: { + readonly kind: NetElementKind; + readonly id: string; +}): string => (kind === "arc" ? canonicalizeArcId(id) : toPetrinautId(id)); + /** An arc by its endpoints, as the arc actions identify it. */ export interface ArcElement { readonly transitionId: string; From 23c778ef7a3501cdd507fe3c6fb65caa877e8f8b Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 16 Oct 2026 17:52:09 +0200 Subject: [PATCH 08/21] Name the browser-tool envelope once in the Brunch library --- .../src/agents/petrinaut/agent.ts | 3 +- .../src/shared/context-projection.ts | 13 ++--- .../src/shared/conversation/net-changes.ts | 15 ++---- .../petrinaut-tools/petrinaut-construction.ts | 16 +++--- ...unch-draft-experiment-interactive-tool.tsx | 7 +-- .../brunch/tools/brunch-petrinaut-tools.ts | 43 +++++++--------- .../brunch-agent/src/client-tools.ts | 2 + .../client-tools/browser-tool-result.test.ts | 49 +++++++++++++++++++ .../src/client-tools/browser-tool-result.ts | 34 ++++++++++--- .../src/client-tools/client-tool-history.ts | 22 ++++----- 10 files changed, 127 insertions(+), 77 deletions(-) create mode 100644 libs/@hashintel/brunch-agent/src/client-tools/browser-tool-result.test.ts diff --git a/apps/brunch-agent/src/agents/petrinaut/agent.ts b/apps/brunch-agent/src/agents/petrinaut/agent.ts index b5c5c8aaf1c..c69e90781b6 100644 --- a/apps/brunch-agent/src/agents/petrinaut/agent.ts +++ b/apps/brunch-agent/src/agents/petrinaut/agent.ts @@ -119,7 +119,7 @@ export function PetrinautAgent({ id }: AgentProps) { : undefined; if (expected && "refusal" in expected) throw new Error(expected.refusal); - const result = await issueBrowserCall({ + return issueBrowserCall({ instanceId: id, toolCallId, toolName, @@ -130,7 +130,6 @@ export function PetrinautAgent({ id }: AgentProps) { : { expectedRevision: expected.revision }), signal, }); - return { output: result.output, metadata: result.metadata }; }, } : {}, diff --git a/apps/brunch-agent/src/shared/context-projection.ts b/apps/brunch-agent/src/shared/context-projection.ts index aa366e5cbe9..68421bd961f 100644 --- a/apps/brunch-agent/src/shared/context-projection.ts +++ b/apps/brunch-agent/src/shared/context-projection.ts @@ -1,4 +1,5 @@ import { + browserToolEnvelopeOf, parsePetrinautUserMessageBody, petrinautContextualUserMessageBody, } from "@hashintel/brunch-agent"; @@ -11,13 +12,11 @@ import type { ContextProjectionEntry, ContextProjectionMessage, } from "@flue/runtime"; - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); +import type { BrowserToolEnvelope } from "@hashintel/brunch-agent"; const browserEnvelope = ( message: ContextProjectionMessage, -): Record | undefined => { +): BrowserToolEnvelope | undefined => { if ( message.role !== "toolResult" || message.isError || @@ -33,11 +32,7 @@ const browserEnvelope = ( } catch { return undefined; } - return isRecord(envelope) && - envelope.brunchBrowserResult === true && - Object.hasOwn(envelope, "output") - ? envelope - : undefined; + return browserToolEnvelopeOf(envelope); }; /** diff --git a/apps/brunch-agent/src/shared/conversation/net-changes.ts b/apps/brunch-agent/src/shared/conversation/net-changes.ts index b4b18f2ebab..c8c1b4a162f 100644 --- a/apps/brunch-agent/src/shared/conversation/net-changes.ts +++ b/apps/brunch-agent/src/shared/conversation/net-changes.ts @@ -1,6 +1,7 @@ import { type ArcElement, type NetElementKind, + browserToolEnvelopeOf, generatedArcId, namesId, netReaderLevelOf, @@ -50,17 +51,9 @@ export const netCalls = (snapshot: FlueConversationSnapshot): NetCall[] => part.state !== "output-available" ) return []; - const envelope = part.output; - if ( - typeof envelope !== "object" || - envelope === null || - !("brunchBrowserResult" in envelope) || - envelope.brunchBrowserResult !== true - ) - return []; - if (!("output" in envelope)) return []; - const recordedMetadata = - "metadata" in envelope ? envelope.metadata : undefined; + const envelope = browserToolEnvelopeOf(part.output); + if (!envelope) return []; + const recordedMetadata = envelope.metadata; const metadata = parseClientToolResultMetadata(recordedMetadata); return [ { diff --git a/apps/brunch-agent/src/shared/petrinaut-tools/petrinaut-construction.ts b/apps/brunch-agent/src/shared/petrinaut-tools/petrinaut-construction.ts index 67011998ec2..2198fca2280 100644 --- a/apps/brunch-agent/src/shared/petrinaut-tools/petrinaut-construction.ts +++ b/apps/brunch-agent/src/shared/petrinaut-tools/petrinaut-construction.ts @@ -6,9 +6,14 @@ import { netReaderInputSchema, type NetReaderLevel, netReaderToolNames, + type BrowserToolEnvelope, + type ClientToolResult, } from "@hashintel/brunch-agent"; import { petrinautAiTools } from "@hashintel/petrinaut-core/ai"; +/** What the bound browser settles a call with, without its correlation. */ +type BrowserToolResult = Pick; + type CanonicalJson = | string | number @@ -23,20 +28,17 @@ export type BrowserToolExecutor = (call: { readonly input: unknown; readonly toolCallId: string; readonly signal?: AbortSignal; -}) => Promise<{ readonly output: unknown; readonly metadata?: unknown }>; +}) => Promise; /** The browser's output goes to the model; the envelope and its metadata stay host-only. */ -export const browserResultEnvelope = (result: { - readonly output: unknown; - readonly metadata?: unknown; -}) => +export const browserResultEnvelope = (result: BrowserToolResult) => JSON.parse( JSON.stringify({ brunchBrowserResult: true, output: result.output, ...(result.metadata === undefined ? {} : { metadata: result.metadata }), - }), - ) as CanonicalJson; + } satisfies BrowserToolEnvelope), + ) as BrowserToolEnvelope & CanonicalJson; /** The browser runs `getLatestNetDefinition` and returns only the requested view. */ export const netReaderTools = (execute: BrowserToolExecutor) => diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.tsx index 611da6fae9c..36e5fec38df 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.tsx @@ -195,10 +195,11 @@ export const resolveDraftAuthorityFromHistory = async ( throw new Error( "The AI assistant needs to read the latest model before drafting.", ); - const envelope = latest.output; + // The sidecar is taken from any recorded object, enveloped or not. + const recorded = latest.output; const metadata = - typeof envelope === "object" && envelope !== null && "metadata" in envelope - ? parseClientToolResultMetadata(envelope.metadata) + typeof recorded === "object" && recorded !== null && "metadata" in recorded + ? parseClientToolResultMetadata(recorded.metadata) : undefined; const revision = metadata?.documentRevision.before; if (revision === undefined) diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-petrinaut-tools.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-petrinaut-tools.ts index 412d1dd2104..00f80b5bc4a 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-petrinaut-tools.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-petrinaut-tools.ts @@ -1,5 +1,7 @@ import { + browserToolEnvelopeOf, browserToolMutatesDocument, + type ClientToolHistoryResult, type ClientToolResultMetadata, type NetReaderLevel, netReaderToolNames, @@ -24,14 +26,11 @@ import { import type { PetrinautAiAutomaticTool } from "../chat/ai-automatic-tool"; import type { FlueConversationState } from "@flue/sdk"; -type DocumentRevisionMetadata = ClientToolResultMetadata; - -type RetainedCall = { - readonly toolName: string; - readonly input: unknown; - readonly output?: unknown; - readonly metadata?: DocumentRevisionMetadata; -}; +type RetainedCall = Pick & + Partial> & { + readonly input: unknown; + readonly metadata?: ClientToolResultMetadata; + }; export interface CanonicalPetrinautReplay { readonly calls: ReadonlyMap; } @@ -55,21 +54,15 @@ export const issuedCanonicalCallsFromHistory = async ({ for (const part of message.parts) { if (part.type !== "dynamic-tool" || !(part.toolName in petrinautAiTools)) continue; - const output = - part.state === "output-available" && - typeof part.output === "object" && - part.output !== null && - "brunchBrowserResult" in part.output && - part.output.brunchBrowserResult === true && - "output" in part.output - ? part.output.output - : undefined; + const recorded = + part.state === "output-available" ? part.output : undefined; + const output = browserToolEnvelopeOf(recorded)?.output; + // The sidecar is taken from any recorded object, enveloped or not. const metadata = - part.state === "output-available" && - typeof part.output === "object" && - part.output !== null && - "metadata" in part.output - ? (part.output.metadata as DocumentRevisionMetadata) + typeof recorded === "object" && + recorded !== null && + "metadata" in recorded + ? (recorded.metadata as ClientToolResultMetadata) : undefined; calls.set(part.toolCallId, { toolName: part.toolName, @@ -119,7 +112,7 @@ export const createCanonicalPetrinautHostTools = ( readonly output: unknown; } >(); - const metadata = new Map(); + const metadata = new Map(); /** The revision each settled Brunch change started from, keyed by the one it produced. */ const producedFrom = new Map(); for (const call of replay.values()) { @@ -306,7 +299,7 @@ export const createCanonicalPetrinautHostTools = ( clientToolResultMetadataFor: ( toolCallId: string, _output?: unknown, - ): DocumentRevisionMetadata => { + ): ClientToolResultMetadata => { const existing = metadata.get(toolCallId); if (existing) return existing; const revisionBefore = before.get(toolCallId); @@ -319,7 +312,7 @@ export const createCanonicalPetrinautHostTools = ( (toolName === undefined || browserToolMutatesDocument(toolName)); if (changed) producedFrom.set(revisionAfter, revisionBefore); const definition = changed ? input.handle.doc() : undefined; - const result: DocumentRevisionMetadata = { + const result: ClientToolResultMetadata = { documentRevision: { ...(revisionBefore === undefined ? {} : { before: revisionBefore }), ...(changed ? { after: revisionAfter } : {}), diff --git a/libs/@hashintel/brunch-agent/src/client-tools.ts b/libs/@hashintel/brunch-agent/src/client-tools.ts index 1ca97dbb54a..01f7b4767e5 100644 --- a/libs/@hashintel/brunch-agent/src/client-tools.ts +++ b/libs/@hashintel/brunch-agent/src/client-tools.ts @@ -5,8 +5,10 @@ import { toolName } from "./conversation/naming"; export { + browserToolEnvelopeOf, browserToolOutput, CLIENT_TOOL_RESULT_CONTEXT_MAX_LENGTH, + type BrowserToolEnvelope, type ClientToolResult, } from "./client-tools/browser-tool-result"; export { diff --git a/libs/@hashintel/brunch-agent/src/client-tools/browser-tool-result.test.ts b/libs/@hashintel/brunch-agent/src/client-tools/browser-tool-result.test.ts new file mode 100644 index 00000000000..05b00e9edaa --- /dev/null +++ b/libs/@hashintel/brunch-agent/src/client-tools/browser-tool-result.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; + +import { + browserToolEnvelopeOf, + browserToolOutput, +} from "./browser-tool-result"; + +describe("browserToolEnvelopeOf", () => { + test("recognises a settled envelope, with or without its sidecar", () => { + expect( + browserToolEnvelopeOf({ + brunchBrowserResult: true, + output: { applied: true }, + metadata: { documentRevision: {} }, + }), + ).toEqual({ + brunchBrowserResult: true, + output: { applied: true }, + metadata: { documentRevision: {} }, + }); + const bare = browserToolEnvelopeOf({ + brunchBrowserResult: true, + output: undefined, + }); + expect(bare).toEqual({ brunchBrowserResult: true, output: undefined }); + expect(bare && "metadata" in bare).toBe(false); + }); + + test("rejects outputs without the marker or without an output", () => { + expect(browserToolEnvelopeOf({ output: 1 })).toBeUndefined(); + expect( + browserToolEnvelopeOf({ brunchBrowserResult: "true", output: 1 }), + ).toBeUndefined(); + expect( + browserToolEnvelopeOf({ brunchBrowserResult: true }), + ).toBeUndefined(); + expect(browserToolEnvelopeOf(null)).toBeUndefined(); + expect(browserToolEnvelopeOf("text")).toBeUndefined(); + }); +}); + +describe("browserToolOutput", () => { + test("unwraps an envelope and passes anything else through", () => { + expect( + browserToolOutput({ brunchBrowserResult: true, output: { ok: 1 } }), + ).toEqual({ ok: 1 }); + expect(browserToolOutput({ ok: 1 })).toEqual({ ok: 1 }); + }); +}); diff --git a/libs/@hashintel/brunch-agent/src/client-tools/browser-tool-result.ts b/libs/@hashintel/brunch-agent/src/client-tools/browser-tool-result.ts index d6fede0cfa9..e3247a44d65 100644 --- a/libs/@hashintel/brunch-agent/src/client-tools/browser-tool-result.ts +++ b/libs/@hashintel/brunch-agent/src/client-tools/browser-tool-result.ts @@ -10,15 +10,33 @@ export interface ClientToolResult { readonly metadata?: unknown; } +/** How the server records a settled browser result as the tool's output in history. */ +export type BrowserToolEnvelope = Pick< + ClientToolResult, + "output" | "metadata" +> & { readonly brunchBrowserResult: true }; + +/** The settled browser-tool envelope a recorded tool output holds, if it is one. */ +export const browserToolEnvelopeOf = ( + value: unknown, +): BrowserToolEnvelope | undefined => + typeof value === "object" && + value !== null && + "brunchBrowserResult" in value && + value.brunchBrowserResult === true && + "output" in value + ? { + brunchBrowserResult: true, + output: value.output, + ...("metadata" in value ? { metadata: value.metadata } : {}), + } + : undefined; + /** * The browser's own output inside a settled browser-tool envelope; any other * tool output is returned unchanged. */ -export const browserToolOutput = (output: unknown): unknown => - typeof output === "object" && - output !== null && - "brunchBrowserResult" in output && - output.brunchBrowserResult === true && - "output" in output - ? output.output - : output; +export const browserToolOutput = (output: unknown): unknown => { + const envelope = browserToolEnvelopeOf(output); + return envelope ? envelope.output : output; +}; diff --git a/libs/@hashintel/brunch-agent/src/client-tools/client-tool-history.ts b/libs/@hashintel/brunch-agent/src/client-tools/client-tool-history.ts index 330d01c12ef..60fe2acaa76 100644 --- a/libs/@hashintel/brunch-agent/src/client-tools/client-tool-history.ts +++ b/libs/@hashintel/brunch-agent/src/client-tools/client-tool-history.ts @@ -1,4 +1,8 @@ -import type { ClientToolResult } from "./browser-tool-result"; +import { + browserToolEnvelopeOf, + type ClientToolResult, +} from "./browser-tool-result"; + import type { FlueConversationMessage, FlueConversationPart } from "@flue/sdk"; type DynamicToolPart = Extract; @@ -47,22 +51,16 @@ const resultsFrom = ( ): readonly ClientToolHistoryResult[] => messages.flatMap((message) => { return message.parts.flatMap((part): ClientToolHistoryResult[] => { - if ( - part.type !== "dynamic-tool" || - part.state !== "output-available" || - !isRecord(part.output) || - part.output.brunchBrowserResult !== true || - !("output" in part.output) - ) + if (part.type !== "dynamic-tool" || part.state !== "output-available") return []; + const envelope = browserToolEnvelopeOf(part.output); + if (!envelope) return []; return [ { toolCallId: part.toolCallId, toolName: part.toolName, - output: part.output.output, - ...("metadata" in part.output - ? { metadata: part.output.metadata } - : {}), + output: envelope.output, + ...("metadata" in envelope ? { metadata: envelope.metadata } : {}), }, ]; }); From 3b138188b076ab64b9155da10a59124d3e942401 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 16 Oct 2026 17:56:20 +0200 Subject: [PATCH 09/21] Own the browser-call protocol and the browser-executed tools in the Brunch library --- .../petrinaut-agent-mounting.test.ts | 12 +++ apps/brunch-agent/src/app.ts | 6 +- .../src/server/http/browser-calls.ts | 37 +++----- .../src/shared/context-projection.ts | 4 +- .../conversation/browser-call-rendezvous.ts | 51 +++++----- .../src/shared/conversation/net-changes.ts | 12 +-- .../src/shared/conversation/net-view.ts | 5 +- .../brunch-agent/src/shared/tool-catalogue.ts | 25 ++++- .../conversation/use-process-agent-binding.ts | 12 +-- .../brunch/tools/brunch-client-tools.ts | 9 +- .../brunch/tools/in-band-browser-call.ts | 93 +++++++------------ .../plugins/brunch/use-brunch-assistant.tsx | 7 +- libs/@hashintel/brunch-agent/README.md | 2 +- .../brunch-agent/src/client-tools.ts | 11 +++ .../src/client-tools/browser-calls.ts | 60 ++++++++++++ libs/@hashintel/brunch-agent/src/constants.ts | 2 + libs/@hashintel/brunch-agent/src/index.ts | 5 + .../petrinaut/browser-executed-tools.test.ts | 38 ++++++++ .../src/petrinaut/browser-executed-tools.ts | 39 ++++++++ 19 files changed, 280 insertions(+), 150 deletions(-) create mode 100644 libs/@hashintel/brunch-agent/src/client-tools/browser-calls.ts create mode 100644 libs/@hashintel/brunch-agent/src/petrinaut/browser-executed-tools.test.ts create mode 100644 libs/@hashintel/brunch-agent/src/petrinaut/browser-executed-tools.ts diff --git a/apps/brunch-agent/src/agents/petrinaut/petrinaut-agent-mounting.test.ts b/apps/brunch-agent/src/agents/petrinaut/petrinaut-agent-mounting.test.ts index 811e1c56e7c..f4ce7e2d6e7 100644 --- a/apps/brunch-agent/src/agents/petrinaut/petrinaut-agent-mounting.test.ts +++ b/apps/brunch-agent/src/agents/petrinaut/petrinaut-agent-mounting.test.ts @@ -11,6 +11,7 @@ import { import { petrinautAiTools } from "@hashintel/petrinaut-core/ai"; import { + assertBrowserExecutedToolConformance, assertPetrinautToolCatalogueConformance, canonicalPetrinautToolCatalogue, brunchToolCatalogue, @@ -248,3 +249,14 @@ test("the Brunch catalogue classifies every canonical tool", () => { ]), ).toThrow(/unclassified/u); }); + +test("the catalogue's website-executed tools are the library's browser-executed tools", () => { + expect(() => assertBrowserExecutedToolConformance()).not.toThrow(); + expect(() => + assertBrowserExecutedToolConformance( + brunchToolCatalogue.filter( + ({ name }) => name !== brunchTools.draftPetrinautExperiment, + ), + ), + ).toThrow(/missing: draft_petrinaut_experiment/u); +}); diff --git a/apps/brunch-agent/src/app.ts b/apps/brunch-agent/src/app.ts index 4016273ab01..826376cf155 100644 --- a/apps/brunch-agent/src/app.ts +++ b/apps/brunch-agent/src/app.ts @@ -9,6 +9,7 @@ import { createAgentRouter } from "@flue/runtime/routing"; import { Hono } from "hono"; import { + browserExecutedToolNames, brunchEnv, brunchRoutes, brunchTools, @@ -43,7 +44,6 @@ import { withBufferedToolAdmission, } from "./server/provider-admission.ts"; import { diagnostics } from "./server/runtime-diagnostics.ts"; -import { inBandBrowserToolNames } from "./shared/tool-catalogue.ts"; import type { Provider } from "@earendil-works/pi-ai"; @@ -154,9 +154,9 @@ if (accounting) { } // The Pi AI patch preserves complete Anthropic schemas independently of strict // generation. Retained contracts and removal proofs: docs/dependency-patches.md. -const browserToolNames = inBandBrowserToolNames; +const browserToolNames = browserExecutedToolNames; const mixedToolNames = new Set([ - ...inBandBrowserToolNames, + ...browserExecutedToolNames, brunchTools.ping, brunchTools.ledgerCommit, brunchTools.ledgerCompile, diff --git a/apps/brunch-agent/src/server/http/browser-calls.ts b/apps/brunch-agent/src/server/http/browser-calls.ts index c0120852cf0..f0332268941 100644 --- a/apps/brunch-agent/src/server/http/browser-calls.ts +++ b/apps/brunch-agent/src/server/http/browser-calls.ts @@ -1,6 +1,13 @@ import { Hono, type Context } from "hono"; import * as v from "valibot"; +import { + browserCallFailureBodySchema, + browserCallLeaseBodySchema, + browserCallResultBodySchema, + brunchRoutes, +} from "@hashintel/brunch-agent"; + import { claimBrowserCall, failBrowserCall, @@ -8,21 +15,7 @@ import { settleBrowserCall, } from "../../shared/conversation/browser-call-rendezvous.ts"; -const leaseBody = v.object({ capability: v.string(), binding: v.string() }); -const outcomeEntries = { - ...leaseBody.entries, - toolName: v.string(), - canonicalInput: v.unknown(), -}; -const resultBody = v.object({ - ...outcomeEntries, - output: v.unknown(), - metadata: v.optional(v.unknown()), -}); -const failureBody = v.object({ - ...outcomeEntries, - disposition: v.picklist(["unstarted", "stale", "failed"]), -}); +const callRoute = `/:id/${brunchRoutes.browserCalls}/:callId` as const; const readBody = async ( context: Context, @@ -37,7 +30,7 @@ const readBody = async ( export const createBrowserCallRouter = (): Hono => { const router = new Hono(); - router.get("/:id/browser-calls/:callId", (context) => { + router.get(callRoute, (context) => { context.header("Cache-Control", "no-store"); const binding = context.req.query("binding"); if (binding === undefined) @@ -49,8 +42,8 @@ export const createBrowserCallRouter = (): Hono => { : context.json({ error: "not-issued" }, 404); }); - router.post("/:id/browser-calls/:callId/lease", async (context) => { - const body = await readBody(context, leaseBody); + router.post(`${callRoute}/lease`, async (context) => { + const body = await readBody(context, browserCallLeaseBodySchema); if (!body) return context.json({ error: "invalid-result" }, 400); const { id, callId } = context.req.param(); return renewBrowserCall({ instanceId: id, toolCallId: callId, ...body }) @@ -58,8 +51,8 @@ export const createBrowserCallRouter = (): Hono => { : context.json({ error: "not-issued" }, 409); }); - router.post("/:id/browser-calls/:callId", async (context) => { - const body = await readBody(context, resultBody); + router.post(callRoute, async (context) => { + const body = await readBody(context, browserCallResultBodySchema); if (!body) return context.json({ error: "invalid-result" }, 400); const { id, callId } = context.req.param(); return settleBrowserCall({ @@ -71,8 +64,8 @@ export const createBrowserCallRouter = (): Hono => { : context.json({ error: "not-issued" }, 409); }); - router.post("/:id/browser-calls/:callId/fail", async (context) => { - const body = await readBody(context, failureBody); + router.post(`${callRoute}/fail`, async (context) => { + const body = await readBody(context, browserCallFailureBodySchema); if (!body) return context.json({ error: "invalid-result" }, 400); const { id, callId } = context.req.param(); return failBrowserCall({ instanceId: id, toolCallId: callId, ...body }) diff --git a/apps/brunch-agent/src/shared/context-projection.ts b/apps/brunch-agent/src/shared/context-projection.ts index 68421bd961f..fc8f50c7a68 100644 --- a/apps/brunch-agent/src/shared/context-projection.ts +++ b/apps/brunch-agent/src/shared/context-projection.ts @@ -1,11 +1,11 @@ import { + browserExecutedToolNames, browserToolEnvelopeOf, parsePetrinautUserMessageBody, petrinautContextualUserMessageBody, } from "@hashintel/brunch-agent"; import { netResultView } from "./conversation/net-view.ts"; -import { inBandBrowserToolNames } from "./tool-catalogue.ts"; import type { ContextProjection, @@ -20,7 +20,7 @@ const browserEnvelope = ( if ( message.role !== "toolResult" || message.isError || - !inBandBrowserToolNames.has(message.toolName) + !browserExecutedToolNames.has(message.toolName) ) return undefined; const text = message.content diff --git a/apps/brunch-agent/src/shared/conversation/browser-call-rendezvous.ts b/apps/brunch-agent/src/shared/conversation/browser-call-rendezvous.ts index 07ee279e511..9c41d5bb47e 100644 --- a/apps/brunch-agent/src/shared/conversation/browser-call-rendezvous.ts +++ b/apps/brunch-agent/src/shared/conversation/browser-call-rendezvous.ts @@ -2,7 +2,14 @@ import { randomBytes } from "node:crypto"; import { browserToolMutatesDocument } from "@hashintel/brunch-agent"; -import type { ClientToolResult } from "@hashintel/brunch-agent"; +import type { + BrowserCallClaim, + BrowserCallFailureBody, + BrowserCallFailureDisposition, + BrowserCallLeaseBody, + BrowserCallResultBody, + ClientToolResult, +} from "@hashintel/brunch-agent"; /** Process-local handoff for the documented single-owner Node deployment. No durable effects live here. */ const leaseMs = 25_000; @@ -29,7 +36,7 @@ const calls = new Map(); /** The one model-facing error for a call that ends without a result: unstarted, unchanged, or unknown. */ const outcomeError = ( entry: IssuedCall, - cause: "unstarted" | "stale" | "failed" | "expired" | "stopped", + cause: BrowserCallFailureDisposition | "expired" | "stopped", ): Error => { if (cause === "stale") return new Error(BROWSER_CALL_STALE_ERROR); if (!entry.claimed || cause === "unstarted") @@ -68,13 +75,14 @@ const retire = (key: string) => { calls.delete(key); }; -interface LeaseProof { +/** The call a browser request addresses, by its route parameters. */ +interface CallAddress { readonly instanceId: string; readonly toolCallId: string; - readonly capability: string; - readonly binding: string; } +type LeaseProof = CallAddress & Readonly; + /** The claimed, unexpired call this capability and binding hold. */ const leasedCall = (proof: LeaseProof): IssuedCall | undefined => { const entry = calls.get(keyFor(proof.instanceId, proof.toolCallId)); @@ -89,10 +97,8 @@ const leasedCall = (proof: LeaseProof): IssuedCall | undefined => { /** A leased call that can still finish, for exactly the tool input it was issued with. */ const deliverableCall = ( - proof: LeaseProof & { - readonly toolName: string; - readonly canonicalInput: unknown; - }, + proof: LeaseProof & + Readonly>, ): IssuedCall | undefined => { const entry = leasedCall(proof); return entry && @@ -161,7 +167,7 @@ export const claimBrowserCall = ( instanceId: string, toolCallId: string, binding: string, -) => { +): BrowserCallClaim | undefined => { const entry = calls.get(keyFor(instanceId, toolCallId)); if ( !entry || @@ -195,15 +201,9 @@ export const renewBrowserCall = (input: LeaseProof): boolean => { return true; }; -export const failBrowserCall = (input: { - readonly instanceId: string; - readonly toolCallId: string; - readonly capability: string; - readonly binding: string; - readonly toolName: string; - readonly canonicalInput: unknown; - readonly disposition: "unstarted" | "stale" | "failed"; -}): boolean => { +export const failBrowserCall = ( + input: CallAddress & Readonly, +): boolean => { const key = keyFor(input.instanceId, input.toolCallId); const entry = deliverableCall(input); if (!entry) return false; @@ -212,16 +212,9 @@ export const failBrowserCall = (input: { return true; }; -export const settleBrowserCall = (input: { - readonly instanceId: string; - readonly toolCallId: string; - readonly capability: string; - readonly binding: string; - readonly toolName: string; - readonly canonicalInput: unknown; - readonly output: unknown; - readonly metadata?: unknown; -}): "settled" | "not-issued" => { +export const settleBrowserCall = ( + input: CallAddress & Readonly, +): "settled" | "not-issued" => { const key = keyFor(input.instanceId, input.toolCallId); const entry = deliverableCall(input); if (!entry) return "not-issued"; diff --git a/apps/brunch-agent/src/shared/conversation/net-changes.ts b/apps/brunch-agent/src/shared/conversation/net-changes.ts index c8c1b4a162f..99ee9b9168e 100644 --- a/apps/brunch-agent/src/shared/conversation/net-changes.ts +++ b/apps/brunch-agent/src/shared/conversation/net-changes.ts @@ -1,17 +1,14 @@ import { type ArcElement, type NetElementKind, + browserNetToolNames, browserToolEnvelopeOf, generatedArcId, namesId, - netReaderLevelOf, parseClientToolResultMetadata, petrinautToolTargets, } from "@hashintel/brunch-agent"; -import { - petrinautAiTools, - getLatestNetDefinitionToolName, -} from "@hashintel/petrinaut-core/ai"; +import { getLatestNetDefinitionToolName } from "@hashintel/petrinaut-core/ai"; import { netResultView } from "./net-view.ts"; @@ -44,10 +41,7 @@ export const netCalls = (snapshot: FlueConversationSnapshot): NetCall[] => ? message.parts.flatMap((part, partIndex) => { if ( part.type !== "dynamic-tool" || - !( - part.toolName in petrinautAiTools || - netReaderLevelOf(part.toolName) !== undefined - ) || + !browserNetToolNames.has(part.toolName) || part.state !== "output-available" ) return []; diff --git a/apps/brunch-agent/src/shared/conversation/net-view.ts b/apps/brunch-agent/src/shared/conversation/net-view.ts index 04dd7deb10e..bd2977e9208 100644 --- a/apps/brunch-agent/src/shared/conversation/net-view.ts +++ b/apps/brunch-agent/src/shared/conversation/net-view.ts @@ -6,16 +6,15 @@ */ import { + browserExecutedToolNames, browserToolMutatesDocument, isNetObservationTool, parseClientToolResultMetadata, } from "@hashintel/brunch-agent"; -import { inBandBrowserToolNames } from "../tool-catalogue.ts"; - /** Whether a settled result of this tool can show the model the net. */ export const mayShowNet = (toolName: string): boolean => - inBandBrowserToolNames.has(toolName) && + browserExecutedToolNames.has(toolName) && (isNetObservationTool(toolName) || browserToolMutatesDocument(toolName)); export type NetResultView = diff --git a/apps/brunch-agent/src/shared/tool-catalogue.ts b/apps/brunch-agent/src/shared/tool-catalogue.ts index eae60839cfd..4ca8bda8e1b 100644 --- a/apps/brunch-agent/src/shared/tool-catalogue.ts +++ b/apps/brunch-agent/src/shared/tool-catalogue.ts @@ -1,4 +1,5 @@ import { + browserExecutedToolNames, brunchTools, netReaderToolNames, type PetrinautToolCapability, @@ -123,9 +124,23 @@ export const brunchToolCatalogue: readonly BrunchToolCatalogueEntry[] = [ draftBrunchTool, ]; -/** Tools the bound browser executes; the server awaits each result in band. */ -export const inBandBrowserToolNames: ReadonlySet = new Set( - brunchToolCatalogue +/** The catalogue's website-executed entries are exactly the tools the library says the browser runs. */ +export const assertBrowserExecutedToolConformance = ( + catalogue: readonly BrunchToolCatalogueEntry[] = brunchToolCatalogue, +): void => { + const catalogued = catalogue .filter(({ executionOwner }) => executionOwner === "petrinaut-website") - .map(({ name }) => name), -); + .map(({ name }) => name); + const missing = [...browserExecutedToolNames].filter( + (name) => !catalogued.includes(name), + ); + const extra = catalogued.filter( + (name) => !browserExecutedToolNames.has(name), + ); + if (missing.length > 0 || extra.length > 0) + throw new Error( + `Browser-executed tool mismatch (missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"}).`, + ); +}; + +assertBrowserExecutedToolConformance(); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/use-process-agent-binding.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/use-process-agent-binding.ts index c54ba92f91a..aa3b9405639 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/use-process-agent-binding.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/use-process-agent-binding.ts @@ -2,15 +2,9 @@ import { useEffect, useMemo, useState } from "react"; import { getOrCreateBrunchConversationId } from "./brunch-conversation-id"; -export interface ProcessAgentBinding { - readonly conversationId: string; - readonly documentId: string; - /** - * The net id again. Brunch servers deployed before conversations were - * scoped by net id reject a binding without it. - */ - readonly incarnationId?: string; -} +import type { BrowserContext } from "@hashintel/brunch-agent"; + +export type ProcessAgentBinding = BrowserContext["binding"]; export interface FixtureProcessAgentConfiguration { readonly conversationId: string; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-client-tools.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-client-tools.ts index 5f7a304ed50..0194d6ec193 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-client-tools.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-client-tools.ts @@ -1,4 +1,4 @@ -import { brunchTools, netReaderToolNames } from "@hashintel/brunch-agent"; +import { browserExecutedToolNames } from "@hashintel/brunch-agent"; import { petrinautAiTools } from "@hashintel/petrinaut-core"; /** Literal stock catalogue handled by Petrinaut's existing static panel tools. */ @@ -7,8 +7,5 @@ export const canonicalPetrinautClientToolNames: ReadonlySet = new Set( ); /** Brunch adds filtered net readers and a reviewed draft without replacing stock tools. */ -export const brunchPetrinautClientToolNames: ReadonlySet = new Set([ - ...canonicalPetrinautClientToolNames, - ...Object.values(netReaderToolNames), - brunchTools.draftPetrinautExperiment, -]); +export const brunchPetrinautClientToolNames: ReadonlySet = + browserExecutedToolNames; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/in-band-browser-call.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/in-band-browser-call.ts index 63536a0891e..43bfb46de21 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/in-band-browser-call.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/in-band-browser-call.ts @@ -1,46 +1,37 @@ import { agentOwnershipHeaders, - brunchTools, + browserExecutedToolInputSchemas, + browserNetToolNames, + brunchRoutes, canonicalContent, - draftPetrinautExperimentInputSchema, - netReaderToolNames, - parseNetReaderInput, } from "@hashintel/brunch-agent"; -import { petrinautAiTools } from "@hashintel/petrinaut-core/ai"; import type { ProcessAgentBinding } from "../conversation/use-process-agent-binding"; import type { FlueClient } from "@flue/sdk"; -import type { ClientToolResultMetadata } from "@hashintel/brunch-agent"; +import type { + BrowserCallClaim, + BrowserCallFailureBody, + BrowserCallFailureDisposition, + BrowserCallLeaseBody, + BrowserCallResultBody, + ClientToolResultMetadata, +} from "@hashintel/brunch-agent"; -/** Every tool the server may issue to this browser, by the schema that parses its input. */ -const issuedInputSchemas: Readonly< - Record unknown }> -> = { - ...Object.fromEntries( - Object.entries(petrinautAiTools).map(([name, tool]) => [ - name, - tool.inputSchema, - ]), - ), - ...Object.fromEntries( - Object.values(netReaderToolNames).map((name) => [ - name, - { parse: parseNetReaderInput }, - ]), - ), - [brunchTools.draftPetrinautExperiment]: draftPetrinautExperimentInputSchema, +/** A call the model made that the server issued to this browser. */ +type IssuedCall = { + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; + readonly signal: AbortSignal; }; /** * Decides, after the claim and before `prepareInput`, whether a call may * start. A refused call settles with the given output and never starts. */ -export type InBandBrowserCallAdmission = (call: { - readonly toolCallId: string; - readonly toolName: string; - readonly input: unknown; - readonly signal: AbortSignal; -}) => Promise< +export type InBandBrowserCallAdmission = ( + call: IssuedCall, +) => Promise< | { readonly admitted: true } | { readonly admitted: false; readonly output: unknown } >; @@ -66,14 +57,9 @@ export const createInBandBrowserCalls = (input: { /** Whether a change issued against `expected` may still run on the bound document. */ readonly acceptsRevision: (expected: string) => boolean; }) => { - const claim = async (call: { - readonly toolCallId: string; - readonly toolName: string; - readonly input: unknown; - readonly signal: AbortSignal; - }) => { + const claim = async (call: IssuedCall) => { const client = await input.client; - const url = `${client.url}/browser-calls/${encodeURIComponent(call.toolCallId)}`; + const url = `${client.url}/${brunchRoutes.browserCalls}/${encodeURIComponent(call.toolCallId)}`; const boundClaimUrl = `${url}?binding=${encodeURIComponent(canonicalContent(input.binding))}`; const headers = agentOwnershipHeaders({ principalKey: input.principalKey, @@ -83,22 +69,14 @@ export const createInBandBrowserCalls = (input: { call.signal, AbortSignal.timeout(6_000), ]); - let issued: - | { - capability: string; - binding: string; - toolName: string; - input: unknown; - expectedRevision?: string; - } - | undefined; + let issued: BrowserCallClaim | undefined; while (!claimSignal.aborted) { const response = await fetch(boundClaimUrl, { headers, signal: claimSignal, }); if (response.ok) { - issued = (await response.json()) as typeof issued; + issued = (await response.json()) as BrowserCallClaim; break; } if (response.status !== 404) @@ -116,7 +94,7 @@ export const createInBandBrowserCalls = (input: { claimSignal.addEventListener("abort", onAbort, { once: true }); }); } - const inputSchema = issuedInputSchemas[call.toolName]; + const inputSchema = browserExecutedToolInputSchemas[call.toolName]; if ( !issued || issued.toolName !== call.toolName || @@ -140,7 +118,7 @@ export const createInBandBrowserCalls = (input: { body: JSON.stringify({ capability: claimed.capability, binding: claimed.binding, - }), + } satisfies BrowserCallLeaseBody), signal: call.signal, }).catch(() => {}); }, 5_000); @@ -167,7 +145,9 @@ export const createInBandBrowserCalls = (input: { }); }, release: () => clearInterval(lease), - fail: async (disposition: "unstarted" | "failed" = "failed") => { + fail: async ( + disposition: Exclude = "failed", + ) => { clearInterval(lease); if (call.signal.aborted) return; const response = await fetch(`${url}/fail`, { @@ -179,7 +159,7 @@ export const createInBandBrowserCalls = (input: { toolName: call.toolName, canonicalInput: claimed.input, disposition: stale ? "stale" : disposition, - }), + } satisfies BrowserCallFailureBody), signal: call.signal, }); if (!response.ok) @@ -201,7 +181,7 @@ export const createInBandBrowserCalls = (input: { canonicalInput: claimed.input, output, ...(metadata === undefined ? {} : { metadata }), - }), + } satisfies BrowserCallResultBody), signal: call.signal, }); if (!response.ok) @@ -212,21 +192,14 @@ export const createInBandBrowserCalls = (input: { }; }; return { - has: (toolName: string) => - toolName in petrinautAiTools || - Object.values(netReaderToolNames).includes(toolName), + has: (toolName: string) => browserNetToolNames.has(toolName), claim, /** * Claim at this call's turn, run it under a renewed lease, then settle it. * A call is unstarted until `execute` is entered; after Stop nothing is reported. */ run: async ( - call: { - readonly toolCallId: string; - readonly toolName: string; - readonly input: unknown; - readonly signal: AbortSignal; - }, + call: IssuedCall, execute: (issuedInput: unknown) => Promise, ) => { const issued = await claim(call); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx index 31fab132ea5..6c259c67de1 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx @@ -91,6 +91,7 @@ import type { VoiceWord } from "../../../../shared/voice-words"; import type { PetrinautAiComposerControlContext } from "./chat/ai-assistant-composer-control"; import type { PetrinautAiMessage } from "./chat/ai-assistant-panel"; import type { PetrinautAiAssistant } from "./chat/petrinaut"; +import type { SdcpnInitialData } from "@hashintel/brunch-agent"; import type { PetrinautDocHandle } from "@hashintel/petrinaut-core"; import type { PetrinautPlugin } from "@hashintel/petrinaut/ui/plugin"; @@ -483,7 +484,11 @@ export const useBrunchAssistant = ({ interviewRepliesAsked, readWords: () => readWords().map((word) => word.spelling), ...(constructionBrowser - ? { initialData: { binding: constructionBrowser.binding } } + ? { + initialData: { + binding: constructionBrowser.binding, + } satisfies SdcpnInitialData, + } : {}), ...(conversationId === null ? {} diff --git a/libs/@hashintel/brunch-agent/README.md b/libs/@hashintel/brunch-agent/README.md index 05e2b680a57..9da72c22f2a 100644 --- a/libs/@hashintel/brunch-agent/README.md +++ b/libs/@hashintel/brunch-agent/README.md @@ -1,6 +1,6 @@ # `@hashintel/brunch-agent` -What the Brunch agent app (`apps/brunch-agent`) and the Petrinaut website (`apps/petrinaut-website`) must agree on: Brunch's named constants, conversation identity, the browser-tool envelope and message metadata, the Petrinaut tool contracts, net element identity, contextual user messages with their submission context, the interview allowance and custom-word spellings, and the Ledger with its projection. It is browser-safe; it never imports the Flue runtime. Besides its main entry, two leaf entries, `@hashintel/brunch-agent/interview-budget` and `@hashintel/brunch-agent/words`, export the interview allowance and the custom-word spelling bounds alone, for the website code its Voice server functions share, whose `nodenext` type check cannot read the main entry's source. +What the Brunch agent app (`apps/brunch-agent`) and the Petrinaut website (`apps/petrinaut-website`) must agree on: Brunch's named constants, conversation identity, the browser-tool envelope, the browser-call protocol and the tools the browser executes, message metadata, the Petrinaut tool contracts, net element identity, contextual user messages with their submission context, the interview allowance and custom-word spellings, and the Ledger with its projection. It is browser-safe; it never imports the Flue runtime. Besides its main entry, two leaf entries, `@hashintel/brunch-agent/interview-budget` and `@hashintel/brunch-agent/words`, export the interview allowance and the custom-word spelling bounds alone, for the website code its Voice server functions share, whose `nodenext` type check cannot read the main entry's source. Code belongs here when the server and the browser must agree on it: a contract between them, a derivation from conversation history that both compute, or how an element's ID is recognised. It belongs here even when only one side calls it today. Code only one side runs, with nothing to agree on, belongs in that app. Types derive from their owner here rather than being restated in either app. diff --git a/libs/@hashintel/brunch-agent/src/client-tools.ts b/libs/@hashintel/brunch-agent/src/client-tools.ts index 01f7b4767e5..63208e9a29c 100644 --- a/libs/@hashintel/brunch-agent/src/client-tools.ts +++ b/libs/@hashintel/brunch-agent/src/client-tools.ts @@ -4,6 +4,17 @@ import { toolName } from "./conversation/naming"; +export { + browserCallFailureBodySchema, + browserCallFailureDispositions, + browserCallLeaseBodySchema, + browserCallResultBodySchema, + type BrowserCallClaim, + type BrowserCallFailureBody, + type BrowserCallFailureDisposition, + type BrowserCallLeaseBody, + type BrowserCallResultBody, +} from "./client-tools/browser-calls"; export { browserToolEnvelopeOf, browserToolOutput, diff --git a/libs/@hashintel/brunch-agent/src/client-tools/browser-calls.ts b/libs/@hashintel/brunch-agent/src/client-tools/browser-calls.ts new file mode 100644 index 00000000000..9dec4169bb1 --- /dev/null +++ b/libs/@hashintel/brunch-agent/src/client-tools/browser-calls.ts @@ -0,0 +1,60 @@ +/** + * The HTTP protocol through which the bound browser claims, leases and + * settles a call the server issued to it, under + * `//`. + */ + +import * as v from "valibot"; + +/** Why a claimed call ended without a result: never started, refused as stale, or failed. */ +const browserCallFailureDispositions = [ + "unstarted", + "stale", + "failed", +] as const; + +export type BrowserCallFailureDisposition = + (typeof browserCallFailureDispositions)[number]; + +/** What the server returns to the browser that claims an issued call. */ +export interface BrowserCallClaim { + /** One-use proof of the claim, sent back with every later request. */ + readonly capability: string; + readonly binding: string; + readonly toolName: string; + readonly input: unknown; + /** The document revision the model last saw; a change from any other is refused. */ + readonly expectedRevision?: string; +} + +export const browserCallLeaseBodySchema = v.object({ + capability: v.string(), + binding: v.string(), +}); + +const outcomeEntries = { + ...browserCallLeaseBodySchema.entries, + toolName: v.string(), + canonicalInput: v.unknown(), +}; + +export const browserCallResultBodySchema = v.object({ + ...outcomeEntries, + output: v.unknown(), + metadata: v.optional(v.unknown()), +}); + +export const browserCallFailureBodySchema = v.object({ + ...outcomeEntries, + disposition: v.picklist(browserCallFailureDispositions), +}); + +export type BrowserCallLeaseBody = v.InferOutput< + typeof browserCallLeaseBodySchema +>; +export type BrowserCallResultBody = v.InferOutput< + typeof browserCallResultBodySchema +>; +export type BrowserCallFailureBody = v.InferOutput< + typeof browserCallFailureBodySchema +>; diff --git a/libs/@hashintel/brunch-agent/src/constants.ts b/libs/@hashintel/brunch-agent/src/constants.ts index e711a6fb52f..5385547a517 100644 --- a/libs/@hashintel/brunch-agent/src/constants.ts +++ b/libs/@hashintel/brunch-agent/src/constants.ts @@ -46,6 +46,8 @@ export const brunchRoutes = { agents: "/agents", /** The Petrinaut agent's route; conversation identity remains its pinned `agentName`. */ petrinautAgent: "petrinaut", + /** Under an agent instance: where the bound browser claims and settles the calls issued to it. */ + browserCalls: "browser-calls", /** Cheap process-liveness probe; dependency readiness is established before listen. */ health: "/health", } as const; diff --git a/libs/@hashintel/brunch-agent/src/index.ts b/libs/@hashintel/brunch-agent/src/index.ts index 75663a1a873..76f6ec2bb0f 100644 --- a/libs/@hashintel/brunch-agent/src/index.ts +++ b/libs/@hashintel/brunch-agent/src/index.ts @@ -14,6 +14,11 @@ export { parseClientToolResultMetadata, type ClientToolResultMetadata, } from "./petrinaut/browser-metadata"; +export { + browserExecutedToolInputSchemas, + browserExecutedToolNames, + browserNetToolNames, +} from "./petrinaut/browser-executed-tools"; export { CANONICAL_PETRINAUT_TOOL_NAMES } from "./petrinaut/construction-tool-names"; export { PETRINAUT_CONTEXTUAL_USER_MESSAGE_PREFIX, diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/browser-executed-tools.test.ts b/libs/@hashintel/brunch-agent/src/petrinaut/browser-executed-tools.test.ts new file mode 100644 index 00000000000..07984560c75 --- /dev/null +++ b/libs/@hashintel/brunch-agent/src/petrinaut/browser-executed-tools.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "vitest"; + +import { petrinautAiTools } from "@hashintel/petrinaut-core/ai"; + +import { brunchTools } from "../constants"; +import { + browserExecutedToolInputSchemas, + browserExecutedToolNames, + browserNetToolNames, +} from "./browser-executed-tools"; +import { netReaderToolNames } from "./net-readers"; + +describe("browser-executed tools", () => { + test("are the canonical tools, the net readers and the draft", () => { + expect([...browserExecutedToolNames].sort()).toEqual( + [ + ...Object.keys(petrinautAiTools), + ...Object.values(netReaderToolNames), + brunchTools.draftPetrinautExperiment, + ].sort(), + ); + }); + + test("the net tools leave out only the draft", () => { + expect([...browserNetToolNames]).toEqual( + [...browserExecutedToolNames].filter( + (name) => name !== brunchTools.draftPetrinautExperiment, + ), + ); + expect(browserNetToolNames.has("toString")).toBe(false); + }); + + test("parse a net reader's input strictly", () => { + const schema = browserExecutedToolInputSchemas[netReaderToolNames.outline]; + expect(schema?.parse({})).toEqual({}); + expect(() => schema?.parse({ extra: true })).toThrow(/Invalid key/u); + }); +}); diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/browser-executed-tools.ts b/libs/@hashintel/brunch-agent/src/petrinaut/browser-executed-tools.ts new file mode 100644 index 00000000000..051f8f3011e --- /dev/null +++ b/libs/@hashintel/brunch-agent/src/petrinaut/browser-executed-tools.ts @@ -0,0 +1,39 @@ +import { petrinautAiTools } from "@hashintel/petrinaut-core/ai"; + +import { brunchTools } from "../constants"; +import { draftPetrinautExperimentInputSchema } from "./draft-experiment"; +import { netReaderToolNames, parseNetReaderInput } from "./net-readers"; + +/** + * Every tool the server issues to the bound browser and awaits in band, by + * the schema that parses its input: Petrinaut's canonical tools, the filtered + * net readers and the reviewed experiment draft. + */ +export const browserExecutedToolInputSchemas: Readonly< + Record unknown }> +> = { + ...Object.fromEntries( + Object.entries(petrinautAiTools).map(([name, tool]) => [ + name, + tool.inputSchema, + ]), + ), + ...Object.fromEntries( + Object.values(netReaderToolNames).map((name) => [ + name, + { parse: parseNetReaderInput }, + ]), + ), + [brunchTools.draftPetrinautExperiment]: draftPetrinautExperimentInputSchema, +}; + +export const browserExecutedToolNames: ReadonlySet = new Set( + Object.keys(browserExecutedToolInputSchemas), +); + +/** The browser-executed tools Petrinaut runs on the net: all but the reviewed draft. */ +export const browserNetToolNames: ReadonlySet = new Set( + [...browserExecutedToolNames].filter( + (name) => name !== brunchTools.draftPetrinautExperiment, + ), +); From df806dc4ce61a2cd13d9c5d0997d1672b5d7a6db Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 16 Oct 2026 17:59:28 +0200 Subject: [PATCH 10/21] Type the net read-back as the projected net structure --- .../src/agents/petrinaut/tools/query-basis.ts | 6 +-- .../src/shared/conversation/net-changes.ts | 8 ++-- .../src/shared/conversation/net-view.ts | 4 +- .../brunch/tools/brunch-petrinaut-tools.ts | 8 ++-- libs/@hashintel/brunch-agent/src/index.ts | 2 + .../src/petrinaut/browser-metadata.ts | 8 +++- .../src/petrinaut/net-readers.test.ts | 14 ++++++- .../brunch-agent/src/petrinaut/net-readers.ts | 38 ++++++++++++++++++- 8 files changed, 71 insertions(+), 17 deletions(-) diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts index a6e44d9253b..a943ff1730d 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts @@ -24,8 +24,8 @@ import type { BrowserContext, LedgerTrace, NetElementAddress, + NetStructure, } from "@hashintel/brunch-agent"; -import type { SDCPN } from "@hashintel/petrinaut-core"; export const queryBasisToolDescription = "Use when the USER asks why a visible net element exists or is modelled as it is, instead of answering from memory. Finds the element in the latest net read, or in the net read back after a later change, and returns its recorded basis: the reflections that name the element and, behind them, the claims, entities and discharged obligations they rest on, each with its ID, and the conversation turns those records came from, each with its ID and the opening of the USER's words. Answer from that basis and cite its record IDs and turns. A claim under `superseded` was later replaced: say so, and give its replacement. It also lists the canonical calls that changed the element, with their document and Ledger revisions. A basis-absent disposition means no reflection names the element: say plainly that its basis was not recorded rather than reconstructing one. A stale-read disposition means the net changed after the latest net view, a read or a change's read-back, without a later read-back: read it again, then query."; @@ -64,13 +64,13 @@ const elementSchema = v.object({ type Selector = v.InferOutput["selector"]; /** The root net and each subnet: places, transitions and their other items can sit in either. */ -const netsOf = (definition: SDCPN) => [ +const netsOf = (definition: NetStructure) => [ definition, ...(definition.subnets ?? []), ]; const elements = ( - definition: SDCPN, + definition: NetStructure, kind: Selector["kind"], ): { id: string; name: string; arc?: ArcElement }[] => { if (kind === "scenario") return definition.scenarios ?? []; diff --git a/apps/brunch-agent/src/shared/conversation/net-changes.ts b/apps/brunch-agent/src/shared/conversation/net-changes.ts index 99ee9b9168e..f3a07b28d1a 100644 --- a/apps/brunch-agent/src/shared/conversation/net-changes.ts +++ b/apps/brunch-agent/src/shared/conversation/net-changes.ts @@ -7,6 +7,7 @@ import { namesId, parseClientToolResultMetadata, petrinautToolTargets, + type NetStructure, } from "@hashintel/brunch-agent"; import { getLatestNetDefinitionToolName } from "@hashintel/petrinaut-core/ai"; @@ -171,14 +172,15 @@ export const latestCanonicalNetReadBefore = ( */ export const latestNetDefinition = ( snapshot: FlueConversationSnapshot, -): { readonly definition: SDCPN; readonly call: NetCall } | undefined => { +): + | { readonly definition: NetStructure; readonly call: NetCall } + | undefined => { const call = netCalls(snapshot).findLast( (entry) => netResultView(entry.toolName, entry.metadata) !== undefined, ); if (!call) return undefined; const view = netResultView(call.toolName, call.metadata); - if (view?.kind === "change") - return { definition: view.readBack as SDCPN, call }; + if (view?.kind === "change") return { definition: view.readBack, call }; return typeof call.output === "object" && call.output !== null && "definition" in call.output diff --git a/apps/brunch-agent/src/shared/conversation/net-view.ts b/apps/brunch-agent/src/shared/conversation/net-view.ts index bd2977e9208..219783d95d3 100644 --- a/apps/brunch-agent/src/shared/conversation/net-view.ts +++ b/apps/brunch-agent/src/shared/conversation/net-view.ts @@ -12,6 +12,8 @@ import { parseClientToolResultMetadata, } from "@hashintel/brunch-agent"; +import type { NetStructure } from "@hashintel/brunch-agent"; + /** Whether a settled result of this tool can show the model the net. */ export const mayShowNet = (toolName: string): boolean => browserExecutedToolNames.has(toolName) && @@ -28,7 +30,7 @@ export type NetResultView = /** The revision the change produced. */ readonly revision: string; /** The net's structure after the change. */ - readonly readBack: object; + readonly readBack: NetStructure; }; /** diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-petrinaut-tools.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-petrinaut-tools.ts index 00f80b5bc4a..70238fc6d97 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-petrinaut-tools.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-petrinaut-tools.ts @@ -5,8 +5,9 @@ import { type ClientToolResultMetadata, type NetReaderLevel, netReaderToolNames, - parseNetReaderInput, + netStructureOf, projectNetDefinition, + parseNetReaderInput, } from "@hashintel/brunch-agent"; import { getLatestNetDefinitionToolName, @@ -319,10 +320,7 @@ export const createCanonicalPetrinautHostTools = ( }, ...(definition ? { - readBack: projectNetDefinition( - structuredClone(definition), - "structure", - ) as object, + readBack: netStructureOf(structuredClone(definition)), } : {}), }; diff --git a/libs/@hashintel/brunch-agent/src/index.ts b/libs/@hashintel/brunch-agent/src/index.ts index 76f6ec2bb0f..052ec7a717d 100644 --- a/libs/@hashintel/brunch-agent/src/index.ts +++ b/libs/@hashintel/brunch-agent/src/index.ts @@ -40,8 +40,10 @@ export { netReaderLevelOf, netReaderToolNames, parseNetReaderInput, + netStructureOf, projectNetDefinition, type NetReaderLevel, + type NetStructure, } from "./petrinaut/net-readers"; export { generatedArcId, diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/browser-metadata.ts b/libs/@hashintel/brunch-agent/src/petrinaut/browser-metadata.ts index be2fe7754ba..bdf44d17f18 100644 --- a/libs/@hashintel/brunch-agent/src/petrinaut/browser-metadata.ts +++ b/libs/@hashintel/brunch-agent/src/petrinaut/browser-metadata.ts @@ -1,3 +1,5 @@ +import type { NetStructure } from "./net-readers"; + /** Key-order-independent JSON; the wire form wherever both sides serialize one value. */ export const canonicalContent: { (value: object): string; @@ -23,7 +25,7 @@ export interface ClientToolResultMetadata { readonly after?: string; }; /** The net's structure after a settled document change, for the model's read-back. */ - readonly readBack?: object; + readonly readBack?: NetStructure; } export const parseClientToolResultMetadata = ( @@ -44,6 +46,8 @@ export const parseClientToolResultMetadata = ( ...(typeof fields.before === "string" ? { before: fields.before } : {}), ...(typeof fields.after === "string" ? { after: fields.after } : {}), }, - ...(typeof readBack === "object" && readBack !== null ? { readBack } : {}), + ...(typeof readBack === "object" && readBack !== null + ? { readBack: readBack as NetStructure } + : {}), }; }; diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/net-readers.test.ts b/libs/@hashintel/brunch-agent/src/petrinaut/net-readers.test.ts index cd2ce524c69..d01ca7b5958 100644 --- a/libs/@hashintel/brunch-agent/src/petrinaut/net-readers.test.ts +++ b/libs/@hashintel/brunch-agent/src/petrinaut/net-readers.test.ts @@ -1,12 +1,16 @@ -import { expect, test } from "vitest"; +import { expect, expectTypeOf, test } from "vitest"; import { isNetObservationTool, netReaderToolNames, + netStructureOf, projectNetDefinition, + type NetStructure, } from "./net-readers"; import { browserToolMutatesDocument } from "./petrinaut-tool-effects"; +import type { SDCPN } from "@hashintel/petrinaut-core"; + const definition = { metadata: { host: "opaque" }, places: [ @@ -60,6 +64,14 @@ test("the structure drops presentational fields and keeps every core property an }); }); +test("a net's structure is its structure-level projection", () => { + const net = structuredClone(definition) as unknown as SDCPN; + expect(netStructureOf(net)).toEqual(projectNetDefinition(net, "structure")); + type StructurePlace = NetStructure["places"][number]; + expectTypeOf().toHaveProperty("colorId"); + expectTypeOf().not.toHaveProperty("x"); +}); + test("the outline also drops code bodies", () => { const outline = projectNetDefinition(definition, "outline"); expect(JSON.stringify(outline)).not.toMatch(/lambdaCode|KernelCode|"code"/u); diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/net-readers.ts b/libs/@hashintel/brunch-agent/src/petrinaut/net-readers.ts index cbeaa9d69b0..3781b7eb4d6 100644 --- a/libs/@hashintel/brunch-agent/src/petrinaut/net-readers.ts +++ b/libs/@hashintel/brunch-agent/src/petrinaut/net-readers.ts @@ -104,7 +104,7 @@ const omitAll = (keys: readonly KeyOf[]): FieldRules => Object.fromEntries(keys.map((key) => [key, "omit"])) as FieldRules; /** Left out of the net and of each item in its collections, never deeper. */ -const presentationalRules = omitAll([ +const presentationalFields = [ "x", "y", "visualizerCode", @@ -112,7 +112,13 @@ const presentationalRules = omitAll([ "iconSlug", "displayColor", "metadata", -]); +] as const satisfies readonly KeyOf[]; + +type PresentationalField = (typeof presentationalFields)[number]; + +const presentationalRules = omitAll( + presentationalFields, +); const adHocBoundsOutline = (settings: unknown) => projectObject( @@ -195,6 +201,30 @@ const outlineItemRules: { componentInstances: omitAll(["parameterValues"]), }; +type WithoutPresentation = Item extends unknown + ? Omit + : never; + +type ItemsWithoutPresentation = Items extends readonly (infer Item)[] + ? WithoutPresentation[] + : Items; + +type CollectionsOf = Pick; + +type StructureOf = Omit< + Net, + PresentationalField | ItemCollection | "subnets" +> & { + [Collection in keyof CollectionsOf]: ItemsWithoutPresentation< + CollectionsOf[Collection] + >; +}; + +/** A net at the `structure` level: every core field, without presentational ones. */ +export type NetStructure = StructureOf & { + subnets?: StructureOf[]; +}; + /** * Drop presentational fields from the net, its subnets and the items of their * collections; the outline also drops code and expressions. Fields are matched @@ -219,3 +249,7 @@ export const projectNetDefinition = ( ), subnets: eachItem((subnet) => projectNetDefinition(subnet, level)), }); + +/** The net's structure, as `readNetStructure` and a change's read-back show it. */ +export const netStructureOf = (definition: SDCPN): NetStructure => + projectNetDefinition(definition, "structure") as NetStructure; From e68843e755aa85276f42daae877068eee530e103 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 16 Oct 2026 18:03:54 +0200 Subject: [PATCH 11/21] Name the task tool and the net-definition read in the Brunch tool references --- apps/brunch-agent/src/shared/tool-catalogue.ts | 2 +- .../tools/brunch-draft-experiment-interactive-tool.tsx | 5 +++-- .../app/plugins/brunch/tools/brunch-tool-presentation.ts | 4 ++-- .../brunch/voice/live/live-brunch-bridge/progress-policy.ts | 2 +- libs/@hashintel/brunch-agent/src/constants.ts | 4 +++- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/brunch-agent/src/shared/tool-catalogue.ts b/apps/brunch-agent/src/shared/tool-catalogue.ts index 4ca8bda8e1b..55cad757843 100644 --- a/apps/brunch-agent/src/shared/tool-catalogue.ts +++ b/apps/brunch-agent/src/shared/tool-catalogue.ts @@ -78,7 +78,7 @@ const draftBrunchTool: BrunchToolCatalogueEntry = { */ export const brunchToolCatalogue: readonly BrunchToolCatalogueEntry[] = [ { - name: "task", + name: brunchTools.task, definitionOwner: "flue", executionOwner: "flue", capability: "substrate", diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.tsx index 36e5fec38df..c9fde532be2 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.tsx @@ -12,6 +12,7 @@ import { } from "@hashintel/brunch-agent"; import { Button } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; +import { getLatestNetDefinitionToolName } from "@hashintel/petrinaut-core"; import { openPetrinautSimulationResource, prepareExperiment, @@ -184,12 +185,12 @@ export const resolveDraftAuthorityFromHistory = async ( const relevant = calls.filter( (call) => canonicalPetrinautClientToolNames.has(call.toolName) && - (call.toolName === "getLatestNetDefinition" || + (call.toolName === getLatestNetDefinitionToolName || browserToolMutatesDocument(call.toolName)), ); const latest = relevant.at(-1); if ( - latest?.toolName !== "getLatestNetDefinition" || + latest?.toolName !== getLatestNetDefinitionToolName || latest.state !== "output-available" ) throw new Error( diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-tool-presentation.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-tool-presentation.ts index cfecb102323..5366930789a 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-tool-presentation.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-tool-presentation.ts @@ -13,7 +13,7 @@ import type { /** Visible Brunch tools whose cards this host titles. */ export const visibleOrdinaryBrunchToolNames = [ - "task", + brunchTools.task, brunchTools.activateSkill, brunchTools.readSkillResource, brunchTools.ledgerCommit, @@ -152,7 +152,7 @@ export const resolveBrunchToolPresentation: PetrinautAiToolPresentationResolver }; } - if (toolName === "task") { + if (toolName === brunchTools.task) { detail = stringProperty(context.input, "description") ?? stringProperty(context.input, "task"); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/progress-policy.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/progress-policy.ts index 5311ee8bd95..11076633329 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/progress-policy.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/progress-policy.ts @@ -21,7 +21,7 @@ const petrinautCapabilities = { const capabilityOf = (name: string): Capability => { if (name === brunchTools.draftPetrinautExperiment) return "experiment"; if ( - name === "task" || + name === brunchTools.task || name === brunchTools.activateSkill || name === brunchTools.readSkillResource ) diff --git a/libs/@hashintel/brunch-agent/src/constants.ts b/libs/@hashintel/brunch-agent/src/constants.ts index 5385547a517..7083bf40f85 100644 --- a/libs/@hashintel/brunch-agent/src/constants.ts +++ b/libs/@hashintel/brunch-agent/src/constants.ts @@ -22,7 +22,8 @@ export const brunchProductName = "brunch"; /** * Model-facing tool names. `activateSkill` and `readSkillResource` are Flue's - * built-in skill tools; Flue exports no constants for their names. + * built-in skill tools and `task` its built-in task tool; Flue exports no + * constants for their names. */ export const brunchTools = { activateSkill: "activate_skill", @@ -32,6 +33,7 @@ export const brunchTools = { ping: "ping", queryBasis: "query_basis", readSkillResource: "read_skill_resource", + task: "task", } as const; /** Request headers that carry conversation identity. */ From 5adf4677f20e320e130c9c2d46aef7ab28d47fc0 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 16 Oct 2026 18:08:19 +0200 Subject: [PATCH 12/21] Derive Ledger record types and arc directions from their owners --- .../src/agents/petrinaut/tools/query-basis.ts | 5 +- .../brunch-agent/src/shared/tool-catalogue.ts | 22 +++---- libs/@hashintel/brunch-agent/src/index.ts | 1 + .../brunch-agent/src/ledger/commits.ts | 11 +--- .../src/ledger/projection/project-ledger.ts | 3 +- .../src/ledger/projection/records.ts | 61 +++++++++---------- .../src/ledger/shared/epistemics.ts | 6 ++ .../src/petrinaut/construction-tool-names.ts | 4 +- .../src/petrinaut/net-element-ids.ts | 9 ++- 9 files changed, 63 insertions(+), 59 deletions(-) diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts index a943ff1730d..e0549778bea 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts @@ -2,6 +2,7 @@ import { defineTool } from "@flue/runtime"; import * as v from "valibot"; import { + arcDirections, brunchTools, foldCommits, generatedArcId, @@ -46,7 +47,7 @@ const elementSchema = v.object({ ), arcDirection: v.optional( v.pipe( - v.picklist(["input", "output"]), + v.picklist(arcDirections), v.description( "For an arc: its direction relative to the transition.", ), @@ -99,7 +100,7 @@ const netElements = ( // An arc is identified by its transition and endpoint, not a standalone ID. return net.transitions.flatMap((transition) => { const arcElements = ( - arcDirection: "input" | "output", + arcDirection: ArcElement["arcDirection"], arcs: readonly Pick< (typeof transition.inputArcs)[number], "endpoint" | "placeId" diff --git a/apps/brunch-agent/src/shared/tool-catalogue.ts b/apps/brunch-agent/src/shared/tool-catalogue.ts index 55cad757843..ed29bbb187a 100644 --- a/apps/brunch-agent/src/shared/tool-catalogue.ts +++ b/apps/brunch-agent/src/shared/tool-catalogue.ts @@ -1,14 +1,11 @@ import { browserExecutedToolNames, brunchTools, + CANONICAL_PETRINAUT_TOOL_NAMES, netReaderToolNames, type PetrinautToolCapability, petrinautToolEffects, } from "@hashintel/brunch-agent"; -import { - petrinautAiTools, - type PetrinautAiToolName, -} from "@hashintel/petrinaut-core/ai"; export type ToolDefinitionOwner = "flue" | "petrinaut-core" | "brunch-app"; @@ -30,7 +27,7 @@ export interface BrunchToolCatalogueEntry { } export const assertPetrinautToolCatalogueConformance = ( - canonicalNames: readonly string[] = Object.keys(petrinautAiTools), + canonicalNames: readonly string[] = CANONICAL_PETRINAUT_TOOL_NAMES, ): void => { const classifiedNames = Object.keys(petrinautToolEffects); const unclassified = canonicalNames.filter( @@ -47,14 +44,13 @@ export const assertPetrinautToolCatalogueConformance = ( assertPetrinautToolCatalogueConformance(); -export const canonicalPetrinautToolCatalogue = Object.keys( - petrinautAiTools, -).map((name): BrunchToolCatalogueEntry => ({ - name, - definitionOwner: "petrinaut-core", - executionOwner: "petrinaut-website", - capability: petrinautToolEffects[name as PetrinautAiToolName].capability, -})); +export const canonicalPetrinautToolCatalogue = + CANONICAL_PETRINAUT_TOOL_NAMES.map((name): BrunchToolCatalogueEntry => ({ + name, + definitionOwner: "petrinaut-core", + executionOwner: "petrinaut-website", + capability: petrinautToolEffects[name].capability, + })); const netReaderTools: readonly BrunchToolCatalogueEntry[] = Object.values( netReaderToolNames, diff --git a/libs/@hashintel/brunch-agent/src/index.ts b/libs/@hashintel/brunch-agent/src/index.ts index 052ec7a717d..a9a25610c88 100644 --- a/libs/@hashintel/brunch-agent/src/index.ts +++ b/libs/@hashintel/brunch-agent/src/index.ts @@ -46,6 +46,7 @@ export { type NetStructure, } from "./petrinaut/net-readers"; export { + arcDirections, generatedArcId, namesId, storedNetElementId, diff --git a/libs/@hashintel/brunch-agent/src/ledger/commits.ts b/libs/@hashintel/brunch-agent/src/ledger/commits.ts index 0ff796b455c..e968bcbd2b8 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/commits.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/commits.ts @@ -3,6 +3,7 @@ import * as v from "valibot"; import { brunchTools } from "../constants"; import { vLedgerAppend, type LedgerAppend } from "./append"; import { owedObligations, projectLedger } from "./projection"; +import { questionStatuses, type Origin } from "./shared/epistemics"; import type { ClaimRecord, @@ -225,12 +226,6 @@ const reserveAddresses = ( return next; }; -/** Statuses the map lists as questions, which need no entity to be shown. */ -const entityFreeClaimStatuses: ReadonlySet = new Set([ - "open", - "conflicted", -]); - /** Why a new claim would never be shown, if one would not. */ const claimWithoutEntity = ( entries: LedgerAppend["entries"], @@ -239,7 +234,7 @@ const claimWithoutEntity = ( if ( route === "claim/create" && payload.entities.length === 0 && - !entityFreeClaimStatuses.has(payload.status) + !questionStatuses.has(payload.status) ) return `Claim $${index} is ${payload.status} but names no entity, so the Ledger map would never show it. Reference the entities it is about, or record an unresolved gap as an open claim.`; return undefined; @@ -581,7 +576,7 @@ const exchangesThrough = ( return history.messages.slice(0, end + 1).filter(isUserMessage).length; }; -const accountOrigins = new Set(["stated", "evidenced"]); +const accountOrigins: ReadonlySet = new Set(["stated", "evidenced"]); /** A claim of the agent's own superseding the account: the account leaves the map. */ const accountDisplacements = ( diff --git a/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts b/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts index 9c1342da4d8..68e1ad92923 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts @@ -4,6 +4,7 @@ import { type EntityKind, type EntityStage, } from "../elicitation/entities"; +import { questionStatuses } from "../shared/epistemics"; import type { ClaimRecord, @@ -198,7 +199,7 @@ export const projectLedger = (ledger: LedgerState): LedgerProjection => { .filter((section) => section.kinds.length > 0); const questions = currentClaims - .filter((claim) => claim.status === "open" || claim.status === "conflicted") + .filter((claim) => questionStatuses.has(claim.status)) .map((claim) => projectQuestion(claim, ledger)); const nameOf = (address: string) => diff --git a/libs/@hashintel/brunch-agent/src/ledger/projection/records.ts b/libs/@hashintel/brunch-agent/src/ledger/projection/records.ts index 5a901ccac9f..8909b55167f 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/projection/records.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection/records.ts @@ -1,6 +1,7 @@ -import type { NetElementAddress } from "../construction/reflections"; -import type { EntityKind } from "../elicitation/entities"; -import type { Origin, Status } from "../shared/epistemics"; +import type { Obligation } from "../construction/obligations"; +import type { Reflection } from "../construction/reflections"; +import type { Claim } from "../elicitation/claims"; +import type { Entity } from "../elicitation/entities"; /** A conversation turn anchor: the preceding user message's ID and excerpt. */ export interface Turn { @@ -8,42 +9,36 @@ export interface Turn { excerpt: string; } -export interface EntityRecord { +/** What the system adds to a committed payload: its address and turn anchor. */ +interface RecordAnchor { address: string; - name: string; - kind: EntityKind; - origin: Origin; - status: Status; turn: string; } -export interface ClaimRecord { - address: string; - text: string; - entities: string[]; - origin: Origin; - status: Status; - supersedes?: string[]; - turn: string; -} +export type EntityRecord = Entity & RecordAnchor; -export interface ReflectionRecord { - address: string; - text: string; - netElements?: NetElementAddress[]; - claims?: string[]; - entities?: string[]; - discharges?: string[]; - turn: string; -} +/** References resolve from `$n`/ID references to the addresses they name. */ +export type ClaimRecord = Omit & + RecordAnchor & { + entities: string[]; + supersedes?: string[]; + }; -export interface ObligationRecord { - address: string; - text: string; - claims?: string[]; - entities?: string[]; - turn: string; -} +export type ReflectionRecord = Omit< + Reflection, + "claims" | "entities" | "discharges" +> & + RecordAnchor & { + claims?: string[]; + entities?: string[]; + discharges?: string[]; + }; + +export type ObligationRecord = Omit & + RecordAnchor & { + claims?: string[]; + entities?: string[]; + }; /** * Committed Ledger records as the system holds them after commits: payloads diff --git a/libs/@hashintel/brunch-agent/src/ledger/shared/epistemics.ts b/libs/@hashintel/brunch-agent/src/ledger/shared/epistemics.ts index eec2aca3432..a0f4d6bfd03 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/shared/epistemics.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/shared/epistemics.ts @@ -59,3 +59,9 @@ export const vStatus = v.pipe( export type Origin = v.InferOutput; export type Status = v.InferOutput; + +/** Statuses the map lists as questions, which need no entity to be shown. */ +export const questionStatuses: ReadonlySet = new Set([ + "open", + "conflicted", +] satisfies Status[]); diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/construction-tool-names.ts b/libs/@hashintel/brunch-agent/src/petrinaut/construction-tool-names.ts index 977cf8f586c..6699cc3b17a 100644 --- a/libs/@hashintel/brunch-agent/src/petrinaut/construction-tool-names.ts +++ b/libs/@hashintel/brunch-agent/src/petrinaut/construction-tool-names.ts @@ -1,5 +1,7 @@ import { petrinautAiTools } from "@hashintel/petrinaut-core/ai"; +import type { PetrinautAiToolName } from "@hashintel/petrinaut-core/ai"; + export const CANONICAL_PETRINAUT_TOOL_NAMES = Object.keys( petrinautAiTools, -) as (keyof typeof petrinautAiTools)[]; +) as PetrinautAiToolName[]; diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts b/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts index edffa9dd048..bfe72ac5b72 100644 --- a/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts +++ b/libs/@hashintel/brunch-agent/src/petrinaut/net-element-ids.ts @@ -7,6 +7,7 @@ import { } from "@hashintel/petrinaut-core"; import type { NetElementKind } from "./petrinaut-tool-effects"; +import type { PetrinautAiToolInput } from "@hashintel/petrinaut-core/ai"; /** * Whether `written` names `id`: as written, or as the converted id the @@ -31,10 +32,16 @@ export const storedNetElementId = ({ /** An arc by its endpoints, as the arc actions identify it. */ export interface ArcElement { readonly transitionId: string; - readonly arcDirection: "input" | "output"; + readonly arcDirection: PetrinautAiToolInput<"removeArc">["arcDirection"]; readonly placeId: string; } +/** Every arc direction, relative to the arc's transition. */ +export const arcDirections = [ + "input", + "output", +] as const satisfies readonly ArcElement["arcDirection"][]; + /** The ID Petrinaut generates for an arc, which `deleteItemsByIds` uses. */ export const generatedArcId = (arc: ArcElement): string => { const place = getArcEndpointKey(placeArcEndpoint(arc.placeId)); From 8acc3eaa0e60b87f76b120b2fb3d6308f5086dfe Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 16 Oct 2026 18:12:17 +0200 Subject: [PATCH 13/21] Take server thinking levels, JSON, chronology and identity types from their owners --- .../evals/petrinaut/persona/browser-turn.ts | 15 ++++++---- .../evals/petrinaut/persona/launch.test.ts | 6 ++-- .../evals/petrinaut/persona/launch.ts | 3 +- .../evals/petrinaut/persona/launch/resume.ts | 10 +++---- .../petrinaut/persona/launch/role-settings.ts | 5 ++-- .../evals/petrinaut/persona/rpc-protocol.ts | 2 +- .../petrinaut/run-packet/tool-categories.ts | 30 +++++++++---------- .../evals/petrinaut/turn-probe.ts | 8 ++--- .../src/agents/petrinaut/tools/query-basis.ts | 10 +++++-- .../src/server/http/agent-ownership.test.ts | 9 ++---- .../brunch-agent/src/server/http/cors.test.ts | 8 ++--- .../src/server/http/live-tool-route.test.ts | 8 ++--- .../src/server/observe-turn-chronology.ts | 10 +++++-- .../brunch-agent/src/server/transcript-cli.ts | 9 ++---- apps/brunch-agent/src/shared/chat-model.ts | 11 ++++--- .../conversation-identity.test.ts | 8 ++--- .../src/shared/conversation/identity.ts | 7 ----- .../src/shared/conversation/net-changes.ts | 8 ++--- .../petrinaut-tools/petrinaut-construction.ts | 12 ++------ .../test/browser-witness/browser-fixture.ts | 8 ++--- .../integration/compaction.integration.ts | 8 ++--- .../test/integration/guidance.integration.ts | 8 ++--- .../history-retention-crash.integration.ts | 8 ++--- .../integration/ledger-history.integration.ts | 9 ++---- .../test/integration/words.integration.ts | 8 ++--- 25 files changed, 95 insertions(+), 133 deletions(-) diff --git a/apps/brunch-agent/evals/petrinaut/persona/browser-turn.ts b/apps/brunch-agent/evals/petrinaut/persona/browser-turn.ts index 954989104af..1984b03be54 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/browser-turn.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/browser-turn.ts @@ -12,9 +12,13 @@ import { type Response as BrowserResponse, } from "@playwright/test"; -import { brunchHeaders, brunchRoutes } from "@hashintel/brunch-agent"; +import { + agentOwnershipHeaders, + brunchHeaders, + brunchRoutes, +} from "@hashintel/brunch-agent"; -import { agentOwnershipHeaders } from "../../../src/shared/conversation/identity.ts"; +import type { ConversationIdentity } from "@hashintel/brunch-agent"; /** A failed persona turn that still carries the admitted conversation's history. */ export class PersonaBrowserTurnError extends Error { @@ -29,13 +33,12 @@ export class PersonaBrowserTurnError extends Error { } } -export interface PersonaBrowserSession { +/** `initialData` stays as the browser sent it; resume parses it with `sdcpnInitialDataSchema`. */ +export type PersonaBrowserSession = ConversationIdentity & { readonly url: string; - readonly principalKey: string; - readonly conversationId: string; readonly uid: string; readonly initialData: unknown; -} +}; /** A destructive edit the harness allowed, described as the approval panel listed it. */ export interface PersonaAllowedEdit { diff --git a/apps/brunch-agent/evals/petrinaut/persona/launch.test.ts b/apps/brunch-agent/evals/petrinaut/persona/launch.test.ts index ea9f4611482..aa1388845a0 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/launch.test.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/launch.test.ts @@ -13,7 +13,7 @@ import { DEFAULT_CHAT_MODEL, DEFAULT_CHAT_THINKING, } from "../../../src/shared/chat-model.ts"; -import { flueConversationIdFrom } from "../../../src/shared/conversation/identity.ts"; +import { flueConversationId } from "../../../src/shared/conversation/identity.ts"; import { documentIdFromInitialData, personaEnvironment, @@ -167,7 +167,7 @@ test.each([false, true])( JSON.stringify({ ...identity, uid: "TEST-uid", - url: `${config.panelOrigin}/agents/petrinaut/${flueConversationIdFrom(identity)}`, + url: `${config.panelOrigin}/agents/petrinaut/${flueConversationId(identity)}`, initialData: { binding: { conversationId: identity.conversationId, @@ -270,7 +270,7 @@ test("resume requires the bridge log that Pi-era runs lack", async () => { JSON.stringify({ ...identity, uid: "TEST-uid", - url: `${panelOrigin}/agents/petrinaut/${flueConversationIdFrom(identity)}`, + url: `${panelOrigin}/agents/petrinaut/${flueConversationId(identity)}`, initialData: { binding: { conversationId: identity.conversationId, diff --git a/apps/brunch-agent/evals/petrinaut/persona/launch.ts b/apps/brunch-agent/evals/petrinaut/persona/launch.ts index d42172396d5..d0767b7f3ab 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/launch.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/launch.ts @@ -20,7 +20,7 @@ import { createFlueClient, FlueExecutionError } from "@flue/sdk"; import { chromium, type Page } from "@playwright/test"; import { loadEnv } from "vite"; -import { brunchEnv } from "@hashintel/brunch-agent"; +import { agentOwnershipHeaders, brunchEnv } from "@hashintel/brunch-agent"; import { parseSDCPNFile, toPetrinautId } from "@hashintel/petrinaut-core"; import { @@ -31,7 +31,6 @@ import { DEFAULT_CHAT_MODEL, DEFAULT_CHAT_THINKING, } from "../../../src/shared/chat-model.ts"; -import { agentOwnershipHeaders } from "../../../src/shared/conversation/identity.ts"; import { writeRunPacket } from "../run-packet.ts"; import { openPersonaBrowserBridge } from "./browser-bridge.ts"; import { diff --git a/apps/brunch-agent/evals/petrinaut/persona/launch/resume.ts b/apps/brunch-agent/evals/petrinaut/persona/launch/resume.ts index b6c09ff659f..397949cd5f5 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/launch/resume.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/launch/resume.ts @@ -6,12 +6,12 @@ import { join, resolve } from "node:path"; import { createFlueClient } from "@flue/sdk"; import * as v from "valibot"; -import { sdcpnInitialDataSchema } from "@hashintel/brunch-agent"; - import { agentOwnershipHeaders, - flueConversationIdFrom, -} from "../../../../src/shared/conversation/identity.ts"; + sdcpnInitialDataSchema, +} from "@hashintel/brunch-agent"; + +import { flueConversationId } from "../../../../src/shared/conversation/identity.ts"; import { lastAdmittedUtterance } from "./bridge-log.ts"; import { roleSettingsFromRun } from "./role-settings.ts"; @@ -73,7 +73,7 @@ export const readPersonaResume = async (directory: string) => { assert.equal(resolve(config.databasePath), join(run, "conversation.db")); assert.equal( session.url, - `${config.panelOrigin}/agents/petrinaut/${flueConversationIdFrom(session)}`, + `${config.panelOrigin}/agents/petrinaut/${flueConversationId(session)}`, ); const binding = session.initialData.binding; assert.equal(binding.conversationId, session.conversationId); diff --git a/apps/brunch-agent/evals/petrinaut/persona/launch/role-settings.ts b/apps/brunch-agent/evals/petrinaut/persona/launch/role-settings.ts index 222421767ca..21e0a223c7a 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/launch/role-settings.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/launch/role-settings.ts @@ -11,13 +11,14 @@ import { DEFAULT_CHAT_MODEL, DEFAULT_CHAT_THINKING, STEP_A_MODEL_ID, - type ChatThinkingLevel, } from "../../../../src/shared/chat-model.ts"; +import type { ThinkingLevel } from "@flue/runtime"; + /** Brunch's own model; the persona agent chooses its model through its harness. */ export type PersonaRoleSettings = { brunchModel: string; - brunchThinking: ChatThinkingLevel; + brunchThinking: ThinkingLevel; }; const catalog = () => { diff --git a/apps/brunch-agent/evals/petrinaut/persona/rpc-protocol.ts b/apps/brunch-agent/evals/petrinaut/persona/rpc-protocol.ts index e3e4968c503..f1ee27fa370 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/rpc-protocol.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/rpc-protocol.ts @@ -55,7 +55,7 @@ export const personaRecordSchema = v.union([ personaResponseSchema, personaEventSchema, ]); -export type PersonaRecord = PersonaResponse | PersonaEvent; +export type PersonaRecord = v.InferOutput; export type PersonaTranscriptEntry = { readonly speaker: "user" | "brunch"; diff --git a/apps/brunch-agent/evals/petrinaut/run-packet/tool-categories.ts b/apps/brunch-agent/evals/petrinaut/run-packet/tool-categories.ts index edf80fe954a..1c938445b3a 100644 --- a/apps/brunch-agent/evals/petrinaut/run-packet/tool-categories.ts +++ b/apps/brunch-agent/evals/petrinaut/run-packet/tool-categories.ts @@ -3,19 +3,15 @@ import { netReaderToolNames, petrinautToolEffects, } from "@hashintel/brunch-agent"; +import { + createExperimentToolName, + getLatestNetDefinitionToolName, + getNetCompilationErrorsToolName, + readPetrinautDocToolName, +} from "@hashintel/petrinaut-core/ai"; /** What a tool call spends a step on, for turn-time accounting. */ -export type ToolCategory = - | "ledger" - | "skills" - | "net-read" - | "compile-check" - | "layout" - | "net-change" - | "experiment" - | "other"; - -export const toolCategories: readonly ToolCategory[] = [ +export const toolCategories = [ "ledger", "skills", "net-read", @@ -24,11 +20,13 @@ export const toolCategories: readonly ToolCategory[] = [ "net-change", "experiment", "other", -]; +] as const; + +export type ToolCategory = (typeof toolCategories)[number]; const netReadTools = new Set([ - "getLatestNetDefinition", - "readPetrinautDoc", + getLatestNetDefinitionToolName, + readPetrinautDocToolName, ...Object.values(netReaderToolNames), ]); @@ -54,11 +52,11 @@ export const toolCategoryOf = (toolName: string): ToolCategory => { return "skills"; } if (netReadTools.has(toolName)) return "net-read"; - if (toolName === "getNetCompilationErrors") return "compile-check"; + if (toolName === getNetCompilationErrorsToolName) return "compile-check"; if (layoutTools.has(toolName)) return "layout"; if ( toolName === brunchTools.draftPetrinautExperiment || - toolName === "createExperiment" + toolName === createExperimentToolName ) { return "experiment"; } diff --git a/apps/brunch-agent/evals/petrinaut/turn-probe.ts b/apps/brunch-agent/evals/petrinaut/turn-probe.ts index a86c5435f8e..45a76674a66 100644 --- a/apps/brunch-agent/evals/petrinaut/turn-probe.ts +++ b/apps/brunch-agent/evals/petrinaut/turn-probe.ts @@ -17,6 +17,7 @@ import { fileURLToPath } from "node:url"; import { createFlueClient } from "@flue/sdk"; import { + agentOwnershipHeaders, brunchEnv, foldCommits, projectLedger, @@ -27,10 +28,7 @@ import { DEFAULT_CHAT_MODEL, DEFAULT_CHAT_THINKING, } from "../../src/shared/chat-model.ts"; -import { - agentOwnershipHeaders, - flueConversationIdFrom, -} from "../../src/shared/conversation/identity.ts"; +import { flueConversationId } from "../../src/shared/conversation/identity.ts"; import { loadBuiltBrunchApplication } from "../../test/load-built-application.ts"; import { writeRunPacket } from "./run-packet.ts"; @@ -72,7 +70,7 @@ try { conversationId: `probe-${randomUUID()}`, }; const client = createFlueClient({ - url: `http://brunch.local/agents/petrinaut/${flueConversationIdFrom(identity)}`, + url: `http://brunch.local/agents/petrinaut/${flueConversationId(identity)}`, headers: agentOwnershipHeaders(identity), fetch: async (input, init) => application.fetch( diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts index e0549778bea..9123ca8f037 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts @@ -17,6 +17,7 @@ import { latestNetDefinition, netCalls, type NetCall, + type NetElement, } from "../../../shared/conversation/net-changes.ts"; import type { FlueConversationSnapshot } from "@flue/sdk"; @@ -64,6 +65,9 @@ const elementSchema = v.object({ }); type Selector = v.InferOutput["selector"]; +/** A net element the selector can match: by ID, by name, or by its arc's endpoints. */ +type ElementCandidate = Omit & { readonly name: string }; + /** The root net and each subnet: places, transitions and their other items can sit in either. */ const netsOf = (definition: NetStructure) => [ definition, @@ -73,7 +77,7 @@ const netsOf = (definition: NetStructure) => [ const elements = ( definition: NetStructure, kind: Selector["kind"], -): { id: string; name: string; arc?: ArcElement }[] => { +): ElementCandidate[] => { if (kind === "scenario") return definition.scenarios ?? []; if (kind === "metric") return definition.metrics ?? []; if (kind === "subnet") return definition.subnets ?? []; @@ -83,7 +87,7 @@ const elements = ( const netElements = ( net: ReturnType[number], kind: Exclude, -): { id: string; name: string; arc?: ArcElement }[] => { +): ElementCandidate[] => { if (kind === "place") return net.places; if (kind === "transition") return net.transitions; if (kind === "type") return net.types; @@ -142,7 +146,7 @@ const basisContent = (trace: LedgerTrace) => ({ /** Whether a reflection's net element address names the target, as the document or the model wrote it. */ const namesTarget = - (kind: Selector["kind"], target: { id: string; arc?: ArcElement }) => + (kind: Selector["kind"], target: Omit) => (address: NetElementAddress) => address.kind === kind && (namesId(address.id, target.id) || diff --git a/apps/brunch-agent/src/server/http/agent-ownership.test.ts b/apps/brunch-agent/src/server/http/agent-ownership.test.ts index 266cfe5dc55..92edae2c954 100644 --- a/apps/brunch-agent/src/server/http/agent-ownership.test.ts +++ b/apps/brunch-agent/src/server/http/agent-ownership.test.ts @@ -1,12 +1,9 @@ import { Hono } from "hono"; import { expect, test } from "vitest"; -import { brunchRoutes } from "@hashintel/brunch-agent"; +import { agentOwnershipHeaders, brunchRoutes } from "@hashintel/brunch-agent"; -import { - agentOwnershipHeaders, - flueConversationIdFrom, -} from "../../shared/conversation/identity.ts"; +import { flueConversationId } from "../../shared/conversation/identity.ts"; import { agentOwnershipGuard } from "./ownership.ts"; const mount = `${brunchRoutes.agents}/${brunchRoutes.petrinautAgent}`; @@ -17,7 +14,7 @@ const identity = { principalKey: "principal-a", conversationId: "conversation-1", }; -const url = `http://brunch.test${mount}/${flueConversationIdFrom(identity)}`; +const url = `http://brunch.test${mount}/${flueConversationId(identity)}`; test("the mounted route requires the exact owner", async () => { expect((await app.fetch(new Request(url))).status).toBe(401); diff --git a/apps/brunch-agent/src/server/http/cors.test.ts b/apps/brunch-agent/src/server/http/cors.test.ts index e9ea956ac74..f9957d2094b 100644 --- a/apps/brunch-agent/src/server/http/cors.test.ts +++ b/apps/brunch-agent/src/server/http/cors.test.ts @@ -2,15 +2,13 @@ import { Hono } from "hono"; import { describe, expect, test } from "vitest"; import { + agentOwnershipHeaders, brunchEnv, brunchHeaders, brunchRoutes, } from "@hashintel/brunch-agent"; -import { - agentOwnershipHeaders, - flueConversationIdFrom, -} from "../../shared/conversation/identity.ts"; +import { flueConversationId } from "../../shared/conversation/identity.ts"; import { createAgentCors, parseCorsAllowedOrigins } from "./cors.ts"; import { agentOwnershipGuard } from "./ownership.ts"; @@ -102,7 +100,7 @@ const identity = { principalKey: "principal-cors", conversationId: "conversation-cors", }; -const instanceId = flueConversationIdFrom(identity); +const instanceId = flueConversationId(identity); const conversationUrl = `http://brunch.test${mount}/${instanceId}`; const wildcardOrigin = "https://*.stage.example.com"; diff --git a/apps/brunch-agent/src/server/http/live-tool-route.test.ts b/apps/brunch-agent/src/server/http/live-tool-route.test.ts index b85d4f6223f..e892f6bdb00 100644 --- a/apps/brunch-agent/src/server/http/live-tool-route.test.ts +++ b/apps/brunch-agent/src/server/http/live-tool-route.test.ts @@ -1,16 +1,14 @@ import { Hono } from "hono"; import { expect, test } from "vitest"; +import { agentOwnershipHeaders } from "@hashintel/brunch-agent"; import { createLiveToolBroadcaster, liveToolResponse, liveToolRouteSegment, } from "@local/flue-aisdk-transport/server"; -import { - agentOwnershipHeaders, - flueConversationIdFrom, -} from "../../shared/conversation/identity.ts"; +import { flueConversationId } from "../../shared/conversation/identity.ts"; import { agentOwnershipGuard } from "./ownership.ts"; const mount = "/agents/chat"; @@ -18,7 +16,7 @@ const identity = { conversationId: "live-route-conversation", principalKey: "live-route-principal", }; -const instanceId = flueConversationIdFrom(identity); +const instanceId = flueConversationId(identity); const url = `http://brunch.test${mount}/${instanceId}/${liveToolRouteSegment}?submissionId=submission-1`; test("guards the live SSE route with the existing conversation ownership", async () => { diff --git a/apps/brunch-agent/src/server/observe-turn-chronology.ts b/apps/brunch-agent/src/server/observe-turn-chronology.ts index 747652f1fd7..da24adf84c3 100644 --- a/apps/brunch-agent/src/server/observe-turn-chronology.ts +++ b/apps/brunch-agent/src/server/observe-turn-chronology.ts @@ -11,6 +11,7 @@ import type { FlueEventContext, FlueObservation, FlueObservationSubscriber, + LlmTurnPurpose, } from "@flue/runtime"; type ToolCallChronology = { @@ -44,7 +45,7 @@ type ResponseShape = { type TurnChronology = { readonly turnId: string; - readonly purpose: string; + readonly purpose: LlmTurnPurpose; /** Milliseconds from `turn_start` to the first model event; null when none arrived. */ readonly timeToFirstEventMs: number | null; /** Milliseconds from `turn_start` to `turn`, or to settlement when no `turn` arrived. */ @@ -62,7 +63,10 @@ type TurnChronology = { export type SubmissionChronology = { readonly submissionId: string; - readonly outcome: "completed" | "failed" | "aborted"; + readonly outcome: Extract< + FlueObservation, + { type: "submission_settled" } + >["outcome"]; readonly turns: readonly TurnChronology[]; }; @@ -78,7 +82,7 @@ type ToolCallState = { type TurnState = { turnId: string; - purpose: string; + purpose: LlmTurnPurpose; startedAt: number; firstEventAt: number | null; endedAt: number | null; diff --git a/apps/brunch-agent/src/server/transcript-cli.ts b/apps/brunch-agent/src/server/transcript-cli.ts index 704e076d1c0..37deedef735 100644 --- a/apps/brunch-agent/src/server/transcript-cli.ts +++ b/apps/brunch-agent/src/server/transcript-cli.ts @@ -12,12 +12,9 @@ import { createFlueClient } from "@flue/sdk"; -import { brunchRoutes } from "@hashintel/brunch-agent"; +import { agentOwnershipHeaders, brunchRoutes } from "@hashintel/brunch-agent"; -import { - agentOwnershipHeaders, - flueConversationIdFrom, -} from "../shared/conversation/identity.ts"; +import { flueConversationId } from "../shared/conversation/identity.ts"; import { formatFlueTranscript } from "../shared/conversation/transcript.ts"; import { defaultChatOrigin } from "./http/local-origins.ts"; @@ -44,7 +41,7 @@ if (principalKey === undefined || conversationId === undefined) { } const identity = { principalKey, conversationId }; -const instanceId = flueConversationIdFrom(identity); +const instanceId = flueConversationId(identity); const snapshot = await createFlueClient({ url: `${origin}${brunchRoutes.agents}/${brunchRoutes.petrinautAgent}/${instanceId}`, headers: agentOwnershipHeaders(identity), diff --git a/apps/brunch-agent/src/shared/chat-model.ts b/apps/brunch-agent/src/shared/chat-model.ts index bfe3cf562f9..d0b7acaba80 100644 --- a/apps/brunch-agent/src/shared/chat-model.ts +++ b/apps/brunch-agent/src/shared/chat-model.ts @@ -1,5 +1,7 @@ import { brunchEnv } from "@hashintel/brunch-agent"; +import type { ThinkingLevel } from "@flue/runtime"; + /** Bare Anthropic Sonnet id used by tests and legacy resume. */ export const STEP_A_MODEL_ID = "claude-sonnet-4-6"; @@ -16,12 +18,9 @@ const thinkingLevels = [ "high", "xhigh", "max", -] as const; -export type ChatThinkingLevel = (typeof thinkingLevels)[number]; +] as const satisfies readonly ThinkingLevel[]; -export const isChatThinkingLevel = ( - value: string, -): value is ChatThinkingLevel => +export const isChatThinkingLevel = (value: string): value is ThinkingLevel => thinkingLevels.some((level) => level === value); /** Full `provider/id` specifier. Bare ids stay Anthropic so existing tests keep working. */ @@ -36,7 +35,7 @@ export const selectChatModelSpecifier = ( /** The default thinking level belongs to the default model; an overriding model gets none unless one is set. */ export const selectChatThinking = ( environment: NodeJS.ProcessEnv = process.env, -): ChatThinkingLevel | undefined => { +): ThinkingLevel | undefined => { const value = environment[brunchEnv.chatThinking]; if (!value) return environment[brunchEnv.chatModel] ? undefined : DEFAULT_CHAT_THINKING; diff --git a/apps/brunch-agent/src/shared/conversation/conversation-identity.test.ts b/apps/brunch-agent/src/shared/conversation/conversation-identity.test.ts index dc0c9802fed..01b8692c982 100644 --- a/apps/brunch-agent/src/shared/conversation/conversation-identity.test.ts +++ b/apps/brunch-agent/src/shared/conversation/conversation-identity.test.ts @@ -2,11 +2,7 @@ import { expect, test } from "vitest"; import { flueConversationIdWeb } from "@hashintel/brunch-agent"; -import { - flueConversationId, - flueConversationIdFrom, - ownsFlueInstance, -} from "./identity.ts"; +import { flueConversationId, ownsFlueInstance } from "./identity.ts"; test("the same principal and conversation id always hash to the same Flue instance", () => { const identity = { @@ -35,7 +31,7 @@ test("ownsFlueInstance admits only the principal and conversation that hashed th principalKey: "principal-a", conversationId: "conversation-1", }; - const instanceId = flueConversationIdFrom(identity); + const instanceId = flueConversationId(identity); expect(ownsFlueInstance(identity, instanceId)).toBe(true); expect( ownsFlueInstance({ ...identity, principalKey: "principal-b" }, instanceId), diff --git a/apps/brunch-agent/src/shared/conversation/identity.ts b/apps/brunch-agent/src/shared/conversation/identity.ts index 59631fe2ca4..7fcf7863a00 100644 --- a/apps/brunch-agent/src/shared/conversation/identity.ts +++ b/apps/brunch-agent/src/shared/conversation/identity.ts @@ -7,16 +7,9 @@ import { identityPayload, } from "@hashintel/brunch-agent"; -export { agentOwnershipHeaders } from "@hashintel/brunch-agent"; -export type { ConversationIdentity }; - export const flueConversationId = (identity: ConversationIdentity): string => createHash("sha256").update(identityPayload(identity)).digest("hex"); -export const flueConversationIdFrom = ( - identity: ConversationIdentity, -): string => flueConversationId(identity); - export const ownsFlueInstance = ( identity: ConversationIdentity, instanceId: string, diff --git a/apps/brunch-agent/src/shared/conversation/net-changes.ts b/apps/brunch-agent/src/shared/conversation/net-changes.ts index f3a07b28d1a..9656e7581c1 100644 --- a/apps/brunch-agent/src/shared/conversation/net-changes.ts +++ b/apps/brunch-agent/src/shared/conversation/net-changes.ts @@ -1,6 +1,6 @@ import { type ArcElement, - type NetElementKind, + type NetElementAddress, browserNetToolNames, browserToolEnvelopeOf, generatedArcId, @@ -16,11 +16,7 @@ import { netResultView } from "./net-view.ts"; import type { FlueConversationSnapshot } from "@flue/sdk"; import type { SDCPN } from "@hashintel/petrinaut-core"; -export interface NetElement { - readonly kind: NetElementKind; - readonly id: string; - readonly arc?: ArcElement; -} +export type NetElement = NetElementAddress & { readonly arc?: ArcElement }; export interface NetCall { readonly toolCallId: string; diff --git a/apps/brunch-agent/src/shared/petrinaut-tools/petrinaut-construction.ts b/apps/brunch-agent/src/shared/petrinaut-tools/petrinaut-construction.ts index 2198fca2280..e6fafcb1599 100644 --- a/apps/brunch-agent/src/shared/petrinaut-tools/petrinaut-construction.ts +++ b/apps/brunch-agent/src/shared/petrinaut-tools/petrinaut-construction.ts @@ -11,17 +11,11 @@ import { } from "@hashintel/brunch-agent"; import { petrinautAiTools } from "@hashintel/petrinaut-core/ai"; +import type { JsonValue } from "@flue/runtime"; + /** What the bound browser settles a call with, without its correlation. */ type BrowserToolResult = Pick; -type CanonicalJson = - | string - | number - | boolean - | null - | CanonicalJson[] - | { [key: string]: CanonicalJson }; - /** Issues one call to the bound browser and resolves with its settled result. */ export type BrowserToolExecutor = (call: { readonly toolName: string; @@ -38,7 +32,7 @@ export const browserResultEnvelope = (result: BrowserToolResult) => output: result.output, ...(result.metadata === undefined ? {} : { metadata: result.metadata }), } satisfies BrowserToolEnvelope), - ) as BrowserToolEnvelope & CanonicalJson; + ) as BrowserToolEnvelope & JsonValue; /** The browser runs `getLatestNetDefinition` and returns only the requested view. */ export const netReaderTools = (execute: BrowserToolExecutor) => diff --git a/apps/brunch-agent/test/browser-witness/browser-fixture.ts b/apps/brunch-agent/test/browser-witness/browser-fixture.ts index 1ec47f84da8..fdcff770400 100644 --- a/apps/brunch-agent/test/browser-witness/browser-fixture.ts +++ b/apps/brunch-agent/test/browser-witness/browser-fixture.ts @@ -21,13 +21,11 @@ import { import { createFlueClient } from "@flue/sdk"; import { chromium, type Page } from "@playwright/test"; +import { agentOwnershipHeaders } from "@hashintel/brunch-agent"; import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; import { nativeOpenaiProvider } from "@local/pi-faux-providers/native-openai-provider"; -import { - agentOwnershipHeaders, - flueConversationIdFrom, -} from "../../src/shared/conversation/identity.ts"; +import { flueConversationId } from "../../src/shared/conversation/identity.ts"; import type { BuiltBrunchApplication } from "../load-built-application.ts"; @@ -297,7 +295,7 @@ export const openBrowserFixture = async (app: BuiltBrunchApplication) => { conversationId: binding.conversationId, }; const client = createFlueClient({ - url: `${origin}/agents/petrinaut/${flueConversationIdFrom(identity)}`, + url: `${origin}/agents/petrinaut/${flueConversationId(identity)}`, headers: agentOwnershipHeaders(identity), }); return { binding, client }; diff --git a/apps/brunch-agent/test/integration/compaction.integration.ts b/apps/brunch-agent/test/integration/compaction.integration.ts index 57106f008f2..ece20367076 100644 --- a/apps/brunch-agent/test/integration/compaction.integration.ts +++ b/apps/brunch-agent/test/integration/compaction.integration.ts @@ -22,12 +22,10 @@ import { } from "@earendil-works/pi-ai"; import { createFlueClient } from "@flue/sdk"; +import { agentOwnershipHeaders } from "@hashintel/brunch-agent"; import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; -import { - agentOwnershipHeaders, - flueConversationIdFrom, -} from "../../src/shared/conversation/identity.ts"; +import { flueConversationId } from "../../src/shared/conversation/identity.ts"; import { loadBuiltBrunchApplication } from "../load-built-application.ts"; const directory = @@ -101,7 +99,7 @@ const identity = { conversationId: `compaction-${crypto.randomUUID()}`, }; const client = createFlueClient({ - url: `http://brunch.local/agents/petrinaut/${flueConversationIdFrom(identity)}`, + url: `http://brunch.local/agents/petrinaut/${flueConversationId(identity)}`, headers: agentOwnershipHeaders(identity), fetch: async (input, init) => application.fetch( diff --git a/apps/brunch-agent/test/integration/guidance.integration.ts b/apps/brunch-agent/test/integration/guidance.integration.ts index bd341adac14..078b81b6a40 100644 --- a/apps/brunch-agent/test/integration/guidance.integration.ts +++ b/apps/brunch-agent/test/integration/guidance.integration.ts @@ -14,12 +14,10 @@ import { } from "@earendil-works/pi-ai"; import { createFlueClient } from "@flue/sdk"; +import { agentOwnershipHeaders } from "@hashintel/brunch-agent"; import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; -import { - agentOwnershipHeaders, - flueConversationIdFrom, -} from "../../src/shared/conversation/identity.ts"; +import { flueConversationId } from "../../src/shared/conversation/identity.ts"; import { loadBuiltBrunchApplication } from "../load-built-application.ts"; const directory = mkdtempSync(join(tmpdir(), "brunch-guidance-")); @@ -130,7 +128,7 @@ try { conversationId: crypto.randomUUID(), }; const client = createFlueClient({ - url: `http://brunch.local/agents/petrinaut/${flueConversationIdFrom(identity)}`, + url: `http://brunch.local/agents/petrinaut/${flueConversationId(identity)}`, headers: agentOwnershipHeaders(identity), fetch: async (input, init) => application.fetch( diff --git a/apps/brunch-agent/test/integration/history-retention-crash.integration.ts b/apps/brunch-agent/test/integration/history-retention-crash.integration.ts index 85cebcd06b2..194e7a2215c 100644 --- a/apps/brunch-agent/test/integration/history-retention-crash.integration.ts +++ b/apps/brunch-agent/test/integration/history-retention-crash.integration.ts @@ -14,12 +14,10 @@ import { } from "@earendil-works/pi-ai"; import { createFlueClient } from "@flue/sdk"; +import { agentOwnershipHeaders } from "@hashintel/brunch-agent"; import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; -import { - agentOwnershipHeaders, - flueConversationIdFrom, -} from "../../src/shared/conversation/identity.ts"; +import { flueConversationId } from "../../src/shared/conversation/identity.ts"; import { loadBuiltBrunchApplication } from "../load-built-application.ts"; import type { AgentSendResult, FlueConversationSnapshot } from "@flue/sdk"; @@ -135,7 +133,7 @@ faux.setResponses( ); const application = await loadBuiltBrunchApplication(); const client = createFlueClient({ - url: `http://a4.in-process/agents/petrinaut/${flueConversationIdFrom(identity)}`, + url: `http://a4.in-process/agents/petrinaut/${flueConversationId(identity)}`, headers: agentOwnershipHeaders(identity), fetch: async (input, init) => application.fetch( diff --git a/apps/brunch-agent/test/integration/ledger-history.integration.ts b/apps/brunch-agent/test/integration/ledger-history.integration.ts index b0f82bcb29e..6aa4e695f5f 100644 --- a/apps/brunch-agent/test/integration/ledger-history.integration.ts +++ b/apps/brunch-agent/test/integration/ledger-history.integration.ts @@ -24,13 +24,10 @@ import { } from "@earendil-works/pi-ai"; import { createFlueClient } from "@flue/sdk"; -import { foldCommits } from "@hashintel/brunch-agent"; +import { agentOwnershipHeaders, foldCommits } from "@hashintel/brunch-agent"; import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; -import { - agentOwnershipHeaders, - flueConversationIdFrom, -} from "../../src/shared/conversation/identity.ts"; +import { flueConversationId } from "../../src/shared/conversation/identity.ts"; import { loadBuiltBrunchApplication } from "../load-built-application.ts"; const directory = @@ -162,7 +159,7 @@ const identity = { conversationId: `ledger-${crypto.randomUUID()}`, }; const client = createFlueClient({ - url: `http://brunch.local/agents/petrinaut/${flueConversationIdFrom(identity)}`, + url: `http://brunch.local/agents/petrinaut/${flueConversationId(identity)}`, headers: agentOwnershipHeaders(identity), fetch: async (input, init) => application.fetch( diff --git a/apps/brunch-agent/test/integration/words.integration.ts b/apps/brunch-agent/test/integration/words.integration.ts index f40c0cee399..5cf10051db5 100644 --- a/apps/brunch-agent/test/integration/words.integration.ts +++ b/apps/brunch-agent/test/integration/words.integration.ts @@ -15,16 +15,14 @@ import { import { createFlueClient } from "@flue/sdk"; import { + agentOwnershipHeaders, brunchTools, petrinautContextualUserMessageBody, petrinautSubmissionContext, } from "@hashintel/brunch-agent"; import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; -import { - agentOwnershipHeaders, - flueConversationIdFrom, -} from "../../src/shared/conversation/identity.ts"; +import { flueConversationId } from "../../src/shared/conversation/identity.ts"; import { loadBuiltBrunchApplication } from "../load-built-application.ts"; const directory = mkdtempSync(join(tmpdir(), "brunch-words-")); @@ -44,7 +42,7 @@ const identity = { conversationId: `words-${crypto.randomUUID()}`, }; const client = createFlueClient({ - url: `http://brunch.local/agents/petrinaut/${flueConversationIdFrom(identity)}`, + url: `http://brunch.local/agents/petrinaut/${flueConversationId(identity)}`, headers: agentOwnershipHeaders(identity), fetch: async (input, init) => application.fetch( From 660b61a8b4c49953b1586d1dbfab2519873e0a2d Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 16 Oct 2026 18:17:21 +0200 Subject: [PATCH 14/21] Project Brunch website voice, approval and replay types from their owners --- .../conversation/brunch-conversation-id.ts | 5 +- .../brunch/conversation/brunch-principal.ts | 5 +- .../brunch/tools/brunch-mutation-approval.tsx | 5 +- .../brunch/tools/brunch-petrinaut-tools.ts | 6 +- .../plugins/brunch/use-brunch-assistant.tsx | 13 ++-- .../use-immutable-replay-baseline.ts | 6 +- .../brunch/voice/brunch-voice-mode.tsx | 2 +- .../voice/live/live-brunch-bridge.test.ts | 2 +- .../brunch/voice/live/live-brunch-bridge.ts | 3 +- .../live/live-conversation-control.test.tsx | 2 +- .../voice/realtime/realtime-brunch-bridge.ts | 25 +++----- .../voice/session/voice-audio-settings.ts | 6 +- .../session/voice-audio-settings/preview.ts | 15 +++-- .../voice/session/voice-interview-control.tsx | 29 ++++++--- .../voice/session/voice-turn-controller.ts | 63 +++++++++---------- .../src/server/voice/openai-voice-config.ts | 7 ++- .../src/shared/voice-mediation.ts | 9 ++- .../src/shared/voice-settings.ts | 11 ++++ 18 files changed, 115 insertions(+), 99 deletions(-) diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/brunch-conversation-id.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/brunch-conversation-id.ts index 8c49c6aa8c4..f8b1110d216 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/brunch-conversation-id.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/brunch-conversation-id.ts @@ -11,10 +11,7 @@ export const brunchEvaluationConversationIdFrom = ( conversationId: string, ): string => `${conversationId}:evaluation-I`; -interface ConversationStorage { - getItem(key: string): string | null; - setItem(key: string, value: string): void; -} +type ConversationStorage = Pick; /** Change only the pointer; old conversation history and the model are retained. */ export const replaceBrunchConversationId = ( diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/brunch-principal.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/brunch-principal.ts index 285b6c1c227..8ba47751d8f 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/brunch-principal.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/brunch-principal.ts @@ -1,9 +1,6 @@ const principalStorageKey = "brunch-principal-v1"; -interface PrincipalStorage { - getItem(key: string): string | null; - setItem(key: string, value: string): void; -} +type PrincipalStorage = Pick; export const getOrCreateBrunchPrincipal = ( storage: PrincipalStorage = window.localStorage, diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.tsx index 68d87c079f2..269f950e151 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.tsx @@ -30,6 +30,9 @@ type BrunchMutationApprovalDecision = | { readonly decision: "allow" } | { readonly decision: "deny"; readonly reason: string }; +/** Where a call stands at the approval gate; `null` from `approvalState` means it is past it. */ +export type ToolApprovalState = "awaiting" | "refused"; + type PendingApproval = { readonly resolve: (decision: BrunchMutationApprovalDecision) => void; readonly removeAbortListener: () => void; @@ -47,7 +50,7 @@ export interface BrunchMutationApprovalCoordinator { * A refused call keeps its streamed input-available state until the refusal * output round-trips, so its part alone cannot tell it from an executing call. */ - approvalState(toolCallId: string): "awaiting" | "refused" | null; + approvalState(toolCallId: string): ToolApprovalState | null; /** * The calls waiting for a decision. A new set whenever a call enters or * leaves the approval gate, so rendering from it re-renders on every change. diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-petrinaut-tools.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-petrinaut-tools.ts index 70238fc6d97..b44a16e362f 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-petrinaut-tools.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-petrinaut-tools.ts @@ -25,6 +25,7 @@ import { } from "./shared/document-revision"; import type { PetrinautAiAutomaticTool } from "../chat/ai-automatic-tool"; +import type { ReplayReadiness } from "../use-brunch-assistant/use-immutable-replay-baseline"; import type { FlueConversationState } from "@flue/sdk"; type RetainedCall = Pick & @@ -35,9 +36,8 @@ type RetainedCall = Pick & export interface CanonicalPetrinautReplay { readonly calls: ReadonlyMap; } -export type CanonicalPetrinautReplayReadiness = - | { readonly status: "pending" } - | { readonly status: "ready"; readonly replay: CanonicalPetrinautReplay }; +type CanonicalPetrinautReplayReadiness = + ReplayReadiness; export const EMPTY_CANONICAL_PETRINAUT_REPLAY: CanonicalPetrinautReplay = { calls: new Map(), }; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx index 6c259c67de1..b9743c04178 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx @@ -70,8 +70,8 @@ import { createCanonicalPetrinautHostTools, issuedCanonicalCallsFromHistory, EMPTY_CANONICAL_PETRINAUT_REPLAY, + type CanonicalPetrinautHostToolsInput, type CanonicalPetrinautReplay, - type CanonicalPetrinautReplayReadiness, } from "./tools/brunch-petrinaut-tools"; import { resolveBrunchToolPresentation } from "./tools/brunch-tool-presentation"; import { createInBandBrowserCalls } from "./tools/in-band-browser-call"; @@ -92,7 +92,6 @@ import type { PetrinautAiComposerControlContext } from "./chat/ai-assistant-comp import type { PetrinautAiMessage } from "./chat/ai-assistant-panel"; import type { PetrinautAiAssistant } from "./chat/petrinaut"; import type { SdcpnInitialData } from "@hashintel/brunch-agent"; -import type { PetrinautDocHandle } from "@hashintel/petrinaut-core"; import type { PetrinautPlugin } from "@hashintel/petrinaut/ui/plugin"; export const brunchPreviewConfig = resolveBrunchPreviewConfig( @@ -139,10 +138,10 @@ const useProcessAgentSession = (input: { }; /** The open document as Brunch's browser tools reach it. */ -export interface BrunchEditorDocument { - readonly handle: PetrinautDocHandle; - readonly readTitle: () => string; -} +export type BrunchEditorDocument = Pick< + CanonicalPetrinautHostToolsInput, + "handle" | "readTitle" +>; /** The document open in the editor. */ export interface BrunchOpenDocument { @@ -420,7 +419,7 @@ export const useBrunchAssistant = ({ historyPhase: flueHistory.phase, snapshot: flueHistory.snapshot, derive: deriveCanonicalReplay, - }) satisfies CanonicalPetrinautReplayReadiness; + }); const canonicalHostTools = useMemo(() => { if (!constructionBrowser || !editor) return undefined; return createCanonicalPetrinautHostTools({ diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant/use-immutable-replay-baseline.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant/use-immutable-replay-baseline.ts index 04b5d7252f9..4461ea80baf 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant/use-immutable-replay-baseline.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant/use-immutable-replay-baseline.ts @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; -type ReplayReadiness = +import type { AgentConversationObservationPhase } from "@flue/sdk"; + +export type ReplayReadiness = | { readonly status: "pending" } | { readonly status: "ready"; readonly replay: Replay }; @@ -19,7 +21,7 @@ type ReplayBaseline = { export const useImmutableReplayBaseline = (input: { readonly bindingKey: string | undefined; readonly emptyReplay: Replay; - readonly historyPhase: string | undefined; + readonly historyPhase: AgentConversationObservationPhase | undefined; readonly snapshot: Snapshot | undefined; readonly derive: (snapshot: Snapshot) => Promise; }): ReplayReadiness => { diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/brunch-voice-mode.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/brunch-voice-mode.tsx index 5f4d7879ec5..b748952b85a 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/brunch-voice-mode.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/brunch-voice-mode.tsx @@ -13,8 +13,8 @@ import type { PetrinautAiVoiceMode, PetrinautAiVoiceModeContext, } from "../chat/ai-assistant-composer-control"; +import type { ToolApprovalState } from "../tools/brunch-mutation-approval"; import type { VoiceMediationHistory } from "./history/voice-mediation-history"; -import type { ToolApprovalState } from "./live/live-brunch-bridge"; import type { FlueConversationSettlement, FlueConversationState, diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.test.ts index 2dffa0938aa..fc4064cbc43 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.test.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.test.ts @@ -5,7 +5,7 @@ import { VoiceMediationHistory } from "../history/voice-mediation-history"; import { LiveBrunchBridge } from "./live-brunch-bridge"; import type { UtteranceJudgment } from "../../../../../../shared/live-utterance-judgment"; -import type { ToolApprovalState } from "./live-brunch-bridge"; +import type { ToolApprovalState } from "../../tools/brunch-mutation-approval"; import type { FlueConversationState } from "@flue/sdk"; import type { MockInstance } from "vitest"; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.ts index ce160f22d55..e90876c9148 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.ts @@ -20,6 +20,7 @@ import type { import type { VoiceBriefFields } from "../../../../../../shared/voice-mediation"; import type { PetrinautAiVoiceModeContext } from "../../chat/ai-assistant-composer-control"; import type { PetrinautAiMessage } from "../../chat/ai-assistant-panel"; +import type { ToolApprovalState } from "../../tools/brunch-mutation-approval"; import type { VoiceMediationHistory } from "../history/voice-mediation-history"; import type { RealtimeBrunchBridge, @@ -135,8 +136,6 @@ interface Dependencies { ) => Promise; } -export type ToolApprovalState = "awaiting" | "refused"; - /** GPT-Live accepts at most 500 tokens per append; stay well inside it. */ const liveAppendCharacterBudget = 1_400; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation-control.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation-control.test.tsx index 5cae6769ff3..05589a20f42 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation-control.test.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation-control.test.tsx @@ -23,7 +23,7 @@ import { import { createLiveConversation } from "./live-conversation"; import type { PetrinautAiVoiceModeContext } from "../../chat/ai-assistant-composer-control"; -import type { ToolApprovalState } from "./live-brunch-bridge"; +import type { ToolApprovalState } from "../../tools/brunch-mutation-approval"; import type { FlueClient, FlueConversationState } from "@flue/sdk"; const liveConversationMocks = vi.hoisted(() => ({ diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/realtime-brunch-bridge.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/realtime-brunch-bridge.ts index 3034f68ff84..020d50bade4 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/realtime-brunch-bridge.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/realtime-brunch-bridge.ts @@ -8,12 +8,14 @@ import { } from "../../../../../../voice-diagnostics"; import { classifyInterruption } from "../shared/classify-interruption"; +import type { BrunchPanelAdmissionTarget } from "../../brunch-panel-transport"; import type { PetrinautAiComposerSubmitTextResult, PetrinautAiVoiceModeContext, } from "../../chat/ai-assistant-composer-control"; import type { CanonicalSpeechSegment } from "../live/canonical-speech"; import type { + OpenAIRealtimeSession, OpenAIRealtimeSessionEvent, OpenAIRealtimeTranscriptKey, } from "./openai-realtime-session"; @@ -22,7 +24,6 @@ import type { FlueChatAdmissionFailure, FlueChatResponseMessageCompletedEvent, FlueChatResponseMessageStartedEvent, - FlueChatTransportOptions, } from "@local/flue-aisdk-transport"; export type VoiceSubmissionSettlement = Pick< @@ -34,7 +35,7 @@ export interface CancelPendingSpeechOptions { readonly discardPendingInterruption?: boolean; } -interface ChatUpdate { +export interface ChatUpdate { readonly canAcceptInterviewAnswer: boolean; readonly canonicalSegments: CanonicalSpeechSegment[]; readonly questionSegment?: CanonicalSpeechSegment; @@ -45,25 +46,17 @@ interface ChatUpdate { readonly status: PetrinautAiVoiceModeContext["status"]; } -interface RealtimeBridgeSession { - getTranscriptionPrompt?(): string; - speakCanonical(segments: CanonicalSpeechSegment[]): void; - subscribe(listener: (event: OpenAIRealtimeSessionEvent) => void): () => void; -} +type RealtimeBridgeSession = Pick< + OpenAIRealtimeSession, + "speakCanonical" | "subscribe" +> & + Partial>; type SubmitVoiceInput = Parameters< PetrinautAiVoiceModeContext["submitVoiceInput"] >[0]; -type FlueChatAdmission = Parameters< - NonNullable ->[0]; -export type RealtimeBrunchAdmissionTarget = Pick< - FlueChatAdmission, - "kind" | "messageId" ->; - type SubmitInterviewAnswerInput = Pick & { - readonly admissionTarget: RealtimeBrunchAdmissionTarget; + readonly admissionTarget: BrunchPanelAdmissionTarget; readonly id: string; readonly onAdmission: (submissionId: AgentSendResult["submissionId"]) => void; readonly signal: AbortSignal; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings.ts index 760d63b4e57..5415357c797 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings.ts @@ -9,15 +9,13 @@ import type { PetrinautAiVoiceModeControls, PetrinautAiVoiceSessionState, } from "../../chat/ai-assistant-composer-control"; +import type { PreviewOutput } from "./voice-audio-settings/preview"; type SettingsState = NonNullable; type SettingsActions = NonNullable< PetrinautAiVoiceModeControls["audioSettings"] >; -type AudioOutput = { - muted: boolean; - volume: number; - sinkId?: string; +type AudioOutput = PreviewOutput & { setSinkId?: (deviceId: string) => Promise; }; type AudioDevices = Pick< diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings/preview.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings/preview.ts index 9815eb5ef84..266be516e6a 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings/preview.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings/preview.ts @@ -1,7 +1,12 @@ import type { VoiceProvider } from "../../../../../../../shared/voice-settings"; +import type { PetrinautAiVoiceSessionState } from "../../../chat/voice-session/types"; -type PreviewState = "loading" | "playing" | null; -type Output = { muted: boolean; volume: number; sinkId?: string }; +type PreviewState = Exclude< + NonNullable["voicePreview"], + undefined +>; +/** The session's audio output as a preview follows it. */ +export type PreviewOutput = { muted: boolean; volume: number; sinkId?: string }; // Bump the path version whenever a clip changes; production serves it immutable. const previewRoot = "/voice-previews/v1"; @@ -15,7 +20,7 @@ export const previewVoice = ({ }: { provider: VoiceProvider; voice: string; - output: () => Output; + output: () => PreviewOutput; onState: (state: PreviewState, error?: string) => void; }): (() => void) => { const abort = new AbortController(); @@ -42,7 +47,7 @@ export const previewVoice = ({ state = nextState; onState(nextState); }; - const syncOutput = async (): Promise => { + const syncOutput = async (): Promise => { if (!audio) throw new Error("Preview audio is unavailable"); const current = output(); audio.muted = current.muted; @@ -63,7 +68,7 @@ export const previewVoice = ({ } return current; }; - const updatePlayingState = (current: Output) => { + const updatePlayingState = (current: PreviewOutput) => { setState( started && !current.muted && current.volume > 0 ? "playing" : null, ); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-interview-control.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-interview-control.tsx index 624fe4eb295..95f14071b93 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-interview-control.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-interview-control.tsx @@ -18,7 +18,6 @@ import { LiveConversationControl } from "../live/live-conversation-control"; import { OpenAIRealtimeSession } from "../realtime/openai-realtime-session"; import { RealtimeBrunchBridge, - type RealtimeBrunchAdmissionTarget, type VoiceSubmissionSettlement, } from "../realtime/realtime-brunch-bridge"; import { VoiceAudioSettings } from "./voice-audio-settings"; @@ -31,15 +30,19 @@ import { } from "./voice-turn-controller"; import type { InterviewBudgetLevel } from "../../../../../../shared/interview-budget"; -import type { UtteranceJudgmentMode } from "../../../../../../shared/live-utterance-judgment"; +import type { + VoiceConfigResponse, + VoiceProvider, +} from "../../../../../../shared/voice-settings"; import type { VoiceWord } from "../../../../../../shared/voice-words"; +import type { BrunchPanelAdmissionTarget } from "../../brunch-panel-transport"; import type { PetrinautAiInputMode, PetrinautAiVoiceModeContext, } from "../../chat/ai-assistant-composer-control"; +import type { ToolApprovalState } from "../../tools/brunch-mutation-approval"; import type { VoiceMediationHistory } from "../history/voice-mediation-history"; import type { CanonicalSpeechSegment } from "../live/canonical-speech"; -import type { ToolApprovalState } from "../live/live-brunch-bridge"; import type { AgentSendResult, FlueConversationState } from "@flue/sdk"; type ResolveSubmission = ( @@ -49,11 +52,11 @@ type ResolveSubmissions = ( messageId: string, ) => readonly AgentSendResult["submissionId"][] | undefined; type SubscribeToAdmission = ( - target: RealtimeBrunchAdmissionTarget, + target: BrunchPanelAdmissionTarget, listener: (submissionId: AgentSendResult["submissionId"]) => void, ) => () => void; type SubscribeToAdmissionFailure = ( - target: RealtimeBrunchAdmissionTarget, + target: BrunchPanelAdmissionTarget, listener: (error: FlueChatAdmissionError) => void, ) => () => void; type SubscribeToResponseMessageCompleted = ( @@ -138,11 +141,17 @@ export const submitVoiceInputWithAdmission = async ({ } }; -export interface OpenAIVoiceConfig { +/** The `/api/voice/config` response once checked: available, with only the fields that apply. */ +export interface OpenAIVoiceConfig extends Pick< + VoiceConfigResponse, + "connectionTimeoutMs" +> { readonly available: true; - readonly connectionTimeoutMs: number; - readonly provider?: "realtime" | "live"; - readonly utteranceJudgment?: Exclude; + readonly provider?: VoiceProvider; + readonly utteranceJudgment?: Exclude< + VoiceConfigResponse["utteranceJudgment"], + "off" + >; } export const VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY = @@ -279,7 +288,7 @@ export const loadOpenAIVoiceConfig = async ( connectionTimeoutMs: body.connectionTimeoutMs as number, ...(body.provider === undefined ? {} - : { provider: body.provider as "realtime" | "live" }), + : { provider: body.provider as VoiceProvider }), ...(body.provider === "live" && body.utteranceJudgment === "log" ? { utteranceJudgment: body.utteranceJudgment } : {}), diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-turn-controller.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-turn-controller.ts index f0732a7acdb..38623da7b9f 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-turn-controller.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-turn-controller.ts @@ -3,17 +3,17 @@ import { type VoiceErrorCode, } from "../../../../../../voice-diagnostics"; -import type { - PetrinautAiComposerControlContext, - PetrinautAiVoiceModeContext, -} from "../../chat/ai-assistant-composer-control"; +import type { PetrinautAiComposerControlContext } from "../../chat/ai-assistant-composer-control"; import type { CanonicalSpeechSegment } from "../live/canonical-speech"; -import type { OpenAIRealtimeSessionEvent } from "../realtime/openai-realtime-session"; import type { - CancelPendingSpeechOptions, + OpenAIRealtimeSession, + OpenAIRealtimeSessionEvent, +} from "../realtime/openai-realtime-session"; +import type { + ChatUpdate, RealtimeBridgeErrorCode, + RealtimeBrunchBridge, RealtimeBrunchBridgeEvent, - VoiceSubmissionSettlement, } from "../realtime/realtime-brunch-bridge"; type VoiceConnectionState = "idle" | "connecting" | "connected" | "error"; @@ -70,26 +70,28 @@ export interface VoiceLatencyEvent { | "answer-ready"; } -interface RealtimeSession { - cancelOutput(): Promise; - connect(): Promise; - disconnect(): Promise; - setInterruptionBySpeaking(enabled: boolean): void; - setMicrophoneEnabled(enabled: boolean): void; - setSpeakerMuted(muted: boolean): void; - setSpeakerVolume(volume: number): void; - speakCanonical(segments: CanonicalSpeechSegment[]): void; - subscribe(listener: (event: OpenAIRealtimeSessionEvent) => void): () => void; -} +type RealtimeSession = Pick< + OpenAIRealtimeSession, + | "cancelOutput" + | "connect" + | "disconnect" + | "setInterruptionBySpeaking" + | "setMicrophoneEnabled" + | "setSpeakerMuted" + | "setSpeakerVolume" + | "speakCanonical" + | "subscribe" +>; -interface RealtimeBridge { - cancelPendingSpeech(options?: CancelPendingSpeechOptions): void; - completeTurnHandoff(): void; - start(connectionEpoch: number): void; - stop(): void; - subscribe(listener: (event: RealtimeBrunchBridgeEvent) => void): () => void; - updateChat(update: ChatUpdate): void; -} +type RealtimeBridge = Pick< + RealtimeBrunchBridge, + | "cancelPendingSpeech" + | "completeTurnHandoff" + | "start" + | "stop" + | "subscribe" + | "updateChat" +>; type ComposerSubmitTextInput = Parameters< PetrinautAiComposerControlContext["submitText"] @@ -107,15 +109,6 @@ interface VoiceTurnControllerDependencies { readonly submitText: (input: SubmitTextInput) => Promise; } -interface ChatUpdate { - readonly canAcceptInterviewAnswer: boolean; - readonly canonicalSegments: CanonicalSpeechSegment[]; - readonly questionSegment?: CanonicalSpeechSegment; - readonly settlements?: readonly VoiceSubmissionSettlement[]; - readonly stopped?: boolean; - readonly status: PetrinautAiVoiceModeContext["status"]; -} - interface PendingSubmissionSettlement { readonly deliveryId: string; readonly promise: Promise; diff --git a/apps/petrinaut-website/src/server/voice/openai-voice-config.ts b/apps/petrinaut-website/src/server/voice/openai-voice-config.ts index 1e9157645ed..1837dfafbb9 100644 --- a/apps/petrinaut-website/src/server/voice/openai-voice-config.ts +++ b/apps/petrinaut-website/src/server/voice/openai-voice-config.ts @@ -1,7 +1,10 @@ import { getOpenAIVoiceAvailability } from "./openai-voice-policy.js"; import type { UtteranceJudgmentMode } from "../../shared/live-utterance-judgment.js"; -import type { VoiceProvider } from "../../shared/voice-settings.js"; +import type { + VoiceConfigResponse, + VoiceProvider, +} from "../../shared/voice-settings.js"; interface VoiceEnvironment { readonly OPENAI_VOICE_API_KEY?: string; @@ -65,7 +68,7 @@ export const createOpenAIVoiceConfigHandler = provider, available: availability.available && provider !== null, utteranceJudgment: getUtteranceJudgmentMode(environment), - }, + } satisfies VoiceConfigResponse, { headers: { "cache-control": "no-store" }, }, diff --git a/apps/petrinaut-website/src/shared/voice-mediation.ts b/apps/petrinaut-website/src/shared/voice-mediation.ts index f791a2930d6..8e3d3bd3848 100644 --- a/apps/petrinaut-website/src/shared/voice-mediation.ts +++ b/apps/petrinaut-website/src/shared/voice-mediation.ts @@ -14,7 +14,14 @@ export const voiceBriefExtractionSchema = z.object({ ask: quote, }); -export type VoiceBriefFields = Record; +/** Verbatim excerpts by extraction field, plus the fields still open. */ +export type VoiceBriefFields = Partial< + Record< + | Exclude, "kind"> + | "stillOpen", + string + > +>; export type VoiceLine = { text: string; state: "streaming" | "done" }; export const voiceWrapUpResponseSchema = z.object({ text: z.string() }); diff --git a/apps/petrinaut-website/src/shared/voice-settings.ts b/apps/petrinaut-website/src/shared/voice-settings.ts index a0338afafb5..79511741159 100644 --- a/apps/petrinaut-website/src/shared/voice-settings.ts +++ b/apps/petrinaut-website/src/shared/voice-settings.ts @@ -1,3 +1,5 @@ +import type { UtteranceJudgmentMode } from "./live-utterance-judgment.js"; + /** Provider allowlists shared by the browser picker and session-creation boundary. */ export const voiceNames = { realtime: [ @@ -30,6 +32,15 @@ export const voiceNames = { } as const; export type VoiceProvider = keyof typeof voiceNames; + +/** The `/api/voice/config` response body; `provider` is null when the configured provider is unsupported. */ +export interface VoiceConfigResponse { + readonly available: boolean; + readonly connectionTimeoutMs: number; + readonly provider: VoiceProvider | null; + readonly utteranceJudgment: UtteranceJudgmentMode; +} + export const voicePreferenceHeader = "x-petrinaut-voice"; export const isSupportedVoice = ( From 981cfb08ceaaf019804ea8728537a5ba0e34061a Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 16 Oct 2026 18:22:54 +0200 Subject: [PATCH 15/21] Export from the Brunch library only what its consumers import --- libs/@hashintel/brunch-agent/src/client-tools.ts | 5 ----- .../src/client-tools/browser-tool-result.ts | 3 --- libs/@hashintel/brunch-agent/src/index.ts | 11 ++++++++--- libs/@hashintel/brunch-agent/src/ledger.ts | 10 +--------- libs/@hashintel/brunch-agent/src/ledger/commits.ts | 4 ++-- .../brunch-agent/src/ledger/projection.ts | 14 +------------- .../src/petrinaut/contextual-user-message.ts | 7 +++---- 7 files changed, 15 insertions(+), 39 deletions(-) diff --git a/libs/@hashintel/brunch-agent/src/client-tools.ts b/libs/@hashintel/brunch-agent/src/client-tools.ts index 63208e9a29c..db963d20395 100644 --- a/libs/@hashintel/brunch-agent/src/client-tools.ts +++ b/libs/@hashintel/brunch-agent/src/client-tools.ts @@ -6,7 +6,6 @@ import { toolName } from "./conversation/naming"; export { browserCallFailureBodySchema, - browserCallFailureDispositions, browserCallLeaseBodySchema, browserCallResultBodySchema, type BrowserCallClaim, @@ -18,15 +17,11 @@ export { export { browserToolEnvelopeOf, browserToolOutput, - CLIENT_TOOL_RESULT_CONTEXT_MAX_LENGTH, type BrowserToolEnvelope, type ClientToolResult, } from "./client-tools/browser-tool-result"; export { clientToolHistoryFrom, - type ClientToolHistory, - type ClientToolHistoryCall, - type ClientToolHistoryMessage, type ClientToolHistoryResult, } from "./client-tools/client-tool-history"; export { projectBrunchMessageMetadata } from "./client-tools/message-metadata"; diff --git a/libs/@hashintel/brunch-agent/src/client-tools/browser-tool-result.ts b/libs/@hashintel/brunch-agent/src/client-tools/browser-tool-result.ts index e3247a44d65..3a610260906 100644 --- a/libs/@hashintel/brunch-agent/src/client-tools/browser-tool-result.ts +++ b/libs/@hashintel/brunch-agent/src/client-tools/browser-tool-result.ts @@ -1,6 +1,3 @@ -/** Keep transient model context comfortably below Flue's delivered-message limit. */ -export const CLIENT_TOOL_RESULT_CONTEXT_MAX_LENGTH = 32_000; - /** A browser-executed result, exchanged in band with the server. */ export interface ClientToolResult { readonly toolCallId: string; diff --git a/libs/@hashintel/brunch-agent/src/index.ts b/libs/@hashintel/brunch-agent/src/index.ts index a9a25610c88..589a2e1ab31 100644 --- a/libs/@hashintel/brunch-agent/src/index.ts +++ b/libs/@hashintel/brunch-agent/src/index.ts @@ -5,7 +5,14 @@ * enforces. Flue tools, guidance and server mechanics live in the app. */ -export * from "./constants"; +export { + brunchEnv, + brunchHeaders, + brunchRoutes, + brunchTools, + localUiPrincipal, + previewConversationIdPrefix, +} from "./constants"; export * from "./conversation-identity"; export * from "./client-tools"; export * from "./ledger"; @@ -37,7 +44,6 @@ export { isNetObservationTool, netReaderDescriptions, netReaderInputSchema, - netReaderLevelOf, netReaderToolNames, parseNetReaderInput, netStructureOf, @@ -57,7 +63,6 @@ export { netElementKinds, petrinautToolTargets, petrinautToolEffects, - type NetElementKind, type PetrinautToolCapability, } from "./petrinaut/petrinaut-tool-effects"; export { diff --git a/libs/@hashintel/brunch-agent/src/ledger.ts b/libs/@hashintel/brunch-agent/src/ledger.ts index 2eb6f9ffb9b..3ec1740fa47 100644 --- a/libs/@hashintel/brunch-agent/src/ledger.ts +++ b/libs/@hashintel/brunch-agent/src/ledger.ts @@ -11,13 +11,10 @@ export { isRefusedLedgerCommit, ledgerAppendOutputSchema, prepareAppend, - type AcceptedCommit, type FoldedLedger, - type LedgerAppendOutput, type LedgerHistory, type LedgerHistoryMessage, } from "./ledger/commits"; -export { vObligation } from "./ledger/construction/obligations"; export { vClaim } from "./ledger/elicitation/claims"; export { entityKindStages, @@ -25,14 +22,9 @@ export { vEntityKind, } from "./ledger/elicitation/entities"; export { - vNetElementAddress, vReflection, type NetElementAddress, } from "./ledger/construction/reflections"; export { vOrigin, vStatus } from "./ledger/shared/epistemics"; -export { - traceLedger, - type LedgerTrace, - type LedgerTraceSeeds, -} from "./ledger/trace"; +export { traceLedger, type LedgerTrace } from "./ledger/trace"; export * from "./ledger/projection"; diff --git a/libs/@hashintel/brunch-agent/src/ledger/commits.ts b/libs/@hashintel/brunch-agent/src/ledger/commits.ts index e968bcbd2b8..7ed9e6207c9 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/commits.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/commits.ts @@ -2,7 +2,7 @@ import * as v from "valibot"; import { brunchTools } from "../constants"; import { vLedgerAppend, type LedgerAppend } from "./append"; -import { owedObligations, projectLedger } from "./projection"; +import { owedObligations, projectLedger } from "./projection/project-ledger"; import { questionStatuses, type Origin } from "./shared/epistemics"; import type { @@ -12,7 +12,7 @@ import type { ObligationRecord, ReflectionRecord, Turn, -} from "./projection"; +} from "./projection/records"; import type { FlueConversationMessage } from "@flue/sdk"; /** A Flue conversation message as the fold reads it: parts stay `unknown` because the fold parses them. */ diff --git a/libs/@hashintel/brunch-agent/src/ledger/projection.ts b/libs/@hashintel/brunch-agent/src/ledger/projection.ts index b1774ccc614..44e81a81bd0 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/projection.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection.ts @@ -5,24 +5,12 @@ * it as a document. */ -export type { - ClaimRecord, - EntityRecord, - LedgerState, - ObligationRecord, - ReflectionRecord, - Turn, -} from "./projection/records"; +export type { LedgerState, ReflectionRecord } from "./projection/records"; export { - owedObligations, projectLedger, type LedgerProjection, type ProjectedClaim, type ProjectedEntity, - type ProjectedKindGroup, - type ProjectedMetObligation, type ProjectedObligation, - type ProjectedQuestion, - type ProjectedSection, } from "./projection/project-ledger"; export { renderLedgerMarkdown } from "./projection/render-ledger-markdown"; diff --git a/libs/@hashintel/brunch-agent/src/petrinaut/contextual-user-message.ts b/libs/@hashintel/brunch-agent/src/petrinaut/contextual-user-message.ts index 706d63a4992..c58630a9aed 100644 --- a/libs/@hashintel/brunch-agent/src/petrinaut/contextual-user-message.ts +++ b/libs/@hashintel/brunch-agent/src/petrinaut/contextual-user-message.ts @@ -1,10 +1,10 @@ -import { CLIENT_TOOL_RESULT_CONTEXT_MAX_LENGTH } from "../client-tools"; - export const PETRINAUT_CONTEXTUAL_USER_MESSAGE_PREFIX = "petrinaut-contextual-user-message:v1\n"; const submissionContextPrefix = "petrinaut-contextual-user-message:v2\n"; export const PETRINAUT_CONTEXTUAL_USER_TEXT_MAX_LENGTH = 32_000; const PETRINAUT_CONTEXTUAL_USER_BODY_MAX_LENGTH = 256_000; +/** Keep diagnostics context comfortably below Flue's delivered-message limit. */ +const diagnosticsContextMaxLength = 32_000; /** * Host data for one submission. The envelope never reads its keys; each @@ -58,8 +58,7 @@ export const petrinautContextualUserMessageBody = ( (payload.submissionContext === undefined ? payload.diagnosticsContext.length === 0 : !isSubmissionContext(payload.submissionContext)) || - Array.from(payload.diagnosticsContext).length > - CLIENT_TOOL_RESULT_CONTEXT_MAX_LENGTH + Array.from(payload.diagnosticsContext).length > diagnosticsContextMaxLength ) { throw new Error( "The contextual user message payload is invalid or too long.", From a535eb00ecc044c7cbb024d615acd9fcd2e24c62 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Mon, 19 Oct 2026 15:33:10 +0200 Subject: [PATCH 16/21] Have query_basis answers quote the turns they rest on Co-authored-by: Cursor --- apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts index 9123ca8f037..c7fd61b0fba 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts @@ -30,7 +30,7 @@ import type { } from "@hashintel/brunch-agent"; export const queryBasisToolDescription = - "Use when the USER asks why a visible net element exists or is modelled as it is, instead of answering from memory. Finds the element in the latest net read, or in the net read back after a later change, and returns its recorded basis: the reflections that name the element and, behind them, the claims, entities and discharged obligations they rest on, each with its ID, and the conversation turns those records came from, each with its ID and the opening of the USER's words. Answer from that basis and cite its record IDs and turns. A claim under `superseded` was later replaced: say so, and give its replacement. It also lists the canonical calls that changed the element, with their document and Ledger revisions. A basis-absent disposition means no reflection names the element: say plainly that its basis was not recorded rather than reconstructing one. A stale-read disposition means the net changed after the latest net view, a read or a change's read-back, without a later read-back: read it again, then query."; + "Use when the USER asks why a visible net element exists or is modelled as it is, instead of answering from memory. Finds the element in the latest net read, or in the net read back after a later change, and returns its recorded basis: the reflections that name the element and, behind them, the claims, entities and discharged obligations they rest on, each with its ID, and the conversation turns those records came from, each with its ID and the opening of the USER's words. Answer from that basis and cite its record IDs. Cite every returned turn too, by quoting the opening of the USER's words, never by its ID, so the USER can see what they said that the answer rests on. A claim under `superseded` was later replaced: say so, and give its replacement. It also lists the canonical calls that changed the element, with their document and Ledger revisions. A basis-absent disposition means no reflection names the element: say plainly that its basis was not recorded rather than reconstructing one. A stale-read disposition means the net changed after the latest net view, a read or a change's read-back, without a later read-back: read it again, then query."; const elementSchema = v.object({ selector: v.pipe( From 36c3deb37daeb4cb532b9da672daed15fba21d2a Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Mon, 19 Oct 2026 15:37:34 +0200 Subject: [PATCH 17/21] Keep query_basis answers from inventing a rationale when no basis was recorded Co-authored-by: Cursor --- apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts index c7fd61b0fba..0b0fe0f6971 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts @@ -30,7 +30,7 @@ import type { } from "@hashintel/brunch-agent"; export const queryBasisToolDescription = - "Use when the USER asks why a visible net element exists or is modelled as it is, instead of answering from memory. Finds the element in the latest net read, or in the net read back after a later change, and returns its recorded basis: the reflections that name the element and, behind them, the claims, entities and discharged obligations they rest on, each with its ID, and the conversation turns those records came from, each with its ID and the opening of the USER's words. Answer from that basis and cite its record IDs. Cite every returned turn too, by quoting the opening of the USER's words, never by its ID, so the USER can see what they said that the answer rests on. A claim under `superseded` was later replaced: say so, and give its replacement. It also lists the canonical calls that changed the element, with their document and Ledger revisions. A basis-absent disposition means no reflection names the element: say plainly that its basis was not recorded rather than reconstructing one. A stale-read disposition means the net changed after the latest net view, a read or a change's read-back, without a later read-back: read it again, then query."; + "Use when the USER asks why a visible net element exists or is modelled as it is, instead of answering from memory. Finds the element in the latest net read, or in the net read back after a later change, and returns its recorded basis: the reflections that name the element and, behind them, the claims, entities and discharged obligations they rest on, each with its ID, and the conversation turns those records came from, each with its ID and the opening of the USER's words. Answer from that basis and cite its record IDs. Cite every returned turn too, by quoting the opening of the USER's words, never by its ID, so the USER can see what they said that the answer rests on. A claim under `superseded` was later replaced: say so, and give its replacement. It also lists the canonical calls that changed the element, with their document and Ledger revisions. A basis-absent disposition means no reflection names the element: say plainly that its basis was not recorded rather than reconstructing one. You may point to related records, but do not offer a rationale of your own for it. A stale-read disposition means the net changed after the latest net view, a read or a change's read-back, without a later read-back: read it again, then query."; const elementSchema = v.object({ selector: v.pipe( From 7120fbc5ca5c4baa2abdff28abf0a09e1c114953 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Mon, 19 Oct 2026 23:35:04 +0200 Subject: [PATCH 18/21] Keep a reflection under its entity after the claim it cites is replaced Co-authored-by: Cursor --- .../src/ledger/projection.test.ts | 25 ++++++++++++++++++- .../src/ledger/projection/project-ledger.ts | 17 ++++++++----- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts b/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts index dbc0604bef6..a254bf6530e 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts @@ -89,7 +89,7 @@ test("a question that names no entity drops its concerns clause", () => { expect(rendered).not.toContain("Concerns: ."); }); -test("each entity carries the reflections naming it or one of its current claims", () => { +test("each entity carries the reflections naming it or one of its claims", () => { const reflectionsOf = (address: string) => projection.sections .flatMap(({ kinds }) => kinds) @@ -101,3 +101,26 @@ test("each entity carries the reflections naming it or one of its current claims expect(reflectionsOf("e6")).toEqual(["r2"]); expect(markdown).not.toContain(supportDeskLedger.reflections[0]?.text); }); + +test("a reflection stays under the entity after the claim it cites is replaced", () => { + const replaced = projectLedger({ + ...supportDeskLedger, + claims: [ + ...supportDeskLedger.claims, + { + address: "c99", + text: "Calls arrive in two peaks, not one.", + entities: ["e6"], + origin: "stated", + status: "confirmed", + supersedes: ["c8"], + turn: "msg-05", + }, + ], + }); + const e6 = replaced.sections + .flatMap(({ kinds }) => kinds) + .flatMap(({ entities }) => entities) + .find(({ record }) => record.address === "e6"); + expect(e6?.reflections.map(({ address }) => address)).toEqual(["r2"]); +}); diff --git a/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts b/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts index 68e1ad92923..2560f54b18c 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection/project-ledger.ts @@ -35,7 +35,7 @@ export interface ProjectedEntity { claims: ProjectedClaim[]; /** * Reflections on how the net represents this entity: those naming it or one - * of its current claims, in recording order. Renderers choose whether to + * of its current claims or the claims they replaced, in recording order. Renderers choose whether to * show them; the model's Markdown does not. */ reflections: ReflectionRecord[]; @@ -155,13 +155,18 @@ export const projectLedger = (ledger: LedgerState): LedgerProjection => { const claims = currentClaims.filter((claim) => claim.entities.includes(record.address), ); - const claimAddresses = new Set(claims.map(({ address }) => address)); + const projected = claims.map((claim) => ({ + record: claim, + history: historyOf(claim), + })); + const claimAddresses = new Set( + projected.flatMap(({ record: claim, history }) => + [claim, ...history].map(({ address }) => address), + ), + ); return { record, - claims: claims.map((claim) => ({ - record: claim, - history: historyOf(claim), - })), + claims: projected, reflections: ledger.reflections.filter( (reflection) => reflection.entities?.includes(record.address) === true || From a8938519828440c17dd441ad19b295c06c5fda04 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Mon, 19 Oct 2026 23:35:05 +0200 Subject: [PATCH 19/21] Fade the Ledger for a traced selection only when a traced record is shown Co-authored-by: Cursor --- .../plugins/brunch/ledger/brunch-ledger-document.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx index db0d2a4895c..14964905737 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx @@ -29,10 +29,12 @@ const documentStyle = css({ "& h2": { fontSize: "lg" }, "& ul": { listStyleType: "disc", paddingLeft: "5", marginBottom: "3" }, "& ul ul": { marginBottom: "0" }, - // While a selection is traced, records outside the trace recede. - "&[data-tracing] [data-ledger-address]:not([data-traced])": { - opacity: "[0.4]", - }, + // While a selection is traced, records outside the trace recede, but only + // when a traced record is shown: the trace may reach only hidden reflections. + "&[data-tracing]:has([data-traced]) [data-ledger-address]:not([data-traced])": + { + opacity: "[0.4]", + }, }); const recordStyle = css({ From 91aa61efee41489055a28c475332acc8ab5fa978 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 20 Oct 2026 17:51:00 +0200 Subject: [PATCH 20/21] Identify arcs in query_basis by Petrinaut's generated arc ID Co-authored-by: Cursor --- .../src/agents/petrinaut/tools/query-basis.test.ts | 1 + .../src/agents/petrinaut/tools/query-basis.ts | 12 +++++------- .../src/shared/conversation/net-changes.test.ts | 9 ++++++++- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.test.ts b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.test.ts index 657c59d8bce..9b629e4347a 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.test.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.test.ts @@ -330,6 +330,7 @@ test("query_basis matches a reflection that names an arc by Petrinaut's generate }), ).toMatchObject({ disposition: "basis-declared", + target: { id: "$A_place:queue___serve" }, basis: { reflections: [{ id: "r1" }] }, }); }); diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts index 0b0fe0f6971..3c09e8bd96a 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.ts @@ -101,7 +101,7 @@ const netElements = ( name: element.name, })), ); - // An arc is identified by its transition and endpoint, not a standalone ID. + // An arc is selected by its transition and endpoint, and identified by the ID Petrinaut generates from them. return net.transitions.flatMap((transition) => { const arcElements = ( arcDirection: ArcElement["arcDirection"], @@ -115,9 +115,10 @@ const netElements = ( arc.endpoint?.kind === "place" ? arc.endpoint.placeId : (arc.placeId ?? ""); + const element = { transitionId: transition.id, arcDirection, placeId }; return { - arc: { transitionId: transition.id, arcDirection, placeId }, - id: `${transition.id}:${arcDirection}:${placeId}`, + arc: element, + id: generatedArcId(element), name: `${transition.name} ${arcDirection}`, }; }); @@ -148,10 +149,7 @@ const basisContent = (trace: LedgerTrace) => ({ const namesTarget = (kind: Selector["kind"], target: Omit) => (address: NetElementAddress) => - address.kind === kind && - (namesId(address.id, target.id) || - (target.arc !== undefined && - namesId(address.id, generatedArcId(target.arc)))); + address.kind === kind && namesId(address.id, target.id); /** The Ledger revision current when a call was made. */ const ledgerRevisionAt = ( diff --git a/apps/brunch-agent/src/shared/conversation/net-changes.test.ts b/apps/brunch-agent/src/shared/conversation/net-changes.test.ts index 4815fe0a88d..80b3f5e1b63 100644 --- a/apps/brunch-agent/src/shared/conversation/net-changes.test.ts +++ b/apps/brunch-agent/src/shared/conversation/net-changes.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "vitest"; +import { generatedArcId } from "@hashintel/brunch-agent"; import { toPetrinautId } from "@hashintel/petrinaut-core"; import { queryBasis } from "../../agents/petrinaut/tools/query-basis.ts"; @@ -347,7 +348,13 @@ test("query_basis matches a root arc to canonical addArc by transition, directio }, }), ).toMatchObject({ - target: { id: `${toPetrinautId("serve")}:input:${toPetrinautId("queue")}` }, + target: { + id: generatedArcId({ + transitionId: toPetrinautId("serve"), + arcDirection: "input", + placeId: toPetrinautId("queue"), + }), + }, changes: [ { toolCallId: "delete-arc", operation: "deleteItemsByIds" }, { From 9afaf8d81747adc752236cfaee8f24770bf5f8ed Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 20 Oct 2026 18:07:30 +0200 Subject: [PATCH 21/21] Name the claim that stands now for a traced claim replaced more than once Co-authored-by: Cursor --- .../brunch-agent/src/ledger/trace.test.ts | 22 +++++++++++++++++++ .../brunch-agent/src/ledger/trace.ts | 14 +++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/brunch-agent/src/ledger/trace.test.ts b/libs/@hashintel/brunch-agent/src/ledger/trace.test.ts index a9b7b3c7595..a63b6e0c052 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/trace.test.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/trace.test.ts @@ -121,6 +121,28 @@ describe("traceLedger", () => { expect(trace.superseded).toEqual({ c2: "c3" }); }); + test("names the claim that stands now for a claim replaced more than once", () => { + const trace = traceLedger( + { + ...ledger, + claims: [ + ...ledger.claims, + { + address: "c4", + text: "Four clerks work the queue.", + entities: ["e2", "e1"], + origin: "stated", + status: "confirmed", + supersedes: ["c3"], + turn: "turn-4", + }, + ], + }, + { netElement: ({ id }) => id === "clerks" }, + ); + expect(trace.superseded).toEqual({ c2: "c4" }); + }); + test("starts from record addresses without pulling in every claim about an entity", () => { const trace = traceLedger(ledger, { records: ["e2", "c1", "c9"] }); expect(addresses(trace.claims)).toEqual(["c1"]); diff --git a/libs/@hashintel/brunch-agent/src/ledger/trace.ts b/libs/@hashintel/brunch-agent/src/ledger/trace.ts index 8942f3c3679..52cf8ee227b 100644 --- a/libs/@hashintel/brunch-agent/src/ledger/trace.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/trace.ts @@ -25,7 +25,7 @@ export interface LedgerTrace { readonly obligations: readonly ObligationRecord[]; readonly claims: readonly ClaimRecord[]; readonly entities: readonly EntityRecord[]; - /** Each traced claim a later claim replaced, with the address of its replacement. */ + /** Each traced claim a later claim replaced, with the address of the claim that stands now. */ readonly superseded: Readonly>; /** The turns the traced records were recorded in, in conversation order. */ readonly turns: readonly Turn[]; @@ -110,10 +110,18 @@ export const traceLedger = ( entities: inOrder(state.entities), }; - const superseded: Record = {}; + const replacedBy = new Map(); for (const claim of state.claims) for (const earlier of claim.supersedes ?? []) - if (reached.has(earlier)) superseded[earlier] = claim.address; + replacedBy.set(earlier, claim.address); + // A claim only supersedes earlier claims, so the chain ends. + const standing = (address: string): string => { + const next = replacedBy.get(address); + return next === undefined ? address : standing(next); + }; + const superseded: Record = {}; + for (const { address } of traced.claims) + if (replacedBy.has(address)) superseded[address] = standing(address); const turnIds = new Set( [