From db6eb7802b18d17d514d5cddce1ca8a1e3b02c68 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 26 Aug 2026 17:37:53 +0200 Subject: [PATCH 1/4] Show capture and completion facts in sweep results Expose current captures and the full completion report through readable sweep output so Petrinaut's existing generic tool renderer can display live elicitation state. Co-authored-by: Cursor --- .../test/baseline-harness.test.ts | 53 ++++++++++++++ .../packages/binding-flue/src/index.ts | 69 ++++++++++++++++--- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/apps/brunch-agent/test/baseline-harness.test.ts b/apps/brunch-agent/test/baseline-harness.test.ts index 7f7d6e614d5..09fe90d597f 100644 --- a/apps/brunch-agent/test/baseline-harness.test.ts +++ b/apps/brunch-agent/test/baseline-harness.test.ts @@ -14,6 +14,9 @@ import { runNodeScript } from "./run-node-script"; import type { HarnessRunRecord } from "../../../libs/@hashintel/brunch-agent/evaluations/protocols/process-model-elicitation/baseline/harness-run.ts"; +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + const testDirectory = import.meta.dirname; const contextRoot = join( testDirectory, @@ -94,6 +97,56 @@ test("condition 5 drives the shipped elicitor through the binding and reads the captures: 1, complete: false, }); + const appliedSweepPart = run.history.messages + .flatMap((message) => message.parts) + .find( + (part) => + part.type === "dynamic-tool" && + part.toolName === "brunch_sweep" && + part.state === "output-available", + ); + if ( + appliedSweepPart?.type !== "dynamic-tool" || + appliedSweepPart.state !== "output-available" || + !isRecord(appliedSweepPart.output) + ) { + throw new Error("The applied sweep did not expose a readable output."); + } + expect(appliedSweepPart.output.status).toBe("applied"); + expect(appliedSweepPart.output.title).toBe("Sweep applied"); + expect(appliedSweepPart.output.detail).toBe( + "1 new capture · 1 total · incomplete", + ); + const { items } = appliedSweepPart.output; + expect(Array.isArray(items)).toBe(true); + if (!Array.isArray(items)) throw new Error("Sweep items were not a list."); + expect( + items.some( + (item) => + typeof item === "string" && item.includes(EXPERT_OBJECTIVE_QUOTE), + ), + ).toBe(true); + expect( + items.some( + (item) => + typeof item === "string" && + item.includes("Completion: 5 requirements remain"), + ), + ).toBe(true); + const { completion } = appliedSweepPart.output; + if (!isRecord(completion) || !Array.isArray(completion.failures)) { + throw new Error("The sweep did not expose its completion report."); + } + expect(completion.complete).toBe(false); + expect(completion.failures).toHaveLength(5); + expect( + completion.failures.every( + (failure) => + isRecord(failure) && + typeof failure.diagnostic === "string" && + typeof failure.message === "string", + ), + ).toBe(true); // Then the interviewer closes without asking; the runner counts three such // turns before the wrap and declares the interview stalled — no classifier. diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts index 90f50222bdc..0461e7a455d 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts @@ -54,6 +54,7 @@ import { toolName, type CaptureStore, type CaptureStoreSnapshot, + type CompletionReport, type FreeTextAffordanceValue, type Plugin, type SweepState, @@ -70,6 +71,50 @@ const SweepToolOutput = v.looseObject({ status: v.picklist(SWEEP_RESULT_STATUSES), }); +const captureSummary = ( + capture: CaptureStoreSnapshot["captures"][number], +): string => { + const content = + "value" in capture.content + ? JSON.stringify(capture.content.value) + : `absence: ${capture.content.absence}`; + const provenance = + "evidence" in capture + ? capture.evidence.map((evidence) => `“${evidence.excerpt}”`).join("; ") + : `${capture.basis.type}: ${capture.basis.description}`; + return `Capture ${capture.id}: ${content} — ${provenance}`; +}; + +type ReadableCompletionReport = CompletionReport & { + readonly cue: string; + readonly unmapped: readonly unknown[]; + readonly unsatisfied: number; +}; + +const readableAppliedSweep = ({ + appliedCaptureIds, + completion, + snapshot, +}: { + readonly appliedCaptureIds: readonly string[]; + readonly completion?: ReadableCompletionReport; + readonly snapshot: CaptureStoreSnapshot; +}) => ({ + title: "Sweep applied", + detail: `${appliedCaptureIds.length} new capture${appliedCaptureIds.length === 1 ? "" : "s"} · ${snapshot.captures.length} total · ${completion?.complete === true ? "complete" : "incomplete"}`, + items: [ + ...snapshot.captures.map(captureSummary), + ...(completion === undefined + ? [] + : [ + `Completion: ${completion.complete ? "complete" : `${completion.failures.length} requirements remain`}.`, + ...completion.failures.map( + (failure) => `Completion gap: ${failure.message}`, + ), + ]), + ], +}); + export { CAPABILITIES, type Capability, type Provision } from "./capabilities"; export { createFlueHistoryReader, @@ -132,9 +177,7 @@ export function useElicitation( const report = evaluateCompletion(model, demands); const sweepList = buildSweepList(model, report, slotModel.patterns); return { - complete: report.complete, - revision: report.revision, - pluginVersion: report.pluginVersion, + ...report, unsatisfied: report.failures.length, unmapped: model.unmapped, cue: buildCompletionCueSignal(model, report, sweepList).body, @@ -247,13 +290,16 @@ export function useElicitation( applied.snapshot, session.sessionId, ); + const appliedCaptureIds = + "appliedCaptureIds" in applied.value + ? applied.value.appliedCaptureIds + : []; + const completion = + slotModel === undefined ? undefined : completionCue(applied.snapshot); return { output: { status: "applied" as const, - appliedCaptureIds: - "appliedCaptureIds" in applied.value - ? applied.value.appliedCaptureIds - : [], + appliedCaptureIds, skippedDedupKeys: "skippedDedupKeys" in applied.value ? applied.value.skippedDedupKeys @@ -262,9 +308,12 @@ export function useElicitation( ...("advisories" in applied.value ? applied.value.advisories : []), ...computeUnaccountedAskAdvisories(range, accountedEntryIds), ], - ...(slotModel === undefined - ? {} - : { completion: completionCue(applied.snapshot) }), + ...(completion === undefined ? {} : { completion }), + ...readableAppliedSweep({ + appliedCaptureIds, + completion, + snapshot: applied.snapshot, + }), }, }; }, From a7517540a85b5c008dc17799b796b259a2026376 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 26 Aug 2026 17:38:37 +0200 Subject: [PATCH 2/4] State the Petrinaut attach contract Pin the chat stream, structured ask schema, and principal identity as the stable UI and voice integration surface that changes only with notice. Co-authored-by: Cursor --- .../docs/specs/petrinaut-integration.md | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/brunch-agent/docs/specs/petrinaut-integration.md b/libs/@hashintel/brunch-agent/docs/specs/petrinaut-integration.md index 76cca430e50..f33dff6981c 100644 --- a/libs/@hashintel/brunch-agent/docs/specs/petrinaut-integration.md +++ b/libs/@hashintel/brunch-agent/docs/specs/petrinaut-integration.md @@ -4,7 +4,8 @@ (`docs/adr/0004-in-petrinaut-staging-and-the-monorepo-import.md`) · **Supersedes**: `recommendation-demo-vehicle.md` as the September staging plan · **Evidence base**: the Petrinaut survey (FE-1358, `research/petrinaut-survey.md`), re-verified against -`hashintel/hash` source on 2026-08-18. +`hashintel/hash` source on 2026-08-18 · **Amended**: FE-1506 (stable UI and voice attach +contract). ## Problem Statement @@ -45,6 +46,24 @@ which the design needs anyway: 4. **The artifact seam** (`parseSDCPNFile` / `sdcpnFileSchema`): unchanged; net validity checked in CI through the pure parser. +## Attach Contract + +The panel and the voice edge attach to Brunch through one stable surface: + +1. **Chat stream**: the UI sends `POST /api/chat`; a successful response is an AI SDK v6 + UI-message stream over HTTP/SSE. +2. **Question affordance**: the client-executed tool is named `brunch_ask`. Its input schema is + `{ question: non-empty string }`; its submitted output schema is + `{ answer: non-empty string }`. +3. **Principal identity**: every request carries one non-empty, opaque principal in the + `x-brunch-principal` header. The current UI shell keeps that value in localStorage so it is + stable across reloads; replacing the local UID with authenticated identity must preserve the + same request-level ownership semantics. + +These three parts change only with notice to the panel and voice-edge owners. A provider-specific +voice requirement does not silently alter this surface; it arrives as a generic UI-shell extension +or triggers an explicit contract revision. + ## User Stories 1. As a demo.petrinaut.org visitor, I want to converse with the elicitor in the same chat @@ -99,8 +118,8 @@ which the design needs anyway: **Topology and packaging** - The elicitor server is a thin host-authored agent (spec §13) around the harness library, - deployed remotely; the demo site addresses it cross-origin, bypassing the site's own - `/api/chat` and Vercel function limits. + deployed remotely; the demo site's same-origin `/api/chat` route reaches that server without + routing through the stock Petrinaut assistant or its prompt. - Implemented by FE-1436 (the durable AI SDK transport): package `transport-aisdk` is the server end of the ui shell's reply transport. It translates harness-level parts to AI SDK v6 UI-message-stream chunks, using the `ai` package for stream From 7a70a8e9f797f66d1c56fa157c961d8ae5c655e6 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 26 Aug 2026 18:00:30 +0200 Subject: [PATCH 3/4] Keep sweep presentation in the Petrinaut host Move readable sweep summaries to the app-owned transport so the reusable binding exposes raw capture history and completion facts without renderer coupling. Co-authored-by: Cursor --- .../test/baseline-harness.test.ts | 34 +-- .../brunch-panel-transport.test.ts | 224 ++++++++++++++++++ .../brunch-panel-transport.ts | 202 ++++++++++++++++ .../local-storage-demo-app.tsx | 13 +- .../docs/specs/petrinaut-integration.md | 6 +- .../packages/binding-flue/src/index.ts | 56 +---- .../transport-aisdk/src/client-tools.ts | 1 + .../@hashintel/petrinaut/docs/ai-assistant.md | 5 +- 8 files changed, 461 insertions(+), 80 deletions(-) create mode 100644 apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts create mode 100644 apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts diff --git a/apps/brunch-agent/test/baseline-harness.test.ts b/apps/brunch-agent/test/baseline-harness.test.ts index 09fe90d597f..7eed3d23721 100644 --- a/apps/brunch-agent/test/baseline-harness.test.ts +++ b/apps/brunch-agent/test/baseline-harness.test.ts @@ -110,29 +110,21 @@ test("condition 5 drives the shipped elicitor through the binding and reads the appliedSweepPart.state !== "output-available" || !isRecord(appliedSweepPart.output) ) { - throw new Error("The applied sweep did not expose a readable output."); + throw new Error("The applied sweep did not expose its result."); } expect(appliedSweepPart.output.status).toBe("applied"); - expect(appliedSweepPart.output.title).toBe("Sweep applied"); - expect(appliedSweepPart.output.detail).toBe( - "1 new capture · 1 total · incomplete", - ); - const { items } = appliedSweepPart.output; - expect(Array.isArray(items)).toBe(true); - if (!Array.isArray(items)) throw new Error("Sweep items were not a list."); - expect( - items.some( - (item) => - typeof item === "string" && item.includes(EXPERT_OBJECTIVE_QUOTE), - ), - ).toBe(true); - expect( - items.some( - (item) => - typeof item === "string" && - item.includes("Completion: 5 requirements remain"), - ), - ).toBe(true); + const sweepCaptures = appliedSweepPart.output.captures; + if (!Array.isArray(sweepCaptures) || !isRecord(sweepCaptures[0])) { + throw new Error("The sweep did not expose its current captures."); + } + expect(sweepCaptures).toHaveLength(1); + expect(sweepCaptures[0].status).toBe("active"); + const { evidence } = sweepCaptures[0]; + expect(Array.isArray(evidence)).toBe(true); + if (!Array.isArray(evidence) || !isRecord(evidence[0])) { + throw new Error("The capture did not expose its evidence."); + } + expect(evidence[0].excerpt).toBe(EXPERT_OBJECTIVE_QUOTE); const { completion } = appliedSweepPart.output; if (!isRecord(completion) || !Array.isArray(completion.failures)) { throw new Error("The sweep did not expose its completion report."); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts new file mode 100644 index 00000000000..4140c06200b --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts @@ -0,0 +1,224 @@ +import { expect, test } from "vitest"; + +import { createBrunchPanelTransport } from "./brunch-panel-transport"; + +import type { PetrinautAiChatTransport } from "@hashintel/petrinaut/ui"; +import type { UIMessageChunk } from "ai"; + +const sweepChunks: UIMessageChunk[] = [ + { + type: "tool-input-available", + toolCallId: "no-range", + toolName: "brunch_sweep", + input: {}, + }, + { + type: "tool-output-available", + toolCallId: "no-range", + output: { status: "no-settled-range" }, + }, + { + type: "tool-input-available", + toolCallId: "refused", + toolName: "brunch_sweep", + input: {}, + }, + { + type: "tool-output-available", + toolCallId: "refused", + output: { + status: "refused", + refusal: { + code: "evidence-quote-not-found", + message: "Use an exact quote.", + }, + }, + }, + { + type: "tool-input-available", + toolCallId: "applied", + toolName: "brunch_sweep", + input: {}, + }, + { + type: "tool-output-available", + toolCallId: "applied", + output: { + status: "applied", + appliedCaptureIds: ["capture-1"], + captures: [ + { + id: "capture-1", + status: "active", + epistemicStatus: "explicit", + confidence: "high", + content: { value: { type: "slot-asserted" } }, + evidence: [{ excerpt: "Line A runs next." }], + }, + { + id: "capture-old", + status: "superseded", + epistemicStatus: "inferred", + confidence: "medium", + content: { absence: "unknown" }, + basis: { + type: "default-rule", + description: "The earlier default.", + }, + alternativeGroup: "line-a-owner", + }, + { + id: "capture-withdrawn", + status: "retracted", + epistemicStatus: "explicit", + confidence: "high", + content: { value: "Sam" }, + evidence: [{ excerpt: "Not Sam after all." }], + supersedes: "capture-old", + }, + ], + completion: { + complete: false, + pluginVersion: "1.0.0", + revision: "revision-1", + failures: [ + { + diagnostic: "unaddressed", + nodeId: "activity:line-a", + kind: "activity", + slot: "owner", + requirement: "named", + actual: "not mentioned", + message: "The owner is missing.", + captureIds: ["capture-1"], + }, + ], + sliceNodeIds: ["activity:line-a"], + outsideSlice: [ + { + nodeId: "activity:line-b", + kind: "activity", + open: [], + }, + ], + }, + }, + }, +]; + +const sourceTransport: PetrinautAiChatTransport = { + reconnectToStream: async () => null, + sendMessages: async () => + new ReadableStream({ + start(controller) { + for (const chunk of sweepChunks) controller.enqueue(chunk); + controller.close(); + }, + }), +}; + +const readChunks = async ( + stream: ReadableStream, +): Promise => { + const chunks: UIMessageChunk[] = []; + for await (const chunk of stream) chunks.push(chunk); + return chunks; +}; + +test("makes every Brunch sweep outcome readable in the panel", async () => { + const transport = createBrunchPanelTransport(sourceTransport); + const stream = await transport.sendMessages({} as never); + const chunks = await readChunks(stream); + const outputs = chunks.filter( + (chunk) => chunk.type === "tool-output-available", + ); + + expect(outputs.map((outputChunk) => outputChunk.output)).toEqual([ + { + status: "no-settled-range", + title: "No settled range to sweep", + detail: "The conversation has no settled user entries.", + }, + { + status: "refused", + refusal: { + code: "evidence-quote-not-found", + message: "Use an exact quote.", + }, + title: "Sweep refused", + detail: "Use an exact quote.", + items: ["Refusal: evidence-quote-not-found"], + }, + { + status: "applied", + appliedCaptureIds: ["capture-1"], + captures: [ + { + id: "capture-1", + status: "active", + epistemicStatus: "explicit", + confidence: "high", + content: { value: { type: "slot-asserted" } }, + evidence: [{ excerpt: "Line A runs next." }], + }, + { + id: "capture-old", + status: "superseded", + epistemicStatus: "inferred", + confidence: "medium", + content: { absence: "unknown" }, + basis: { + type: "default-rule", + description: "The earlier default.", + }, + alternativeGroup: "line-a-owner", + }, + { + id: "capture-withdrawn", + status: "retracted", + epistemicStatus: "explicit", + confidence: "high", + content: { value: "Sam" }, + evidence: [{ excerpt: "Not Sam after all." }], + supersedes: "capture-old", + }, + ], + completion: { + complete: false, + pluginVersion: "1.0.0", + revision: "revision-1", + failures: [ + { + diagnostic: "unaddressed", + nodeId: "activity:line-a", + kind: "activity", + slot: "owner", + requirement: "named", + actual: "not mentioned", + message: "The owner is missing.", + captureIds: ["capture-1"], + }, + ], + sliceNodeIds: ["activity:line-a"], + outsideSlice: [ + { + nodeId: "activity:line-b", + kind: "activity", + open: [], + }, + ], + }, + title: "Sweep applied", + detail: "1 new capture · 3 total · incomplete", + items: [ + 'Capture capture-1 (active; explicit; confidence high): {"type":"slot-asserted"} — “Line A runs next.”', + "Capture capture-old (superseded; inferred; confidence medium): absence: unknown — default-rule: The earlier default.; alternative group line-a-owner", + 'Capture capture-withdrawn (retracted; explicit; confidence high): "Sam" — “Not Sam after all.”; supersedes capture-old', + "Completion: incomplete · plugin 1.0.0 · revision revision-1", + "Completion slice: activity:line-a", + "Completion gap [unaddressed] at activity:line-a.owner: needs named; actual not mentioned. The owner is missing. Captures: capture-1", + "Outside completion slice: activity:line-b (activity); 0 open requirements", + ], + }, + ]); +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts new file mode 100644 index 00000000000..7f56d2c8a49 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts @@ -0,0 +1,202 @@ +import { z } from "zod"; + +import { SWEEP_TOOL_NAME } from "@hashintel/brunch-agent-transport-aisdk/client-tools"; + +import type { PetrinautAiChatTransport } from "@hashintel/petrinaut/ui"; +import type { UIMessageChunk } from "ai"; + +const completionFailureSchema = z.object({ + diagnostic: z.string(), + nodeId: z.string().optional(), + kind: z.string().optional(), + slot: z.string().optional(), + requirement: z.string(), + actual: z.string(), + message: z.string(), + captureIds: z.array(z.string()), +}); + +const completionReportSchema = z.object({ + complete: z.boolean(), + pluginVersion: z.string(), + revision: z.string(), + failures: z.array(completionFailureSchema), + sliceNodeIds: z.array(z.string()), + outsideSlice: z.array( + z.object({ + nodeId: z.string(), + kind: z.string(), + open: z.array(completionFailureSchema), + }), + ), +}); + +const captureSchema = z.object({ + id: z.string(), + status: z.enum(["active", "superseded", "retracted"]), + epistemicStatus: z.string(), + confidence: z.string(), + content: z.union([ + z.object({ value: z.unknown() }), + z.object({ absence: z.string() }), + ]), + evidence: z.array(z.object({ excerpt: z.string() })).optional(), + basis: z + .object({ + type: z.string(), + description: z.string(), + }) + .optional(), + alternativeGroup: z.string().optional(), + supersedes: z.string().optional(), +}); + +const sweepOutputSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("no-settled-range") }), + z.object({ + status: z.literal("refused"), + refusal: z.object({ + code: z.string(), + message: z.string(), + }), + }), + z.object({ + status: z.literal("applied"), + appliedCaptureIds: z.array(z.string()), + captures: z.array(captureSchema), + completion: completionReportSchema.optional(), + }), +]); + +type CompletionFailure = z.infer; +type CompletionReport = z.infer; +type VisibleCapture = z.infer; + +const formatFailure = (failure: CompletionFailure): string => { + const location = + failure.nodeId === undefined + ? "" + : ` at ${failure.nodeId}${failure.slot === undefined ? "" : `.${failure.slot}`}`; + const captures = + failure.captureIds.length === 0 + ? "" + : ` Captures: ${failure.captureIds.join(", ")}`; + return `Completion gap [${failure.diagnostic}]${location}: needs ${failure.requirement}; actual ${failure.actual}. ${failure.message}${captures}`; +}; + +const formatCapture = (capture: VisibleCapture): string => { + const content = + "value" in capture.content + ? JSON.stringify(capture.content.value) + : `absence: ${capture.content.absence}`; + const provenance = + capture.evidence !== undefined + ? capture.evidence.map((evidence) => `“${evidence.excerpt}”`).join("; ") + : capture.basis === undefined + ? "no provenance" + : `${capture.basis.type}: ${capture.basis.description}`; + const history = [ + capture.alternativeGroup === undefined + ? undefined + : `alternative group ${capture.alternativeGroup}`, + capture.supersedes === undefined + ? undefined + : `supersedes ${capture.supersedes}`, + ].filter((fact) => fact !== undefined); + return `Capture ${capture.id} (${capture.status}; ${capture.epistemicStatus}; confidence ${capture.confidence}): ${content} — ${provenance}${history.length === 0 ? "" : `; ${history.join("; ")}`}`; +}; + +const formatCompletion = (report: CompletionReport): string[] => [ + `Completion: ${report.complete ? "complete" : "incomplete"} · plugin ${report.pluginVersion} · revision ${report.revision}`, + `Completion slice: ${report.sliceNodeIds.join(", ") || "none"}`, + ...report.failures.map(formatFailure), + ...report.outsideSlice.flatMap((node) => [ + `Outside completion slice: ${node.nodeId} (${node.kind}); ${node.open.length} open requirement${node.open.length === 1 ? "" : "s"}`, + ...node.open.map( + (failure) => `Outside-slice ${formatFailure(failure).toLowerCase()}`, + ), + ]), +]; + +const summarizeSweepOutput = ( + output: unknown, +): + | { + readonly title: string; + readonly detail: string; + readonly items?: readonly string[]; + } + | undefined => { + const parsed = sweepOutputSchema.safeParse(output); + if (!parsed.success) return undefined; + + const sweep = parsed.data; + switch (sweep.status) { + case "no-settled-range": + return { + title: "No settled range to sweep", + detail: "The conversation has no settled user entries.", + }; + case "refused": + return { + title: "Sweep refused", + detail: sweep.refusal.message, + items: [`Refusal: ${sweep.refusal.code}`], + }; + case "applied": + return { + title: "Sweep applied", + detail: `${sweep.appliedCaptureIds.length} new capture${sweep.appliedCaptureIds.length === 1 ? "" : "s"} · ${sweep.captures.length} total · ${sweep.completion?.complete === true ? "complete" : "incomplete"}`, + items: [ + ...sweep.captures.map(formatCapture), + ...(sweep.completion === undefined + ? [] + : formatCompletion(sweep.completion)), + ], + }; + } +}; + +const decorateBrunchStream = ( + stream: ReadableStream, +): ReadableStream => { + const toolNamesByCallId = new Map(); + return stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + if (chunk.type === "tool-input-available") { + toolNamesByCallId.set(chunk.toolCallId, chunk.toolName); + } + if ( + chunk.type === "tool-output-available" && + toolNamesByCallId.get(chunk.toolCallId) === SWEEP_TOOL_NAME + ) { + const summary = summarizeSweepOutput(chunk.output); + if ( + summary !== undefined && + typeof chunk.output === "object" && + chunk.output !== null + ) { + controller.enqueue({ + ...chunk, + output: { ...chunk.output, ...summary }, + }); + return; + } + } + controller.enqueue(chunk); + }, + }), + ); +}; + +export const createBrunchPanelTransport = ( + transport: PetrinautAiChatTransport, +): PetrinautAiChatTransport => ({ + reconnectToStream: async (options) => { + const stream = await transport.reconnectToStream(options); + return stream === null ? null : decorateBrunchStream(stream); + }, + sendMessages: async (options) => + decorateBrunchStream(await transport.sendMessages(options)), +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx index 81d6b3bbf22..540d073db8e 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx @@ -19,6 +19,7 @@ import { import { useSentryFeedbackAction } from "../sentry-feedback-button"; import { brunchAskInteractiveTool } from "./brunch-ask-interactive-tool"; +import { createBrunchPanelTransport } from "./brunch-panel-transport"; import { getOrCreateBrunchPrincipal } from "./brunch-principal"; import { useLocalStorageAiMessages } from "./use-local-storage-ai-messages"; import { @@ -83,12 +84,14 @@ const createHandle = (net: SDCPNInLocalStorage): PetrinautDocHandle => }); const petrinautAiChatTransport: PetrinautAiChatTransport = - new DefaultChatTransport({ - api: "/api/chat", - headers: () => ({ - [BRUNCH_PRINCIPAL_HEADER]: getOrCreateBrunchPrincipal(), + createBrunchPanelTransport( + new DefaultChatTransport({ + api: "/api/chat", + headers: () => ({ + [BRUNCH_PRINCIPAL_HEADER]: getOrCreateBrunchPrincipal(), + }), }), - }); + ); const getStoredSDCPNsForDisplay = ( storedSDCPNs: Record, diff --git a/libs/@hashintel/brunch-agent/docs/specs/petrinaut-integration.md b/libs/@hashintel/brunch-agent/docs/specs/petrinaut-integration.md index f33dff6981c..256b247ee2a 100644 --- a/libs/@hashintel/brunch-agent/docs/specs/petrinaut-integration.md +++ b/libs/@hashintel/brunch-agent/docs/specs/petrinaut-integration.md @@ -23,7 +23,7 @@ storage. The problem is connecting the second to the first without rebuilding ei The brunch elicitor runs as a **remote server** built on the harness + `binding-flue`. The demo site swaps its `aiAssistant.transport` to point at that server; everything else in the panel — rendering, the diagnostics decorator, client-side tool execution — is reused as-is. -The elicitor drives Petrinaut's editor through the **existing client-executed tool surface** +The elicitor drives Petrinaut's editor through the **existing UI-executed tool surface** (schemas imported from `petrinaut-core`), riding the harness's turn-suspension protocol: a turn ends with tool calls pending, the panel executes them, and the outputs return on the next dispatch. Sessions, captures, and IRs persist server-side, keyed to an opaque principal the @@ -52,7 +52,7 @@ The panel and the voice edge attach to Brunch through one stable surface: 1. **Chat stream**: the UI sends `POST /api/chat`; a successful response is an AI SDK v6 UI-message stream over HTTP/SSE. -2. **Question affordance**: the client-executed tool is named `brunch_ask`. Its input schema is +2. **Question affordance**: the UI-executed tool is named `brunch_ask`. Its input schema is `{ question: non-empty string }`; its submitted output schema is `{ answer: non-empty string }`. 3. **Principal identity**: every request carries one non-empty, opaque principal in the @@ -156,7 +156,7 @@ or triggers an explicit contract revision. - The elicitor's Petrinaut tools are generated from `petrinaut-core`'s exported tool schemas, so the tool surface tracks Petrinaut's own contract rather than a hand-copied one. - The panel executes only tool names it knows and throws on unknowns; brunch-only tools - therefore execute server-side. If a client-executed brunch tool is ever needed, the change + therefore execute server-side. If a UI-executed brunch tool is ever needed, the change is a generic host-supplied-handlers extension to the `aiAssistant` prop (post-import, per ADR-0004's boundary discipline). diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts index 0461e7a455d..4eb130de583 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts @@ -41,6 +41,7 @@ import { createInitialSweepState, decidePendingAffordance, decideSettlementTrigger, + deriveCaptureStatus, evaluateCompletion, foldElicitedModel, mintAskAffordance, @@ -54,7 +55,6 @@ import { toolName, type CaptureStore, type CaptureStoreSnapshot, - type CompletionReport, type FreeTextAffordanceValue, type Plugin, type SweepState, @@ -71,50 +71,6 @@ const SweepToolOutput = v.looseObject({ status: v.picklist(SWEEP_RESULT_STATUSES), }); -const captureSummary = ( - capture: CaptureStoreSnapshot["captures"][number], -): string => { - const content = - "value" in capture.content - ? JSON.stringify(capture.content.value) - : `absence: ${capture.content.absence}`; - const provenance = - "evidence" in capture - ? capture.evidence.map((evidence) => `“${evidence.excerpt}”`).join("; ") - : `${capture.basis.type}: ${capture.basis.description}`; - return `Capture ${capture.id}: ${content} — ${provenance}`; -}; - -type ReadableCompletionReport = CompletionReport & { - readonly cue: string; - readonly unmapped: readonly unknown[]; - readonly unsatisfied: number; -}; - -const readableAppliedSweep = ({ - appliedCaptureIds, - completion, - snapshot, -}: { - readonly appliedCaptureIds: readonly string[]; - readonly completion?: ReadableCompletionReport; - readonly snapshot: CaptureStoreSnapshot; -}) => ({ - title: "Sweep applied", - detail: `${appliedCaptureIds.length} new capture${appliedCaptureIds.length === 1 ? "" : "s"} · ${snapshot.captures.length} total · ${completion?.complete === true ? "complete" : "incomplete"}`, - items: [ - ...snapshot.captures.map(captureSummary), - ...(completion === undefined - ? [] - : [ - `Completion: ${completion.complete ? "complete" : `${completion.failures.length} requirements remain`}.`, - ...completion.failures.map( - (failure) => `Completion gap: ${failure.message}`, - ), - ]), - ], -}); - export { CAPABILITIES, type Capability, type Provision } from "./capabilities"; export { createFlueHistoryReader, @@ -308,12 +264,12 @@ export function useElicitation( ...("advisories" in applied.value ? applied.value.advisories : []), ...computeUnaccountedAskAdvisories(range, accountedEntryIds), ], + captures: applied.snapshot.captures.map((capture) => + Object.assign({}, capture, { + status: deriveCaptureStatus(applied.snapshot, capture.id), + }), + ), ...(completion === undefined ? {} : { completion }), - ...readableAppliedSweep({ - appliedCaptureIds, - completion, - snapshot: applied.snapshot, - }), }, }; }, diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/client-tools.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/client-tools.ts index d2de4a7c9d8..a61e161d6dd 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/client-tools.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/client-tools.ts @@ -11,6 +11,7 @@ import { } from "@hashintel/brunch-agent/client-tools"; export const ASK_TOOL_NAME = toolName("ask"); +export const SWEEP_TOOL_NAME = toolName("sweep"); export type BrunchAskInput = v.InferOutput; export type BrunchAskOutput = v.InferOutput; diff --git a/libs/@hashintel/petrinaut/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md index a23a0f8672e..926e839142c 100644 --- a/libs/@hashintel/petrinaut/docs/ai-assistant.md +++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md @@ -32,7 +32,10 @@ The assistant has tools for inspecting and modifying the current net. You'll see - **Host-specific questions and actions** -- an application embedding Petrinaut may add interactive widgets. For example, an elicitation assistant can ask a structured question inline and continue after you submit the answer. The - control stays visible as a read-only record of your submitted value. + control stays visible as a read-only record of your submitted value. During + elicitation, inline sweep cards may also list the facts captured so far, + whether earlier facts were superseded or retracted, and any requirements that + still prevent completion. Clicking a mutation card usually selects the entity it touched (place, transition, scenario, metric, etc.) so you can inspect what changed. From 0ba730fc16eaac09232db074f023cc3ea72bc718 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Thu, 27 Aug 2026 11:17:26 +0200 Subject: [PATCH 4/4] Keep identifier case in outside-slice completion labels. Lowercasing the whole formatFailure string also folded node, slot, and capture ids, so a sweep card could show the same node under two identities. Co-authored-by: Cursor --- .../brunch-panel-transport.test.ts | 33 ++++++++++++++++--- .../brunch-panel-transport.ts | 4 +-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts index 4140c06200b..fce09d1658a 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts @@ -96,9 +96,20 @@ const sweepChunks: UIMessageChunk[] = [ sliceNodeIds: ["activity:line-a"], outsideSlice: [ { - nodeId: "activity:line-b", + nodeId: "activity:Line-B", kind: "activity", - open: [], + open: [ + { + diagnostic: "unaddressed", + nodeId: "activity:Line-B", + kind: "activity", + slot: "Owner", + requirement: "named", + actual: "not mentioned", + message: "The owner is missing.", + captureIds: ["Capture-2"], + }, + ], }, ], }, @@ -202,9 +213,20 @@ test("makes every Brunch sweep outcome readable in the panel", async () => { sliceNodeIds: ["activity:line-a"], outsideSlice: [ { - nodeId: "activity:line-b", + nodeId: "activity:Line-B", kind: "activity", - open: [], + open: [ + { + diagnostic: "unaddressed", + nodeId: "activity:Line-B", + kind: "activity", + slot: "Owner", + requirement: "named", + actual: "not mentioned", + message: "The owner is missing.", + captureIds: ["Capture-2"], + }, + ], }, ], }, @@ -217,7 +239,8 @@ test("makes every Brunch sweep outcome readable in the panel", async () => { "Completion: incomplete · plugin 1.0.0 · revision revision-1", "Completion slice: activity:line-a", "Completion gap [unaddressed] at activity:line-a.owner: needs named; actual not mentioned. The owner is missing. Captures: capture-1", - "Outside completion slice: activity:line-b (activity); 0 open requirements", + "Outside completion slice: activity:Line-B (activity); 1 open requirement", + "Outside-slice Completion gap [unaddressed] at activity:Line-B.Owner: needs named; actual not mentioned. The owner is missing. Captures: Capture-2", ], }, ]); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts index 7f56d2c8a49..e9b0915734f 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts @@ -112,9 +112,7 @@ const formatCompletion = (report: CompletionReport): string[] => [ ...report.failures.map(formatFailure), ...report.outsideSlice.flatMap((node) => [ `Outside completion slice: ${node.nodeId} (${node.kind}); ${node.open.length} open requirement${node.open.length === 1 ? "" : "s"}`, - ...node.open.map( - (failure) => `Outside-slice ${formatFailure(failure).toLowerCase()}`, - ), + ...node.open.map((failure) => `Outside-slice ${formatFailure(failure)}`), ]), ];