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
1 change: 1 addition & 0 deletions apps/brunch-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@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-transport-aisdk": "workspace:*",
"@hashintel/petrinaut-core": "workspace:*",
Expand Down
9 changes: 9 additions & 0 deletions apps/brunch-agent/src/agents/chat-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

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 { ping } from "../tools/ping.ts";
import { readPetrinautDoc } from "../tools/read-petrinaut-doc.ts";

Expand All @@ -31,12 +34,18 @@ export function ChatAgent() {
useSkill(confirmPath);
useTool(ping);
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.`,
"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}\`.`,
"Ask one concise question per turn.",
`After calling \`${ASK_TOOL_NAME}\`, wait for the client-tool-result signal before continuing.`,
`Treat the correlated \`{ answer }\` output for \`${ASK_TOOL_NAME}\` as the user's answer.`,
"Never claim that you modified the Petrinaut canvas.",
].join("\n");
}

Expand Down
2 changes: 2 additions & 0 deletions apps/brunch-agent/src/client-tool.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/** 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";

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,
readPetrinautDocToolName,
]);

Expand Down
34 changes: 22 additions & 12 deletions apps/brunch-agent/src/flue-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,26 @@ import {
type FlueConversationSnapshot,
} from "@flue/sdk";

import { ASK_TOOL_NAME } from "@hashintel/brunch-agent/client-tools";

import {
CLIENT_TOOL_RESULT_SIGNAL,
isAwaitingClient,
providerExecutedFor,
} from "./client-tool.ts";

type UiMessageToolPart = {
readonly toolCallId: string;
readonly state: "output-available" | "output-error" | "input-available";
readonly input: unknown;
readonly output?: unknown;
readonly errorText?: string;
readonly providerExecuted?: boolean;
} & (
| { readonly type: `tool-${string}` }
| { readonly type: "dynamic-tool"; readonly toolName: string }
);

type UiMessagePart =
| { readonly type: "text"; readonly text: string; readonly state: "done" }
| {
Expand All @@ -29,15 +43,7 @@ type UiMessagePart =
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;
};
| UiMessageToolPart;

const unhandledConversationPart = (part: never): never => {
throw new Error(`Unhandled Flue conversation part: ${JSON.stringify(part)}`);
Expand Down Expand Up @@ -117,9 +123,13 @@ const toolPartFrom = (
part.state === "output-available"
? providerExecutedFor(isAwaitingClient(part.output))
: undefined;
const toolIdentity =
part.toolName === ASK_TOOL_NAME
? { type: "dynamic-tool" as const, toolName: part.toolName }
: { type: `tool-${part.toolName}` as const };
if (part.state === "output-error") {
return {
type: `tool-${part.toolName}`,
...toolIdentity,
toolCallId: part.toolCallId,
state: "output-error",
input: part.input,
Expand All @@ -129,7 +139,7 @@ const toolPartFrom = (
}
if (output !== undefined) {
return {
type: `tool-${part.toolName}`,
...toolIdentity,
toolCallId: part.toolCallId,
state: "output-available",
input: part.input,
Expand All @@ -138,7 +148,7 @@ const toolPartFrom = (
};
}
return {
type: `tool-${part.toolName}`,
...toolIdentity,
toolCallId: part.toolCallId,
state: "input-available",
input: part.input,
Expand Down
3 changes: 3 additions & 0 deletions apps/brunch-agent/src/flue-ui-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { type ConversationStreamChunk } from "@flue/sdk";

import { ASK_TOOL_NAME } from "@hashintel/brunch-agent/client-tools";

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

import type { UIMessageChunk } from "ai";
Expand Down Expand Up @@ -139,6 +141,7 @@ export const createFlueUiStream = (
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
input: chunk.input,
...(chunk.toolName === ASK_TOOL_NAME ? { dynamic: true } : {}),
Comment thread
kostandinang marked this conversation as resolved.
...(providerExecuted === undefined ? {} : { providerExecuted }),
});
return;
Expand Down
19 changes: 19 additions & 0 deletions apps/brunch-agent/src/tools/brunch-ask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { defineTool } from "@flue/runtime";
import * as v from "valibot";

import { ASK_TOOL_NAME, AskInput } from "@hashintel/brunch-agent/client-tools";

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

export const brunchAsk = defineTool({
name: ASK_TOOL_NAME,
description:
"Ask one concise interview question. The browser executes this tool. After calling it, wait for a client-tool-result signal carrying the correlated { answer } output before continuing.",
input: AskInput,
output: v.object({
awaiting: v.literal(AWAITING_CLIENT),
}),
run() {
return { output: { awaiting: AWAITING_CLIENT }, terminate: true };
},
});
41 changes: 41 additions & 0 deletions apps/brunch-agent/test/flue-transcript.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test } from "vitest";

import { ASK_TOOL_NAME } from "@hashintel/brunch-agent/client-tools";

import {
formatFlueTranscript,
snapshotToUiMessages,
Expand Down Expand Up @@ -53,6 +55,27 @@ const snapshotWithCompletedClientTool: FlueConversationSnapshot = {
],
};

const snapshotWithPendingAsk: FlueConversationSnapshot = {
...snapshotWithPendingClientTool,
messages: [
{
id: "assistant-ask",
role: "assistant",
purpose: "assistant",
display: "visible",
parts: [
{
type: "dynamic-tool",
toolCallId: "tool-ask-1",
toolName: ASK_TOOL_NAME,
state: "input-available",
input: { question: "What happens after approval?" },
},
],
},
],
};

const snapshotWithDataPart: FlueConversationSnapshot = {
v: 1,
conversationId: "conversation-1",
Expand Down Expand Up @@ -89,6 +112,24 @@ test("history reconstruction leaves an unfinished client tool available to run",
]);
});

test("history reconstructs brunch asks as dynamic tools", () => {
expect(snapshotToUiMessages(snapshotWithPendingAsk)).toEqual([
{
id: "assistant-ask",
role: "assistant",
parts: [
{
type: "dynamic-tool",
toolName: ASK_TOOL_NAME,
toolCallId: "tool-ask-1",
state: "input-available",
input: { question: "What happens after approval?" },
},
],
},
]);
});

test("history reconstruction uses the browser result even when it is null", () => {
const [message] = snapshotToUiMessages(snapshotWithCompletedClientTool);
expect(message?.parts).toEqual([
Expand Down
9 changes: 9 additions & 0 deletions apps/brunch-agent/test/petrinaut-chat-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export interface PetrinautChatResult {
readonly resumedStatus: number;
readonly resumedText: string;
readonly resumedFinish: UIMessageChunk | undefined;
readonly askCall: Extract<
UIMessageChunk,
{ type: "tool-input-available" }
> | null;
readonly askToolOutputsBeforeResume: readonly UIMessageChunk[];
readonly pendingHistoryAskState: string | undefined;
readonly answerResumeStatus: number;
readonly answerResumeText: string;
readonly answerResumeFinish: UIMessageChunk | undefined;
readonly retriedStatus: number;
readonly retriedResumeStatus: number;
readonly historyUserEntryCount: number;
Expand Down
97 changes: 96 additions & 1 deletion apps/brunch-agent/test/petrinaut-chat.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
import { sqlite, start } from "@flue/runtime/node";
import { createFlueClient, FlueApiError } from "@flue/sdk";

import { ASK_TOOL_NAME } from "@hashintel/brunch-agent/client-tools";

import {
ACTIVATE_SKILL_TOOL_NAME,
CHAT_MODEL_ID,
Expand Down Expand Up @@ -149,9 +151,24 @@ try {
],
{ stopReason: "toolUse" },
),
fauxAssistantMessage(
[
fauxThinking("The user explicitly requested an interview."),
fauxText(
"The guide says the assistant can read its own documentation pages.",
),
fauxToolCall(
ASK_TOOL_NAME,
{ question: "What outcome should this process reliably produce?" },
{ id: "tool-ask-1" },
),
],
{ stopReason: "toolUse" },
),
fauxAssistantMessage([
fauxThinking("Use the correlated client-tool answer."),
fauxText(
"The guide says the assistant can read its own documentation pages.",
"I received your answer: a reliable handoff. The Petrinaut canvas was not modified.",
),
]),
fauxAssistantMessage([
Expand Down Expand Up @@ -179,6 +196,12 @@ try {
if (userMessage === undefined) {
throw new Error("panel-initial.post.json is missing the user message");
}
userMessage.parts = [
{
type: "text",
text: "Start an interview and run the FE-1435 transport probe.",
},
];

const initialResponse = await app.fetch(
new Request("http://brunch.test/api/chat", {
Expand Down Expand Up @@ -275,6 +298,65 @@ try {
}),
);
const resumedChunks = chunksFrom(await resumeResponse.text());
const askCall =
resumedChunks.find(
(
chunk,
): chunk is Extract<UIMessageChunk, { type: "tool-input-available" }> =>
chunk.type === "tool-input-available" &&
chunk.toolName === ASK_TOOL_NAME,
) ?? null;
const pendingAskHistoryResponse = await app.fetch(
new Request(
`http://brunch.test/api/chat?id=${encodeURIComponent(conversationId)}`,
{
method: "GET",
headers: { "x-brunch-principal": principalKey },
},
),
);
const pendingAskHistoryBody = (await pendingAskHistoryResponse.json()) as {
messages?: {
parts?: { toolCallId?: string; state?: string }[];
}[];
};
const pendingHistoryAskState = pendingAskHistoryBody.messages
?.flatMap((message) => message.parts ?? [])
.find((part) => part.toolCallId === askCall?.toolCallId)?.state;
const answerResumeBody = {
id: conversationId,
trigger: "submit-message",
messageId: startChunk?.messageId,
messages: [
userMessage,
{
id: startChunk?.messageId,
role: "assistant",
parts: [
{
type: "dynamic-tool",
toolName: ASK_TOOL_NAME,
toolCallId: askCall?.toolCallId,
state: "output-available",
input: askCall?.input,
output: { answer: "A reliable handoff." },
},
],
},
],
};
const answerResumeResponse = await app.fetch(
new Request("http://brunch.test/api/chat", {
method: "POST",
headers: {
"content-type": "application/json",
"x-brunch-principal": principalKey,
"x-request-id": "request-mission-1-ask-resume",
},
body: JSON.stringify(answerResumeBody),
}),
);
const answerResumeChunks = chunksFrom(await answerResumeResponse.text());
const retriedResumeResponse = await app.fetch(
new Request("http://brunch.test/api/chat", {
method: "POST",
Expand Down Expand Up @@ -409,6 +491,19 @@ try {
.map((chunk) => chunk.delta)
.join(""),
resumedFinish: resumedChunks.at(-1),
askCall,
askToolOutputsBeforeResume: resumedChunks.filter(
(chunk) =>
chunk.type === "tool-output-available" &&
chunk.toolCallId === askCall?.toolCallId,
),
pendingHistoryAskState,
answerResumeStatus: answerResumeResponse.status,
answerResumeText: answerResumeChunks
.filter((chunk) => chunk.type === "text-delta")
.map((chunk) => chunk.delta)
.join(""),
answerResumeFinish: answerResumeChunks.at(-1),
retriedStatus: retriedResponse.status,
retriedResumeStatus: retriedResumeResponse.status,
historyUserEntryCount: userEntryIds.length,
Expand Down
Loading
Loading