Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/brunch-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Brunch agent application

## Run the process-model panel locally

From the repository root, make `ANTHROPIC_API_KEY` available in the environment and run:

```sh
yarn dev:brunch
```

The command 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, where the panel runs the SDCPN
process-model elicitor.

Conversations persist in `apps/brunch-agent/.data-wipe-me/conversations.db`. Owned target documents
persist as per-document JSON files under `apps/brunch-agent/.data-wipe-me/target-documents/`.
`BRUNCH_DEV_DB_PATH` and `BRUNCH_DEV_TARGET_DOCUMENT_DIR` override those local paths.
4 changes: 1 addition & 3 deletions apps/brunch-agent/petrinaut-local.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion apps/brunch-agent/src/agents/gherkin-elicitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { createGherkinElicitationSession } from "../elicitation-session.ts";
export const GHERKIN_MODEL_ID = "claude-haiku-4-5";

const gherkinElicitorInitialData = v.object({
ownerKey: v.optional(v.pipe(v.string(), v.nonEmpty())),
targetDocumentId: v.pipe(v.string(), v.nonEmpty()),
});

Expand All @@ -44,7 +45,11 @@ export function GherkinElicitor(props: AgentProps) {
useInitialData<v.InferOutput<typeof gherkinElicitorInitialData>>();
return useElicitation(
gherkin,
createGherkinElicitationSession(props.id, initialData.targetDocumentId),
createGherkinElicitationSession(
props.id,
initialData.targetDocumentId,
initialData.ownerKey,
),
);
}

Expand Down
7 changes: 6 additions & 1 deletion apps/brunch-agent/src/agents/sdcpn-elicitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const SDCPN_MODEL_ID =
process.env["BRUNCH_SDCPN_MODEL"] || "claude-haiku-4-5";

const sdcpnElicitorInitialData = v.object({
ownerKey: v.optional(v.pipe(v.string(), v.nonEmpty())),
targetDocumentId: v.pipe(v.string(), v.nonEmpty()),
});

Expand All @@ -42,7 +43,11 @@ export function SdcpnElicitor(props: AgentProps) {
useInitialData<v.InferOutput<typeof sdcpnElicitorInitialData>>();
return useElicitation(
sdcpn,
createSdcpnElicitationSession(props.id, initialData.targetDocumentId),
createSdcpnElicitationSession(
props.id,
initialData.targetDocumentId,
initialData.ownerKey,
),
);
}

Expand Down
23 changes: 21 additions & 2 deletions apps/brunch-agent/src/elicitation-session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** Host-owned wiring for the local Flue binding's history transport and store. */
import { createHash } from "node:crypto";

