diff --git a/src/gateway/agent-prompt.e2e.test.ts b/src/gateway/agent-prompt.e2e.test.ts new file mode 100644 index 00000000000..80fc92e4819 --- /dev/null +++ b/src/gateway/agent-prompt.e2e.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { buildHistoryContextFromEntries } from "../auto-reply/reply/history.js"; +import { buildAgentMessageFromConversationEntries } from "./agent-prompt.js"; + +describe("gateway agent prompt", () => { + it("returns empty for no entries", () => { + expect(buildAgentMessageFromConversationEntries([])).toBe(""); + }); + + it("returns current body when there is no history", () => { + expect( + buildAgentMessageFromConversationEntries([ + { role: "user", entry: { sender: "User", body: "hi" } }, + ]), + ).toBe("hi"); + }); + + it("uses history context when there is history", () => { + const entries = [ + { role: "assistant", entry: { sender: "Assistant", body: "prev" } }, + { role: "user", entry: { sender: "User", body: "next" } }, + ] as const; + + const expected = buildHistoryContextFromEntries({ + entries: entries.map((e) => e.entry), + currentMessage: "User: next", + formatEntry: (e) => `${e.sender}: ${e.body}`, + }); + + expect(buildAgentMessageFromConversationEntries([...entries])).toBe(expected); + }); + + it("prefers last tool entry over assistant for current message", () => { + const entries = [ + { role: "user", entry: { sender: "User", body: "question" } }, + { role: "tool", entry: { sender: "Tool:x", body: "tool output" } }, + { role: "assistant", entry: { sender: "Assistant", body: "assistant text" } }, + ] as const; + + const expected = buildHistoryContextFromEntries({ + entries: [entries[0].entry, entries[1].entry], + currentMessage: "Tool:x: tool output", + formatEntry: (e) => `${e.sender}: ${e.body}`, + }); + + expect(buildAgentMessageFromConversationEntries([...entries])).toBe(expected); + }); +}); diff --git a/src/gateway/agent-prompt.ts b/src/gateway/agent-prompt.ts new file mode 100644 index 00000000000..58e12bacd02 --- /dev/null +++ b/src/gateway/agent-prompt.ts @@ -0,0 +1,43 @@ +import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply/reply/history.js"; + +export type ConversationEntry = { + role: "user" | "assistant" | "tool"; + entry: HistoryEntry; +}; + +export function buildAgentMessageFromConversationEntries(entries: ConversationEntry[]): string { + if (entries.length === 0) { + return ""; + } + + // Prefer the last user/tool entry as "current message" so the agent responds to + // the latest user input or tool output, not the assistant's previous message. + let currentIndex = -1; + for (let i = entries.length - 1; i >= 0; i -= 1) { + const role = entries[i]?.role; + if (role === "user" || role === "tool") { + currentIndex = i; + break; + } + } + if (currentIndex < 0) { + currentIndex = entries.length - 1; + } + + const currentEntry = entries[currentIndex]?.entry; + if (!currentEntry) { + return ""; + } + + const historyEntries = entries.slice(0, currentIndex).map((e) => e.entry); + if (historyEntries.length === 0) { + return currentEntry.body; + } + + const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${entry.body}`; + return buildHistoryContextFromEntries({ + entries: [...historyEntries, currentEntry], + currentMessage: formatEntry(currentEntry), + formatEntry, + }); +} diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 2b9df17cdfe..57eec192bf1 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -1,12 +1,15 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { randomUUID } from "node:crypto"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; -import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply/reply/history.js"; import { createDefaultDeps } from "../cli/deps.js"; import { agentCommand } from "../commands/agent.js"; import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js"; import { logWarn } from "../logger.js"; import { defaultRuntime } from "../runtime.js"; +import { + buildAgentMessageFromConversationEntries, + type ConversationEntry, +} from "./agent-prompt.js"; import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; import { readJsonBodyOrError, @@ -83,8 +86,7 @@ function buildAgentPrompt(messagesUnknown: unknown): { const messages = asMessages(messagesUnknown); const systemParts: string[] = []; - const conversationEntries: Array<{ role: "user" | "assistant" | "tool"; entry: HistoryEntry }> = - []; + const conversationEntries: ConversationEntry[] = []; for (const msg of messages) { if (!msg || typeof msg !== "object") { @@ -121,34 +123,7 @@ function buildAgentPrompt(messagesUnknown: unknown): { }); } - let message = ""; - if (conversationEntries.length > 0) { - let currentIndex = -1; - for (let i = conversationEntries.length - 1; i >= 0; i -= 1) { - const entryRole = conversationEntries[i]?.role; - if (entryRole === "user" || entryRole === "tool") { - currentIndex = i; - break; - } - } - if (currentIndex < 0) { - currentIndex = conversationEntries.length - 1; - } - const currentEntry = conversationEntries[currentIndex]?.entry; - if (currentEntry) { - const historyEntries = conversationEntries.slice(0, currentIndex).map((entry) => entry.entry); - if (historyEntries.length === 0) { - message = currentEntry.body; - } else { - const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${entry.body}`; - message = buildHistoryContextFromEntries({ - entries: [...historyEntries, currentEntry], - currentMessage: formatEntry(currentEntry), - formatEntry, - }); - } - } - } + const message = buildAgentMessageFromConversationEntries(conversationEntries); return { message, diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index c4d8b9bef19..bb8cbf7e639 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -12,7 +12,6 @@ import type { ClientToolDefinition } from "../agents/pi-embedded-runner/run/para import type { ImageContent } from "../commands/agent/types.js"; import type { GatewayHttpResponsesConfig } from "../config/types.gateway.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; -import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply/reply/history.js"; import { createDefaultDeps } from "../cli/deps.js"; import { agentCommand } from "../commands/agent.js"; import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js"; @@ -36,6 +35,10 @@ import { type InputImageSource, } from "../media/input-files.js"; import { defaultRuntime } from "../runtime.js"; +import { + buildAgentMessageFromConversationEntries, + type ConversationEntry, +} from "./agent-prompt.js"; import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; import { readJsonBodyOrError, @@ -196,8 +199,7 @@ export function buildAgentPrompt(input: string | ItemParam[]): { } const systemParts: string[] = []; - const conversationEntries: Array<{ role: "user" | "assistant" | "tool"; entry: HistoryEntry }> = - []; + const conversationEntries: ConversationEntry[] = []; for (const item of input) { if (item.type === "message") { @@ -227,36 +229,7 @@ export function buildAgentPrompt(input: string | ItemParam[]): { // Skip reasoning and item_reference for prompt building (Phase 1) } - let message = ""; - if (conversationEntries.length > 0) { - // Find the last user or tool message as the current message - let currentIndex = -1; - for (let i = conversationEntries.length - 1; i >= 0; i -= 1) { - const entryRole = conversationEntries[i]?.role; - if (entryRole === "user" || entryRole === "tool") { - currentIndex = i; - break; - } - } - if (currentIndex < 0) { - currentIndex = conversationEntries.length - 1; - } - - const currentEntry = conversationEntries[currentIndex]?.entry; - if (currentEntry) { - const historyEntries = conversationEntries.slice(0, currentIndex).map((entry) => entry.entry); - if (historyEntries.length === 0) { - message = currentEntry.body; - } else { - const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${entry.body}`; - message = buildHistoryContextFromEntries({ - entries: [...historyEntries, currentEntry], - currentMessage: formatEntry(currentEntry), - formatEntry, - }); - } - } - } + const message = buildAgentMessageFromConversationEntries(conversationEntries); return { message,