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():