Skip to content

devin: tool calls never complete on OpenAI chat/Responses clients (missing status, wrong tool_calls index, StopReason ignored) #5851

Description

@Hakunm

Version: v7.3.4 (8335eac); the files below are unchanged on dev @ 6724a95
Path: Devin OAuth (devin/swe-2) → /v1/chat/completions and /v1/responses, streaming
Clients: opencode (@ai-sdk/openai, chat completions) and ZCode (@ai-sdk/openai, Responses API); both are Vercel AI SDK v5 clients
OS: CPA in Docker on Ubuntu/arm64; clients on Windows 11

Summary

With devin/swe-2 behind CPA, any turn that ends in a tool call fails on both clients while plain-text turns work. The tool call is streamed to the client (tool-input-start + N × tool-input-delta) but the AI SDK never emits the final tool-call; the client renders the tool as failed/orphaned and the turn ends with finishReason: stop. In practice: small answers are fine, every real edit fails, and larger edits fail more reliably.

Tracing the two endpoints separately shows two different defects producing the same symptom, plus a spec violation on the same path:

  1. /v1/responses: the function_call response.output_item.done frame carries no status. The AI SDK schema rejects it and silently downgrades it to an unknown chunk, so the tool call is never emitted. This hits every tool call, independent of size. (ZCode)
  2. /v1/chat/completions: tool_calls[].index is the interactions step index, not the index into tool_calls. A tool call preceded by a thought or text step is emitted as tool_calls[1]/[2] with no [0].
  3. Both endpoints: Devin's per-frame StopReason is decoded but never used, so an output-budget truncation mid tool call is reported as finish_reason: "stop" (or response.completed) with an unparsable arguments string. The AI SDK chat path only emits tool-call once arguments parses as JSON, so the call is never emitted. (opencode)

Defects 1 and 2 are in the shared interactions → OpenAI translators, so they likely affect every interactions-backed provider (Gemini Interactions API, Antigravity) on these endpoints, not only Devin; I have only verified them against Devin. Defect 3 is in the Devin executor.

Example A — ZCode, /v1/responses

Task: "generate an HTML file with an SVG pelican-on-a-bicycle animation" → the model answers with a short text and one write_file call (~260 lines).

CPA main.log: 200 | 4m20s | POST "/v1/responses", no warnings.

ZCode's AI SDK diagnostics for the turn:

finishReason=stop toolCallCount=0 emittedError=false errorChunkCount=0
chunkCounts: reasoning-start=1 reasoning-end=1 text-start=1 text-delta=42 text-end=1
             tool-input-start=1 tool-input-delta=4705 tool-input-end=0 tool-call=0

Every other item closed normally (reasoning-end, text-end), only the function_call never got its tool-input-end/tool-call. Three earlier turns the same day show the identical pattern (3528 / 3621 / 4705 deltas, always tool-input-end=0, finishReason=stop, no error chunk). ZCode shows the tool as "执行失败" (execution failed) because the turn ended with a pending tool input.

Reproducing the same request with curl shows what CPA sends for the function_call item:

{"type":"response.output_item.added","output_index":1,
 "item":{"id":"write_file_0#…","type":"function_call","call_id":"write_file_0#…","name":"write_file","arguments":""}}

{"type":"response.output_item.done","output_index":1,
 "item":{"id":"write_file_0#…","type":"function_call","call_id":"write_file_0#…","name":"write_file","arguments":"{…}"}}

Neither item has a status. In the AI SDK bundled with ZCode (@ai-sdk/openai, Responses model), the chunk schema for response.output_item.done is a discriminated union whose function_call member is:

z.object({ type: z.literal('function_call'), id: z.string(), call_id: z.string(),
           name: z.string(), arguments: z.string(), status: z.literal('completed'),})

status is required. The outer chunk union ends with a catch-all

z.object({ type: z.string() }).loose().transform(e => ({ type: 'unknown_chunk', message: e.type }))

so a frame that fails the strict member is not an error — it becomes unknown_chunk and the isResponseOutputItemDoneChunk handler (which emits tool-input-end + tool-call and sets hasFunctionCall = true) never runs. response.completed then parses fine (it carries usage), hasFunctionCall is still false, and the finish reason is mapped to stop. That is exactly the diagnostics above: no error chunk, no tool-input-end, finishReason=stop.

The response.completed payload is also fragile for the same reason: it starts from "usage":{} and only fills fields that the interaction provides, while the schema requires usage.input_tokens and usage.output_tokens as numbers. With Devin the usage is present so this frame survives; an interactions source without usage would lose the terminal frame too.

Related in the request direction: ConvertOpenAIResponsesRequestToInteractions only maps max_output_tokens for Antigravity (agent_config.max_total_tokens); for other models max_output_tokens, temperature and top_p are dropped, so a Responses client cannot bound the output at all (a max_output_tokens: 400 request produced 2868 output tokens), whereas the chat-completions request translator does map them (copyOpenAIChatGenerationConfigToInteractions).

Whereinternal/translator/openai/interactions/responses/interactions_openai_responses_response.go

  • L376 interactionsStepStopToResponses: done-item template {"id":"","type":"function_call","call_id":"","name":"","arguments":""} — no status
  • L262 interactionsStepStartToResponses: added-item template — no status (OpenAI sends in_progress)
  • L518 responsesCompletedOutputItem: function_call output in response.completed — no status
  • L395 responsesCompletedEvent: "usage":{} template; always response.completed / status: "completed"
  • interactions_openai_responses_request.go L60–L68: max_output_tokens mapping is Antigravity-only

