Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/brunch-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@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:*",
"hono": "4.13.2",
"react": "19.2.6",
Expand Down
54 changes: 54 additions & 0 deletions apps/brunch-agent/src/agents/sdcpn-elicitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"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<v.InferOutput<typeof sdcpnElicitorInitialData>>();
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;
10 changes: 9 additions & 1 deletion apps/brunch-agent/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ 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 { assetHandler } from "./assets.ts";
import { petrinautChatHandler } from "./petrinaut-chat.ts";
import { GHERKIN_AGENT_ROUTE, PETRINAUT_CHAT_ROUTE } from "./routes.ts";
import {
GHERKIN_AGENT_ROUTE,
PETRINAUT_CHAT_ROUTE,
SDCPN_AGENT_ROUTE,
} from "./routes.ts";

const app = new Hono();

Expand All @@ -24,6 +29,9 @@ const app = new Hono();
// 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));

// The application owns the HTTP mount; transport-aisdk owns only request validation
// and AI SDK stream encoding. No parallel conversation renderer is introduced.
Expand Down
24 changes: 21 additions & 3 deletions apps/brunch-agent/src/elicitation-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type FlueHistoryReaderOptions,
} from "@hashintel/brunch-agent-binding-flue";

import { GHERKIN_AGENT_ROUTE } from "./routes.ts";
import { AGENT_ROUTES, type AgentTarget } from "./routes.ts";
import { targetDocumentPath } from "./target-document-path.ts";

const appTransport: FlueHistoryReaderOptions["transport"] = async (
Expand All @@ -18,7 +18,13 @@ const appTransport: FlueHistoryReaderOptions["transport"] = async (
return app.fetch(input instanceof Request ? input : new Request(input, init));
};

export const createGherkinElicitationSession = (
/**
* 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 => {
Expand All @@ -30,9 +36,21 @@ export const createGherkinElicitationSession = (
captureStore,
historyReader: createFlueHistoryReader({
resolveConversationUrl: (id) =>
`http://brunch.local/agents/${GHERKIN_AGENT_ROUTE}/${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);
14 changes: 13 additions & 1 deletion apps/brunch-agent/src/routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
/** Browser-facing route segment; conversation identity remains the agent's pinned `agentName`. */
/** Browser-facing route segments; conversation identity remains each agent's pinned `agentName`. */
export const GHERKIN_AGENT_ROUTE = "gherkin";
export const SDCPN_AGENT_ROUTE = "sdcpn";

/** 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);

/** Stock `DefaultChatTransport` endpoint used by Petrinaut's local panel. */
export const PETRINAUT_CHAT_ROUTE = "/api/chat";
12 changes: 10 additions & 2 deletions apps/brunch-agent/src/ui/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ import * as v from "valibot";

import { FreeTextAffordance } from "@hashintel/brunch-agent";

import { GHERKIN_AGENT_ROUTE } from "../routes.ts";
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"];

function VisibleMessage({ message }: { message: FlueConversationMessage }) {
if (
message.display !== "visible" ||
Expand Down Expand Up @@ -58,7 +66,7 @@ export function Chat() {
const client = useMemo(
() =>
createFlueClient({
url: `/agents/${GHERKIN_AGENT_ROUTE}/${conversationId}`,
url: `/agents/${agentRoute}/${conversationId}`,
}),
[],
);
Expand Down
18 changes: 11 additions & 7 deletions apps/brunch-agent/test/build-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,17 @@ beforeAll(() => {

/** The pinned identity of every agent module in the app, read from source. */
function declaredAgentIdentities(): string[] {
const agentSource = readFileSync(
join(DEV_APP, "src/agents/gherkin-elicitor.ts"),
"utf8",
);
return [
...agentSource.matchAll(/\w+\.agentName\s*=\s*(["'])([^"']+)\1/gu),
].map((match) => match[2]!);
const agentsDirectory = join(DEV_APP, "src/agents");
return readdirSync(agentsDirectory)
.filter((entry) => entry.endsWith(".ts"))
.flatMap((entry) =>
Array.from(
readFileSync(join(agentsDirectory, entry), "utf8").matchAll(
/\w+\.agentName\s*=\s*(["'])([^"']+)\1/gu,
),
),
)
.map((match) => match[2]!);
}

describe("the emitted server bundle", () => {
Expand Down
2 changes: 2 additions & 0 deletions libs/@hashintel/brunch-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ This directory is its context and agent-session root, not a package workspace:
- [`packages/binding-flue/`](./packages/binding-flue/) is the Flue binding.
- [`packages/transport-aisdk/`](./packages/transport-aisdk/) is the AI SDK transport.
- [`packages/plugin-gherkin/`](./packages/plugin-gherkin/) is the Gherkin target plugin.
- [`packages/plugin-sdcpn/`](./packages/plugin-sdcpn/) is the SDCPN target plugin: `plugin.md` and its
slot-assertion proposal type.
- [`../../../apps/brunch-agent/`](../../../apps/brunch-agent/) is the remote server and diagnostic
application.

Expand Down
Loading
Loading