diff --git a/README.md b/README.md index 271d683..b80ada9 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # AgentCore AgentCore is the single source of truth for product-neutral agent runtime code -shared by MiroHarness and FrontierAgentInternal. +shared by ApodexHarness and FrontierAgentInternal. The repository exists to remove a failure-prone workflow: implementing a core change independently in two products and then opening more pull requests to @@ -20,10 +20,11 @@ Version `0.1.x` contains the converged foundation layer: - provider-neutral LLM response, stream, and client contracts; - loop configuration, lifecycle contexts, observer protocol, intervention merging, and observer dispatch helpers. +- streamed tool-call recovery checks for missing required arguments. The initial extraction is based on the already-merged integration branches: -- MiroHarness `c1229050` (PR #501); +- ApodexHarness `c1229050` (PR #501); - FrontierAgentInternal `63b89c8` (PR #92). Those revisions are provenance, not runtime dependencies. AgentCore tests and @@ -96,7 +97,7 @@ clean checkout and CI unreproducible. 1. Reproduce a shared bug with an AgentCore test. 2. Change AgentCore in one pull request and pass its standalone CI. 3. Merge and record the immutable commit SHA (or publish a tagged version). -4. Automation opens dependency-bump PRs in MiroHarness and +4. Automation opens dependency-bump PRs in ApodexHarness and FrontierAgentInternal. 5. Product CI validates adapters and end-to-end behavior. Product PRs must not patch vendored/shared implementation code. diff --git a/agent_core/runtime/loop/tool_call_recovery.py b/agent_core/runtime/loop/tool_call_recovery.py new file mode 100644 index 0000000..8b409bc --- /dev/null +++ b/agent_core/runtime/loop/tool_call_recovery.py @@ -0,0 +1,107 @@ +"""Recovery checks for incomplete streamed tool calls.""" + +from __future__ import annotations + +import json +from typing import Any, cast + +from agent_core.llm import LLMResponse + + +def _required_tool_arguments(llm: Any) -> dict[str, set[str]]: + """Map bound tool name to its non-empty set of required arguments. + + Tools with no required property are omitted entirely: for them ``{}`` is + a legitimate call and must never trigger a second model request. + """ + required_by_name: dict[str, set[str]] = {} + raw_tools: object = getattr(llm, "tools", None) + if not isinstance(raw_tools, list): + return required_by_name + + for raw_schema in cast(list[object], raw_tools): + if not isinstance(raw_schema, dict): + continue + schema = cast(dict[object, object], raw_schema) + raw_function = schema.get("function") + if not isinstance(raw_function, dict): + continue + function = cast(dict[object, object], raw_function) + name = function.get("name") + raw_parameters = function.get("parameters") + if not isinstance(name, str) or not isinstance(raw_parameters, dict): + continue + parameters = cast(dict[object, object], raw_parameters) + raw_required = parameters.get("required") + if isinstance(raw_required, list) and raw_required: + fields = { + field for field in cast(list[object], raw_required) if isinstance(field, str) + } + if fields: + required_by_name[name] = fields + return required_by_name + + +def _lost_required_tool_arguments( + raw_arguments: Any, + required_fields: set[str], +) -> bool: + """Return whether a streamed arguments payload lost required fields. + + Empty payloads and JSON objects missing a required field both count as + lost. Invalid JSON also counts as lost because the native tool-call + normalizer cannot preserve or repair the raw fragment: it degrades a + ``json.loads`` failure to ``args={}`` before tool validation runs. + """ + if raw_arguments is None: + return True + if not isinstance(raw_arguments, str): + return False + if not raw_arguments.strip(): + return True + try: + parsed: object = json.loads(raw_arguments) + except (ValueError, TypeError): + return True + if not isinstance(parsed, dict): + return False + parsed_arguments = cast(dict[object, object], parsed) + return bool(required_fields - parsed_arguments.keys()) + + +def stream_tool_calls_missing_required_arguments( + response: LLMResponse, + llm: Any, +) -> list[str]: + """Return required-argument tool names with incomplete streamed args. + + Some OpenAI-compatible serving parsers emit a native tool-call shell with + a valid function name but empty arguments when generation stops before the + closing parameter marker. The non-streaming parser can often recover the + same truncated payload, so callers use this result to request one replay. + """ + required_by_name = _required_tool_arguments(llm) + if not required_by_name: + return [] + + missing: list[str] = [] + for raw_tool_call in cast(list[object], response.tool_calls): + if not isinstance(raw_tool_call, dict): + continue + tool_call = cast(dict[object, object], raw_tool_call) + raw_function = tool_call.get("function") + if not isinstance(raw_function, dict): + continue + function = cast(dict[object, object], raw_function) + name = function.get("name") + if not isinstance(name, str): + continue + required_fields = required_by_name.get(name) + if required_fields and _lost_required_tool_arguments( + function.get("arguments"), required_fields + ): + missing.append(name) + return missing + + +__all__ = ["stream_tool_calls_missing_required_arguments"] diff --git a/tests/test_tool_call_recovery.py b/tests/test_tool_call_recovery.py new file mode 100644 index 0000000..3eb6c09 --- /dev/null +++ b/tests/test_tool_call_recovery.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest + +from agent_core.llm import LLMResponse +from agent_core.runtime.loop.tool_call_recovery import ( + _lost_required_tool_arguments, + _required_tool_arguments, + stream_tool_calls_missing_required_arguments, +) + + +@dataclass +class BoundLLM: + tools: list[Any] | None + + +def tool_schema(name: str, *, required: list[Any] | None = None) -> dict[str, Any]: + parameters: dict[str, Any] = {"type": "object", "properties": {}} + if required is not None: + parameters["required"] = required + return { + "type": "function", + "function": {"name": name, "parameters": parameters}, + } + + +def tool_call(name: str, arguments: Any) -> dict[str, Any]: + return { + "id": f"call-{name}", + "type": "function", + "function": {"name": name, "arguments": arguments}, + } + + +def test_required_tool_arguments_ignores_optional_and_malformed_schemas() -> None: + llm = BoundLLM( + tools=[ + tool_schema("search", required=["query", "limit", 3]), + tool_schema("clock", required=[]), + tool_schema("ping"), + None, + {"type": "function"}, + {"function": {"name": 42, "parameters": {"required": ["value"]}}}, + ] + ) + + assert _required_tool_arguments(llm) == {"search": {"query", "limit"}} + + +@pytest.mark.parametrize("arguments", [None, "", " ", '{\"query\":']) +def test_lost_required_tool_arguments_detects_empty_or_invalid_json(arguments: Any) -> None: + assert _lost_required_tool_arguments(arguments, {"query"}) + + +def test_lost_required_tool_arguments_detects_each_missing_field() -> None: + assert _lost_required_tool_arguments('{"query": "weather"}', {"query", "limit"}) + assert not _lost_required_tool_arguments( + '{"query": "weather", "limit": 5}', {"query", "limit"} + ) + + +def test_non_string_arguments_are_left_to_downstream_validation() -> None: + assert not _lost_required_tool_arguments({}, {"query"}) + + +def test_stream_check_reports_only_known_tools_missing_required_arguments() -> None: + llm = BoundLLM( + tools=[ + tool_schema("search", required=["query"]), + tool_schema("fetch", required=["url"]), + tool_schema("clock", required=[]), + ] + ) + response = LLMResponse( + tool_calls=[ + tool_call("search", "{}"), + tool_call("fetch", '{"url": "https://example.com"}'), + tool_call("clock", "{}"), + tool_call("unknown", ""), + ] # type: ignore[arg-type] + ) + + assert stream_tool_calls_missing_required_arguments(response, llm) == ["search"] + + +def test_stream_check_preserves_duplicate_failed_calls_for_diagnostics() -> None: + llm = BoundLLM(tools=[tool_schema("search", required=["query"])]) + response = LLMResponse( + tool_calls=[tool_call("search", ""), tool_call("search", "{}")] # type: ignore[arg-type] + ) + + assert stream_tool_calls_missing_required_arguments(response, llm) == ["search", "search"] + + +def test_stream_check_is_silent_without_bound_required_tools() -> None: + response = LLMResponse(tool_calls=[tool_call("search", "")]) # type: ignore[arg-type] + + assert stream_tool_calls_missing_required_arguments(response, BoundLLM(tools=None)) == []