diff --git a/apps/brunch-agent/package.json b/apps/brunch-agent/package.json index fb22a4e054a..ed76579ee4c 100644 --- a/apps/brunch-agent/package.json +++ b/apps/brunch-agent/package.json @@ -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:*", diff --git a/apps/brunch-agent/src/agents/chat-agent.ts b/apps/brunch-agent/src/agents/chat-agent.ts index 01768fcbdad..de58d5c3b19 100644 --- a/apps/brunch-agent/src/agents/chat-agent.ts +++ b/apps/brunch-agent/src/agents/chat-agent.ts @@ -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"; @@ -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"); } diff --git a/apps/brunch-agent/src/client-tool.ts b/apps/brunch-agent/src/client-tool.ts index f5b853f0796..4161e8c27bc 100644 --- a/apps/brunch-agent/src/client-tool.ts +++ b/apps/brunch-agent/src/client-tool.ts @@ -1,5 +1,6 @@ /** 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"; @@ -7,6 +8,7 @@ export const CLIENT_TOOL_RESULT_SIGNAL = "client-tool-result"; export const AWAITING_CLIENT = "client" as const; export const clientToolNames: ReadonlySet = new Set([ + ASK_TOOL_NAME, readPetrinautDocToolName, ]); diff --git a/apps/brunch-agent/src/flue-transcript.ts b/apps/brunch-agent/src/flue-transcript.ts index a8072f9cf31..e5c20f9f57e 100644 --- a/apps/brunch-agent/src/flue-transcript.ts +++ b/apps/brunch-agent/src/flue-transcript.ts @@ -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" } | { @@ -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)}`); @@ -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, @@ -129,7 +139,7 @@ const toolPartFrom = ( } if (output !== undefined) { return { - type: `tool-${part.toolName}`, + ...toolIdentity, toolCallId: part.toolCallId, state: "output-available", input: part.input, @@ -138,7 +148,7 @@ const toolPartFrom = ( }; } return { - type: `tool-${part.toolName}`, + ...toolIdentity, toolCallId: part.toolCallId, state: "input-available", input: part.input, diff --git a/apps/brunch-agent/src/flue-ui-stream.ts b/apps/brunch-agent/src/flue-ui-stream.ts index e0dde6eeff6..196813342e8 100644 --- a/apps/brunch-agent/src/flue-ui-stream.ts +++ b/apps/brunch-agent/src/flue-ui-stream.ts @@ -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"; @@ -139,6 +141,7 @@ export const createFlueUiStream = ( toolCallId: chunk.toolCallId, toolName: chunk.toolName, input: chunk.input, + ...(chunk.toolName === ASK_TOOL_NAME ? { dynamic: true } : {}), ...(providerExecuted === undefined ? {} : { providerExecuted }), }); return; diff --git a/apps/brunch-agent/src/tools/brunch-ask.ts b/apps/brunch-agent/src/tools/brunch-ask.ts new file mode 100644 index 00000000000..3644fd010f7 --- /dev/null +++ b/apps/brunch-agent/src/tools/brunch-ask.ts @@ -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 }; + }, +}); diff --git a/apps/brunch-agent/test/flue-transcript.test.ts b/apps/brunch-agent/test/flue-transcript.test.ts index 85f93f31fed..ed531f39ff7 100644 --- a/apps/brunch-agent/test/flue-transcript.test.ts +++ b/apps/brunch-agent/test/flue-transcript.test.ts @@ -1,5 +1,7 @@ import { expect, test } from "vitest"; +import { ASK_TOOL_NAME } from "@hashintel/brunch-agent/client-tools"; + import { formatFlueTranscript, snapshotToUiMessages, @@ -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", @@ -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([ diff --git a/apps/brunch-agent/test/petrinaut-chat-result.ts b/apps/brunch-agent/test/petrinaut-chat-result.ts index eaad4a673a1..683105e4ac3 100644 --- a/apps/brunch-agent/test/petrinaut-chat-result.ts +++ b/apps/brunch-agent/test/petrinaut-chat-result.ts @@ -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; diff --git a/apps/brunch-agent/test/petrinaut-chat.integration.ts b/apps/brunch-agent/test/petrinaut-chat.integration.ts index 6b69f80239f..44333dbeabf 100644 --- a/apps/brunch-agent/test/petrinaut-chat.integration.ts +++ b/apps/brunch-agent/test/petrinaut-chat.integration.ts @@ -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, @@ -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([ @@ -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", { @@ -275,6 +298,65 @@ try { }), ); const resumedChunks = chunksFrom(await resumeResponse.text()); + const askCall = + resumedChunks.find( + ( + chunk, + ): chunk is Extract => + 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", @@ -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, diff --git a/apps/brunch-agent/test/petrinaut-chat.test.ts b/apps/brunch-agent/test/petrinaut-chat.test.ts index aa5ac79bff7..a28ac036c37 100644 --- a/apps/brunch-agent/test/petrinaut-chat.test.ts +++ b/apps/brunch-agent/test/petrinaut-chat.test.ts @@ -66,6 +66,7 @@ test("the committed /api/chat door streams a plain Flue agent through server and toolName: "readPetrinautDoc", input: { doc: "ai-assistant" }, }); + expect(result.clientToolCall).not.toHaveProperty("dynamic"); expect(result.clientToolCall).not.toHaveProperty("providerExecuted"); expect(result.clientToolOutputsOnInitial).toEqual([]); expect(result.initialFinish).toEqual({ @@ -78,26 +79,49 @@ test("the committed /api/chat door streams a plain Flue agent through server and expect(result.resumedText).toContain( "The guide says the assistant can read its own documentation pages.", ); + expect(result.askCall).toMatchObject({ + type: "tool-input-available", + toolName: "brunch_ask", + input: { question: "What outcome should this process reliably produce?" }, + dynamic: true, + }); + expect(result.askCall).not.toHaveProperty("providerExecuted"); + expect(result.askToolOutputsBeforeResume).toEqual([]); expect(result.resumedFinish).toEqual({ + type: "finish", + finishReason: "tool-calls", + }); + expect(result.pendingHistoryAskState).toBe("input-available"); + expect(result.answerResumeStatus).toBe(200); + expect(result.answerResumeText).toContain( + "I received your answer: a reliable handoff.", + ); + expect(result.answerResumeFinish).toEqual({ type: "finish", finishReason: "stop", }); expect(result.retriedStatus).toBe(200); expect(result.retriedResumeStatus).toBe(200); expect(result.historyUserEntryCount).toBe(1); - expect(result.historyClientToolResultCount).toBe(1); + expect(result.historyClientToolResultCount).toBe(2); expect(result.historyGetStatus).toBe(200); expect(result.historyUserText).toContain( - "Run the FE-1435 transport probe.", + "Start an interview and run the FE-1435 transport probe.", ); expect(result.foreignHistoryMessages).toBe(0); expect(result.unauthenticatedHistoryStatus).toBe(401); expect(result.foreignAgentHistoryStatus).toBe(403); - expect(result.transcript).toContain("Run the FE-1435 transport probe."); + expect(result.transcript).toContain( + "Start an interview and run the FE-1435 transport probe.", + ); expect(result.transcript).toContain("Checking the server, then the docs."); expect(result.transcript).toContain("tool ping"); expect(result.transcript).toContain("tool readPetrinautDoc"); + expect(result.transcript).toContain("tool brunch_ask"); + expect(result.transcript).toContain( + '"output":{"answer":"A reliable handoff."}', + ); expect(result.transcript).toContain("tool activate_skill"); expect(result.transcript).toContain( "The assistant can read its own documentation pages.", @@ -107,20 +131,21 @@ test("the committed /api/chat door streams a plain Flue agent through server and toolName: "activate_skill", input: { name: "confirm-path" }, }); - expect(result.interviewerToolNames).toContain("activate_skill"); - expect(result.interviewerToolNames).toContain("ping"); - expect(result.interviewerToolNames).toContain("readPetrinautDoc"); - expect(result.interviewerToolNames).not.toContain("sweep"); - expect(result.interviewerToolNames).not.toContain("brunch_sweep"); + expect(result.interviewerToolNames).toEqual([ + "activate_skill", + "ping", + "readPetrinautDoc", + "brunch_ask", + ]); expect(result.captureIds.length).toBe(1); expect(result.captureExcerpts).toEqual([ - "Run the FE-1435 transport probe.", + "Start an interview and run the FE-1435 transport probe.", ]); expect(result.capturePayloads).toEqual([{}]); expect(result.recaptureIds).toEqual(result.captureIds); expect(result.skippedDedupKeys.length).toBeGreaterThan(0); expect(result.captureUserText).toContain( - "Run the FE-1435 transport probe.", + "Start an interview and run the FE-1435 transport probe.", ); expect(inspectionLines[0]).toMatchObject({ @@ -143,6 +168,11 @@ test("the committed /api/chat door streams a plain Flue agent through server and requestId: "request-mission-1-resume", terminal: "completed", }, + { + type: "request-finish", + requestId: "request-mission-1-ask-resume", + terminal: "completed", + }, { type: "request-finish", requestId: "request-mission-1-resume-retry", @@ -156,7 +186,7 @@ test("the committed /api/chat door streams a plain Flue agent through server and ]); expect( inspectionLines.filter((event) => event.type === "history-read"), - ).toHaveLength(3); + ).toHaveLength(4); const resumed = await runNodeScript( join(testDirectory, "petrinaut-chat.integration.ts"), @@ -176,10 +206,11 @@ test("the committed /api/chat door streams a plain Flue agent through server and ) as PetrinautResumeResult; expect(resumeResult.historyGetStatus).toBe(200); expect(resumeResult.historyUserText).toContain( - "Run the FE-1435 transport probe.", + "Start an interview and run the FE-1435 transport probe.", ); expect(resumeResult.transcript).toContain("tool ping"); expect(resumeResult.transcript).toContain("tool readPetrinautDoc"); + expect(resumeResult.transcript).toContain("tool brunch_ask"); expect(resumeResult.transcript).toContain("tool activate_skill"); } finally { await rm(dbDirectory, { recursive: true, force: true }); diff --git a/yarn.lock b/yarn.lock index 6af226a8342..345ea77533b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -440,6 +440,7 @@ __metadata: "@flue/runtime": "npm:2.0.3" "@flue/sdk": "npm:2.0.3" "@flue/vite": "npm:2.0.3" + "@hashintel/brunch-agent": "workspace:*" "@hashintel/brunch-agent-binding-flue": "workspace:*" "@hashintel/brunch-agent-transport-aisdk": "workspace:*" "@hashintel/petrinaut-core": "workspace:*"