diff --git a/README.md b/README.md index ff86ea6022..6f275082f5 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,7 @@ For Google's official Gemini OpenAI-compatible endpoint, use the ``` - Codex CLI reads `~/.codex/auth.json` + - Codex function tools preserve explicit `strict: true` or `strict: false` in wrapped or flat dictionary definitions. Missing or null settings keep the provider default. `bind_tools` applies the same conversion to dictionaries and `BaseTool` schemas. - Completed Codex responses still return their text and tool calls when token usage is null, omitted, or empty; usage metadata remains unavailable. - The Codex model provider returns completed responses without waiting for the SSE connection to close. Failed or incomplete responses report the provider's error or reason; partial output is not returned as a successful answer. Non-object error details are reported as text. - Claude Code accepts `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_CREDENTIALS_PATH`, or `~/.claude/.credentials.json` diff --git a/backend/packages/harness/deerflow/models/AGENTS.md b/backend/packages/harness/deerflow/models/AGENTS.md index 5b181276d4..2efff7a0bd 100644 --- a/backend/packages/harness/deerflow/models/AGENTS.md +++ b/backend/packages/harness/deerflow/models/AGENTS.md @@ -86,6 +86,13 @@ Each omitted tool result emits a warning with its normalized content length; the warning never includes the result content. Coverage: `tests/test_codex_provider.py`. +Codex function-tool conversion preserves explicit `strict=True` and `False` +for wrapped and flat dictionaries; missing or `None` stays omitted to preserve +the provider default. Wrapped dictionaries and converted `BaseTool` schemas +share `_convert_tools` so binding cannot discard the setting. Keep caller +schemas and definitions unchanged. Offline sync/async request and tool-followup +coverage: `tests/test_codex_tool_strict.py`. + ### Codex SSE termination (`packages/harness/deerflow/models/openai_codex_provider.py`) Completed responses with null, omitted, or empty `usage` retain their text, diff --git a/backend/packages/harness/deerflow/models/openai_codex_provider.py b/backend/packages/harness/deerflow/models/openai_codex_provider.py index b691afca7b..d05cc61278 100644 --- a/backend/packages/harness/deerflow/models/openai_codex_provider.py +++ b/backend/packages/harness/deerflow/models/openai_codex_provider.py @@ -210,23 +210,20 @@ class CodexChatModel(BaseChatModel): for tool in tools: if tool.get("type") == "function" and "function" in tool: fn = tool["function"] - responses_tools.append( - { - "type": "function", - "name": fn["name"], - "description": fn.get("description", ""), - "parameters": fn.get("parameters", {}), - } - ) elif "name" in tool: - responses_tools.append( - { - "type": "function", - "name": tool["name"], - "description": tool.get("description", ""), - "parameters": tool.get("parameters", {}), - } - ) + fn = tool + else: + continue + converted = { + "type": "function", + "name": fn["name"], + "description": fn.get("description", ""), + "parameters": fn.get("parameters", {}), + } + # Omitting strict is not equivalent to the caller's explicit False. + if fn.get("strict") is not None: + converted["strict"] = fn["strict"] + responses_tools.append(converted) return responses_tools def _call_codex_api(self, messages: list[BaseMessage], tools: list[dict] | None = None) -> dict: @@ -474,14 +471,7 @@ class CodexChatModel(BaseChatModel): if isinstance(tool, BaseTool): try: fn = convert_to_openai_function(tool) - formatted_tools.append( - { - "type": "function", - "name": fn["name"], - "description": fn.get("description", ""), - "parameters": fn.get("parameters", {}), - } - ) + formatted_tools.extend(self._convert_tools([fn])) except Exception: formatted_tools.append( { @@ -493,15 +483,7 @@ class CodexChatModel(BaseChatModel): ) elif isinstance(tool, dict): if "function" in tool: - fn = tool["function"] - formatted_tools.append( - { - "type": "function", - "name": fn["name"], - "description": fn.get("description", ""), - "parameters": fn.get("parameters", {}), - } - ) + formatted_tools.extend(self._convert_tools([{"type": "function", "function": tool["function"]}])) else: formatted_tools.append(tool) diff --git a/backend/tests/test_codex_tool_strict.py b/backend/tests/test_codex_tool_strict.py new file mode 100644 index 0000000000..46d22fa9a9 --- /dev/null +++ b/backend/tests/test_codex_tool_strict.py @@ -0,0 +1,118 @@ +"""Keep caller-supplied function strictness through real Codex HTTP requests.""" + +from __future__ import annotations + +import asyncio +import copy +import json + +import httpx +import pytest +from langchain_core.messages import HumanMessage, ToolMessage +from langchain_core.tools import StructuredTool +from langchain_core.utils import function_calling + +from deerflow.models import openai_codex_provider as provider +from deerflow.models.credential_loader import CodexCliCredential + + +@pytest.mark.parametrize("shape,binding", [(shape, binding) for shape in ("wrapped", "flat", "bare") for binding in ("direct", "bound")] + [("base_tool", "bound")]) +@pytest.mark.parametrize("strict", [True, False, None, "omitted"]) +@pytest.mark.parametrize("invocation", ["invoke", "ainvoke"]) +def test_function_strictness_survives_tool_followup(monkeypatch, shape, strict, binding, invocation): + function = { + "name": "read_report", + "description": "Read a prepared report.", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": ["integer", "null"]}}, + "required": ["query"] if strict is False else ["query", "limit"], + "additionalProperties": False, + }, + } + if strict != "omitted": + function["strict"] = strict + if shape == "wrapped": + definition = {"type": "function", "function": function} + elif shape == "flat": + definition = {"type": "function", **function} + elif shape == "bare": + definition = function + else: + definition = StructuredTool.from_function(lambda query, limit=None: "unused", name=function["name"], description=function["description"], args_schema=function["parameters"]) + convert = function_calling.convert_to_openai_function + + def convert_with_strict(tool): + converted = convert(tool) + if "strict" in function: + converted["strict"] = function["strict"] + return converted + + if "strict" in function: + # Exercise a converter-emitted setting without claiming the locked + # LangChain release currently adds strictness to BaseTool schemas. + monkeypatch.setattr(function_calling, "convert_to_openai_function", convert_with_strict) + original_definition = copy.deepcopy(definition) + expected_tool = {"type": "function", **function} + if expected_tool.get("strict") is None: + expected_tool.pop("strict", None) + arguments = {"query": "quarterly"} + if strict is not False: + arguments["limit"] = None + + monkeypatch.setattr(provider, "load_codex_cli_credential", lambda: CodexCliCredential("fake-token", "fake-account")) + model = provider.CodexChatModel(retry_max_attempts=1) + runnable = model.bind_tools([definition]) if binding == "bound" else model + kwargs = {} if binding == "bound" else {"tools": [definition]} + requests = [] + clients = [] + responses = [] + client_class = httpx.Client + + def handle(request): + payload = json.loads(request.content) + requests.append(payload) + if len(requests) == 1: + output = [{"type": "function_call", "name": "read_report", "arguments": json.dumps(arguments), "call_id": "call-report"}] + else: + output = [{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Report ready."}]}] + event = {"type": "response.completed", "response": {"id": "response-report", "status": "completed", "output": output, "usage": None}} + response = httpx.Response(200, headers={"content-type": "text/event-stream"}, content=f"data: {json.dumps(event)}\n\n".encode()) + responses.append(response) + return response + + def make_client(**options): + client = client_class(transport=httpx.MockTransport(handle), **options) + clients.append(client) + return client + + monkeypatch.setattr(provider.httpx, "Client", make_client) + history = [HumanMessage(content="Read the quarterly report.")] + + if invocation == "invoke": + call = runnable.invoke(history, **kwargs) + followup = [*history, call, ToolMessage(content="Report contents", tool_call_id="call-report")] + answer = runnable.invoke(followup, **kwargs) + else: + + async def exchange(): + call = await runnable.ainvoke(history, **kwargs) + followup = [*history, call, ToolMessage(content="Report contents", tool_call_id="call-report")] + answer = await runnable.ainvoke(followup, **kwargs) + return call, followup, answer + + call, followup, answer = asyncio.run(exchange()) + + assert call.tool_calls == [{"name": "read_report", "args": arguments, "id": "call-report", "type": "tool_call"}] + assert answer.content == "Report ready." + assert len(requests) == 2 + assert all(payload["tools"] == [expected_tool] for payload in requests) + assert requests[1]["input"][-2:] == [ + {"type": "function_call", "name": "read_report", "arguments": json.dumps(arguments), "call_id": "call-report"}, + {"type": "function_call_output", "call_id": "call-report", "output": "Report contents"}, + ] + assert definition == original_definition + assert history == [HumanMessage(content="Read the quarterly report.")] + assert followup[-1].content == "Report contents" + assert all(client.is_closed for client in clients) + assert all(response.is_closed for response in responses)