import {
createFlueHistoryReader,
Expand All @@ -10,6 +11,20 @@ import {
import { AGENT_ROUTES, type AgentTarget } from "./routes.ts";
import { targetDocumentPath } from "./target-document-path.ts";

export const resolvePetrinautSessionIdentity = (
principalKey: string,
conversationId: string,
) => {
const sessionDigest = createHash("sha256")
.update(JSON.stringify([principalKey, conversationId]))
.digest("hex");
return {
ownerKey: principalKey,
sessionId: `petrinaut-local:${sessionDigest}`,
targetDocumentId: `petrinaut-local:${principalKey}`,
} as const;
};

const appTransport: FlueHistoryReaderOptions["transport"] = async (
input,
init,
Expand All @@ -27,9 +42,11 @@ const createElicitationSession = (
target: AgentTarget,
sessionId: string,
targetDocumentId: string,
ownerKey?: string,
): ElicitationSession => {
const captureStore = createLocalCaptureStore(
targetDocumentPath(targetDocumentId),
ownerKey === undefined ? {} : { ownerKey },
);
return {
sessionId,
Expand All @@ -46,11 +63,13 @@ const createElicitationSession = (
export const createGherkinElicitationSession = (
sessionId: string,
targetDocumentId: string,
ownerKey?: string,
): ElicitationSession =>
createElicitationSession("gherkin", sessionId, targetDocumentId);
createElicitationSession("gherkin", sessionId, targetDocumentId, ownerKey);

export const createSdcpnElicitationSession = (
sessionId: string,
targetDocumentId: string,
ownerKey?: string,
): ElicitationSession =>
createElicitationSession("sdcpn", sessionId, targetDocumentId);
createElicitationSession("sdcpn", sessionId, targetDocumentId, ownerKey);
37 changes: 25 additions & 12 deletions apps/brunch-agent/src/petrinaut-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ import {
type TransportInspectionEvent,
} from "@hashintel/brunch-agent-transport-aisdk";

import { GherkinElicitor } from "./agents/gherkin-elicitor.ts";
import { createGherkinElicitationSession } from "./elicitation-session.ts";
import { SdcpnElicitor } from "./agents/sdcpn-elicitor.ts";
import {
createSdcpnElicitationSession,
resolvePetrinautSessionIdentity,
} from "./elicitation-session.ts";
import { defaultPanelOrigins } from "./local-dev-origins.ts";

const inspect =
Expand All @@ -29,20 +32,23 @@ const inspect =
}
: 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 streamElicitorTurn = async (
principalKey: string,
conversationId: string,
dispatch: { readonly message: string; readonly idempotencyKey: string },
emit: (event: HarnessReplyEvent) => void,
): Promise<void> => {
const agent = init(GherkinElicitor, { id: conversationId });
const identity = resolvePetrinautSessionIdentity(
principalKey,
conversationId,
);
const agent = init(SdcpnElicitor, { id: identity.sessionId });
const receipt = await agent.dispatch({
...dispatch,
initialData: { targetDocumentId: targetDocumentIdFor(conversationId) },
initialData: {
ownerKey: identity.ownerKey,
targetDocumentId: identity.targetDocumentId,
},
});
const projector = createFlueReplyProjector({
submissionId: receipt.submissionId,
Expand All @@ -61,6 +67,7 @@ export const petrinautChatHandler = createAiSdkChatHandler({
inspect,
runTurn: (input, emit) =>
streamElicitorTurn(
input.principalKey,
input.conversationId,
{ message: input.userMessage.text, idempotencyKey: input.idempotencyKey },
emit,
Expand All @@ -70,12 +77,17 @@ export const petrinautChatHandler = createAiSdkChatHandler({
// 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(
const identity = resolvePetrinautSessionIdentity(
input.principalKey,
input.conversationId,
targetDocumentIdFor(input.conversationId),
);
const session = createSdcpnElicitationSession(
identity.sessionId,
identity.targetDocumentId,
identity.ownerKey,
);
const entries = projectFlueHistoryForSweep(
await session.historyReader.peek(input.conversationId),
await session.historyReader.peek(identity.sessionId),
);
return decideAskReplyAdmission(
pendingAskAffordanceId(entries),
Expand All @@ -86,6 +98,7 @@ export const petrinautChatHandler = createAiSdkChatHandler({
// binds it to the pending affordance, making it the user-affordance reply.
run: (input, emit) =>
streamElicitorTurn(
input.principalKey,
input.conversationId,
{ message: input.ask.answer, idempotencyKey: input.idempotencyKey },
emit,
Expand Down
25 changes: 25 additions & 0 deletions apps/brunch-agent/test/elicitation-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, test } from "vitest";

import { resolvePetrinautSessionIdentity } from "../src/elicitation-session.ts";

test("namespaces panel sessions by principal while keeping one document per principal", () => {
const firstSession = resolvePetrinautSessionIdentity(
"principal-a",
"conversation-shared",
);
const reloadedSession = resolvePetrinautSessionIdentity(
"principal-a",
"conversation-after-reload",
);
const otherPrincipal = resolvePetrinautSessionIdentity(
"principal-b",
"conversation-shared",
);

expect(reloadedSession.targetDocumentId).toBe(firstSession.targetDocumentId);
expect(reloadedSession.sessionId).not.toBe(firstSession.sessionId);
expect(otherPrincipal.sessionId).not.toBe(firstSession.sessionId);
expect(otherPrincipal.targetDocumentId).not.toBe(
firstSession.targetDocumentId,
);
});
20 changes: 20 additions & 0 deletions apps/brunch-agent/test/local-dev-origins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,26 @@ import {
const readAppFile = (relativePath: string): string =>
readFileSync(new URL(`../${relativePath}`, import.meta.url), "utf8");

const readRepoFile = (relativePath: string): string =>
readFileSync(new URL(`../../../${relativePath}`, import.meta.url), "utf8");

test("one documented root command starts the Brunch server and Petrinaut panel", () => {
const rootPackage = JSON.parse(readRepoFile("package.json")) as {
scripts: Record<string, string>;
};

expect(rootPackage.scripts["dev:brunch"]).toBe(
"npm-run-all --parallel dev:brunch:server dev:brunch:panel",
);
expect(rootPackage.scripts["dev:brunch:server"]).toBe(
"yarn workspace @apps/brunch-agent dev",
);
expect(rootPackage.scripts["dev:brunch:panel"]).toBe(
'PETRINAUT_WEBSITE_ROOT="$PWD/apps/petrinaut-website" yarn workspace @apps/brunch-agent petrinaut:dev',
);
expect(readAppFile("README.md")).toContain("yarn dev:brunch");
});

test("dev listens on the chat origin the panel proxy already assumes", () => {
expect(defaultChatOrigin).toBe("http://127.0.0.1:4321");
expect(localChatListen).toEqual({
Expand Down
10 changes: 4 additions & 6 deletions apps/brunch-agent/test/petrinaut-ask.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,7 @@ import {
} from "@earendil-works/pi-ai";
import { start } from "@flue/runtime/node";

import {
GHERKIN_MODEL_ID,
GherkinElicitor,
} from "../src/agents/gherkin-elicitor.ts";
import { SDCPN_MODEL_ID, SdcpnElicitor } from "../src/agents/sdcpn-elicitor.ts";

import type { PetrinautAskResult } from "./petrinaut-ask-result";
import type { UIMessageChunk } from "ai";
Expand All @@ -33,7 +30,7 @@ process.env.BRUNCH_TRANSPORT_AISDK_INSPECT = "1";

const faux = fauxProvider({
provider: "anthropic",
models: [{ id: GHERKIN_MODEL_ID, reasoning: true }],
models: [{ id: SDCPN_MODEL_ID, reasoning: true }],
});
faux.setResponses([
fauxAssistantMessage(
Expand All @@ -58,7 +55,7 @@ faux.setResponses([
]);

const flue = await start({
agents: [GherkinElicitor],
agents: [SdcpnElicitor],
providers: [faux.provider],
});

Expand All @@ -80,6 +77,7 @@ try {
method: "POST",
headers: {
"content-type": "application/json",
"x-brunch-principal": "principal-fe1449",
"x-request-id": requestId,
},
body: JSON.stringify(body),
Expand Down
10 changes: 4 additions & 6 deletions apps/brunch-agent/test/petrinaut-chat.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@ import {
} from "@earendil-works/pi-ai";
import { start } from "@flue/runtime/node";

import {
GHERKIN_MODEL_ID,
GherkinElicitor,
} from "../src/agents/gherkin-elicitor.ts";
import { SDCPN_MODEL_ID, SdcpnElicitor } from "../src/agents/sdcpn-elicitor.ts";

import type { PetrinautChatResult } from "./petrinaut-chat-result";
import type { UIMessageChunk } from "ai";
Expand All @@ -25,7 +22,7 @@ process.env.BRUNCH_TRANSPORT_AISDK_INSPECT = "1";

const faux = fauxProvider({
provider: "anthropic",
models: [{ id: GHERKIN_MODEL_ID, reasoning: true }],
models: [{ id: SDCPN_MODEL_ID, reasoning: true }],
});
faux.setResponses([
fauxAssistantMessage([
Expand All @@ -43,7 +40,7 @@ faux.setResponses([
]);

const flue = await start({
agents: [GherkinElicitor],
agents: [SdcpnElicitor],
providers: [faux.provider],
});

Expand All @@ -60,6 +57,7 @@ try {
method: "POST",
headers: {
"content-type": "application/json",
"x-brunch-principal": "principal-fe1436-application",
"x-request-id": "request-fe1436-application",
},
body: await readFile(fixturePath, "utf8"),
Expand Down
Loading
Loading