OpenAI API tool call processing fixes/correctness

This commit is contained in:
Rob Colbert 2026-05-12 14:39:44 -04:00
parent 1bb2a1d392
commit b090b5308b
6 changed files with 119 additions and 78 deletions

View File

@ -7,7 +7,7 @@ an `AbortError` through the agent loop, and marks the turn as `Aborted` (not
Abort is separate from completion. `workOrderComplete` is a normal end Abort is separate from completion. `workOrderComplete` is a normal end
condition — the agent finished its work. Abort is something the user does condition — the agent finished its work. Abort is something the user does
*before* completion and invalid after it. When a work order completes naturally _before_ completion and invalid after it. When a work order completes naturally
or is aborted, the abort signal mechanism is cleaned up end-to-end. or is aborted, the abort signal mechanism is cleaned up end-to-end.
--- ---
@ -34,9 +34,9 @@ or is aborted, the abort signal mechanism is cleaned up end-to-end.
workOrderComplete(turnId, true, "aborted") workOrderComplete(turnId, true, "aborted")
| |
[DroneSession] --workOrderComplete(turnId, true, "aborted")--> [CodeSession] [DroneSession] --workOrderComplete(turnId, true, "aborted")--> [CodeSession]
| | | |
turn.status = turn.status = "aborted" turn.status = turn.status = "aborted"
ChatTurnStatus.Aborted displayed subtly ChatTurnStatus.Aborted displayed subtly
``` ```
**Key design decision**: `workOrderComplete` is used with `success=true` and **Key design decision**: `workOrderComplete` is used with `success=true` and
@ -136,6 +136,7 @@ and is detected downstream via `error.name === "AbortError"`.
### 5. Frontend (`gadget-code/frontend`) ### 5. Frontend (`gadget-code/frontend`)
**`frontend/src/lib/api.ts`** — Added `"aborted"` to `ChatTurn.status`: **`frontend/src/lib/api.ts`** — Added `"aborted"` to `ChatTurn.status`:
```ts ```ts
status: "processing" | "finished" | "aborted" | "error"; status: "processing" | "finished" | "aborted" | "error";
``` ```
@ -170,13 +171,15 @@ status: "processing" | "finished" | "aborted" | "error";
- Status color: `"aborted"``text-yellow-500` (NOT red) - Status color: `"aborted"``text-yellow-500` (NOT red)
- Aborted display: Subtle info box (not the bright red error box): - Aborted display: Subtle info box (not the bright red error box):
```tsx ```tsx
{turn.status === "aborted" && ( {
<div className="bg-bg-tertiary border border-border-default rounded p-3"> turn.status === "aborted" && (
<div className="text-sm text-text-secondary"> <div className="bg-bg-tertiary border border-border-default rounded p-3">
The turn was aborted by you. <div className="text-sm text-text-secondary">
The turn was aborted by you.
</div>
</div> </div>
</div> );
)} }
``` ```
- The existing big red error block only shows for `status === "error"`, not - The existing big red error block only shows for `status === "error"`, not
`status === "aborted"` `status === "aborted"`
@ -185,22 +188,22 @@ status: "processing" | "finished" | "aborted" | "error";
## Files Changed (14 files) ## Files Changed (14 files)
| File | Change | | File | Change |
|------|--------| | ---------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `.gitignore` | Add root-level log file patterns | | `.gitignore` | Add root-level log file patterns |
| `packages/api/src/messages/ide.ts` | New `AbortWorkOrderCallback`, `AbortWorkOrderMessage` | | `packages/api/src/messages/ide.ts` | New `AbortWorkOrderCallback`, `AbortWorkOrderMessage` |
| `packages/api/src/messages/socket.ts` | Register `abortWorkOrder` in both event interfaces | | `packages/api/src/messages/socket.ts` | Register `abortWorkOrder` in both event interfaces |
| `packages/ai/src/api.ts` | `signal?: AbortSignal` on `IAiChatOptions`, `IAiGenerateOptions` | | `packages/ai/src/api.ts` | `signal?: AbortSignal` on `IAiChatOptions`, `IAiGenerateOptions` |
| `packages/ai/src/ollama.ts` | Pre-request and per-chunk abort checks, signal pass-through | | `packages/ai/src/ollama.ts` | Pre-request and per-chunk abort checks, signal pass-through |
| `packages/ai/src/openai.ts` | Same for `generate()`, `readStreamingChatCompletion()`, `readNonStreamingChatCompletion()` | | `packages/ai/src/openai.ts` | Same for `generate()`, `readStreamingChatCompletion()`, `readNonStreamingChatCompletion()` |
| `gadget-drone/src/services/agent.ts` | `AbortController` property, abort detection, `abortCurrentWorkOrder()` | | `gadget-drone/src/services/agent.ts` | `AbortController` property, abort detection, `abortCurrentWorkOrder()` |
| `gadget-drone/src/gadget-drone.ts` | `onAbortWorkOrder` socket handler | | `gadget-drone/src/gadget-drone.ts` | `onAbortWorkOrder` socket handler |
| `gadget-code/src/lib/code-session.ts` | Forward `abortWorkOrder` to drone | | `gadget-code/src/lib/code-session.ts` | Forward `abortWorkOrder` to drone |
| `gadget-code/src/lib/drone-session.ts` | Detect `"aborted"` in `workOrderComplete` | | `gadget-code/src/lib/drone-session.ts` | Detect `"aborted"` in `workOrderComplete` |
| `gadget-code/frontend/src/lib/api.ts` | `"aborted"` in `ChatTurn.status` | | `gadget-code/frontend/src/lib/api.ts` | `"aborted"` in `ChatTurn.status` |
| `gadget-code/frontend/src/lib/socket.ts` | `abortWorkOrder` method on `SocketClient` | | `gadget-code/frontend/src/lib/socket.ts` | `abortWorkOrder` method on `SocketClient` |
| `gadget-code/frontend/src/pages/ChatSessionView.tsx` | Cancel button, double-Esc, abort flow | | `gadget-code/frontend/src/pages/ChatSessionView.tsx` | Cancel button, double-Esc, abort flow |
| `gadget-code/frontend/src/components/ChatTurn.tsx` | Subtle aborted display, yellow status | | `gadget-code/frontend/src/components/ChatTurn.tsx` | Subtle aborted display, yellow status |
--- ---

View File

@ -11,7 +11,7 @@ describe("AgentService", () => {
await service.start(); await service.start();
}); });
it("replays historical tool results as assistant-readable context, not raw tool-role messages", () => { it("replays historical tool results as role:tool messages with unaltered content", () => {
const user = { const user = {
_id: "user-1", _id: "user-1",
email: "user@example.com", email: "user@example.com",
@ -111,10 +111,15 @@ describe("AgentService", () => {
expect(messages.map((message) => message.role)).toEqual([ expect(messages.map((message) => message.role)).toEqual([
"user", "user",
"assistant", "assistant",
"assistant", "tool",
]); ]);
expect(messages[2]?.content).toContain("Historical tool result: file_read"); expect(messages[1]?.toolCalls).toBeDefined();
expect(messages[2]?.content).toContain("PATH: index.html"); expect(messages[1]?.toolCalls).toHaveLength(1);
expect(messages[1]?.toolCalls?.[0]?.id).toBe("call-1");
expect(messages[1]?.toolCalls?.[0]?.function?.name).toBe("file_read");
expect(messages[2]?.role).toBe("tool");
expect(messages[2]?.content).toBe("PATH: index.html\n---\ncontent");
expect(messages[2]?.toolCallId).toBe("call-1");
}); });
it("does not expose mutating file tools in plan mode", () => { it("does not expose mutating file tools in plan mode", () => {

View File

@ -251,6 +251,14 @@ class AgentService extends GadgetService {
createdAt: turn.createdAt, createdAt: turn.createdAt,
role: "assistant", role: "assistant",
content: response.response, content: response.response,
toolCalls: response.toolCalls.map((tc) => ({
id: tc.callId,
type: "function" as const,
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
}); });
for (const toolCall of response.toolCalls) { for (const toolCall of response.toolCalls) {
@ -289,12 +297,10 @@ class AgentService extends GadgetService {
messages.push({ messages.push({
createdAt: turn.createdAt, createdAt: turn.createdAt,
role: "assistant", role: "tool",
content: this.formatHistoricalToolResult({ content: result,
name: toolCall.function.name, toolCallId: toolCall.callId,
parameters: toolCall.function.arguments, toolName: toolCall.function.name,
response: result,
}),
}); });
inputTokens += Math.ceil(toolCall.function.arguments.length / 4); inputTokens += Math.ceil(toolCall.function.arguments.length / 4);
@ -376,30 +382,36 @@ class AgentService extends GadgetService {
} }
} }
messages.push({ const assistantMsg: IContextChatMessage = {
createdAt: turn.createdAt, createdAt: turn.createdAt,
role: "assistant", role: "assistant",
content: content:
content && content.length content && content.length
? content ? content
: "(you didn't say anything this turn)", : "(you didn't say anything this turn)",
}); };
if (turn.toolCalls?.length > 0) {
assistantMsg.toolCalls = turn.toolCalls.map((tc: IChatToolCall) => ({
id: tc.callId,
type: "function" as const,
function: {
name: tc.name,
arguments: tc.parameters,
},
}));
}
messages.push(assistantMsg);
/*
* Persisted turns do not currently store provider-native assistant
* tool-call messages. Replaying these as role=tool creates invalid
* OpenAI-compatible history. Keep the information, but make it normal
* assistant-readable context.
*/
if (turn.toolCalls?.length > 0) { if (turn.toolCalls?.length > 0) {
for (const toolCall of turn.toolCalls) { for (const toolCall of turn.toolCalls) {
const content = this.formatHistoricalToolResult(toolCall);
messages.push({ messages.push({
createdAt: turn.createdAt, createdAt: turn.createdAt,
role: "assistant", role: "tool",
callId: toolCall.callId, content: toolCall.response,
toolCallId: toolCall.callId,
toolName: toolCall.name, toolName: toolCall.name,
content,
}); });
} }
} }
@ -424,22 +436,6 @@ class AgentService extends GadgetService {
return this.toolbox.getToolNamesForMode(mode); return this.toolbox.getToolNamesForMode(mode);
} }
private formatHistoricalToolResult(toolCall: {
name: string;
parameters?: string;
response?: string;
}): string {
const response = toolCall.response || "";
return [
`Historical tool result: ${toolCall.name}`,
`Parameters: ${toolCall.parameters || "{}"}`,
"---",
response.length > 8000
? `${response.slice(0, 8000)}\n\n[Tool result truncated from ${response.length} characters.]`
: response,
].join("\n");
}
private makeStreamHandler(socket: DroneSocket): IAiResponseStreamFn { private makeStreamHandler(socket: DroneSocket): IAiResponseStreamFn {
return async (chunk) => { return async (chunk) => {
switch (chunk.type) { switch (chunk.type) {
@ -481,7 +477,7 @@ class AgentService extends GadgetService {
modelId, modelId,
params: { params: {
reasoning, reasoning,
temperature: settings?.temperature ?? 0.8, temperature: settings?.temperature ?? 0.1,
topP: settings?.topP ?? 0.9, topP: settings?.topP ?? 0.9,
topK: settings?.topK ?? 40, topK: settings?.topK ?? 40,
numPredict: settings?.numPredict ?? -1, // -1 = unlimited (Ollama) numPredict: settings?.numPredict ?? -1, // -1 = unlimited (Ollama)
@ -657,6 +653,14 @@ class AgentService extends GadgetService {
createdAt: new Date(), createdAt: new Date(),
role: "assistant", role: "assistant",
content: response.response, content: response.response,
toolCalls: response.toolCalls.map((tc) => ({
id: tc.callId,
type: "function" as const,
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
}); });
for (const toolCall of response.toolCalls) { for (const toolCall of response.toolCalls) {
@ -684,12 +688,10 @@ class AgentService extends GadgetService {
messages.push({ messages.push({
createdAt: new Date(), createdAt: new Date(),
role: "assistant", role: "tool",
content: this.formatHistoricalToolResult({ content: result,
name: toolCall.function.name, toolCallId: toolCall.callId,
parameters: toolArgsRaw, toolName: toolCall.function.name,
response: result,
}),
}); });
inputTokens += Math.ceil(toolArgsRaw.length / 4); inputTokens += Math.ceil(toolArgsRaw.length / 4);

View File

@ -61,7 +61,16 @@ export interface IContextChatMessage {
createdAt: Date; createdAt: Date;
role: string; role: string;
callId?: string; callId?: string;
toolCallId?: string;
toolName?: string; toolName?: string;
toolCalls?: Array<{
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
}>;
content: string; content: string;
user?: { user?: {
_id: string; _id: string;

View File

@ -157,6 +157,9 @@ export class OllamaAiApi extends AiApi {
options: { options: {
num_ctx: model.params.numCtx, num_ctx: model.params.numCtx,
num_predict: model.params.numPredict, num_predict: model.params.numPredict,
temperature: model.params.temperature,
top_p: model.params.topP,
top_k: model.params.topK,
}, },
}); });
@ -285,6 +288,9 @@ export class OllamaAiApi extends AiApi {
options: { options: {
num_ctx: model.params.numCtx, num_ctx: model.params.numCtx,
num_predict: model.params.numPredict, num_predict: model.params.numPredict,
temperature: model.params.temperature,
top_p: model.params.topP,
top_k: model.params.topK,
}, },
}); });

View File

@ -206,10 +206,11 @@ export class OpenAiApi extends AiApi {
{ role: "user" as const, content: options.prompt }, { role: "user" as const, content: options.prompt },
], ],
stream: true, stream: true,
...(options.signal ? { signal: options.signal } : {}),
...(model.params.maxCompletionTokens ...(model.params.maxCompletionTokens
? { max_completion_tokens: model.params.maxCompletionTokens } ? { max_completion_tokens: model.params.maxCompletionTokens }
: {}), : {}),
temperature: model.params.temperature,
top_p: model.params.topP,
...(typeof model.params.reasoning === "string" ...(typeof model.params.reasoning === "string"
? { ? {
reasoning_effort: model.params.reasoning as reasoning_effort: model.params.reasoning as
@ -218,7 +219,7 @@ export class OpenAiApi extends AiApi {
| "high", | "high",
} }
: {}), : {}),
}); }, options.signal ? { signal: options.signal } : undefined);
let accumulatedResponse = ""; let accumulatedResponse = "";
let accumulatedThinking = ""; let accumulatedThinking = "";
@ -347,9 +348,18 @@ export class OpenAiApi extends AiApi {
for (const msg of options.context || []) { for (const msg of options.context || []) {
if (!msg.content?.trim()) continue; if (!msg.content?.trim()) continue;
if (msg.role === "tool") { if (msg.role === "tool") {
messages.push({
role: "tool",
tool_call_id: msg.toolCallId || msg.callId || "",
content: msg.content,
});
continue;
}
if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length > 0) {
messages.push({ messages.push({
role: "assistant", role: "assistant",
content: `Historical tool result${msg.toolName ? ` from ${msg.toolName}` : ""}:\n${msg.content}`, content: msg.content,
tool_calls: msg.toolCalls,
}); });
continue; continue;
} }
@ -391,10 +401,11 @@ export class OpenAiApi extends AiApi {
messages, messages,
tools, tools,
stream: true, stream: true,
...(signal ? { signal } : {}),
...(model.params.maxCompletionTokens ...(model.params.maxCompletionTokens
? { max_completion_tokens: model.params.maxCompletionTokens } ? { max_completion_tokens: model.params.maxCompletionTokens }
: {}), : {}),
temperature: model.params.temperature,
top_p: model.params.topP,
...(typeof model.params.reasoning === "string" ...(typeof model.params.reasoning === "string"
? { ? {
reasoning_effort: model.params.reasoning as reasoning_effort: model.params.reasoning as
@ -403,7 +414,7 @@ export class OpenAiApi extends AiApi {
| "high", | "high",
} }
: {}), : {}),
}); }, signal ? { signal } : undefined);
let content = ""; let content = "";
let thinking = ""; let thinking = "";
@ -461,15 +472,20 @@ export class OpenAiApi extends AiApi {
tools: ChatCompletionTool[], tools: ChatCompletionTool[],
signal?: AbortSignal, signal?: AbortSignal,
): Promise<OpenAiChatIterationResult> { ): Promise<OpenAiChatIterationResult> {
if (signal?.aborted) {
throw new DOMException("The operation was aborted", "AbortError");
}
const response = await this.client.chat.completions.create({ const response = await this.client.chat.completions.create({
model: model.modelId, model: model.modelId,
messages, messages,
tools, tools,
stream: false, stream: false,
...(signal ? { signal } : {}),
...(model.params.maxCompletionTokens ...(model.params.maxCompletionTokens
? { max_completion_tokens: model.params.maxCompletionTokens } ? { max_completion_tokens: model.params.maxCompletionTokens }
: {}), : {}),
temperature: model.params.temperature,
top_p: model.params.topP,
...(typeof model.params.reasoning === "string" ...(typeof model.params.reasoning === "string"
? { ? {
reasoning_effort: model.params.reasoning as reasoning_effort: model.params.reasoning as
@ -478,7 +494,7 @@ export class OpenAiApi extends AiApi {
| "high", | "high",
} }
: {}), : {}),
}); }, signal ? { signal } : undefined);
const choice = response.choices[0]; const choice = response.choices[0];
const message = choice?.message; const message = choice?.message;
const content = typeof message?.content === "string" ? message.content : ""; const content = typeof message?.content === "string" ? message.content : "";