diff --git a/fjerkroa_bot/openai_responder.py b/fjerkroa_bot/openai_responder.py index 042a2aa..be3c1aa 100644 --- a/fjerkroa_bot/openai_responder.py +++ b/fjerkroa_bot/openai_responder.py @@ -304,6 +304,31 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): items.append({"role": role, "content": str(content)}) return items + # Only these item types travel back as input; response-only fields like `status` + # are rejected by the API as unknown parameters (live 400, 2026-07-17) + _RESPONSES_FEEDBACK_FIELDS = { + "reasoning": ("id", "summary", "encrypted_content"), + "function_call": ("id", "call_id", "name", "arguments"), + } + + @classmethod + def _responses_feedback(cls, output: List[Any]) -> List[Dict[str, Any]]: + """Reasoning + function_call items in input shape — keeps the chain of thought (ENV-23).""" + items: List[Dict[str, Any]] = [] + for item in output or []: + fields = cls._RESPONSES_FEEDBACK_FIELDS.get(getattr(item, "type", None) or "") + if not fields: + continue # message items need not travel back + data: Dict[str, Any] = {"type": item.type} + for field in fields: + value = getattr(item, field, None) + if field == "summary" and isinstance(value, list): + value = [part if isinstance(part, dict) else part.model_dump() for part in value] + if value is not None: + data[field] = value + items.append(data) + return items + @staticmethod def _responses_refused(result: Any) -> bool: for item in getattr(result, "output", []) or []: @@ -350,8 +375,8 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): return answer, limit tool_names = [call.name for call in calls] logging.info(f"🔧 OpenAI requested function calls: {tool_names}") - # Pass ALL output items back — reasoning items keep the chain of thought (ENV-23) - context = context + [item if isinstance(item, dict) else item.model_dump() for item in result.output] + # Pass reasoning + function_call items back — keeps the chain of thought (ENV-23) + context = context + self._responses_feedback(result.output) for call in calls: function_args = json.loads(call.arguments) if call.arguments else {} logging.info(f"🔧 Executing tool: {call.name} with args: {function_args}") diff --git a/specs/SPEC-001-responder-envelope.md b/specs/SPEC-001-responder-envelope.md index e3a134f..554b1dc 100644 --- a/specs/SPEC-001-responder-envelope.md +++ b/specs/SPEC-001-responder-envelope.md @@ -169,8 +169,10 @@ chat/completions. The Responses path runs with `store=false` and `include=["reasoning.encrypted_content"]` (nothing retained -server-side). On a function call, ALL output items — including -reasoning items — are passed back as input together with one +server-side). On a function call, the reasoning and function_call +output items are passed back as input — reduced to their input-shape +fields, since response-only fields like `status` are rejected as +unknown parameters (live 400, 2026-07-17) — together with one `function_call_output` per call (matched by `call_id`, result sanitized per SAF-03), so the model continues one chain of thought across tool rounds. Up to `responses-tool-rounds` (default 4) rounds diff --git a/tests/test_spec_responses.py b/tests/test_spec_responses.py index 05de954..8316adc 100644 --- a/tests/test_spec_responses.py +++ b/tests/test_spec_responses.py @@ -40,17 +40,21 @@ def _refusal_item(): def _reasoning_item(): item = Mock() item.type = "reasoning" - item.model_dump = lambda: {"type": "reasoning", "encrypted_content": "opaque-cot"} + item.id = "rs_1" + item.summary = [] + item.encrypted_content = "opaque-cot" + item.status = "completed" # response-only field; must NOT travel back return item def _call_item(name, args, call_id="call-1"): item = Mock() item.type = "function_call" + item.id = "fc_1" item.name = name item.arguments = json.dumps(args) item.call_id = call_id - item.model_dump = lambda: {"type": "function_call", "name": name, "arguments": json.dumps(args), "call_id": call_id} + item.status = "completed" return item @@ -116,7 +120,12 @@ class TestResponsesPath(unittest.IsolatedAsyncioTestCase): self.assertEqual(json.loads(answer["content"])["answer"], "done") responder._dispatch_tool.assert_awaited_once() followup_input = responses_mock.await_args_list[1].kwargs["input"] - self.assertIn({"type": "reasoning", "encrypted_content": "opaque-cot"}, followup_input) + reasoning = [item for item in followup_input if isinstance(item, dict) and item.get("type") == "reasoning"] + self.assertEqual(len(reasoning), 1) + self.assertEqual(reasoning[0]["encrypted_content"], "opaque-cot") + self.assertNotIn("status", reasoning[0]) # response-only field stripped (live-400 regression) + calls_back = [item for item in followup_input if isinstance(item, dict) and item.get("type") == "function_call"] + self.assertNotIn("status", calls_back[0]) outputs = [item for item in followup_input if isinstance(item, dict) and item.get("type") == "function_call_output"] self.assertEqual(len(outputs), 1) self.assertEqual(outputs[0]["call_id"], "call-9")