Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
107 changes: 107 additions & 0 deletions agent_core/runtime/loop/tool_call_recovery.py
Original file line number Diff line number Diff line change
@@ -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"]
102 changes: 102 additions & 0 deletions tests/test_tool_call_recovery.py
Original file line number Diff line number Diff line change
@@ -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)) == []