diff --git a/.github/actions/prune-repository/prune.py b/.github/actions/prune-repository/prune.py index 891da3fdfe2..73d3c2fac8d 100644 --- a/.github/actions/prune-repository/prune.py +++ b/.github/actions/prune-repository/prune.py @@ -41,10 +41,15 @@ } # Extras that must not fire on a transitive or prefix match. Brunch core's -# architecture tests need the app and context root, but a job whose requested -# scope is only a sibling or a consumer of core must not pull that fixture. +# architecture and contract tests inspect the app and shipped plugins, but a job +# whose requested scope is only a sibling or a consumer of core must not pull +# those fixtures. REQUESTED_DEPENDENCIES: dict[str, list[str]] = { - "@hashintel/brunch-agent": ["@apps/brunch-agent"], + "@hashintel/brunch-agent": [ + "@apps/brunch-agent", + "@hashintel/brunch-agent-plugin-gherkin", + "@hashintel/brunch-agent-plugin-sdcpn", + ], } # Non-workspace paths required by packages in the *requested* scope. @@ -61,6 +66,9 @@ "libs/@hashintel/brunch-agent/evaluations", "libs/@hashintel/brunch-agent/scripts", ], + # The app's condition-5 test executes the evaluation runner as a child + # process; the context root is not a workspace and must be copied explicitly. + "@apps/brunch-agent": ["libs/@hashintel/brunch-agent/evaluations"], } TURBO_QUERY = """ diff --git a/.github/actions/prune-repository/prune_test.py b/.github/actions/prune-repository/prune_test.py index 8e2722cb52a..65ad7249f2a 100644 --- a/.github/actions/prune-repository/prune_test.py +++ b/.github/actions/prune-repository/prune_test.py @@ -15,29 +15,43 @@ CORE = "@hashintel/brunch-agent" TRANSPORT = "@hashintel/brunch-agent-transport-aisdk" APP = "@apps/brunch-agent" +PLUGIN_GHERKIN = "@hashintel/brunch-agent-plugin-gherkin" +PLUGIN_SDCPN = "@hashintel/brunch-agent-plugin-sdcpn" WEBSITE = "@apps/petrinaut-website" class BrunchRequestedExtras(unittest.TestCase): - def test_core_job_adds_the_app_and_context_paths(self) -> None: - self.assertEqual(extras_for_requested({CORE}), frozenset({APP})) - self.assertIn( - APP, - fixpoint_expand( - {CORE} | extras_for_requested({CORE}), - {CORE: frozenset(), APP: frozenset({CORE})}, - ), + def test_core_job_adds_the_app_plugins_and_context_paths(self) -> None: + expected_workspaces = frozenset({APP, PLUGIN_GHERKIN, PLUGIN_SDCPN}) + self.assertEqual(extras_for_requested({CORE}), expected_workspaces) + expanded = fixpoint_expand( + {CORE} | extras_for_requested({CORE}), + { + CORE: frozenset(), + APP: frozenset({CORE}), + PLUGIN_GHERKIN: frozenset({CORE}), + PLUGIN_SDCPN: frozenset({CORE}), + }, ) + self.assertTrue(expected_workspaces.issubset(expanded)) self.assertEqual( extra_paths_for_requested({CORE}), [ + ".config/oxlint/brunch", "libs/@hashintel/brunch-agent/AGENTS.md", "libs/@hashintel/brunch-agent/CONTEXT.md", "libs/@hashintel/brunch-agent/docs", + "libs/@hashintel/brunch-agent/evaluations", "libs/@hashintel/brunch-agent/scripts", ], ) + def test_app_job_adds_the_baseline_evaluation_paths(self) -> None: + self.assertEqual( + extra_paths_for_requested({APP}), + ["libs/@hashintel/brunch-agent/evaluations"], + ) + def test_sibling_or_website_job_does_not_add_brunch_extras(self) -> None: self.assertEqual(extras_for_requested({TRANSPORT}), frozenset()) self.assertEqual(extras_for_requested({WEBSITE}), frozenset()) diff --git a/apps/brunch-agent/.oxlintrc.json b/apps/brunch-agent/.oxlintrc.json index b83c386eb3b..91f35bada5a 100644 --- a/apps/brunch-agent/.oxlintrc.json +++ b/apps/brunch-agent/.oxlintrc.json @@ -26,7 +26,7 @@ "paths": [ { "name": "@hashintel/petrinaut", - "message": "The Brunch server must remain independent of Petrinaut implementations." + "message": "The Brunch server must remain independent of Petrinaut UI. Import catalogs from @hashintel/petrinaut-core, not the editor package." } ], "patterns": [ @@ -35,8 +35,12 @@ "message": "The Brunch application must not depend on unpublished HASH packages." }, { - "group": ["@hashintel/petrinaut/*", "@hashintel/petrinaut-*"], - "message": "The Brunch server must remain independent of Petrinaut implementations." + "group": [ + "@hashintel/petrinaut/*", + "@hashintel/petrinaut-cli", + "@hashintel/petrinaut-cli/*" + ], + "message": "The Brunch server must remain independent of Petrinaut UI and CLI. Import catalogs from @hashintel/petrinaut-core, not the editor package." } ] } diff --git a/apps/brunch-agent/AGENTS.md b/apps/brunch-agent/AGENTS.md index 90c05dde6d5..7b0f08cd2d7 100644 --- a/apps/brunch-agent/AGENTS.md +++ b/apps/brunch-agent/AGENTS.md @@ -1,9 +1,13 @@ # Brunch agent application This application belongs to the Brunch context rooted at -`../../libs/@hashintel/brunch-agent/`. Read that context's `AGENTS.md`, `CONTEXT.md`, and relevant -ADRs before changing this application. HASH root guidance takes precedence. +`../../libs/@hashintel/brunch-agent/`. Read that context's `AGENTS.md` and current `MISSION.md` +before changing this application. If `MISSION.next.md` exists, it is a scratchpad for later +concerns and is not execution authority. Consult `CONTEXT.md` or historical design documents only when a +concrete question requires them; ADRs and specs are hypotheses, not implementation obligations. +HASH root guidance takes precedence. -The application composes the Brunch packages, Flue runtime, HTTP routes, and local diagnostics. It -must remain independent of Petrinaut implementation packages; `apps/petrinaut-website` meets it -through the AI SDK/HTTP transport. +The application composes the Flue runtime, HTTP routes, and the Brunch packages required by the +current mission. It must remain independent of Petrinaut UI (`@hashintel/petrinaut`); it may import +published catalogs from `@hashintel/petrinaut-core` (for example user-guide page ids the panel +already executes). `apps/petrinaut-website` meets the editor through the AI SDK/HTTP transport. diff --git a/apps/brunch-agent/README.md b/apps/brunch-agent/README.md new file mode 100644 index 00000000000..d31d1761cfd --- /dev/null +++ b/apps/brunch-agent/README.md @@ -0,0 +1,50 @@ +# Brunch agent application + +## Run the Petrinaut panel locally + +From the repository root, make `ANTHROPIC_API_KEY` available in the environment and run: + +```sh +yarn dev:brunch +``` + +The first step builds the Petrinaut libraries the panel imports (`dist/` and design-system +codegen). Then it starts the Brunch server at `http://127.0.0.1:4321` and the real Petrinaut +website at `http://127.0.0.1:4915`. The website proxies `/api/chat` to Brunch. The panel talks to one plain +Flue chat agent: streamed text and reasoning, one server `ping` tool, one stub +skill (`confirm-path`, activated via `activate_skill`), and the existing Petrinaut +`readPetrinautDoc` client tool. There is no elicitation loop, sweep tool, or +`brunch_ask` on this path. Capture is a harness-side pipe: an explicit settled +range of Flue history is applied into a JSON store beside the conversation +database, not by the interviewer. + +Conversations persist in `apps/brunch-agent/.data-wipe-me/conversations.db`. `BRUNCH_DEV_DB_PATH` +overrides that local path. Capture envelopes for one Flue conversation sit beside that sqlite +file, named by the hashed instance id (`.json`). The hermetic `/api/chat` test uses +`BRUNCH_CHAT_DB_PATH` and writes the capture file in that same directory. Flue history is the +conversation log; the capture store is not a second transcript. The browser may cache messages +but reload hydrates from `GET /api/chat?id=`. + +The mounted Flue URL `/agents/chat/:id` requires the same principal and conversation identity (`x-brunch-principal` and `x-brunch-conversation`) as `/api/chat`; the path id is the hash of those, not a bearer token. + +Print a human-readable transcript of one conversation from that same Flue history (server already +running): + +```sh +yarn workspace @apps/brunch-agent transcript -- --principal --id +``` + +## Voice dock + +A second input modality joins the same chat door. It is not a voice route and does not own +provider audio or session state. + +| | | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| URL | `POST /api/chat` (and `GET /api/chat?id=` to hydrate) | +| Identity | `x-brunch-principal` plus body `id` (the conversation id). The server hashes those into the Flue instance id. | +| Initial turn | JSON `{ id, trigger: "submit-message", messages }` whose last user text part is the utterance. | +| Client-tool follow-up | Same POST, with `messageId` of the assistant message and completed client-tool parts (`providerExecuted` not true). Correlated by `toolCallId`. | +| Response | AI SDK UI-message stream (SSE). | + +`OPTIONS /api/chat` is the CORS preflight for that same contract. diff --git a/apps/brunch-agent/index.html b/apps/brunch-agent/index.html index 0b84eae229a..9dc347b8be8 100644 --- a/apps/brunch-agent/index.html +++ b/apps/brunch-agent/index.html @@ -3,7 +3,7 @@ - Elicitation harness — dev app + Brunch Flue chat
diff --git a/apps/brunch-agent/package.json b/apps/brunch-agent/package.json index cf9c5d37003..fb22a4e054a 100644 --- a/apps/brunch-agent/package.json +++ b/apps/brunch-agent/package.json @@ -2,7 +2,7 @@ "name": "@apps/brunch-agent", "version": "0.0.0-private", "private": true, - "description": "Remote Brunch server, local development loop, target gallery, and diagnostic probe surface.", + "description": "Remote Brunch server, local development loop, and Petrinaut /api/chat door.", "license": "AGPL-3.0", "type": "module", "scripts": { @@ -12,17 +12,18 @@ "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", "lint:tsc": "tsgo --noEmit", "petrinaut:dev": "vite dev --config petrinaut-local.vite.config.ts", - "test:unit": "vitest run --config vitest.config.ts" + "test:unit": "vitest run --config vitest.config.ts", + "transcript": "node --experimental-strip-types src/transcript-cli.ts" }, "dependencies": { + "@flue/opentelemetry": "2.0.3", "@flue/react": "2.0.3", "@flue/runtime": "2.0.3", "@flue/sdk": "2.0.3", - "@hashintel/brunch-agent": "workspace:*", "@hashintel/brunch-agent-binding-flue": "workspace:*", - "@hashintel/brunch-agent-plugin-gherkin": "workspace:*", - "@hashintel/brunch-agent-plugin-sdcpn": "workspace:*", "@hashintel/brunch-agent-transport-aisdk": "workspace:*", + "@hashintel/petrinaut-core": "workspace:*", + "@opentelemetry/api": "1.9.1", "hono": "4.13.2", "react": "19.2.6", "react-dom": "19.2.6", diff --git a/apps/brunch-agent/petrinaut-local.vite.config.ts b/apps/brunch-agent/petrinaut-local.vite.config.ts index 249686c8498..58b2973bda7 100644 --- a/apps/brunch-agent/petrinaut-local.vite.config.ts +++ b/apps/brunch-agent/petrinaut-local.vite.config.ts @@ -41,9 +41,7 @@ const withoutIncumbentChatHandler = ( export default defineConfig(async (environment) => { const websiteRoot = process.env.PETRINAUT_WEBSITE_ROOT; if (!websiteRoot) { - throw new Error( - "PETRINAUT_WEBSITE_ROOT must point at hash/apps/petrinaut-website for the real-panel run.", - ); + throw new Error("PETRINAUT_WEBSITE_ROOT is required."); } const root = resolve(websiteRoot); // Babel resolves the React compiler plugin from the launched project's cwd, diff --git a/apps/brunch-agent/src/agent-ownership.ts b/apps/brunch-agent/src/agent-ownership.ts new file mode 100644 index 00000000000..7ede5572276 --- /dev/null +++ b/apps/brunch-agent/src/agent-ownership.ts @@ -0,0 +1,36 @@ +/** Hono middleware: the mounted Flue route enforces the same ownership rule as `/api/chat`. */ + +import { BRUNCH_PRINCIPAL_HEADER } from "@hashintel/brunch-agent-transport-aisdk/headers"; + +import { ownsFlueInstance } from "./conversation-identity.ts"; +import { BRUNCH_CONVERSATION_HEADER } from "./conversation-payload.ts"; + +import type { MiddlewareHandler } from "hono"; + +export const agentOwnershipGuard = (mountPrefix: string): MiddlewareHandler => { + return async (context, next) => { + const principalKey = context.req.header(BRUNCH_PRINCIPAL_HEADER)?.trim(); + const conversationId = context.req + .header(BRUNCH_CONVERSATION_HEADER) + ?.trim(); + if ( + principalKey === undefined || + principalKey.length === 0 || + conversationId === undefined || + conversationId.length === 0 + ) { + return context.json({ error: "unauthorized" }, 401); + } + const instanceId = context.req.path + .slice(mountPrefix.length) + .split("/") + .find((segment) => segment.length > 0); + if ( + instanceId === undefined || + !ownsFlueInstance(principalKey, conversationId, instanceId) + ) { + return context.json({ error: "forbidden" }, 403); + } + return next(); + }; +}; diff --git a/apps/brunch-agent/src/agents/chat-agent.ts b/apps/brunch-agent/src/agents/chat-agent.ts new file mode 100644 index 00000000000..01768fcbdad --- /dev/null +++ b/apps/brunch-agent/src/agents/chat-agent.ts @@ -0,0 +1,46 @@ +"use agent"; +/** + * One plain Flue chat agent for the Petrinaut panel throughline. + * + * Capture is a harness-side pipe, not an interviewer tool. One stub skill is + * mounted so activation can appear in Flue history. + */ + +import { defineSkill, useModel, useSkill, useTool } from "@flue/runtime"; + +import { ping } from "../tools/ping.ts"; +import { readPetrinautDoc } from "../tools/read-petrinaut-doc.ts"; + +export const CHAT_MODEL_ID = + process.env["BRUNCH_CHAT_MODEL"] || "claude-haiku-4-5"; + +export const STUB_SKILL_NAME = "confirm-path"; + +export const ACTIVATE_SKILL_TOOL_NAME = "activate_skill"; + +const confirmPath = defineSkill({ + name: STUB_SKILL_NAME, + description: + "Confirm how this assistant is mounted. Use when checking the server path or tool layout.", + instructions: + "Say that ping confirms the server tool path. Then continue helping the user.", +}); + +export function ChatAgent() { + useModel(`anthropic/${CHAT_MODEL_ID}`); + useSkill(confirmPath); + useTool(ping); + useTool(readPetrinautDoc); + return [ + "You are a concise assistant inside the Petrinaut editor.", + "Call ping when you need to confirm the server tool path.", + `Activate the \`${STUB_SKILL_NAME}\` skill before calling ping.`, + "When the user asks how Petrinaut's UI works, call readPetrinautDoc.", + "A client-tool-result signal is JSON [{ toolCallId, toolName, output }]. Treat output as the browser's result for that call and continue helping the user.", + ].join("\n"); +} + +/** + * Pinned, and never to be edited: conversation storage keys on this literal. + */ +ChatAgent.agentName = "brunch-chat-agent"; diff --git a/apps/brunch-agent/src/agents/gherkin-elicitor.ts b/apps/brunch-agent/src/agents/gherkin-elicitor.ts deleted file mode 100644 index 9aaf8b1e493..00000000000 --- a/apps/brunch-agent/src/agents/gherkin-elicitor.ts +++ /dev/null @@ -1,76 +0,0 @@ -"use agent"; -/** - * The gherkin elicitor (spec §12.5: one agent per target). - * - * Named as a noun — the thing, not the act — and read target-first, so the - * family sorts together as targets multiply: `gherkin-elicitor`, - * `assurance-elicitor`. - * - * The product is the harness library in a thin host-authored agent — Flue's - * build-time scan makes the alternative structurally unavailable, since a - * library cannot ship a pre-registered agent (spec §12.1). So this module is - * deliberately thin: it mounts harness capability and holds no elicitation - * semantics of its own. - * - * Three recorded Flue constraints are honoured here by construction (spec §10): - * the `'use agent'` directive is the file's first statement; `agentName` is a - * pinned string literal, because conversation storage keys on it; and the tool - * set is static, because prompt-cache economics forbid per-question tool - * swapping. - */ - -import { useInitialData, useModel, type AgentProps } from "@flue/runtime"; -import * as v from "valibot"; - -import { useElicitation } from "@hashintel/brunch-agent-binding-flue"; -import { gherkin } from "@hashintel/brunch-agent-plugin-gherkin"; - -import { createGherkinElicitationSession } from "../elicitation-session.ts"; - -/** - * One definition for the agent and the faux provider alike: the two must name - * the same model id, and drift fails at resolution only if both sides resolve - * the same string (Flue patterns audit, 2026-08-17). - */ -export const GHERKIN_MODEL_ID = "claude-haiku-4-5"; - -const gherkinElicitorInitialData = v.object({ - targetDocumentId: v.pipe(v.string(), v.nonEmpty()), -}); - -export function GherkinElicitor(props: AgentProps) { - useModel(`anthropic/${GHERKIN_MODEL_ID}`); - const initialData = - useInitialData>(); - return useElicitation( - gherkin, - createGherkinElicitationSession(props.id, initialData.targetDocumentId), - ); -} - -/** - * Pinned, and never to be edited: conversation storage keys on this literal, - * so changing it orphans every existing conversation. Flue requires a string - * literal here because build targets derive durable identifiers from it before - * any user code runs. - * - * Product-prefixed on purpose, and this is the one place the prefix is not - * cosmetic. Agent identities are global per application, and the September - * demo shell is chartered to mount this library alongside the Petrinaut - * libraries — a bare `gherkin-elicitor` could collide with another library's - * agent, and the collision would land on durable conversation storage. - * - * The exported symbol stays the shorter `GherkinElicitor` because it reads - * better at the mount site; `agentName` exists precisely to let durable - * identity and source-level name differ. - */ -GherkinElicitor.agentName = "brunch-gherkin-elicitor"; - -/** - * Session→document binding (spec §9.1, adjudication L4): a new session's - * `initialData` carries the target-document id, validated once at creation and - * immutable thereafter — Flue's own lane for a target descriptor. Dispatching - * to an existing conversation id resumes that session against the current state - * of its target-document. - */ -GherkinElicitor.initialData = gherkinElicitorInitialData; diff --git a/apps/brunch-agent/src/agents/sdcpn-elicitor.ts b/apps/brunch-agent/src/agents/sdcpn-elicitor.ts deleted file mode 100644 index d0858804085..00000000000 --- a/apps/brunch-agent/src/agents/sdcpn-elicitor.ts +++ /dev/null @@ -1,54 +0,0 @@ -"use agent"; -/** - * The SDCPN elicitor (spec §12.5: one agent per target). - * - * The second entry in the target gallery, and the first whose plugin is a - * file: `@hashintel/brunch-agent-plugin-sdcpn` loads `plugin.md` and the - * harness reads its three tables (ADR-0006). This module is as thin as the - * gherkin one — it mounts harness capability and holds no elicitation - * semantics of its own; what the interviewer asks, demands, and treats as - * complete all comes from the plugin file through the binding. - * - * The same three recorded Flue constraints hold here by construction - * (spec §10): `'use agent'` is the file's first statement; `agentName` is a - * pinned string literal; the tool set is static. - */ - -import { useInitialData, useModel, type AgentProps } from "@flue/runtime"; -import * as v from "valibot"; - -import { useElicitation } from "@hashintel/brunch-agent-binding-flue"; -import { sdcpn } from "@hashintel/brunch-agent-plugin-sdcpn"; - -import { createSdcpnElicitationSession } from "../elicitation-session.ts"; - -/** One definition for the agent and any faux provider alike (see the gherkin elicitor). */ -export const SDCPN_MODEL_ID = "claude-haiku-4-5"; - -const sdcpnElicitorInitialData = v.object({ - targetDocumentId: v.pipe(v.string(), v.nonEmpty()), -}); - -export function SdcpnElicitor(props: AgentProps) { - useModel(`anthropic/${SDCPN_MODEL_ID}`); - const initialData = - useInitialData>(); - return useElicitation( - sdcpn, - createSdcpnElicitationSession(props.id, initialData.targetDocumentId), - ); -} - -/** - * Pinned, and never to be edited: conversation storage keys on this literal, - * so changing it orphans every existing conversation. Product-prefixed for the - * same reason as the gherkin elicitor — agent identities are global per - * application and the demo shell mounts this library beside others. - */ -SdcpnElicitor.agentName = "brunch-sdcpn-elicitor"; - -/** - * Session→document binding (spec §9.1): `initialData` carries the - * target-document id, validated once at creation and immutable thereafter. - */ -SdcpnElicitor.initialData = sdcpnElicitorInitialData; diff --git a/apps/brunch-agent/src/app.ts b/apps/brunch-agent/src/app.ts index 43f0315289f..71917611093 100644 --- a/apps/brunch-agent/src/app.ts +++ b/apps/brunch-agent/src/app.ts @@ -1,54 +1,37 @@ /** - * The dev app's route map — the "mount" half of the thin host (spec §12.1). + * The app's route map — one plain Flue chat agent plus Petrinaut's /api/chat door. * - * The dev app is chartered with three roles, none of them "the product" - * (spec §12.5): the local dev loop against every plugin, the colleague-facing - * target-gallery demo, and the diagnostic probe surface. Milestone one keeps - * affordance renderers here rather than in a ui package. + * Both doors require principal + conversation id. `/api/chat` takes the principal + * header and body `id`; `/agents/chat/:id` takes the same principal plus + * `x-brunch-conversation` and admits the request only when those re-derive the + * path id. The Flue instance id is derived, not a bearer token. */ import { readFile } from "node:fs/promises"; +import { createOpenTelemetryInstrumentation } from "@flue/opentelemetry"; +import { instrument } from "@flue/runtime"; import { createAgentRouter } from "@flue/runtime/routing"; import { Hono } from "hono"; -import { GherkinElicitor } from "./agents/gherkin-elicitor.ts"; -import { SdcpnElicitor } from "./agents/sdcpn-elicitor.ts"; +import { agentOwnershipGuard } from "./agent-ownership.ts"; +import { ChatAgent } from "./agents/chat-agent.ts"; import { assetHandler } from "./assets.ts"; import { petrinautChatHandler } from "./petrinaut-chat.ts"; -import { - GHERKIN_AGENT_ROUTE, - PETRINAUT_CHAT_ROUTE, - SDCPN_AGENT_ROUTE, -} from "./routes.ts"; +import { CHAT_AGENT_ROUTE, PETRINAUT_CHAT_ROUTE } from "./routes.ts"; + +instrument(createOpenTelemetryInstrumentation({ content: false })); const app = new Hono(); -// One route per target agent. The gallery grows an entry per plugin; gherkin -// is the tracer that wires end-to-end first (spec §13). The browser and mount -// share the route constant; Flue still keys storage on the agent's independent, -// pinned identity. -app.route(`/agents/${GHERKIN_AGENT_ROUTE}`, createAgentRouter(GherkinElicitor)); -// The SDCPN elicitor is the process-model target (ADR-0006): the plugin file -// is code the harness loads, and this mount is what FE-1404's run talks to. -app.route(`/agents/${SDCPN_AGENT_ROUTE}`, createAgentRouter(SdcpnElicitor)); +const chatAgentMount = `/agents/${CHAT_AGENT_ROUTE}`; +app.use(`${chatAgentMount}/*`, agentOwnershipGuard(`${chatAgentMount}/`)); +app.route(chatAgentMount, createAgentRouter(ChatAgent)); -// The application owns the HTTP mount; transport-aisdk owns only request validation -// and AI SDK stream encoding. No parallel conversation renderer is introduced. -app.on(["POST", "OPTIONS"], PETRINAUT_CHAT_ROUTE, (c) => +app.on(["GET", "POST", "OPTIONS"], PETRINAUT_CHAT_ROUTE, (c) => petrinautChatHandler(c.req.raw), ); -// The flue dev controller owns the whole request space — no fall-through to -// vite's html serving — so the ui is app-served, in dev and in production -// alike (spec §10, recorded facts). -// -// Two different files, because two different builds produce them: in dev, the -// source `index.html` whose script tag vite resolves live; in production, the -// client build's emitted `index.html`, whose script tag points at a real -// bundled asset. `@flue/vite` emits the server environment only, so that -// client build is a second, plain vite build — without it the ui tree would -// have no build coverage at all. const uiRoot = new URL( // oxlint-disable-next-line typescript/no-unnecessary-condition -- import.meta.env is absent when Node executes this module directly. import.meta.env?.DEV === false ? "./client/" : "../", @@ -59,8 +42,6 @@ app.get("/", async (c) => c.html(await readFile(new URL("index.html", uiRoot), "utf8")), ); -// Production only: in dev, vite serves the module graph under /src. A -// wildcard, not `:file` — bundlers may emit nested asset paths. app.get("/assets/*", assetHandler(uiRoot)); export default app; diff --git a/apps/brunch-agent/src/capture-sweep.ts b/apps/brunch-agent/src/capture-sweep.ts new file mode 100644 index 00000000000..0b991c0c5b6 --- /dev/null +++ b/apps/brunch-agent/src/capture-sweep.ts @@ -0,0 +1,102 @@ +/** + * Harness-side apply-sweep over a named Flue history range. + * + * The interviewer does not call this. A test or harness fact names the range. + * Stub extraction: one envelope per user utterance, quote = that text, payload {}. + */ + +import { + createFlueHistoryReader, + createLocalCaptureStore, + projectFlueHistoryForSweep, +} from "@hashintel/brunch-agent-binding-flue"; + +import { + agentOwnershipHeaders, + flueConversationIdFrom, + type ConversationIdentity, +} from "./conversation-identity.ts"; +import { captureStorePath } from "./db-path.ts"; +import { CHAT_AGENT_ROUTE } from "./routes.ts"; + +export interface CaptureSweepCapture { + readonly id: string; + readonly excerpt: string; + readonly payload: unknown; +} + +export interface CaptureSweepResult { + readonly appliedCaptureIds: readonly string[]; + readonly skippedDedupKeys: readonly string[]; + readonly captures: readonly CaptureSweepCapture[]; +} + +const conversationUrl = (instanceId: string): string => + `http://brunch.local/agents/${CHAT_AGENT_ROUTE}/${instanceId}`; + +const ownedTransport = (identity: ConversationIdentity): typeof fetch => { + const ownership = agentOwnershipHeaders(identity); + return async (input, init) => { + const { default: app } = await import("./app.ts"); + const headers = new Headers(init?.headers); + for (const [key, value] of Object.entries(ownership)) { + headers.set(key, value); + } + return app.fetch( + input instanceof Request + ? new Request(input, { headers }) + : new Request(input, { ...init, headers }), + ); + }; +}; + +export const applyCaptureSweep = async ( + identity: ConversationIdentity, + userEntryIds: readonly string[], +): Promise => { + const instanceId = flueConversationIdFrom(identity); + const store = createLocalCaptureStore(captureStorePath(instanceId), { + ownerKey: identity.principalKey, + }); + const historyReader = createFlueHistoryReader({ + resolveConversationUrl: conversationUrl, + transport: ownedTransport(identity), + archive: store, + }); + const snapshot = await historyReader.read(instanceId); + const range = new Set(userEntryIds); + const proposals = projectFlueHistoryForSweep(snapshot) + .filter( + (entry) => + entry.kind === "user" && range.has(entry.id) && entry.text.length > 0, + ) + .map((entry) => ({ + evidence: [{ excerpt: entry.text }], + epistemicStatus: "explicit" as const, + confidence: "high", + content: { value: {} }, + })); + const applied = await store.execute( + { type: "apply-sweep", proposals }, + { sessionId: instanceId }, + ); + if (!applied.ok) { + throw new Error( + `apply-sweep refused: ${applied.refusal.code}: ${applied.refusal.message}`, + ); + } + if (!("appliedCaptureIds" in applied.value)) { + throw new Error("apply-sweep did not return a sweep value."); + } + return { + appliedCaptureIds: applied.value.appliedCaptureIds, + skippedDedupKeys: applied.value.skippedDedupKeys, + captures: applied.snapshot.captures.map((capture) => ({ + id: capture.id, + excerpt: + "evidence" in capture ? (capture.evidence[0]?.excerpt ?? "") : "", + payload: + "value" in capture.content ? capture.content.value : capture.content, + })), + }; +}; diff --git a/apps/brunch-agent/src/channels/.gitkeep b/apps/brunch-agent/src/channels/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/brunch-agent/src/client-tool.ts b/apps/brunch-agent/src/client-tool.ts new file mode 100644 index 00000000000..f5b853f0796 --- /dev/null +++ b/apps/brunch-agent/src/client-tool.ts @@ -0,0 +1,20 @@ +/** Flue-side client-tool signal contract: awaiting sentinel, result signal, tool names. */ + +import { readPetrinautDocToolName } from "@hashintel/petrinaut-core/ai"; + +export const CLIENT_TOOL_RESULT_SIGNAL = "client-tool-result"; + +export const AWAITING_CLIENT = "client" as const; + +export const clientToolNames: ReadonlySet = new Set([ + readPetrinautDocToolName, +]); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +export const isAwaitingClient = (output: unknown): boolean => + isRecord(output) && output.awaiting === AWAITING_CLIENT; + +export const providerExecutedFor = (clientTool: boolean): true | undefined => + clientTool ? undefined : true; diff --git a/apps/brunch-agent/src/conversation-identity-web.ts b/apps/brunch-agent/src/conversation-identity-web.ts new file mode 100644 index 00000000000..8faf2eb2d84 --- /dev/null +++ b/apps/brunch-agent/src/conversation-identity-web.ts @@ -0,0 +1,14 @@ +/** Browser-safe instance-id hash; must match `flueConversationId` byte-for-byte. */ + +import { hexFromDigest, identityPayload } from "./conversation-payload.ts"; + +export const flueConversationIdWeb = async ( + principalKey: string, + conversationId: string, +): Promise => { + const payload = identityPayload(principalKey, conversationId); + const bytes = new ArrayBuffer(payload.byteLength); + new Uint8Array(bytes).set(payload); + const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes); + return hexFromDigest(digest); +}; diff --git a/apps/brunch-agent/src/conversation-identity.ts b/apps/brunch-agent/src/conversation-identity.ts new file mode 100644 index 00000000000..4fb33d29a9b --- /dev/null +++ b/apps/brunch-agent/src/conversation-identity.ts @@ -0,0 +1,49 @@ +/** Stable Flue instance id for one principal + panel conversation. */ + +import { createHash, timingSafeEqual } from "node:crypto"; + +import { + BRUNCH_CONVERSATION_HEADER, + BRUNCH_PRINCIPAL_HEADER, + identityPayload, +} from "./conversation-payload.ts"; + +import type { ConversationIdentity } from "@hashintel/brunch-agent-transport-aisdk"; + +export { + BRUNCH_CONVERSATION_HEADER, + BRUNCH_PRINCIPAL_HEADER, + LOCAL_UI_PRINCIPAL, +} from "./conversation-payload.ts"; +export type { ConversationIdentity }; + +export const flueConversationId = ( + principalKey: string, + conversationId: string, +): string => + createHash("sha256") + .update(identityPayload(principalKey, conversationId)) + .digest("hex"); + +export const flueConversationIdFrom = ( + identity: ConversationIdentity, +): string => flueConversationId(identity.principalKey, identity.conversationId); + +export const ownsFlueInstance = ( + principalKey: string, + conversationId: string, + instanceId: string, +): boolean => { + const expected = flueConversationId(principalKey, conversationId); + const expectedBytes = Buffer.from(expected); + const presentedBytes = Buffer.from(instanceId); + if (expectedBytes.length !== presentedBytes.length) return false; + return timingSafeEqual(expectedBytes, presentedBytes); +}; + +export const agentOwnershipHeaders = ( + identity: ConversationIdentity, +): Record => ({ + [BRUNCH_PRINCIPAL_HEADER]: identity.principalKey, + [BRUNCH_CONVERSATION_HEADER]: identity.conversationId, +}); diff --git a/apps/brunch-agent/src/conversation-payload.ts b/apps/brunch-agent/src/conversation-payload.ts new file mode 100644 index 00000000000..2507070f3ed --- /dev/null +++ b/apps/brunch-agent/src/conversation-payload.ts @@ -0,0 +1,29 @@ +/** Identity headers and payload encoding shared by Node and the local Flue UI. */ + +export { BRUNCH_PRINCIPAL_HEADER } from "@hashintel/brunch-agent-transport-aisdk/headers"; + +export const BRUNCH_CONVERSATION_HEADER = "x-brunch-conversation"; + +/** Principal for the stock Flue UI at `/`. Not a second ownership rule. */ +export const LOCAL_UI_PRINCIPAL = "local"; + +export const identityPayload = ( + principalKey: string, + conversationId: string, +): Uint8Array => { + const encoder = new TextEncoder(); + const principalBytes = encoder.encode(principalKey); + const conversationBytes = encoder.encode(conversationId); + const payload = new Uint8Array( + principalBytes.length + 1 + conversationBytes.length, + ); + payload.set(principalBytes, 0); + payload[principalBytes.length] = 0; + payload.set(conversationBytes, principalBytes.length + 1); + return payload; +}; + +export const hexFromDigest = (digest: ArrayBuffer): string => + [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); diff --git a/apps/brunch-agent/src/db-path.ts b/apps/brunch-agent/src/db-path.ts index faaec3fcec7..44639f85422 100644 --- a/apps/brunch-agent/src/db-path.ts +++ b/apps/brunch-agent/src/db-path.ts @@ -12,8 +12,12 @@ * Flue Node runtime and SQLite adapter. */ +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +const conversationDbFileFrom = (override: string): string => + override.endsWith(".db") ? override : join(override, "conversations.db"); + export function conversationDbPath(): string { // Truthiness, not nullish, on purpose: a set-but-empty override would pass // '' through to sqlite(), which opens an anonymous temporary database @@ -25,3 +29,21 @@ export function conversationDbPath(): string { new URL("../.data-wipe-me/conversations.db", import.meta.url), ); } + +/** + * Capture JSON lives beside the Flue sqlite file, named by Flue instance id. + * The hermetic chat test sets `BRUNCH_CHAT_DB_PATH` (not `BRUNCH_DEV_DB_PATH`), + * so that directory wins when present. + */ +export function captureStorePath(instanceId: string): string { + if (instanceId.length === 0) { + throw new TypeError( + "A Flue instance id is required for the capture store path.", + ); + } + const chatDb = process.env.BRUNCH_CHAT_DB_PATH; + const directory = dirname( + chatDb ? conversationDbFileFrom(chatDb) : conversationDbPath(), + ); + return join(directory, `${instanceId}.json`); +} diff --git a/apps/brunch-agent/src/db.ts b/apps/brunch-agent/src/db.ts index a63b9680437..3750ec79bda 100644 --- a/apps/brunch-agent/src/db.ts +++ b/apps/brunch-agent/src/db.ts @@ -1,15 +1,8 @@ /** * The substrate's conversation storage — host-authored because Flue requires - * it of the consuming app (spec §9.6, adjudication C1). + * it of the consuming app. * - * Not to be confused with the capture store: that is the harness's storage - * port, harness-defined and implemented in `@hashintel/brunch-agent-binding-flue`, and plugins are - * blind to both. This file holds only the live transport copy of conversations. - * The provenance record is the target-document's own session-log archive. - * - * Without this file conversations are process-memory and a restart loses them - * (recorded Flue fact, spec §10). Restart durability of the full stack is an - * open verification item (spec §14.5) that this file exists to make testable. + * Without this file conversations are process-memory and a restart loses them. */ import { sqlite } from "@flue/runtime/node"; diff --git a/apps/brunch-agent/src/elicitation-session.ts b/apps/brunch-agent/src/elicitation-session.ts deleted file mode 100644 index be16fd92e6c..00000000000 --- a/apps/brunch-agent/src/elicitation-session.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** Host-owned wiring for the local Flue binding's history transport and store. */ - -import { - createFlueHistoryReader, - createLocalCaptureStore, - type ElicitationSession, - type FlueHistoryReaderOptions, -} from "@hashintel/brunch-agent-binding-flue"; - -import { AGENT_ROUTES, type AgentTarget } from "./routes.ts"; -import { targetDocumentPath } from "./target-document-path.ts"; - -const appTransport: FlueHistoryReaderOptions["transport"] = async ( - input, - init, -) => { - const { default: app } = await import("./app.ts"); - return app.fetch(input instanceof Request ? input : new Request(input, init)); -}; - -/** - * One session factory per target agent. The history reader resolves - * conversations through the agent's own route, so each target gets a - * named creator rather than a shared one that guesses the route. - */ -const createElicitationSession = ( - target: AgentTarget, - sessionId: string, - targetDocumentId: string, -): ElicitationSession => { - const captureStore = createLocalCaptureStore( - targetDocumentPath(targetDocumentId), - ); - return { - sessionId, - captureStore, - historyReader: createFlueHistoryReader({ - resolveConversationUrl: (id) => - `http://brunch.local/agents/${AGENT_ROUTES[target]}/${id}`, - transport: appTransport, - archive: captureStore, - }), - }; -}; - -export const createGherkinElicitationSession = ( - sessionId: string, - targetDocumentId: string, -): ElicitationSession => - createElicitationSession("gherkin", sessionId, targetDocumentId); - -export const createSdcpnElicitationSession = ( - sessionId: string, - targetDocumentId: string, -): ElicitationSession => - createElicitationSession("sdcpn", sessionId, targetDocumentId); diff --git a/apps/brunch-agent/src/flue-transcript.ts b/apps/brunch-agent/src/flue-transcript.ts new file mode 100644 index 00000000000..a8072f9cf31 --- /dev/null +++ b/apps/brunch-agent/src/flue-transcript.ts @@ -0,0 +1,269 @@ +/** Human-readable and UI-message projections of Flue's public conversation snapshot. */ + +import { + type FlueConversationMessage, + type FlueConversationPart, + type FlueConversationSnapshot, +} from "@flue/sdk"; + +import { + CLIENT_TOOL_RESULT_SIGNAL, + isAwaitingClient, + providerExecutedFor, +} from "./client-tool.ts"; + +type UiMessagePart = + | { readonly type: "text"; readonly text: string; readonly state: "done" } + | { + readonly type: "reasoning"; + readonly text: string; + readonly state: "done"; + } + | { + readonly type: `data-${string}`; + readonly data: unknown; + } + | { + readonly type: "file"; + readonly mediaType: string; + readonly url: string; + readonly filename?: string; + } + | { + readonly type: `tool-${string}`; + readonly toolCallId: string; + readonly state: "output-available" | "output-error" | "input-available"; + readonly input: unknown; + readonly output?: unknown; + readonly errorText?: string; + readonly providerExecuted?: boolean; + }; + +const unhandledConversationPart = (part: never): never => { + throw new Error(`Unhandled Flue conversation part: ${JSON.stringify(part)}`); +}; + +const isFlueDataPart = ( + part: FlueConversationPart, +): part is Extract => + part.type.startsWith("data-"); + +export interface UiHistoryMessage { + readonly id: string; + readonly role: "user" | "assistant"; + readonly parts: readonly UiMessagePart[]; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const clientToolResultsFrom = ( + snapshot: FlueConversationSnapshot, +): ReadonlyMap => { + const outputsByCallId = new Map(); + for (const message of snapshot.messages) { + if (message.purpose !== "dispatch") continue; + if (message.signal?.tagName !== CLIENT_TOOL_RESULT_SIGNAL) continue; + const parsed: unknown = (() => { + try { + return JSON.parse( + message.parts + .filter( + (part): part is Extract => + part.type === "text", + ) + .map((part) => part.text) + .join(""), + ) as unknown; + } catch { + return undefined; + } + })(); + const results = Array.isArray(parsed) ? parsed : []; + for (const result of results) { + if ( + !isRecord(result) || + typeof result.toolCallId !== "string" || + !("output" in result) + ) { + continue; + } + outputsByCallId.set(result.toolCallId, result.output); + } + } + return outputsByCallId; +}; + +const resolveToolOutput = ( + part: Extract, + clientOutputs: ReadonlyMap, +): unknown => { + const clientOutput = clientOutputs.get(part.toolCallId); + if (part.state === "output-available" && isAwaitingClient(part.output)) { + return clientOutput; + } + if (part.state === "output-available") { + return part.output; + } + return clientOutput; +}; + +const toolPartFrom = ( + part: Extract, + clientOutputs: ReadonlyMap, +): UiMessagePart => { + const output = resolveToolOutput(part, clientOutputs); + const providerExecuted = + part.state === "output-available" + ? providerExecutedFor(isAwaitingClient(part.output)) + : undefined; + if (part.state === "output-error") { + return { + type: `tool-${part.toolName}`, + toolCallId: part.toolCallId, + state: "output-error", + input: part.input, + errorText: part.errorText, + ...(providerExecuted === undefined ? {} : { providerExecuted }), + }; + } + if (output !== undefined) { + return { + type: `tool-${part.toolName}`, + toolCallId: part.toolCallId, + state: "output-available", + input: part.input, + output, + ...(providerExecuted === undefined ? {} : { providerExecuted }), + }; + } + return { + type: `tool-${part.toolName}`, + toolCallId: part.toolCallId, + state: "input-available", + input: part.input, + }; +}; + +const partsFrom = ( + message: FlueConversationMessage, + clientOutputs: ReadonlyMap, +): UiMessagePart[] => { + const parts: UiMessagePart[] = []; + for (const part of message.parts) { + if (part.type === "text") { + parts.push({ type: "text", text: part.text, state: "done" }); + continue; + } + if (part.type === "reasoning") { + parts.push({ type: "reasoning", text: part.text, state: "done" }); + continue; + } + if (part.type === "dynamic-tool") { + parts.push(toolPartFrom(part, clientOutputs)); + continue; + } + if (part.type === "file") { + parts.push({ + type: "file", + mediaType: part.mediaType, + url: part.url ?? "", + ...(part.filename === undefined ? {} : { filename: part.filename }), + }); + continue; + } + if (isFlueDataPart(part)) { + parts.push({ type: part.type, data: part.data }); + continue; + } + unhandledConversationPart(part); + } + return parts; +}; + +export const snapshotToUiMessages = ( + snapshot: FlueConversationSnapshot, +): UiHistoryMessage[] => { + const clientOutputs = clientToolResultsFrom(snapshot); + const messages: UiHistoryMessage[] = []; + for (const message of snapshot.messages) { + if (message.display !== "visible") continue; + if (message.purpose !== "user" && message.purpose !== "assistant") continue; + if (message.role !== "user" && message.role !== "assistant") continue; + const parts = partsFrom(message, clientOutputs); + if (parts.length === 0) continue; + messages.push({ id: message.id, role: message.role, parts }); + } + return messages; +}; + +const textOf = (message: FlueConversationMessage): string => + message.parts + .filter( + (part): part is Extract => + part.type === "text", + ) + .map((part) => part.text) + .join(""); + +const formatToolPart = ( + part: Extract, + clientOutputs: ReadonlyMap, +): string => { + const output = resolveToolOutput(part, clientOutputs); + const result = + part.state === "output-error" + ? `error: ${part.errorText}` + : output === undefined + ? "pending" + : JSON.stringify(output); + return `- tool ${part.toolName} (${part.toolCallId}): ${result}`; +}; + +/** + * Built-in Flue `history()` snapshot → human-readable transcript. + * User text, assistant text, and tool interactions, including client-tool + * results delivered as signals. + */ +export const formatFlueTranscript = ( + snapshot: FlueConversationSnapshot, +): string => { + const clientOutputs = clientToolResultsFrom(snapshot); + const sections: string[] = []; + for (const message of snapshot.messages) { + if (message.purpose === "dispatch") { + if (message.signal?.tagName !== CLIENT_TOOL_RESULT_SIGNAL) continue; + const body = textOf(message); + if (body.length === 0) continue; + sections.push(`Signal ${CLIENT_TOOL_RESULT_SIGNAL}: ${body}`); + continue; + } + if (message.display !== "visible") continue; + if (message.purpose !== "user" && message.purpose !== "assistant") continue; + const speaker = message.purpose === "user" ? "User" : "Assistant"; + const lines: string[] = []; + const text = textOf(message); + if (text.length > 0) lines.push(text); + for (const part of message.parts) { + if (part.type === "text" || part.type === "reasoning") continue; + if (part.type === "dynamic-tool") { + lines.push(formatToolPart(part, clientOutputs)); + continue; + } + if (part.type === "file") { + lines.push(`- file ${part.filename ?? part.mediaType}`); + continue; + } + if (isFlueDataPart(part)) { + lines.push( + `- data ${part.type.slice("data-".length)}: ${JSON.stringify(part.data)}`, + ); + continue; + } + unhandledConversationPart(part); + } + if (lines.length === 0) continue; + sections.push(`## ${speaker}\n${lines.join("\n")}`); + } + return sections.join("\n\n"); +}; diff --git a/apps/brunch-agent/src/flue-ui-stream.ts b/apps/brunch-agent/src/flue-ui-stream.ts new file mode 100644 index 00000000000..e0dde6eeff6 --- /dev/null +++ b/apps/brunch-agent/src/flue-ui-stream.ts @@ -0,0 +1,196 @@ +/** Project Flue live conversation chunks into AI SDK UI-message-stream chunks. */ + +import { type ConversationStreamChunk } from "@flue/sdk"; + +import { providerExecutedFor } from "./client-tool.ts"; + +import type { UIMessageChunk } from "ai"; + +export interface FlueUiStreamOptions { + readonly submissionId: string; + readonly clientToolNames: ReadonlySet; + readonly write: (chunk: UIMessageChunk) => void; +} + +type StreamingPart = { + readonly kind: "text" | "reasoning"; + readonly partId: string; +}; + +const unhandledConversationChunk = (chunk: never): never => { + throw new Error( + `Unhandled Flue conversation chunk: ${JSON.stringify(chunk)}`, + ); +}; + +export const createFlueUiStream = ( + options: FlueUiStreamOptions, +): { accept: (chunk: ConversationStreamChunk) => void } => { + let accepting = false; + let messageId: string | undefined; + let turnId: string | undefined; + let partOrdinal = 0; + let streamingPart: StreamingPart | undefined; + const pendingClientToolCallIds = new Set(); + + const finishPart = (): void => { + if (!streamingPart) return; + options.write({ + type: `${streamingPart.kind}-end`, + id: streamingPart.partId, + }); + streamingPart = undefined; + }; + + const finishTurn = (): void => { + finishPart(); + if (!turnId) return; + options.write({ type: "finish-step" }); + turnId = undefined; + }; + + const startPart = (kind: StreamingPart["kind"]): StreamingPart => { + finishPart(); + partOrdinal += 1; + const part = { + kind, + partId: `${messageId}:${kind}:${partOrdinal}`, + } as const; + options.write({ type: `${kind}-start`, id: part.partId }); + streamingPart = part; + return part; + }; + + return { + accept(chunk) { + switch (chunk.type) { + case "message-started": { + accepting = chunk.submissionId === options.submissionId; + if (!accepting) return; + + if (messageId === undefined) { + messageId = chunk.messageId; + options.write({ type: "start", messageId }); + } + finishTurn(); + turnId = chunk.turnId ?? `${messageId}:turn`; + options.write({ type: "start-step" }); + return; + } + case "submission-settled": { + if (chunk.submissionId !== options.submissionId) return; + finishTurn(); + switch (chunk.outcome) { + case "completed": + options.write({ + type: "finish", + finishReason: + pendingClientToolCallIds.size > 0 ? "tool-calls" : "stop", + }); + break; + case "failed": + options.write({ + type: "error", + errorText: "The chat turn failed.", + }); + break; + case "aborted": + options.write({ + type: "abort", + reason: "The chat turn aborted.", + }); + break; + default: + unhandledConversationChunk(chunk.outcome); + } + accepting = false; + return; + } + case "conversation-reset": + case "message-appended": + case "stream-checkpoint": + // Observe/reconnect machinery, not assistant-message content. This + // projector emits one AI SDK assistant message for one Flue + // submission; these chunks are not parts of that message. + return; + case "message-delta": { + if (!accepting || messageId === undefined) return; + if (chunk.messageId !== messageId) return; + const part = + streamingPart?.kind === chunk.kind + ? streamingPart + : startPart(chunk.kind); + options.write({ + type: `${part.kind}-delta`, + id: part.partId, + delta: chunk.delta, + }); + return; + } + case "tool-input": { + if (!accepting || messageId === undefined) return; + if (chunk.messageId !== messageId) return; + finishPart(); + const isClientTool = options.clientToolNames.has(chunk.toolName); + if (isClientTool) pendingClientToolCallIds.add(chunk.toolCallId); + const providerExecuted = providerExecutedFor(isClientTool); + options.write({ + type: "tool-input-available", + toolCallId: chunk.toolCallId, + toolName: chunk.toolName, + input: chunk.input, + ...(providerExecuted === undefined ? {} : { providerExecuted }), + }); + return; + } + case "tool-output": { + if (!accepting || messageId === undefined) return; + if (pendingClientToolCallIds.has(chunk.toolCallId)) return; + options.write({ + type: "tool-output-available", + toolCallId: chunk.toolCallId, + output: chunk.output, + providerExecuted: true, + }); + return; + } + case "tool-output-error": { + if (!accepting || messageId === undefined) return; + if (pendingClientToolCallIds.has(chunk.toolCallId)) return; + options.write({ + type: "tool-output-error", + toolCallId: chunk.toolCallId, + errorText: chunk.errorText, + providerExecuted: true, + }); + return; + } + case "message-completed": { + if (!accepting || messageId === undefined) return; + if (chunk.messageId === messageId) finishTurn(); + return; + } + case "message-metadata": { + if (!accepting || messageId === undefined) return; + if (chunk.messageId !== messageId) return; + options.write({ + type: "message-metadata", + messageMetadata: chunk.metadata, + }); + return; + } + case "data-part": { + if (!accepting || messageId === undefined) return; + if (chunk.messageId !== messageId) return; + options.write({ + type: `data-${chunk.name}`, + data: chunk.data, + }); + return; + } + default: + unhandledConversationChunk(chunk); + } + }, + }; +}; diff --git a/apps/brunch-agent/src/petrinaut-chat.ts b/apps/brunch-agent/src/petrinaut-chat.ts index 280fed99ad8..6ea6b1865d4 100644 --- a/apps/brunch-agent/src/petrinaut-chat.ts +++ b/apps/brunch-agent/src/petrinaut-chat.ts @@ -1,56 +1,111 @@ /** Application composition for Petrinaut's stock AI SDK chat transport. */ import { init } from "@flue/runtime"; +import { createFlueClient, type FlueConversationSnapshot } from "@flue/sdk"; -import { - decideAskReplyAdmission, - pendingAskAffordanceId, -} from "@hashintel/brunch-agent"; -import { - createFlueReplyProjector, - projectFlueHistoryForSweep, -} from "@hashintel/brunch-agent-binding-flue"; import { createAiSdkChatHandler, - type HarnessReplyEvent, + type ChatResumeInput, + type ChatTurnInput, + type ConversationIdentity, type TransportInspectionEvent, } from "@hashintel/brunch-agent-transport-aisdk"; -import { GherkinElicitor } from "./agents/gherkin-elicitor.ts"; -import { createGherkinElicitationSession } from "./elicitation-session.ts"; +import { ChatAgent } from "./agents/chat-agent.ts"; +import { clientToolNames, CLIENT_TOOL_RESULT_SIGNAL } from "./client-tool.ts"; +import { + agentOwnershipHeaders, + flueConversationIdFrom, +} from "./conversation-identity.ts"; +import { snapshotToUiMessages } from "./flue-transcript.ts"; +import { createFlueUiStream } from "./flue-ui-stream.ts"; import { defaultPanelOrigins } from "./local-dev-origins.ts"; +import { CHAT_AGENT_ROUTE } from "./routes.ts"; + +import type { UIMessageChunk } from "ai"; const inspect = process.env.BRUNCH_TRANSPORT_AISDK_INSPECT === "1" ? (event: TransportInspectionEvent): void => { - // This is an opt-in shell diagnostic stream. It is never dispatched - // into Flue and therefore cannot become elicitation evidence. process.stdout.write(`TRANSPORT_AISDK ${JSON.stringify(event)}\n`); } : undefined; -// FE-1439 replaces this local one-conversation/one-document identity -// with principal-owned private session lookup. Keep it opaque here. -const targetDocumentIdFor = (conversationId: string): string => - `petrinaut-local:${conversationId}`; +const appTransport: typeof fetch = async (input, init) => { + const { default: app } = await import("./app.ts"); + return app.fetch(input instanceof Request ? input : new Request(input, init)); +}; -const streamElicitorTurn = async ( - conversationId: string, - dispatch: { readonly message: string; readonly idempotencyKey: string }, - emit: (event: HarnessReplyEvent) => void, -): Promise => { - const agent = init(GherkinElicitor, { id: conversationId }); - const receipt = await agent.dispatch({ - ...dispatch, - initialData: { targetDocumentId: targetDocumentIdFor(conversationId) }, +const conversationUrl = (instanceId: string): string => + `http://brunch.local/agents/${CHAT_AGENT_ROUTE}/${instanceId}`; + +const historyClient = (identity: ConversationIdentity) => + createFlueClient({ + url: conversationUrl(flueConversationIdFrom(identity)), + fetch: appTransport, + headers: agentOwnershipHeaders(identity), }); - const projector = createFlueReplyProjector({ + +const streamTurn = async ( + instanceId: string, + dispatch: Parameters["dispatch"]>[0], + write: (chunk: UIMessageChunk) => void, +): Promise => { + const agent = init(ChatAgent, { id: instanceId }); + const receipt = await agent.dispatch(dispatch); + const projector = createFlueUiStream({ submissionId: receipt.submissionId, - emit, + clientToolNames, + write, }); await agent.read(receipt, { onEvent: (chunk) => projector.accept(chunk) }); }; +const runUserTurn = ( + input: ChatTurnInput, + write: (chunk: UIMessageChunk) => void, +): Promise => + streamTurn( + flueConversationIdFrom(input), + { message: input.userMessage.text, idempotencyKey: input.idempotencyKey }, + write, + ); + +const runClientToolResume = ( + input: ChatResumeInput, + write: (chunk: UIMessageChunk) => void, +): Promise => + streamTurn( + flueConversationIdFrom(input), + { + message: { + kind: "signal", + type: CLIENT_TOOL_RESULT_SIGNAL, + tagName: CLIENT_TOOL_RESULT_SIGNAL, + body: JSON.stringify(input.toolResults), + attributes: { + toolCallIds: input.toolResults + .map((result) => result.toolCallId) + .join(","), + }, + }, + idempotencyKey: input.idempotencyKey, + }, + write, + ); + +const loadHistory = async ( + identity: ConversationIdentity, +): Promise<{ readonly messages: readonly unknown[] }> => { + let snapshot: FlueConversationSnapshot; + try { + snapshot = await historyClient(identity).history(); + } catch { + return { messages: [] }; + } + return { messages: snapshotToUiMessages(snapshot) }; +}; + export const petrinautChatHandler = createAiSdkChatHandler({ allowedOrigins: ( process.env.BRUNCH_PETRINAUT_ORIGINS ?? defaultPanelOrigins.join(",") @@ -59,36 +114,7 @@ export const petrinautChatHandler = createAiSdkChatHandler({ .map((origin) => origin.trim()) .filter((origin) => origin.length > 0), inspect, - runTurn: (input, emit) => - streamElicitorTurn( - input.conversationId, - { message: input.userMessage.text, idempotencyKey: input.idempotencyKey }, - emit, - ), - askReply: { - // Admission consults durable Flue history, not request-shaped claims: the - // submission resumes the conversation only when its tool-call id - // correlates with the one ask still awaiting a reply. - async admit(input) { - const session = createGherkinElicitationSession( - input.conversationId, - targetDocumentIdFor(input.conversationId), - ); - const entries = projectFlueHistoryForSweep( - await session.historyReader.peek(input.conversationId), - ); - return decideAskReplyAdmission( - pendingAskAffordanceId(entries), - input.ask.toolCallId, - ); - }, - // The admitted answer is a fresh user dispatch (spec §7.4); the binding - // binds it to the pending affordance, making it the user-affordance reply. - run: (input, emit) => - streamElicitorTurn( - input.conversationId, - { message: input.ask.answer, idempotencyKey: input.idempotencyKey }, - emit, - ), - }, + runTurn: runUserTurn, + resumeTurn: runClientToolResume, + loadHistory, }); diff --git a/apps/brunch-agent/src/routes.ts b/apps/brunch-agent/src/routes.ts index 40aaa363945..5100c2cb669 100644 --- a/apps/brunch-agent/src/routes.ts +++ b/apps/brunch-agent/src/routes.ts @@ -1,17 +1,6 @@ -/** Browser-facing route segments; conversation identity remains each agent's pinned `agentName`. */ -export const GHERKIN_AGENT_ROUTE = "gherkin"; -export const SDCPN_AGENT_ROUTE = "sdcpn"; +/** Browser-facing route segments; conversation identity remains the agent's pinned `agentName`. */ -/** One route per target agent; the gallery grows an entry per plugin (spec §13). */ -export const AGENT_ROUTES = { - gherkin: GHERKIN_AGENT_ROUTE, - sdcpn: SDCPN_AGENT_ROUTE, -} as const; - -export type AgentTarget = keyof typeof AGENT_ROUTES; - -export const isAgentTarget = (value: string | null): value is AgentTarget => - value !== null && Object.hasOwn(AGENT_ROUTES, value); +export const CHAT_AGENT_ROUTE = "chat"; /** Stock `DefaultChatTransport` endpoint used by Petrinaut's local panel. */ export const PETRINAUT_CHAT_ROUTE = "/api/chat"; diff --git a/apps/brunch-agent/src/skills/.gitkeep b/apps/brunch-agent/src/skills/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/brunch-agent/src/subagents/.gitkeep b/apps/brunch-agent/src/subagents/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/brunch-agent/src/target-document-path.ts b/apps/brunch-agent/src/target-document-path.ts deleted file mode 100644 index a2939f7ee7b..00000000000 --- a/apps/brunch-agent/src/target-document-path.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** Resolve one target-document's local binding store without trusting its id as a path. */ - -import { createHash } from "node:crypto"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const defaultDirectory = (): string => - fileURLToPath(new URL("../.data-wipe-me/target-documents/", import.meta.url)); - -export function targetDocumentPath(targetDocumentId: string): string { - if (targetDocumentId.length === 0) - throw new TypeError("A target-document id cannot be empty."); - const directory = - process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR || defaultDirectory(); - const identity = createHash("sha256").update(targetDocumentId).digest("hex"); - return join(directory, `${identity}.json`); -} diff --git a/apps/brunch-agent/src/tools/ping.ts b/apps/brunch-agent/src/tools/ping.ts new file mode 100644 index 00000000000..add6a82a085 --- /dev/null +++ b/apps/brunch-agent/src/tools/ping.ts @@ -0,0 +1,20 @@ +import { defineTool } from "@flue/runtime"; +import * as v from "valibot"; + +export const PING_TOOL_NAME = "ping"; + +export const ping = defineTool({ + name: PING_TOOL_NAME, + description: + "Return a short server-side acknowledgement. Call this when you need to confirm the server is in the loop.", + input: v.object({ + note: v.optional(v.pipe(v.string(), v.nonEmpty())), + }), + output: v.object({ + ok: v.literal(true), + note: v.string(), + }), + run({ data }) { + return { output: { ok: true as const, note: data.note ?? "pong" } }; + }, +}); diff --git a/apps/brunch-agent/src/tools/read-petrinaut-doc.ts b/apps/brunch-agent/src/tools/read-petrinaut-doc.ts new file mode 100644 index 00000000000..7b5bf99f7f4 --- /dev/null +++ b/apps/brunch-agent/src/tools/read-petrinaut-doc.ts @@ -0,0 +1,26 @@ +import { defineTool } from "@flue/runtime"; +import * as v from "valibot"; + +import { + petrinautDocNames, + readPetrinautDocToolName, +} from "@hashintel/petrinaut-core/ai"; + +import { AWAITING_CLIENT } from "../client-tool.ts"; + +export const READ_PETRINAUT_DOC_TOOL_NAME = readPetrinautDocToolName; + +export const readPetrinautDoc = defineTool({ + name: READ_PETRINAUT_DOC_TOOL_NAME, + description: + "Read one page of the Petrinaut user guide. The browser executes this tool. After you call it, wait for a client-tool-result signal carrying the page text, then continue from that text.", + input: v.object({ + doc: v.picklist(petrinautDocNames), + }), + output: v.object({ + awaiting: v.literal(AWAITING_CLIENT), + }), + run() { + return { output: { awaiting: AWAITING_CLIENT }, terminate: true }; + }, +}); diff --git a/apps/brunch-agent/src/transcript-cli.ts b/apps/brunch-agent/src/transcript-cli.ts new file mode 100644 index 00000000000..5d138ec429f --- /dev/null +++ b/apps/brunch-agent/src/transcript-cli.ts @@ -0,0 +1,52 @@ +/** + * Print a human-readable transcript from Flue `history()` for one conversation. + * + * Usage, with the Brunch server already running (`yarn dev:brunch`): + * + * yarn workspace @apps/brunch-agent transcript -- --principal --id + * + * Identity matches POST /api/chat: principal + conversation id hash to the + * Flue instance. The mounted URL requires those same values as headers. This + * is a read of canonical Flue history, not a second log. + */ + +import { createFlueClient } from "@flue/sdk"; + +import { + agentOwnershipHeaders, + flueConversationIdFrom, +} from "./conversation-identity.ts"; +import { formatFlueTranscript } from "./flue-transcript.ts"; +import { defaultChatOrigin } from "./local-dev-origins.ts"; +import { CHAT_AGENT_ROUTE } from "./routes.ts"; + +const readFlag = ( + argv: readonly string[], + name: string, +): string | undefined => { + const index = argv.indexOf(name); + if (index < 0) return undefined; + const value = argv[index + 1]; + return value === undefined || value.length === 0 ? undefined : value; +}; + +const argv = process.argv.slice(2); +const principalKey = readFlag(argv, "--principal"); +const conversationId = readFlag(argv, "--id"); +const origin = readFlag(argv, "--origin") ?? defaultChatOrigin; + +if (principalKey === undefined || conversationId === undefined) { + process.stderr.write( + "usage: transcript -- --principal --id [--origin ]\n", + ); + process.exit(1); +} + +const identity = { principalKey, conversationId }; +const instanceId = flueConversationIdFrom(identity); +const snapshot = await createFlueClient({ + url: `${origin}/agents/${CHAT_AGENT_ROUTE}/${instanceId}`, + headers: agentOwnershipHeaders(identity), +}).history(); + +process.stdout.write(`${formatFlueTranscript(snapshot)}\n`); diff --git a/apps/brunch-agent/src/ui/chat.tsx b/apps/brunch-agent/src/ui/chat.tsx index df13a33924b..ca4687efbf6 100644 --- a/apps/brunch-agent/src/ui/chat.tsx +++ b/apps/brunch-agent/src/ui/chat.tsx @@ -1,21 +1,18 @@ import { useFlueAgent } from "@flue/react"; -import { createFlueClient, type FlueConversationMessage } from "@flue/sdk"; -import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; -import * as v from "valibot"; +import { + createFlueClient, + type FlueClient, + type FlueConversationMessage, +} from "@flue/sdk"; +import { useEffect, useMemo, useState, type FormEvent } from "react"; -import { FreeTextAffordance } from "@hashintel/brunch-agent"; - -import { AGENT_ROUTES, isAgentTarget } from "../routes.ts"; - -const conversationId = crypto.randomUUID(); - -// `?target=sdcpn` selects the agent; the gallery is one route per plugin and -// gherkin remains the default tracer. -const requestedTarget = new URLSearchParams(window.location.search).get( - "target", -); -const agentRoute = - AGENT_ROUTES[isAgentTarget(requestedTarget) ? requestedTarget : "gherkin"]; +import { flueConversationIdWeb } from "../conversation-identity-web.ts"; +import { + BRUNCH_CONVERSATION_HEADER, + BRUNCH_PRINCIPAL_HEADER, + LOCAL_UI_PRINCIPAL, +} from "../conversation-payload.ts"; +import { CHAT_AGENT_ROUTE } from "../routes.ts"; function VisibleMessage({ message }: { message: FlueConversationMessage }) { if ( @@ -28,77 +25,28 @@ function VisibleMessage({ message }: { message: FlueConversationMessage }) { return (

- {message.role === "user" ? "You" : "Interviewer"} + {message.role === "user" ? "You" : "Assistant"}

- {message.parts.map((part, index) => { + {message.parts.map((part, partIndex) => { if (part.type === "text") { return ( // oxlint-disable-next-line react/no-array-index-key -- Flue text parts expose no stable identifier. -

+

{part.text}

); } - if (part.type === "data-affordance") { - const affordance = v.safeParse(FreeTextAffordance, part.data); - if (!affordance.success) return null; - return ( -
- Question -

{affordance.output.markdown}

- - Reply in your own words below. - -
- ); - } return null; })}
); } -export function Chat() { +function ChatConversation({ client }: { client: FlueClient }) { const [input, setInput] = useState(""); - const [bootstrapping, setBootstrapping] = useState(true); - const [startupError, setStartupError] = useState(); - const started = useRef(false); - const client = useMemo( - () => - createFlueClient({ - url: `/agents/${agentRoute}/${conversationId}`, - }), - [], - ); const agent = useFlueAgent({ client }); - useEffect(() => { - if (!agent.historyReady || agent.messages.length > 0 || started.current) - return; - started.current = true; - - void (async () => { - try { - const admission = await client.send({ - message: { kind: "user", body: "Begin the interview." }, - initialData: { targetDocumentId: `dev-${conversationId}` }, - }); - await client.wait(admission); - agent.refresh(); - } catch (error: unknown) { - setStartupError( - error instanceof Error - ? error.message - : "The interview could not start.", - ); - } finally { - setBootstrapping(false); - } - })(); - }, [agent, client]); - const busy = - bootstrapping || agent.status === "connecting" || agent.status === "submitted" || agent.status === "streaming"; @@ -115,8 +63,8 @@ export function Chat() {
-

Brunch / elicitation field notes

-

Tell me how it should behave.

+

Brunch / Flue chat

+

Plain Flue conversation

{agent.status}
@@ -125,21 +73,17 @@ export function Chat() { {agent.messages.map((message) => ( ))} - {agent.messages.length === 0 && !startupError ? ( -

Opening a fresh interview…

- ) : null} - {startupError ?

{startupError}

: null} {agent.error ?

{agent.error.message}

: null}
- +