Example B — opencode, /v1/chat/completions

Same kind of task (one large write/edit tool call). opencode's log for the failing turns ends with the tool never resolving:

… stream error
… orphaned interrupted tool

and the UI shows the edit as interrupted. Small edits on the same model succeed.

curl against /v1/chat/completions with one tool and max_tokens: 400 to force the budget:

"delta":{"tool_calls":[{"index":1,"id":"write_file_0#…","type":"function","function":{"name":"write_file","arguments":""}}]}

"delta":{"tool_calls":[{"index":1,"function":{"arguments":""}}]}          ← ×N, JSON cut mid-string

"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":584,"completion_tokens":400,"total_tokens":984}

Two things are wrong in this stream:

tool_calls[].index = 1 for the only tool call. The Devin executor numbers interactions steps with one running counter across thoughtmodel_outputfunction_call, and the chat translator writes that step index straight into tool_calls[].index (and into the call_%d fallback id). OpenAI defines index as the position in the tool_calls array — 0-based and contiguous per response. A client that assembles the array from index ends up with [null, {…}]; anything that reads tool_calls[0] or validates contiguity breaks. This happens on every tool call that follows a thought or text step, i.e. on essentially every Devin tool call. I have not isolated a client failure caused by this alone, but it is a plain spec violation on the primary path.

finish_reason: "stop" with completion_tokens == max_tokens and truncated arguments. Devin's Connect-RPC response frames carry a StopReason varint in field 5. helps.ParseDevinFrame decodes it into DevinFrameResult.StopReason (devin_wire.go L112, L542 — the comment there already notes 2/4=stop, 10=tool_calls) but devin_executor.go never reads the field; interaction.completed is always emitted with status: "completed" (L823 streaming, L1086 non-stream). The interactions consumers therefore can only guess: chat completions emits stop/tool_calls (L215–L217), never length; Responses always emits response.completed; Claude emits end_turn/tool_use, never max_tokens.

The enum values (from the Windsurf extension's setEnumType("StopReason", …), as documented in rsvedant/opencode-windsurf-auth src/cloud-direct/chat.ts):

0 UNSPECIFIED        2 STOP_PATTERN        10 FUNCTION_CALL
1 INCOMPLETE         3 MAX_TOKENS          11 CONTENT_FILTER
4–9 internal         12 NON_INSERTION      13 ERROR (errors arrive via the Connect trailer)

1 and 3 mean the output was cut short. Without them the client receives a tool call whose arguments never becomes valid JSON and a finish_reason that says the model stopped on its own. @ai-sdk/openai (chat) emits tool-call only when the accumulated arguments parses, so for a truncated call it emits nothing at all; opencode sees a stream that ended with a half-built tool and reports orphaned interrupted tool. Because the budget is only exceeded by long outputs, this is why "large edits always fail while small ones work".

Where

  • internal/translator/openai/interactions/chat-completions/openai_interactions_response.go L161 (interactionsStepStartToOpenAIChat passes the step index), L245 / L255 (sjson.SetBytes(toolCall, "index", index)), L215–L217 (finish_reason derivation)
  • internal/runtime/executor/devin_executor.go L570 / L968 (ParseDevinFrame result consumed, StopReason ignored), L823 / L1086 (completion payload always completed)
  • internal/runtime/executor/helps/devin_wire.go L112, L542

Steps to reproduce

Chat completions (defects 2 and 3):

curl -N https://<cpa>/v1/chat/completions -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{
  "model":"devin/swe-2","stream":true,"max_tokens":400,
  "messages":[{"role":"user","content":"Use write_file to create notes.md with a 2000-word essay about the history of Go. Do not answer in text, call the tool."}],
  "tools":[{"type":"function","function":{"name":"write_file","parameters":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}}}]}'

Observed: tool_calls[].index: 1, finish_reason: "stop", completion_tokens: 400, arguments not valid JSON.

Responses (defect 1; any tool call, no truncation needed):

curl -N https://<cpa>/v1/responses -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{
  "model":"devin/swe-2","stream":true,
  "input":[{"role":"user","content":"Use write_file to create poem.txt with a 30-line poem. Do not answer in text, call the tool."}],
  "tools":[{"type":"function","name":"write_file","parameters":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}}]}'

Observed: response.output_item.added / .done items for the function_call have no status; fed through @ai-sdk/openai's Responses model the tool call is never emitted and the turn finishes with stop. Adding "max_output_tokens": 400 to the same request has no effect on output length.

Expected behavior

  • /v1/responses: function_call items carry status (in_progress on added, completed on done) so standard Responses clients accept them; a truncated response ends with response.incomplete and incomplete_details.reason: "max_output_tokens"; max_output_tokens is honored.
  • /v1/chat/completions: tool_calls[].index is the 0-based position within the response's tool calls; a truncated response ends with finish_reason: "length".
  • Devin's StopReason (1 INCOMPLETE, 3 MAX_TOKENS, 11 CONTENT_FILTER) is reflected in the completion payload so every downstream format (length / response.incomplete / max_tokens) can report truncation instead of a normal stop.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions