Fix Responses tool call completion

Recover function calls emitted during streaming when a terminal Responses event omits them. Advertise the final response tool with a strict text-only schema across agent profiles.

Alessandro committed Aug 13, 2026 at 01:48 UTC a767824fb63e4117a26802dda1f34e9dbe34c108
6 files changed +62 -28
helpers/litellm_transport.py
+7 -1
@@ -428,7 +428,13 @@ class LiteLLMTransport:
428 ) -> LLMResult | None:
429 if parser.completed_response is None:
430 return None
431 - return self._llm_result_from_response(parser.completed_response, request)
431 + response = _object_to_dict(parser.completed_response)
432 + output = _as_list(response.get("output"))
433 + if parser.function_calls and not any(
434 + _get_value(item, "type") == "function_call" for item in output
435 + ):
436 + response["output"] = [*output, *parser.function_calls.values()]
437 + return self._llm_result_from_response(response, request)
438
439 def _stream_result_from_chat_parser(
440 self, parser: "ChatCompletionsStreamParser"
helpers/litellm_transport.py.dox.md
+1
@@ -33,6 +33,7 @@
33 - Fall back to Chat Completions when a Responses endpoint fails before output with an endpoint-specific server error, proxy path-unavailable error, or LiteLLM proxy-extra import error.
34 - Fall back to Chat Completions when LiteLLM's Responses mock streaming path tries to JSON-decode a real SSE stream before any output.
35 - Preserve Chat Completions tool calls from both non-streaming responses and streaming deltas as canonical `LLMResult` function-call items.
36 +- Preserve Responses function calls collected from stream events when a terminal completed event omits them.
37 - Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported.
38 - Keep prompt-cache markers only for providers that accept them.
39
helpers/responses_tools.py
+22 -11
@@ -39,20 +39,31 @@ def build_responses_function_tools(agent: Any) -> tuple[list[dict[str, Any]], di
39 continue
40 native_name = _native_tool_name(tool_name)
41 name_map[native_name] = tool_name
42 - tools.append(
42 + parameters = (
43 {
44 - "type": "function",
45 - "name": native_name,
46 - "description": _truncate(
47 - tool_policy.tool_prompt_description(
48 - prompt,
49 - tool_name,
50 - fallback=tool_name,
51 - )
52 - ),
53 - "parameters": _schema_from_prompt(prompt),
44 + "type": "object",
45 + "properties": {"text": {"type": "string"}},
46 + "required": ["text"],
47 + "additionalProperties": False,
48 }
49 + if tool_name == "response"
50 + else _schema_from_prompt(prompt)
51 )
52 + tool = {
53 + "type": "function",
54 + "name": native_name,
55 + "description": _truncate(
56 + tool_policy.tool_prompt_description(
57 + prompt,
58 + tool_name,
59 + fallback=tool_name,
60 + )
61 + ),
62 + "parameters": parameters,
63 + }
64 + if tool_name == "response":
65 + tool["strict"] = True
66 + tools.append(tool)
67
68 for tool_name, tool in _mcp_tools(agent):
69 if not tool_policy.resolve_tool(
helpers/responses_tools.py.dox.md
+1
@@ -17,6 +17,7 @@
17 owns the Responses-specific prompt-name compatibility rules.
18 - Local prompt-derived function names use existing bullet declarations that pair a backticked name with `arg` or `args` for multi-tool prompt files, otherwise prefer explicit `"tool_name"` examples, then the first prompt heading, and finally the prompt filename.
19 - Apply registered tool-prompt render kwargs before deriving native metadata so descriptions never expose unresolved prompt templates.
20 +- Always expose the native `response` tool as strict with one required `text` string, independent of profile prompt wording.
21 - Use an explicitly embedded JSON input schema when present. Infer only an unambiguous single backticked argument on an otherwise empty `args:` line; all other local tools receive an honest permissive object schema instead of prose-guessed types.
22 - Native local-tool descriptions reuse the tool catalog's compact prompt
23 description; Responses retains native-name mapping, schema derivation, and
tests/test_responses_architecture.py
+5 -10
@@ -249,7 +249,9 @@ async def test_transport_downgrades_unsupported_builtin_tools(monkeypatch):
249
250
251 @pytest.mark.asyncio
252 -async def test_unified_turn_captures_response_id_without_stop_request(monkeypatch):
252 +async def test_unified_turn_keeps_streamed_call_when_completion_omits_output(
253 + monkeypatch,
254 +):
255 stream = _AsyncEventStream(
256 [
257 {
@@ -274,15 +276,7 @@ async def test_unified_turn_captures_response_id_without_stop_request(monkeypatc
276 "type": "response.completed",
277 "response": {
278 "id": "resp_1",
277 - "output": [
278 - {
279 - "type": "function_call",
280 - "id": "fc_1",
281 - "call_id": "call_1",
282 - "name": "lookup",
283 - "arguments": '{"q":"a0"}',
284 - }
285 - ],
279 + "output": [],
280 },
281 },
282 ]
@@ -315,6 +309,7 @@ async def test_unified_turn_captures_response_id_without_stop_request(monkeypatc
309 assert stream.closed is False
310 assert result.response_id == "resp_1"
311 assert result.function_calls[0].call_id == "call_1"
312 + assert result.function_calls[0].arguments == {"q": "a0"}
313
314
315 @pytest.mark.asyncio
tests/test_responses_tools.py
+26 -6
@@ -168,20 +168,40 @@ def test_responses_function_tools_add_empty_properties_to_mcp_schemas(
168 ]
169
170
171 -def test_response_tool_native_contract_omits_wrapper_and_exposes_text():
172 - prompt = (PROJECT_ROOT / "prompts" / "agent.system.tool.response.md").read_text(
173 - encoding="utf-8"
174 - )
171 +def test_response_tool_native_contract_is_strict_and_requires_text(monkeypatch):
172 + prompt_root = PROJECT_ROOT / "agents" / "agent0" / "prompts"
173 + prompt = (prompt_root / "agent.system.tool.response.md").read_text(encoding="utf-8")
174
175 description = tool_policy.tool_prompt_description(
176 prompt,
177 "response",
178 fallback="response",
179 )
181 - schema = responses_tools._schema_from_prompt(prompt)
180 + monkeypatch.setattr(
181 + responses_tools.subagents,
182 + "get_paths",
183 + lambda *args, **kwargs: [str(prompt_root)],
184 + )
185 + monkeypatch.setattr(
186 + responses_tools,
187 + "_include_local_tool_prompt",
188 + lambda agent, tool_name: True,
189 + )
190 + monkeypatch.setattr(responses_tools, "_vision_tool_prompt", lambda agent: "")
191 + monkeypatch.setattr(responses_tools, "_mcp_tools", lambda agent: [])
192 + tools, _name_map = responses_tools.build_responses_function_tools(
193 + FakeAgent(prompt_root)
194 + )
195 + response_tool = next(tool for tool in tools if tool["name"] == "response")
196
197 assert description == "final answer to user"
184 - assert schema["properties"] == {"text": {"type": "string"}}
198 + assert response_tool["parameters"] == {
199 + "type": "object",
200 + "properties": {"text": {"type": "string"}},
201 + "required": ["text"],
202 + "additionalProperties": False,
203 + }
204 + assert response_tool["strict"] is True
205
206
207 def test_complex_prompt_args_are_not_guessed_as_string_schemas():