Skip to content
Open
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
7 changes: 7 additions & 0 deletions apps/brunch-agent/src/agents/chat-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import { defineSkill, useModel, useSkill, useTool } from "@flue/runtime";
import { ASK_TOOL_NAME } from "@hashintel/brunch-agent/client-tools";

import { brunchAsk } from "../tools/brunch-ask.ts";
import {
getLatestNetDefinition,
getLatestNetDefinitionClientToolName,
} from "../tools/get-latest-net-definition.ts";
import { ping } from "../tools/ping.ts";
import { readPetrinautDoc } from "../tools/read-petrinaut-doc.ts";

Expand All @@ -33,12 +37,15 @@ export function ChatAgent() {
useModel(`anthropic/${CHAT_MODEL_ID}`);
useSkill(confirmPath);
useTool(ping);
useTool(getLatestNetDefinition);
useTool(readPetrinautDoc);
useTool(brunchAsk);
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.`,
`Before answering any request about this net, the current net, or the existing netβ€”including before beginning an interviewβ€”call \`${getLatestNetDefinitionClientToolName}\`.`,
`Do not say the canvas is unavailable while you can call \`${getLatestNetDefinitionClientToolName}\`.`,
"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.",
`When the user explicitly requests an interview, call \`${ASK_TOOL_NAME}\`.`,
Expand Down
6 changes: 5 additions & 1 deletion apps/brunch-agent/src/client-tool.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
/** Flue-side client-tool signal contract: awaiting sentinel, result signal, tool names. */

import { ASK_TOOL_NAME } from "@hashintel/brunch-agent/client-tools";
import { readPetrinautDocToolName } from "@hashintel/petrinaut-core/ai";
import {
getLatestNetDefinitionToolName,
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<string> = new Set([
ASK_TOOL_NAME,
getLatestNetDefinitionToolName,
readPetrinautDocToolName,
]);

Expand Down
22 changes: 22 additions & 0 deletions apps/brunch-agent/src/tools/get-latest-net-definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { defineTool } from "@flue/runtime";
import * as v from "valibot";

import { getLatestNetDefinitionToolName } from "@hashintel/petrinaut-core/ai";

import { AWAITING_CLIENT } from "../client-tool.ts";

export const getLatestNetDefinitionClientToolName =
getLatestNetDefinitionToolName;

export const getLatestNetDefinition = defineTool({
name: getLatestNetDefinitionClientToolName,
description:
"Get the live Petrinaut net state as `{ title, definition, extensions }`. The browser executes this tool. After you call it, wait for a client-tool-result signal carrying the current state, then continue from that state.",
input: v.strictObject({}),
output: v.object({
awaiting: v.literal(AWAITING_CLIENT),
}),
run() {
return { output: { awaiting: AWAITING_CLIENT }, terminate: true };
},
});
7 changes: 7 additions & 0 deletions apps/brunch-agent/test/petrinaut-chat-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,17 @@ export interface PetrinautChatResult {
{ type: "tool-input-available" }
> | null;
readonly clientToolOutputsOnInitial: readonly UIMessageChunk[];
readonly latestNetDefinitionCall: Extract<
UIMessageChunk,
{ type: "tool-input-available" }
> | null;
readonly latestNetDefinitionOutputsOnInitial: readonly UIMessageChunk[];
readonly initialFinish: UIMessageChunk | undefined;
readonly pendingHistoryClientToolState: string | undefined;
readonly pendingHistoryLatestNetDefinitionState: string | undefined;
readonly resumedStatus: number;
readonly resumedText: string;
readonly resumedTextBeforeAsk: string;
readonly resumedFinish: UIMessageChunk | undefined;
readonly askCall: Extract<
UIMessageChunk,
Expand Down
117 changes: 110 additions & 7 deletions apps/brunch-agent/test/petrinaut-chat.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { sqlite, start } from "@flue/runtime/node";
import { createFlueClient, FlueApiError } from "@flue/sdk";

import { ASK_TOOL_NAME } from "@hashintel/brunch-agent/client-tools";
import { getLatestNetDefinitionToolName } from "@hashintel/petrinaut-core/ai";

import {
ACTIVATE_SKILL_TOOL_NAME,
Expand Down Expand Up @@ -42,6 +43,58 @@ const principalKey = "principal-mission-1";
const conversationId = "conversation-mission-1";
const identity = { principalKey, conversationId };
const instanceId = flueConversationIdFrom(identity);
const latestNetDefinitionFixture = {
title: "Invoice review conveyor",
definition: {
places: [
{
id: "incoming-invoices",
name: "Incoming invoices",
colorId: null,
dynamicsEnabled: false,
differentialEquationId: null,
x: 100,
y: 100,
},
{
id: "approved-invoices",
name: "Approved invoices",
colorId: null,
dynamicsEnabled: false,
differentialEquationId: null,
x: 500,
y: 100,
},
],
transitions: [
{
id: "review-invoice",
name: "Review invoice",
inputArcs: [
{ placeId: "incoming-invoices", weight: 1, type: "standard" },
],
outputArcs: [{ placeId: "approved-invoices", weight: 1 }],
lambdaType: "predicate",
lambdaCode: "return true;",
transitionKernelCode: "",
x: 300,
y: 125,
},
],
types: [],
parameters: [],
differentialEquations: [],
subnets: [],
componentInstances: [],
},
extensions: {
colors: false,
stochasticity: false,
dynamics: false,
parameters: false,
subnets: false,
},
} as const;
const dbPath =
process.env.BRUNCH_CHAT_DB_PATH ??
(await mkdtemp(join(tmpdir(), "brunch-chat-")));
Expand Down Expand Up @@ -142,7 +195,14 @@ try {
),
fauxAssistantMessage(
[
fauxThinking("The ping returned. Read the user guide next."),
fauxThinking(
"The ping returned. Read the current net and user guide next.",
),
fauxToolCall(
getLatestNetDefinitionToolName,
{},
{ id: "tool-net-1" },
),
fauxToolCall(
READ_PETRINAUT_DOC_TOOL_NAME,
{ doc: "ai-assistant" },
Expand All @@ -153,13 +213,18 @@ try {
),
fauxAssistantMessage(
[
fauxThinking("The user explicitly requested an interview."),
fauxThinking(
"Use the current net context before the first interview question.",
),
fauxText(
"The guide says the assistant can read its own documentation pages.",
"The Invoice review conveyor moves Incoming invoices through Review invoice into Approved invoices. The guide says the assistant can read its own documentation pages.",
),
fauxToolCall(
ASK_TOOL_NAME,
{ question: "What outcome should this process reliably produce?" },
{
question:
"What should happen when review cannot approve an invoice?",
},
{ id: "tool-ask-1" },
),
],
Expand All @@ -168,7 +233,7 @@ try {
fauxAssistantMessage([
fauxThinking("Use the correlated client-tool answer."),
fauxText(
"I received your answer: a reliable handoff. The Petrinaut canvas was not modified.",
"I received your answer: send it to manual review. The Petrinaut canvas was not modified.",
),
]),
fauxAssistantMessage([
Expand Down Expand Up @@ -199,7 +264,7 @@ try {
userMessage.parts = [
{
type: "text",
text: "Start an interview and run the FE-1435 transport probe.",
text: "Interview this Petri net. What does it do?",
},
];

Expand Down Expand Up @@ -245,6 +310,14 @@ try {
chunk.type === "tool-input-available" &&
chunk.toolName === READ_PETRINAUT_DOC_TOOL_NAME,
) ?? null;
const latestNetDefinitionCall =
initialChunks.find(
(
chunk,
): chunk is Extract<UIMessageChunk, { type: "tool-input-available" }> =>
chunk.type === "tool-input-available" &&
chunk.toolName === getLatestNetDefinitionToolName,
) ?? null;

const pendingHistoryResponse = await app.fetch(
new Request(
Expand All @@ -263,6 +336,11 @@ try {
const pendingHistoryClientToolState = pendingHistoryBody.messages
?.flatMap((message) => message.parts ?? [])
.find((part) => part.toolCallId === clientToolCall?.toolCallId)?.state;
const pendingHistoryLatestNetDefinitionState = pendingHistoryBody.messages
?.flatMap((message) => message.parts ?? [])
.find(
(part) => part.toolCallId === latestNetDefinitionCall?.toolCallId,
)?.state;

const resumeBody = {
id: conversationId,
Expand All @@ -274,6 +352,13 @@ try {
id: startChunk?.messageId,
role: "assistant",
parts: [
{
type: `tool-${getLatestNetDefinitionToolName}`,
toolCallId: latestNetDefinitionCall?.toolCallId,
state: "output-available",
input: {},
output: latestNetDefinitionFixture,
},
{
type: `tool-${READ_PETRINAUT_DOC_TOOL_NAME}`,
toolCallId: clientToolCall?.toolCallId,
Expand Down Expand Up @@ -306,6 +391,16 @@ try {
chunk.type === "tool-input-available" &&
chunk.toolName === ASK_TOOL_NAME,
) ?? null;
const askChunkIndex = resumedChunks.findIndex(
(chunk) =>
chunk.type === "tool-input-available" &&
chunk.toolName === ASK_TOOL_NAME,
);
const resumedTextBeforeAsk = resumedChunks
.slice(0, askChunkIndex === -1 ? resumedChunks.length : askChunkIndex)
.filter((chunk) => chunk.type === "text-delta")
.map((chunk) => chunk.delta)
.join("");
const pendingAskHistoryResponse = await app.fetch(
new Request(
`http://brunch.test/api/chat?id=${encodeURIComponent(conversationId)}`,
Expand Down Expand Up @@ -339,7 +434,7 @@ try {
toolCallId: askCall?.toolCallId,
state: "output-available",
input: askCall?.input,
output: { answer: "A reliable handoff." },
output: { answer: "Send it to manual review." },
},
],
},
Expand Down Expand Up @@ -483,13 +578,21 @@ try {
chunk.type === "tool-output-available" &&
chunk.toolCallId === clientToolCall?.toolCallId,
),
latestNetDefinitionCall,
latestNetDefinitionOutputsOnInitial: initialChunks.filter(
(chunk) =>
chunk.type === "tool-output-available" &&
chunk.toolCallId === latestNetDefinitionCall?.toolCallId,
),
initialFinish: initialChunks.at(-1),
pendingHistoryClientToolState,
pendingHistoryLatestNetDefinitionState,
resumedStatus: resumeResponse.status,
resumedText: resumedChunks
.filter((chunk) => chunk.type === "text-delta")
.map((chunk) => chunk.delta)
.join(""),
resumedTextBeforeAsk,
resumedFinish: resumedChunks.at(-1),
askCall,
askToolOutputsBeforeResume: resumedChunks.filter(
Expand Down
Loading
Loading