From 8be5e3464cf7cb6dcacb9a55831e24623d859551 Mon Sep 17 00:00:00 2001 From: zhanghanduo Date: Wed, 7 Oct 2026 17:28:35 +0800 Subject: [PATCH 1/2] feat(loop): validate streamed tool calls before execution --- agent_core/loop_types.py | 3 + agent_core/runtime/loop/_call.py | 121 +++++++++----- agent_core/runtime/loop/agent_loop.py | 35 ++++- agent_core/runtime/loop/tool_call_parser.py | 9 ++ .../runtime/loop/tool_call_validation.py | 68 ++++++++ changes/73.feature.md | 1 + docs/llm-runtime-boundary.md | 21 +++ pyproject.toml | 1 + tests/test_completion_recovery.py | 14 +- tests/test_llm_wait_bounds.py | 17 +- tests/test_stream_llm_tokens_toggle.py | 12 ++ tests/test_streamed_tool_validation.py | 138 ++++++++++++++++ uv.lock | 148 ++++++++++++++++++ 13 files changed, 532 insertions(+), 56 deletions(-) create mode 100644 agent_core/runtime/loop/tool_call_validation.py create mode 100644 changes/73.feature.md create mode 100644 tests/test_streamed_tool_validation.py diff --git a/agent_core/loop_types.py b/agent_core/loop_types.py index fc0b28b..3e6f8fc 100644 --- a/agent_core/loop_types.py +++ b/agent_core/loop_types.py @@ -202,6 +202,9 @@ class LoopConfig: system_addendum_per_call: str | Callable[[], str] = "" system_addendum_min_turn: int = 0 system_addendum_per_call_role: str = "system" + # Keyword-only to preserve every existing positional LoopConfig argument. + # When unset, retain the legacy stream_llm_tokens/watchdog selection. + stream_transport: bool | None = field(default=None, kw_only=True) @dataclass diff --git a/agent_core/runtime/loop/_call.py b/agent_core/runtime/loop/_call.py index f788a45..eb3ac05 100644 --- a/agent_core/runtime/loop/_call.py +++ b/agent_core/runtime/loop/_call.py @@ -57,6 +57,7 @@ _stream_stall_max_before_advance, ) from .tool_call_recovery import stream_tool_calls_missing_required_arguments +from .tool_call_validation import invalid_native_tool_calls logger = logging.getLogger(__name__) _OBSERVATION_TIMEOUT_S = 5.0 @@ -157,8 +158,8 @@ def _llm_gate() -> asyncio.Semaphore | None: # callback, so this package never touches context-local storage. _WALL_DEADLINE_FLOOR_S = 20.0 -# Floor for the non-streaming replay that recovers a tool call whose streamed -# arguments came back empty. The replay is opportunistic: below this many +# Floor for a streaming retry that recovers an invalid assembled tool call. +# The retry is opportunistic: below this many # seconds of remaining attempt budget it would almost certainly time out, and # the streamed response we already hold is a better outcome than burning the # rest of the turn on a doomed second request. @@ -169,14 +170,14 @@ def _stream_recovery_budget_too_small( remaining_s: float, attempt_budget_s: float, ) -> bool: - """Whether the empty-arguments replay should be skipped for lack of time. + """Whether the tool-call retry should be skipped for lack of time. Skipped only when the remaining budget is under the absolute floor *and* under half of what the attempt started with. The second term matters: a deployment that configures a short ``timeout`` would otherwise never get the recovery at all, since the post-stream remainder is always slightly below a floor set at or above ``timeout``. Being wrong here is cheap — - a failed replay falls back to the streamed response. + a failed retry falls back to the streamed response. """ return ( remaining_s < _STREAM_RECOVERY_MIN_TIMEOUT_S @@ -217,8 +218,9 @@ async def call_llm( wall_deadline_remaining: Callable[[], float | None] | None = None, chain_fallback_active: Callable[[], bool] | None = None, empty_completion_max_retries: int = 2, + stream: bool | None = None, ) -> LLMResponse | None: - """Call ``llm.chat`` (``llm.stream`` when ``on_delta`` is set) with + """Call ``llm.chat`` or ``llm.stream`` (independent of delta delivery) with exponential backoff on transient errors. Default retry schedule (when ``retry_wait_fixed`` is ``None``): @@ -297,7 +299,7 @@ async def call_llm( Admission queueing is bounded separately (``LLM_GATE_WAIT_S``, disabled by default) and never shrinks the generation budget; admission expiry ends the call with reason ``gate_wait`` without retrying. The per-attempt - ``timeout`` starts once a slot is held and also covers any same-attempt tool-argument replay. A + ``timeout`` starts once a slot is held and also covers any same-attempt tool-call retry. A slot is released only after an abandoned provider request settles. Cleanup and passive observation have separate bounded grace periods so cancellation cannot wait indefinitely for I/O finalizers. @@ -479,6 +481,20 @@ def _response_attempt_fields(response: LLMResponse) -> dict[str, Any]: }) attempt_delta = on_delta + use_stream = stream if stream is not None else on_delta is not None + if use_stream and attempt_delta is None: + async def _discard_delta( + delta: str, _accumulated: str, _index: int, + thinking_delta: str = "", *, + tool_call_args_chunks: list[dict] | None = None, + ) -> None: + nonlocal attempt_first_delta + if attempt_first_delta is None and ( + delta or thinking_delta or tool_call_args_chunks + ): + attempt_first_delta = time.monotonic() + + attempt_delta = _discard_delta if on_delta is not None: downstream_accepts_tool_chunks = _accepts_tool_call_arg_chunks( on_delta, @@ -674,7 +690,7 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: # Generation budget starts once a slot is held; a same-attempt # replay below draws from what is left of it. attempt_deadline = time.monotonic() + effective_timeout - if attempt_delta is None: + if not use_stream: response = await await_bounded( _chat_active(effective_timeout), effective_timeout, @@ -682,21 +698,25 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: ) else: response = await _stream_active(effective_timeout) - empty_arg_tools = stream_tool_calls_missing_required_arguments( + invalid_calls = invalid_native_tool_calls( response, llm_active, ) - if empty_arg_tools: + if invalid_calls: # The stream has only been observed/assembled here: no # assistant history or tool execution has happened yet, - # so replacing it with one non-streaming replay cannot + # so replacing it with one streaming retry cannot # duplicate a side effect. streamed_response = response stream_ended_at = time.monotonic() + stream_first_delta = attempt_first_delta + legacy_empty_args_tools = stream_tool_calls_missing_required_arguments( + streamed_response, llm_active, + ) logger.warning( - "Streamed tool call(s) %s had blank arguments despite " - "required schema fields (turn=%d, attempt=%d/%d); " - "replaying the same request non-streaming", - empty_arg_tools, turn, attempt + 1, max_retries, + "Streamed tool call(s) failed validation: %s " + "(turn=%d, attempt=%d/%d); retrying with streaming", + [issue["reason"] for issue in invalid_calls], + turn, attempt + 1, max_retries, ) recovered: LLMResponse | None = None recovery_error: BaseException | None = None @@ -705,14 +725,14 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: try: # Clamp to whatever is left of THIS attempt's own # budget as well as the wall/logical deadline: the - # replay is a second physical request inside one + # retry is a second physical request inside one # attempt, so without the first term a turn could # quietly cost 2x ``timeout`` whenever no deadline # is stamped (direct loop use, SDK, tests). recovery_timeout = min( _effective_timeout_or_deadline_exhausted( attempt=attempt, - reason="stream_empty_tool_arguments", + reason="stream_invalid_tool_call", )[0], max( attempt_deadline - stream_ended_at, @@ -725,7 +745,7 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: raise TimeoutError( f"only {recovery_timeout:.0f}s of the " f"{effective_timeout:.0f}s attempt budget " - f"left for the replay", + f"left for the retry", ) physical_attempt_index += 1 replay_index = physical_attempt_index @@ -740,17 +760,14 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: # re-clamp before the actual replay request starts. recovery_timeout = min( _effective_timeout_or_deadline_exhausted( - attempt=attempt, reason="stream_empty_tool_arguments", + attempt=attempt, reason="stream_invalid_tool_call", )[0], max(attempt_deadline - time.monotonic(), 0.0), ) if _stream_recovery_budget_too_small(recovery_timeout, float(effective_timeout)): - raise TimeoutError("no useful replay budget remains after observation") - recovered = await await_bounded( - _chat_active(recovery_timeout), - recovery_timeout, - hold=True, - ) + raise TimeoutError("no useful streaming retry budget remains after observation") + attempt_first_delta = None + recovered = await _stream_active(recovery_timeout) except Exception as exc: # Recovery is opportunistic. Anything it raises — # an exhausted deadline, a timeout, a provider 5xx @@ -761,11 +778,12 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: if recovered is None: response_ended_at = stream_ended_at + attempt_first_delta = stream_first_delta if replay_started is not None: replay_event: dict[str, Any] = { "phase": "finished", "attempt_index": replay_index, "outcome": ATTEMPT_FAILED, - "reason": "empty_completion" if isinstance(recovery_error, LLMEmptyCompletion) else "stream_empty_args_replay", + "reason": "empty_completion" if isinstance(recovery_error, LLMEmptyCompletion) else "stream_invalid_tool_call_retry", "recovery_action": "keep_streamed_response", "duration_ms": int((time.monotonic() - replay_started) * 1000), "ttft_ms": None, "max_tokens": active_cap, @@ -778,19 +796,24 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: replay_event.update(_response_attempt_fields(partial)) await _emit_attempt(replay_event) logger.warning( - "Non-streaming replay failed (%s: %s); keeping " - "the streamed response with blank tool " - "arguments (turn=%d, attempt=%d/%d)", + "Streaming tool-call retry failed (%s: %s); keeping " + "the first response for invalid-call feedback " + "(turn=%d, attempt=%d/%d)", type(recovery_error).__name__, recovery_error, turn, attempt + 1, max_retries, ) response.response_metadata = { **(response.response_metadata or {}), - "stream_empty_args_fallback": False, - "stream_empty_args_tools": empty_arg_tools, - "stream_empty_args_recovery_error": type( + "invalid_tool_call_retry": False, + "invalid_tool_calls": invalid_calls, + "invalid_tool_call_recovery_error": type( recovery_error, ).__name__, + **({ + "stream_empty_args_fallback": False, + "stream_empty_args_tools": legacy_empty_args_tools, + "stream_empty_args_recovery_error": type(recovery_error).__name__, + } if legacy_empty_args_tools else {}), } else: # Close the discarded stream as its own attempt. @@ -798,33 +821,48 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: # the same, and downstream depends on it twice: # a protocol stream observer drains its sentence / # ```` filters on a non-delivered outcome so - # the abandoned bytes cannot bleed into the replay, + # the abandoned bytes cannot bleed into the retry, # and attempt-finished is the billing record for a # request whose payload never reaches the loop. - # That is also why the replay keeps its OWN usage + # That is also why the retry keeps its OWN usage # untouched: merging the two would bill the stream # a second time. + replay_first_delta = attempt_first_delta + attempt_first_delta = stream_first_delta await _finish_attempt( outcome=ATTEMPT_DISCARDED, - reason="stream_empty_tool_arguments", - recovery_action="replay_non_streaming", + reason="stream_invalid_tool_call", + recovery_action="retry_streaming", response=streamed_response, ended_at=stream_ended_at, ) attempt_index = replay_index attempt_started = replay_started if replay_started is not None else stream_ended_at - attempt_first_delta = None + attempt_first_delta = replay_first_delta response = recovered response.response_metadata = { **(response.response_metadata or {}), - "stream_empty_args_fallback": True, - "stream_empty_args_tools": empty_arg_tools, + "invalid_tool_call_retry": True, + "invalid_tool_calls": invalid_native_tool_calls(recovered, llm_active), "stream_finish_reason": ( streamed_response.finish_reason or "" ), + **({ + "stream_empty_args_fallback": True, + "stream_empty_args_tools": legacy_empty_args_tools, + } if legacy_empty_args_tools else {}), } finally: slot_lease.close() + incomplete_identity = [ + issue for issue in invalid_native_tool_calls(response, llm_active) + if issue["reason"] in {"missing tool name", "missing tool call id"} + ] + if incomplete_identity: + raise LLMCallExhausted( + ValueError(f"incomplete tool-call identity: {incomplete_identity}"), + "invalid_tool_call", + ) if _is_runaway_response(response): last_runaway_reason = "reasoning_runaway" if runaway_state is not None: @@ -941,10 +979,11 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: runaway_state["last_call_reason"] = ( last_runaway_reason or "reasoning_runaway" ) + remaining_invalid = invalid_native_tool_calls(response, llm_active) await _finish_attempt( - outcome=ATTEMPT_ACCEPTED, - reason="", - recovery_action="accepted", + outcome=(ATTEMPT_ACCEPTED_DEGRADED if remaining_invalid else ATTEMPT_ACCEPTED), + reason="invalid_tool_call" if remaining_invalid else "", + recovery_action="return_invalid_tool_feedback" if remaining_invalid else "accepted", response=response, ended_at=response_ended_at, ) diff --git a/agent_core/runtime/loop/agent_loop.py b/agent_core/runtime/loop/agent_loop.py index b428029..5e013d4 100644 --- a/agent_core/runtime/loop/agent_loop.py +++ b/agent_core/runtime/loop/agent_loop.py @@ -454,7 +454,9 @@ async def _run_loop_inner( for observer in obs ) protocol = getattr(profile, "protocol", "chat_completions") - if cfg.stream_llm_tokens is not None: + if cfg.stream_transport is not None: + stream_llm_tokens = cfg.stream_transport + elif cfg.stream_llm_tokens is not None: # The host stated it; the transport may be the reason (see LoopConfig). stream_llm_tokens = cfg.stream_llm_tokens else: @@ -894,6 +896,7 @@ async def _on_delta( response = await call_llm( llm_for_turn, messages_for_call, cfg.llm_timeout, cfg.max_llm_retries, turn, on_delta=_on_delta if stream_llm_tokens else None, + stream=stream_llm_tokens, retry_wait_fixed=cfg.retry_wait_fixed, runaway_state=metadata.setdefault(RUNAWAY_STATE_KEY, {}), first_chunk_s=cfg.first_chunk_timeout, @@ -1465,6 +1468,36 @@ async def _execute_tool_calls( metadata.update(tcv.metadata_updates) if tcv.rewrite_args is not None: tc = {**tc, "args": tcv.rewrite_args} + invalid_reason = None if tcv.rewrite_args is not None else tc.get("_invalid_reason") + if invalid_reason is None: + tool = tool_map.get(str(tc.get("name", "") or "")) + if tool is not None and hasattr(tool, "to_openai_schema"): + from .tool_call_validation import validate_arguments + + schema = cast("Any", tool).to_openai_schema() + function = schema.get("function", {}) if isinstance(schema, dict) else {} + parameters = function.get("parameters") if isinstance(function, dict) else None + if isinstance(parameters, dict): + invalid_reason = validate_arguments(tc.get("args"), parameters) + if invalid_reason: + recorded = metadata.setdefault("invalid_tool_calls", []) + if isinstance(recorded, list): + recorded.append({ + "id": tc["id"], "name": tc.get("name"), + "raw_arguments": tc.get("_raw_arguments"), + "reason": invalid_reason, + }) + invalid_args = tc.get("args") + synthetic.append((idx, ToolResult( + name=str(tc.get("name", "") or ""), + args=invalid_args if isinstance(invalid_args, dict) else {}, + result=f"[invalid tool call] {invalid_reason}; re-issue the call with valid arguments.", + duration_ms=0, + tool_call_id=str(tc["id"]), + is_error=True, + error_kind="invalid_arguments", + ))) + continue if tcv.skip_with_result is not None: raw_args = tc.get("args") synthetic.append((idx, ToolResult( diff --git a/agent_core/runtime/loop/tool_call_parser.py b/agent_core/runtime/loop/tool_call_parser.py index d7e0fb9..0242163 100644 --- a/agent_core/runtime/loop/tool_call_parser.py +++ b/agent_core/runtime/loop/tool_call_parser.py @@ -118,14 +118,23 @@ def _normalize_native_tool_call(tc: Any) -> dict | None: args = json.loads(raw_args) if raw_args.strip() else {} except (ValueError, TypeError): args = {} + invalid_reason = "arguments are invalid JSON" + else: + invalid_reason = None if raw_args.strip() else "arguments are empty" elif isinstance(raw_args, dict): args = raw_args + invalid_reason = None else: args = {} + invalid_reason = "arguments are not a JSON object" + if not isinstance(args, dict): + invalid_reason = "arguments are not a JSON object" return { "name": fn.get("name", "") or "", "args": args if isinstance(args, dict) else {}, "id": tc.get("id", "") or "", + "_invalid_reason": invalid_reason, + "_raw_arguments": raw_args, } # Already-parsed shape (legacy langchain AIMessage.tool_calls). return { diff --git a/agent_core/runtime/loop/tool_call_validation.py b/agent_core/runtime/loop/tool_call_validation.py new file mode 100644 index 0000000..f853d0b --- /dev/null +++ b/agent_core/runtime/loop/tool_call_validation.py @@ -0,0 +1,68 @@ +# pyright: reportUnknownVariableType=false, reportUnknownMemberType=false, reportMissingModuleSource=false +"""Validate assembled native tool calls before any tool side effect.""" + +from __future__ import annotations + +import json +from typing import Any + +from jsonschema import ValidationError +from jsonschema.validators import validator_for + + +def _schema_for(llm: Any, name: str) -> dict[str, Any] | None: + for tool in getattr(llm, "tools", None) or []: + if not isinstance(tool, dict): + continue + function = tool.get("function") + if isinstance(function, dict) and function.get("name") == name: + parameters = function.get("parameters") + return parameters if isinstance(parameters, dict) else None + return None + + +def validate_arguments(value: Any, schema: dict[str, Any], path: str = "arguments") -> str | None: + """Validate against the tool's complete JSON Schema before execution.""" + validator_cls = validator_for(schema) + validator_cls.check_schema(schema) + try: + validator_cls(schema).validate(value) + except ValidationError as exc: + location = ".".join(str(part) for part in exc.absolute_path) + return f"{path}{'.' + location if location else ''}: {exc.message}" + return None + + +def invalid_native_tool_calls(response: Any, llm: Any) -> list[dict[str, Any]]: + """Return diagnostics while preserving each call's original arguments.""" + issues: list[dict[str, Any]] = [] + for index, call in enumerate(getattr(response, "tool_calls", None) or []): + function = call.get("function") if isinstance(call, dict) else None + name = function.get("name") if isinstance(function, dict) else None + raw = function.get("arguments") if isinstance(function, dict) else None + reason: str | None = None + if not isinstance(name, str) or not name: + reason = "missing tool name" + elif not isinstance(call.get("id"), str) or not call["id"]: + reason = "missing tool call id" + elif not isinstance(raw, str): + reason = "arguments are not a JSON string" + else: + try: + args = json.loads(raw) + except ValueError: + reason = "arguments are invalid JSON" + else: + if not isinstance(args, dict): + reason = "arguments must be a JSON object" + else: + schema = _schema_for(llm, name) + if schema is not None: + reason = validate_arguments(args, schema) + if reason: + issues.append({"index": index, "id": call.get("id") if isinstance(call, dict) else None, + "name": name, "raw_arguments": raw, "reason": reason}) + return issues + + +__all__ = ["invalid_native_tool_calls", "validate_arguments"] diff --git a/changes/73.feature.md b/changes/73.feature.md new file mode 100644 index 0000000..efa596b --- /dev/null +++ b/changes/73.feature.md @@ -0,0 +1 @@ +Streaming can now be selected independently of token observers. AgentCore validates completed native tool calls against their JSON schemas, retries invalid streamed calls once using streaming, and returns explicit tool errors without executing malformed calls. diff --git a/docs/llm-runtime-boundary.md b/docs/llm-runtime-boundary.md index e20b437..850392e 100644 --- a/docs/llm-runtime-boundary.md +++ b/docs/llm-runtime-boundary.md @@ -93,6 +93,27 @@ Code" channel, 2026-10-05: an identical `claude-opus-5-5` request at taking 242s. Concurrency was ruled out (five light requests in flight all succeeded; five heavy ones failed at the same 76s mark). +`LoopConfig.stream_transport` is the explicit transport selector for new +callers. It takes precedence over the older `stream_llm_tokens` field, and a +streamed call can be drained into a final `LLMResponse` even when no delta +observer is registered. At the lower-level call boundary, +`call_llm(..., stream=True)` selects the same transport independently of +`on_delta`. Leaving it unset retains the existing automatic choice and older +call sites continue to work. The new `LoopConfig` field is keyword-only so +existing positional construction retains its argument order. + +After a stream ends, native tool arguments are parsed and checked against the +bound tool's JSON Schema. An invalid call triggers at most one streaming retry +within the original attempt's time budget. The discarded request keeps its +own usage and attempt record. A retry that remains invalid is returned with an +explicit invalid-call diagnostic; execution produces an error tool result +without invoking the tool. Raw arguments are retained in metadata for +diagnosis. A missing call ID on a named tool call fails the LLM call before +writing an unreplayable assistant turn into history. The stream assembler +continues to discard nameless slots, as documented by its existing tests. +For calls that matched the previous empty-required-arguments recovery condition, +the `stream_empty_args_*` response metadata keys remain available as aliases. + In automatic mode the choice is also gated by protocol: `UNVERIFIED_STREAM_PROTOCOLS` (`responses`, `bedrock`) stays non-streaming because no test here proves their streamed turn replays like its non-streaming diff --git a/pyproject.toml b/pyproject.toml index 2e34f42..f70ce54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "anthropic[bedrock]>=0.69", "httpx>=0.27,<1", "jinja2>=3.1", + "jsonschema>=4.20,<5", "openai>=1.0", "pydantic>=2.0", "pyyaml>=6.0", diff --git a/tests/test_completion_recovery.py b/tests/test_completion_recovery.py index 3c44eca..0142341 100644 --- a/tests/test_completion_recovery.py +++ b/tests/test_completion_recovery.py @@ -471,13 +471,13 @@ async def test_candidate_empty_fragments_survive_if_the_same_leg_later_gets_a_na @pytest.mark.parametrize("replay", ["blank", "success"]) -async def test_opportunistic_tool_argument_replay_has_its_own_attempt_events(replay): +async def test_streaming_tool_argument_retry_has_its_own_attempt_events(replay): schema = {"type": "function", "function": {"name": "tool", "parameters": { "type": "object", "required": ["x"], "properties": {"x": {"type": "integer"}}, }}} - recovered = LLMResponse() if replay == "blank" else LLMResponse(tool_calls=[{ - "id": "good", "type": "function", "function": {"name": "tool", "arguments": '{"x":1}'}, - }]) + recovered = LLMResponse() if replay == "blank" else [StreamDelta(tool_call_deltas=[{ + "index": 0, "id": "good", "name": "tool", "arguments": '{"x":1}', + }])] llm = bind_tools(Script([ [StreamDelta(tool_call_deltas=[{"index": 0, "id": "original", "name": "tool", "arguments": "{}"}])], recovered, @@ -488,13 +488,17 @@ async def test_opportunistic_tool_argument_replay_has_its_own_attempt_events(rep by_index = {e["attempt_index"]: e for e in finished(events)} assert len(finished(events)) == 2 if replay == "blank": - assert by_index[1]["outcome"] == "accepted" + assert by_index[1]["outcome"] == "accepted_degraded" assert by_index[2]["outcome"] == "failed" assert by_index[2]["reason"] == "empty_completion" assert result.tool_calls[0]["id"] == "original" + assert result.response_metadata["invalid_tool_call_retry"] is False assert result.response_metadata["stream_empty_args_fallback"] is False + assert result.response_metadata["stream_empty_args_tools"] == ["tool"] else: assert by_index[1]["outcome"] == "discarded" assert by_index[2]["outcome"] == "accepted" assert result.tool_calls[0]["id"] == "good" + assert result.response_metadata["invalid_tool_call_retry"] is True assert result.response_metadata["stream_empty_args_fallback"] is True + assert result.response_metadata["stream_empty_args_tools"] == ["tool"] diff --git a/tests/test_llm_wait_bounds.py b/tests/test_llm_wait_bounds.py index 5fdc140..2333495 100644 --- a/tests/test_llm_wait_bounds.py +++ b/tests/test_llm_wait_bounds.py @@ -393,26 +393,25 @@ def release(self): pass monkeypatch.setattr(call_module, "_llm_gate", Gate) - monkeypatch.setattr(call_module, "stream_tool_calls_missing_required_arguments", - lambda response, llm: ["search"]) + checks = iter([[{"reason": "arguments are empty"}], []]) + monkeypatch.setattr(call_module, "invalid_native_tool_calls", + lambda response, llm: next(checks, [])) async def stream_response(llm, messages, timeout, on_delta, **kwargs): read_timeouts.append(timeout) - clock[0] += 30 - return LLMResponse(content="partial") - - async def chat(messages, **kwargs): - read_timeouts.append(kwargs["timeout"]) + if len(read_timeouts) == 1: + clock[0] += 30 + return LLMResponse(content="partial") return LLMResponse(content="repaired") monkeypatch.setattr(call_module, "_stream_llm_response", stream_response) response = await call_module.call_llm( - SimpleNamespace(model="test", chat=chat), [user_msg("hi")], 100, 1, 1, + SimpleNamespace(model="test"), [user_msg("hi")], 100, 1, 1, on_delta=_ignore, ) assert read_timeouts == [100, 70] assert response.content == "repaired" - assert response.response_metadata["stream_empty_args_fallback"] is True + assert response.response_metadata["invalid_tool_call_retry"] is True async def test_external_cancellation_during_normal_eof_cleanup_propagates(monkeypatch): diff --git a/tests/test_stream_llm_tokens_toggle.py b/tests/test_stream_llm_tokens_toggle.py index 03c48a8..ac3f22c 100644 --- a/tests/test_stream_llm_tokens_toggle.py +++ b/tests/test_stream_llm_tokens_toggle.py @@ -10,6 +10,7 @@ """ from __future__ import annotations +import inspect from typing import Any import pytest @@ -107,6 +108,13 @@ async def test_explicit_true_streams_on_any_protocol(protocol) -> None: assert (llm.stream_calls, llm.chat_calls) == (1, 0) +@pytest.mark.asyncio +async def test_transport_can_stream_without_delta_observers() -> None: + llm = RecordingLLM() + await _run(llm, protocol="chat_completions", stream_transport=True) + assert (llm.stream_calls, llm.chat_calls) == (1, 0) + + @pytest.mark.asyncio async def test_explicit_false_outranks_a_delta_hungry_observer() -> None: llm, observer = RecordingLLM(), DeltaObserver() @@ -135,3 +143,7 @@ def test_the_verified_set_is_stated_not_guessed() -> None: """``anthropic`` leaving this set is the behaviour change; pin it so a future edit has to be deliberate.""" assert frozenset({"responses", "bedrock"}) == UNVERIFIED_STREAM_PROTOCOLS + + +def test_new_transport_knob_preserves_positional_loop_config_arguments() -> None: + assert inspect.signature(LoopConfig).parameters["stream_transport"].kind is inspect.Parameter.KEYWORD_ONLY diff --git a/tests/test_streamed_tool_validation.py b/tests/test_streamed_tool_validation.py new file mode 100644 index 0000000..852690c --- /dev/null +++ b/tests/test_streamed_tool_validation.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from agent_core.llm import LLMResponse, StreamDelta +from agent_core.loop_types import LoopConfig, LoopPolicy +from agent_core.runtime.loop._call import call_llm +from agent_core.runtime.loop.agent_loop import run_agent_loop +from agent_core.runtime.loop.tool_call_validation import invalid_native_tool_calls + + +def _call(arguments: str) -> dict[str, Any]: + return {"id": "tc1", "type": "function", "function": {"name": "save", "arguments": arguments}} + + +class SaveTool: + name = "save" + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def to_openai_schema(self) -> dict[str, Any]: + return {"type": "function", "function": {"name": "save", "parameters": { + "type": "object", "properties": {"value": {"type": "string"}}, + "required": ["value"], + }}} + + async def ainvoke(self, args: dict[str, Any]) -> str: + self.calls.append(args) + return "saved" + + +@pytest.mark.parametrize("raw,expected", [ + ("", "invalid JSON"), + ("{", "invalid JSON"), + ("{}", "required property"), + ('{"value": 1}', "not of type 'string'"), +]) +def test_assembled_tool_arguments_are_checked(raw: str, expected: str) -> None: + llm = type("Bound", (), {"tools": [SaveTool().to_openai_schema()]})() + issues = invalid_native_tool_calls(LLMResponse(tool_calls=[_call(raw)]), llm) + assert expected in issues[0]["reason"] + assert issues[0]["raw_arguments"] == raw + + +def test_empty_object_is_valid_for_a_tool_without_required_fields() -> None: + llm = type("Bound", (), {"tools": [{"function": { + "name": "ping", "parameters": {"type": "object", "properties": {}} + }}]})() + response = LLMResponse(tool_calls=[{ + "id": "tc1", "type": "function", + "function": {"name": "ping", "arguments": "{}"}, + }]) + assert invalid_native_tool_calls(response, llm) == [] + + +@pytest.mark.asyncio +async def test_invalid_native_call_never_invokes_tool() -> None: + tool = SaveTool() + + class Client: + model = "test" + + async def chat(self, messages, **kwargs): + if not hasattr(self, "called"): + self.called = True + return LLMResponse(tool_calls=[_call("{")]) + return LLMResponse(content="done") + + result = await run_agent_loop( + system_prompt="system", user_message="start", llm=Client(), tools=[tool], + config=LoopConfig(max_turns=2, max_llm_retries=1, + loop_policy=LoopPolicy(no_tool_behavior="stop")), + ) + assert tool.calls == [] + assert any("[invalid tool call]" in str(message.get("content")) + for message in result.messages if message.get("role") == "tool") + + +@pytest.mark.asyncio +async def test_stream_retry_uses_stream_transport_for_both_requests() -> None: + class Client: + model = "test" + + def __init__(self) -> None: + self.stream_calls = 0 + + async def chat(self, messages, **kwargs): + raise AssertionError("recovery must not use chat") + + async def stream(self, messages, **kwargs): + self.stream_calls += 1 + arguments = "" if self.stream_calls == 1 else '{"value":"ok"}' + yield StreamDelta(tool_call_deltas=[{ + "index": 0, "id": "tc1", "name": "save", "arguments": arguments, + }]) + yield StreamDelta(finish_reason="tool_calls") + + client = Client() + response = await call_llm(client, [{"role": "user", "content": "save"}], + timeout=10, max_retries=1, turn=1, stream=True) + assert client.stream_calls == 2 + assert response.tool_calls[0]["function"]["arguments"] == '{"value":"ok"}' + assert response.response_metadata["invalid_tool_call_retry"] is True + + +@pytest.mark.asyncio +async def test_invalid_stream_after_retry_yields_tool_error_without_invocation() -> None: + tool = SaveTool() + + class Client: + model = "test" + + def __init__(self) -> None: + self.stream_calls = 0 + + async def chat(self, messages, **kwargs): + raise AssertionError("stream transport must be kept") + + async def stream(self, messages, **kwargs): + self.stream_calls += 1 + yield StreamDelta(tool_call_deltas=[{ + "index": 0, "id": f"tc{self.stream_calls}", + "name": "save", "arguments": "{}", + }]) + yield StreamDelta(finish_reason="tool_calls") + + client = Client() + result = await run_agent_loop( + system_prompt="system", user_message="start", llm=client, tools=[tool], + config=LoopConfig(max_turns=1, max_llm_retries=1, stream_transport=True, + loop_policy=LoopPolicy(no_tool_behavior="stop")), + ) + assert client.stream_calls == 2 + assert tool.calls == [] + assert result.metadata["invalid_tool_calls"][0]["raw_arguments"] == "{}" diff --git a/uv.lock b/uv.lock index 4723fff..e16ee30 100644 --- a/uv.lock +++ b/uv.lock @@ -56,6 +56,7 @@ dependencies = [ { name = "anthropic", extra = ["bedrock"] }, { name = "httpx" }, { name = "jinja2" }, + { name = "jsonschema" }, { name = "openai" }, { name = "pydantic" }, { name = "pyyaml" }, @@ -89,6 +90,7 @@ requires-dist = [ { name = "anthropic", extras = ["bedrock"], specifier = ">=0.69" }, { name = "httpx", specifier = ">=0.27,<1" }, { name = "jinja2", specifier = ">=3.1" }, + { name = "jsonschema", specifier = ">=4.20,<5" }, { name = "openai", specifier = ">=1.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyright", extras = ["nodejs"], marker = "extra == 'dev'", specifier = ">=1.1.400" }, @@ -110,6 +112,15 @@ dev = [ { name = "ruff", specifier = ">=0.7" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "boto3" version = "1.43.86" @@ -479,6 +490,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -806,6 +844,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "regex" version = "2026.8.31" @@ -909,6 +961,102 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, +] + [[package]] name = "ruff" version = "0.16.5" From 4e1589d4aec2a9dd9d94c5fad9b6c08422b41fca Mon Sep 17 00:00:00 2001 From: zhanghanduo Date: Wed, 7 Oct 2026 18:28:36 +0800 Subject: [PATCH 2/2] fix(loop): keep tool-call validation backward compatible - Add LoopConfig.tool_argument_validation ("structural" default, "strict", "off"). Structural checks JSON object shape and top-level required properties only, so tool-side type coercion keeps working. - Treat blank/whitespace arguments as {} so zero-argument tools (Anthropic streaming encodes them as "") run without a wasted retry. - Block only provider-native calls at execution; text-mode calls are never rejected by schema validation. Drop the private parser keys that leaked to observers and derive diagnostics from response.tool_calls instead. - Skip validation for tools whose schema is not valid JSON Schema instead of raising; cache compiled validators. - Generate ids for named native calls that lack one instead of failing the LLM call; nameless calls keep their dropped-call handling. - Keep the legacy empty-arguments trigger and its attempt reasons (stream_empty_tool_arguments / stream_empty_args_replay) as aliases. Co-Authored-By: Claude Opus 5.5 (1M context) --- agent_core/loop_types.py | 9 + agent_core/runtime/loop/_call.py | 50 ++-- agent_core/runtime/loop/agent_loop.py | 58 +++- agent_core/runtime/loop/tool_call_parser.py | 9 - .../runtime/loop/tool_call_validation.py | 253 +++++++++++++++--- changes/73.feature.md | 2 +- docs/llm-runtime-boundary.md | 41 ++- tests/test_llm_wait_bounds.py | 2 +- tests/test_streamed_tool_validation.py | 202 +++++++++++++- 9 files changed, 521 insertions(+), 105 deletions(-) diff --git a/agent_core/loop_types.py b/agent_core/loop_types.py index 3e6f8fc..54379aa 100644 --- a/agent_core/loop_types.py +++ b/agent_core/loop_types.py @@ -205,6 +205,15 @@ class LoopConfig: # Keyword-only to preserve every existing positional LoopConfig argument. # When unset, retain the legacy stream_llm_tokens/watchdog selection. stream_transport: bool | None = field(default=None, kw_only=True) + # How strictly native tool-call arguments are checked before execution. + # "structural" (default): decodable JSON object with every top-level + # required property; blank arguments count as {} and property types are + # left to the tool. "strict": full JSON Schema. "off": no checks beyond + # the legacy empty-required-arguments retry. Text-mode calls are never + # blocked. + tool_argument_validation: Literal["structural", "strict", "off"] = field( + default="structural", kw_only=True, + ) @dataclass diff --git a/agent_core/runtime/loop/_call.py b/agent_core/runtime/loop/_call.py index eb3ac05..b214366 100644 --- a/agent_core/runtime/loop/_call.py +++ b/agent_core/runtime/loop/_call.py @@ -57,7 +57,7 @@ _stream_stall_max_before_advance, ) from .tool_call_recovery import stream_tool_calls_missing_required_arguments -from .tool_call_validation import invalid_native_tool_calls +from .tool_call_validation import ensure_tool_call_ids, invalid_native_tool_calls logger = logging.getLogger(__name__) _OBSERVATION_TIMEOUT_S = 5.0 @@ -219,6 +219,7 @@ async def call_llm( chain_fallback_active: Callable[[], bool] | None = None, empty_completion_max_retries: int = 2, stream: bool | None = None, + tool_argument_validation: str = "structural", ) -> LLMResponse | None: """Call ``llm.chat`` or ``llm.stream`` (independent of delta delivery) with exponential backoff on transient errors. @@ -699,9 +700,12 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: else: response = await _stream_active(effective_timeout) invalid_calls = invalid_native_tool_calls( + response, llm_active, tool_argument_validation, + ) + legacy_empty_args_tools = stream_tool_calls_missing_required_arguments( response, llm_active, ) - if invalid_calls: + if invalid_calls or legacy_empty_args_tools: # The stream has only been observed/assembled here: no # assistant history or tool execution has happened yet, # so replacing it with one streaming retry cannot @@ -709,13 +713,17 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: streamed_response = response stream_ended_at = time.monotonic() stream_first_delta = attempt_first_delta - legacy_empty_args_tools = stream_tool_calls_missing_required_arguments( - streamed_response, llm_active, + # Keep the pre-validation event reason for the calls + # the old empty-arguments replay covered, so consumers + # keyed on it keep matching. + tool_retry_reason = ( + "stream_empty_tool_arguments" + if legacy_empty_args_tools else "stream_invalid_tool_call" ) logger.warning( "Streamed tool call(s) failed validation: %s " "(turn=%d, attempt=%d/%d); retrying with streaming", - [issue["reason"] for issue in invalid_calls], + [issue["reason"] for issue in invalid_calls] or legacy_empty_args_tools, turn, attempt + 1, max_retries, ) recovered: LLMResponse | None = None @@ -732,7 +740,7 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: recovery_timeout = min( _effective_timeout_or_deadline_exhausted( attempt=attempt, - reason="stream_invalid_tool_call", + reason=tool_retry_reason, )[0], max( attempt_deadline - stream_ended_at, @@ -760,7 +768,7 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: # re-clamp before the actual replay request starts. recovery_timeout = min( _effective_timeout_or_deadline_exhausted( - attempt=attempt, reason="stream_invalid_tool_call", + attempt=attempt, reason=tool_retry_reason, )[0], max(attempt_deadline - time.monotonic(), 0.0), ) @@ -783,7 +791,10 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: replay_event: dict[str, Any] = { "phase": "finished", "attempt_index": replay_index, "outcome": ATTEMPT_FAILED, - "reason": "empty_completion" if isinstance(recovery_error, LLMEmptyCompletion) else "stream_invalid_tool_call_retry", + "reason": "empty_completion" if isinstance(recovery_error, LLMEmptyCompletion) else ( + "stream_empty_args_replay" if legacy_empty_args_tools + else "stream_invalid_tool_call_retry" + ), "recovery_action": "keep_streamed_response", "duration_ms": int((time.monotonic() - replay_started) * 1000), "ttft_ms": None, "max_tokens": active_cap, @@ -831,7 +842,7 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: attempt_first_delta = stream_first_delta await _finish_attempt( outcome=ATTEMPT_DISCARDED, - reason="stream_invalid_tool_call", + reason=tool_retry_reason, recovery_action="retry_streaming", response=streamed_response, ended_at=stream_ended_at, @@ -843,7 +854,9 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: response.response_metadata = { **(response.response_metadata or {}), "invalid_tool_call_retry": True, - "invalid_tool_calls": invalid_native_tool_calls(recovered, llm_active), + "invalid_tool_calls": invalid_native_tool_calls( + recovered, llm_active, tool_argument_validation, + ), "stream_finish_reason": ( streamed_response.finish_reason or "" ), @@ -854,15 +867,10 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: } finally: slot_lease.close() - incomplete_identity = [ - issue for issue in invalid_native_tool_calls(response, llm_active) - if issue["reason"] in {"missing tool name", "missing tool call id"} - ] - if incomplete_identity: - raise LLMCallExhausted( - ValueError(f"incomplete tool-call identity: {incomplete_identity}"), - "invalid_tool_call", - ) + # A named native call without an id cannot be replayed: the tool + # reply would echo an id the assistant turn never carried. Repair + # it before the turn reaches history instead of failing the call. + ensure_tool_call_ids(response) if _is_runaway_response(response): last_runaway_reason = "reasoning_runaway" if runaway_state is not None: @@ -979,7 +987,9 @@ async def _recover_empty_completion(exc: LLMEmptyCompletion) -> None: runaway_state["last_call_reason"] = ( last_runaway_reason or "reasoning_runaway" ) - remaining_invalid = invalid_native_tool_calls(response, llm_active) + remaining_invalid = invalid_native_tool_calls( + response, llm_active, tool_argument_validation, + ) await _finish_attempt( outcome=(ATTEMPT_ACCEPTED_DEGRADED if remaining_invalid else ATTEMPT_ACCEPTED), reason="invalid_tool_call" if remaining_invalid else "", diff --git a/agent_core/runtime/loop/agent_loop.py b/agent_core/runtime/loop/agent_loop.py index 5e013d4..132d766 100644 --- a/agent_core/runtime/loop/agent_loop.py +++ b/agent_core/runtime/loop/agent_loop.py @@ -77,6 +77,10 @@ MultiFormatToolCallParser, ToolCallParser, ) +from agent_core.runtime.loop.tool_call_validation import ( + IDENTITY_REASONS, + native_tool_call_issues, +) from agent_core.runtime.loop.tool_exec import ( DefaultToolResultPostProcessor, ToolExecutionHooks, @@ -641,6 +645,7 @@ async def _run_loop_inner( result_max_chars=tool_result_cap, profile=profile, max_images_in_history=policy.max_images_in_history, + native_tool_calls=getattr(response, "tool_calls", None), ) total_tool_calls += tool_calls_executed if stop_reason: @@ -911,6 +916,7 @@ async def _on_delta( wall_deadline_remaining=runtime_hooks.wall_deadline_remaining, chain_fallback_active=runtime_hooks.chain_fallback_active, empty_completion_max_retries=cfg.empty_completion_max_retries, + tool_argument_validation=getattr(cfg, "tool_argument_validation", "structural"), ) except LLMCallExhausted as exhausted: if exhausted.reason == "empty_completion": @@ -1436,6 +1442,37 @@ def _effective_tool_result_cap( return cap if cap and cap > 0 else None +def _invalid_native_calls_by_id( + native_tool_calls: list[dict] | None, tool_map: dict[str, ToolLike], mode: str, +) -> dict[str, dict[str, Any]]: + """Argument diagnostics for native calls, keyed by tool-call id.""" + if not native_tool_calls or mode == "off": + return {} + schemas: dict[str, dict[str, Any] | None] = {} + + def schema_for(name: str) -> dict[str, Any] | None: + if name not in schemas: + schemas[name] = None + to_schema = getattr(tool_map.get(name), "to_openai_schema", None) + if callable(to_schema): + try: + schema = to_schema() + except Exception: + logger.debug("to_openai_schema failed for tool %s", name, exc_info=True) + schema = None + function = schema.get("function") if isinstance(schema, dict) else None + parameters = function.get("parameters") if isinstance(function, dict) else None + if isinstance(parameters, dict): + schemas[name] = parameters + return schemas[name] + + return { + str(issue["id"]): issue + for issue in native_tool_call_issues(native_tool_calls, schema_for, mode) + if issue["id"] and issue["reason"] not in IDENTITY_REASONS + } + + async def _execute_tool_calls( cfg: LoopConfig, obs: list, tool_map: dict[str, ToolLike], messages: list[Message], metadata: dict[str, Any], turn: int, total_tool_calls: int, ctx: TurnContext, parsed_calls: list[dict], @@ -1451,9 +1488,15 @@ async def _execute_tool_calls( # -1 disables eviction. Not 0: a defaulted caller must not silently mean # "throw every image away", which is what a 0 default would spell. max_images_in_history: int = -1, + # The raw ``response.tool_calls``. Only these provider-native calls are + # checked before dispatch; text-mode calls keep their lenient handling. + native_tool_calls: list[dict] | None = None, ) -> tuple[str, int]: executable: list[tuple[int, dict]] = [] synthetic: list[tuple[int, ToolResult]] = [] + invalid_native = _invalid_native_calls_by_id( + native_tool_calls, tool_map, getattr(cfg, "tool_argument_validation", "structural"), + ) for idx, tc in enumerate(parsed_calls): # Text-mode calls arrive without a provider id. Assign it here, once, # off the ``parsed_calls`` index -- before the batch is split into @@ -1468,23 +1511,14 @@ async def _execute_tool_calls( metadata.update(tcv.metadata_updates) if tcv.rewrite_args is not None: tc = {**tc, "args": tcv.rewrite_args} - invalid_reason = None if tcv.rewrite_args is not None else tc.get("_invalid_reason") - if invalid_reason is None: - tool = tool_map.get(str(tc.get("name", "") or "")) - if tool is not None and hasattr(tool, "to_openai_schema"): - from .tool_call_validation import validate_arguments - - schema = cast("Any", tool).to_openai_schema() - function = schema.get("function", {}) if isinstance(schema, dict) else {} - parameters = function.get("parameters") if isinstance(function, dict) else None - if isinstance(parameters, dict): - invalid_reason = validate_arguments(tc.get("args"), parameters) + issue = None if tcv.rewrite_args is not None else invalid_native.get(str(tc.get("id") or "")) + invalid_reason = issue["reason"] if issue is not None else None if invalid_reason: recorded = metadata.setdefault("invalid_tool_calls", []) if isinstance(recorded, list): recorded.append({ "id": tc["id"], "name": tc.get("name"), - "raw_arguments": tc.get("_raw_arguments"), + "raw_arguments": issue.get("raw_arguments") if issue else None, "reason": invalid_reason, }) invalid_args = tc.get("args") diff --git a/agent_core/runtime/loop/tool_call_parser.py b/agent_core/runtime/loop/tool_call_parser.py index 0242163..d7e0fb9 100644 --- a/agent_core/runtime/loop/tool_call_parser.py +++ b/agent_core/runtime/loop/tool_call_parser.py @@ -118,23 +118,14 @@ def _normalize_native_tool_call(tc: Any) -> dict | None: args = json.loads(raw_args) if raw_args.strip() else {} except (ValueError, TypeError): args = {} - invalid_reason = "arguments are invalid JSON" - else: - invalid_reason = None if raw_args.strip() else "arguments are empty" elif isinstance(raw_args, dict): args = raw_args - invalid_reason = None else: args = {} - invalid_reason = "arguments are not a JSON object" - if not isinstance(args, dict): - invalid_reason = "arguments are not a JSON object" return { "name": fn.get("name", "") or "", "args": args if isinstance(args, dict) else {}, "id": tc.get("id", "") or "", - "_invalid_reason": invalid_reason, - "_raw_arguments": raw_args, } # Already-parsed shape (legacy langchain AIMessage.tool_calls). return { diff --git a/agent_core/runtime/loop/tool_call_validation.py b/agent_core/runtime/loop/tool_call_validation.py index f853d0b..2986cbb 100644 --- a/agent_core/runtime/loop/tool_call_validation.py +++ b/agent_core/runtime/loop/tool_call_validation.py @@ -1,68 +1,237 @@ -# pyright: reportUnknownVariableType=false, reportUnknownMemberType=false, reportMissingModuleSource=false -"""Validate assembled native tool calls before any tool side effect.""" +# pyright: reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportMissingModuleSource=false +"""Validate assembled native tool calls before any tool side effect. + +Three levels, selected by ``LoopConfig.tool_argument_validation``: + +``"structural"`` (default) + Arguments must decode to a JSON object that carries every top-level + ``required`` property. A blank argument string is ``{}`` -- several + providers (Anthropic streaming among them) encode a zero-argument call + that way. Property types are *not* checked, so tools that coerce + ``"5"`` to ``5`` keep working exactly as before. +``"strict"`` + The structural checks plus full JSON Schema validation. +``"off"`` + No validation; only the legacy empty-required-arguments retry remains. + +A schema that is itself invalid never fails a call: validation for that tool +is skipped with a warning, matching how providers accept loose schemas. +""" from __future__ import annotations import json -from typing import Any +import logging +import uuid +from collections.abc import Callable, Mapping +from functools import lru_cache +from typing import Any, Literal + +logger = logging.getLogger(__name__) + +ToolArgumentValidation = Literal["structural", "strict", "off"] +TOOL_ARGUMENT_VALIDATION_MODES: frozenset[str] = frozenset({"structural", "strict", "off"}) -from jsonschema import ValidationError -from jsonschema.validators import validator_for +# Reasons that describe the call's identity rather than its arguments. They are +# reported, but a model retry cannot be expected to fix them: missing ids are +# repaired locally and nameless slots are already answered as dropped calls. +IDENTITY_REASONS: frozenset[str] = frozenset({"missing tool name", "missing tool call id"}) -def _schema_for(llm: Any, name: str) -> dict[str, Any] | None: +def _normalize_mode(mode: str | None) -> str: + if mode in TOOL_ARGUMENT_VALIDATION_MODES: + return str(mode) + if mode is not None: + logger.warning("Unknown tool_argument_validation %r; using 'structural'", mode) + return "structural" + + +def bound_tool_parameters(llm: Any) -> dict[str, dict[str, Any]]: + """Map each tool bound on ``llm`` (OpenAI schema shape) to its parameters.""" + parameters_by_name: dict[str, dict[str, Any]] = {} for tool in getattr(llm, "tools", None) or []: if not isinstance(tool, dict): continue function = tool.get("function") - if isinstance(function, dict) and function.get("name") == name: - parameters = function.get("parameters") - return parameters if isinstance(parameters, dict) else None - return None + if not isinstance(function, dict): + continue + name = function.get("name") + parameters = function.get("parameters") + if isinstance(name, str) and isinstance(parameters, dict): + parameters_by_name[name] = parameters + return parameters_by_name + + +@lru_cache(maxsize=256) +def _cached_validator(schema_json: str) -> Any | None: + from jsonschema.exceptions import SchemaError + from jsonschema.validators import validator_for + + schema = json.loads(schema_json) + try: + validator_cls = validator_for(schema) + validator_cls.check_schema(schema) + return validator_cls(schema) + except SchemaError as exc: + logger.warning( + "Tool schema is not valid JSON Schema; skipping strict argument " + "validation for it: %s", exc.message, + ) + return None + + +def _validator_for(schema: dict[str, Any]) -> Any | None: + try: + schema_json = json.dumps(schema, sort_keys=True, default=str) + except (TypeError, ValueError): + return None + return _cached_validator(schema_json) def validate_arguments(value: Any, schema: dict[str, Any], path: str = "arguments") -> str | None: - """Validate against the tool's complete JSON Schema before execution.""" - validator_cls = validator_for(schema) - validator_cls.check_schema(schema) + """Validate against the tool's complete JSON Schema. + + Returns a diagnostic string, or ``None`` when the value is valid *or* the + schema itself cannot be used for validation. + """ + validator = _validator_for(schema) + if validator is None: + return None + from jsonschema.exceptions import best_match + try: - validator_cls(schema).validate(value) - except ValidationError as exc: - location = ".".join(str(part) for part in exc.absolute_path) - return f"{path}{'.' + location if location else ''}: {exc.message}" + error = best_match(validator.iter_errors(value)) + except Exception as exc: # unresolvable $ref and similar schema faults + logger.warning("Strict tool argument validation skipped: %s", exc) + return None + if error is None: + return None + location = ".".join(str(part) for part in error.absolute_path) + return f"{path}{'.' + location if location else ''}: {error.message}" + + +def _missing_required(args: dict[str, Any], schema: dict[str, Any]) -> str | None: + required = schema.get("required") + if not isinstance(required, list): + return None + missing = [field for field in required if isinstance(field, str) and field not in args] + if not missing: + return None + if len(missing) == 1: + return f"arguments: '{missing[0]}' is a required property" + return f"arguments: required properties missing: {', '.join(repr(m) for m in missing)}" + + +def argument_issue(raw: Any, schema: dict[str, Any] | None, mode: str = "structural") -> str | None: + """Return why ``raw`` (a native ``function.arguments`` value) is invalid.""" + mode = _normalize_mode(mode) + if mode == "off": + return None + if raw is None or (isinstance(raw, str) and not raw.strip()): + args: Any = {} + elif isinstance(raw, str): + try: + args = json.loads(raw) + except ValueError: + return "arguments are invalid JSON" + elif isinstance(raw, dict): + args = raw + else: + return "arguments must be a JSON object" + if not isinstance(args, dict): + return "arguments must be a JSON object" + if schema is None: + return None + missing = _missing_required(args, schema) + if missing: + return missing + if mode == "strict": + return validate_arguments(args, schema) return None -def invalid_native_tool_calls(response: Any, llm: Any) -> list[dict[str, Any]]: - """Return diagnostics while preserving each call's original arguments.""" +def native_tool_call_issues( + tool_calls: Any, + schema_for: Callable[[str], dict[str, Any] | None] | Mapping[str, dict[str, Any]], + mode: str = "structural", +) -> list[dict[str, Any]]: + """Diagnose native OpenAI-shape tool calls, keeping the raw arguments.""" + lookup: Callable[[str], dict[str, Any] | None] = ( + schema_for.get if isinstance(schema_for, Mapping) else schema_for + ) issues: list[dict[str, Any]] = [] - for index, call in enumerate(getattr(response, "tool_calls", None) or []): - function = call.get("function") if isinstance(call, dict) else None - name = function.get("name") if isinstance(function, dict) else None - raw = function.get("arguments") if isinstance(function, dict) else None - reason: str | None = None + for index, call in enumerate(tool_calls or []): + if not isinstance(call, dict): + continue + function = call.get("function") + if not isinstance(function, dict): + continue + name = function.get("name") + raw = function.get("arguments") + call_id = call.get("id") if not isinstance(name, str) or not name: - reason = "missing tool name" - elif not isinstance(call.get("id"), str) or not call["id"]: + reason: str | None = "missing tool name" + elif not isinstance(call_id, str) or not call_id: reason = "missing tool call id" - elif not isinstance(raw, str): - reason = "arguments are not a JSON string" else: - try: - args = json.loads(raw) - except ValueError: - reason = "arguments are invalid JSON" - else: - if not isinstance(args, dict): - reason = "arguments must be a JSON object" - else: - schema = _schema_for(llm, name) - if schema is not None: - reason = validate_arguments(args, schema) + reason = argument_issue(raw, lookup(name), mode) if reason: - issues.append({"index": index, "id": call.get("id") if isinstance(call, dict) else None, - "name": name, "raw_arguments": raw, "reason": reason}) + issues.append({ + "index": index, "id": call_id, "name": name, + "raw_arguments": raw, "reason": reason, + }) return issues -__all__ = ["invalid_native_tool_calls", "validate_arguments"] +def invalid_native_tool_calls( + response: Any, llm: Any, mode: str = "structural", +) -> list[dict[str, Any]]: + """Return argument diagnostics for ``response`` against ``llm``'s tools. + + Identity problems (missing name / id) are excluded: a retry is not the + remedy for them -- see :func:`ensure_tool_call_ids`. + """ + return [ + issue for issue in native_tool_call_issues( + getattr(response, "tool_calls", None), bound_tool_parameters(llm), mode, + ) + if issue["reason"] not in IDENTITY_REASONS + ] + + +def ensure_tool_call_ids(response: Any) -> list[int]: + """Give every named native call without an id a unique one, in place. + + The id lands on ``response.tool_calls`` before the assistant turn is + written to history, so the tool reply and the replayed assistant message + agree. Returns the indexes that were filled. + """ + filled: list[int] = [] + for index, call in enumerate(getattr(response, "tool_calls", None) or []): + if not isinstance(call, dict): + continue + function = call.get("function") + name = function.get("name") if isinstance(function, dict) else call.get("name") + if not name: + continue + call_id = call.get("id") + if isinstance(call_id, str) and call_id: + continue + call["id"] = f"call_{uuid.uuid4().hex[:24]}" + filled.append(index) + if filled: + logger.warning("Assigned ids to %d native tool call(s) the provider sent without one", len(filled)) + return filled + + +__all__ = [ + "IDENTITY_REASONS", + "TOOL_ARGUMENT_VALIDATION_MODES", + "ToolArgumentValidation", + "argument_issue", + "bound_tool_parameters", + "ensure_tool_call_ids", + "invalid_native_tool_calls", + "native_tool_call_issues", + "validate_arguments", +] diff --git a/changes/73.feature.md b/changes/73.feature.md index efa596b..b0dbcc3 100644 --- a/changes/73.feature.md +++ b/changes/73.feature.md @@ -1 +1 @@ -Streaming can now be selected independently of token observers. AgentCore validates completed native tool calls against their JSON schemas, retries invalid streamed calls once using streaming, and returns explicit tool errors without executing malformed calls. +Streaming can now be selected independently of token observers (`LoopConfig.stream_transport`, `call_llm(stream=...)`). Native tool calls are checked before execution according to the new `LoopConfig.tool_argument_validation` (`"structural"` by default, `"strict"` for full JSON Schema, `"off"`). Invalid streamed calls are retried once using streaming, and invalid native calls that remain return an explicit tool error instead of running. Blank arguments count as `{}`, text-mode calls and tool-side type coercion are unaffected, malformed tool schemas never fail a call, and native calls missing an id get a generated one instead of breaking history replay. diff --git a/docs/llm-runtime-boundary.md b/docs/llm-runtime-boundary.md index 850392e..d99ea88 100644 --- a/docs/llm-runtime-boundary.md +++ b/docs/llm-runtime-boundary.md @@ -102,17 +102,36 @@ observer is registered. At the lower-level call boundary, call sites continue to work. The new `LoopConfig` field is keyword-only so existing positional construction retains its argument order. -After a stream ends, native tool arguments are parsed and checked against the -bound tool's JSON Schema. An invalid call triggers at most one streaming retry -within the original attempt's time budget. The discarded request keeps its -own usage and attempt record. A retry that remains invalid is returned with an -explicit invalid-call diagnostic; execution produces an error tool result -without invoking the tool. Raw arguments are retained in metadata for -diagnosis. A missing call ID on a named tool call fails the LLM call before -writing an unreplayable assistant turn into history. The stream assembler -continues to discard nameless slots, as documented by its existing tests. -For calls that matched the previous empty-required-arguments recovery condition, -the `stream_empty_args_*` response metadata keys remain available as aliases. +After a stream ends, native tool arguments are checked according to +`LoopConfig.tool_argument_validation`: + +- `"structural"` (default): the arguments must decode to a JSON object that + carries every top-level `required` property. A blank argument string counts + as `{}`, which is how Anthropic streaming and several OpenAI-compatible + servers encode a zero-argument call. Property types are left to the tool, + so tools that coerce `"5"` to `5` behave as before. +- `"strict"`: the structural checks plus full JSON Schema validation. +- `"off"`: no new checks; only the legacy empty-required-arguments retry. + +A tool schema that is not itself valid JSON Schema never fails a call: +validation is skipped for that tool with a warning. + +An invalid streamed call triggers at most one streaming retry within the +original attempt's time budget. The discarded request keeps its own usage and +attempt record. Calls that matched the previous empty-required-arguments +condition keep the `stream_empty_tool_arguments` attempt reason (and +`stream_empty_args_replay` for a failed retry) and the `stream_empty_args_*` +response metadata keys; other invalid calls use `stream_invalid_tool_call`. +The retry's `recovery_action` is `retry_streaming`. + +A retry that remains invalid is returned with `invalid_tool_calls` diagnostics +(raw arguments included) and the attempt is `accepted_degraded`. At execution +time, only provider-native calls are checked: an invalid one produces an +error tool result (`error_kind="invalid_arguments"`) without invoking the +tool, unless an observer rewrote its arguments. Text-mode calls are never +blocked by this check. A named native call without an id is given a generated +id before the assistant turn reaches history, so the tool reply always +matches. The stream assembler continues to discard nameless slots. In automatic mode the choice is also gated by protocol: `UNVERIFIED_STREAM_PROTOCOLS` (`responses`, `bedrock`) stays non-streaming diff --git a/tests/test_llm_wait_bounds.py b/tests/test_llm_wait_bounds.py index 2333495..1bf46c3 100644 --- a/tests/test_llm_wait_bounds.py +++ b/tests/test_llm_wait_bounds.py @@ -395,7 +395,7 @@ def release(self): monkeypatch.setattr(call_module, "_llm_gate", Gate) checks = iter([[{"reason": "arguments are empty"}], []]) monkeypatch.setattr(call_module, "invalid_native_tool_calls", - lambda response, llm: next(checks, [])) + lambda response, llm, *_: next(checks, [])) async def stream_response(llm, messages, timeout, on_delta, **kwargs): read_timeouts.append(timeout) diff --git a/tests/test_streamed_tool_validation.py b/tests/test_streamed_tool_validation.py index 852690c..82f536f 100644 --- a/tests/test_streamed_tool_validation.py +++ b/tests/test_streamed_tool_validation.py @@ -6,9 +6,18 @@ from agent_core.llm import LLMResponse, StreamDelta from agent_core.loop_types import LoopConfig, LoopPolicy +from agent_core.runtime.loop._bind import bind_tools from agent_core.runtime.loop._call import call_llm from agent_core.runtime.loop.agent_loop import run_agent_loop -from agent_core.runtime.loop.tool_call_validation import invalid_native_tool_calls +from agent_core.runtime.loop.tool_call_validation import ( + ensure_tool_call_ids, + invalid_native_tool_calls, + validate_arguments, +) + +_PING_SCHEMA = {"type": "function", "function": { + "name": "ping", "parameters": {"type": "object", "properties": {}}, +}} def _call(arguments: str) -> dict[str, Any]: @@ -32,19 +41,56 @@ async def ainvoke(self, args: dict[str, Any]) -> str: return "saved" -@pytest.mark.parametrize("raw,expected", [ - ("", "invalid JSON"), - ("{", "invalid JSON"), - ("{}", "required property"), - ('{"value": 1}', "not of type 'string'"), +@pytest.mark.parametrize("raw,expected,mode", [ + ("", "required property", "structural"), + ("{", "invalid JSON", "structural"), + ("[]", "must be a JSON object", "structural"), + ("{}", "required property", "structural"), + ('{"value": 1}', "not of type 'string'", "strict"), ]) -def test_assembled_tool_arguments_are_checked(raw: str, expected: str) -> None: +def test_assembled_tool_arguments_are_checked(raw: str, expected: str, mode: str) -> None: llm = type("Bound", (), {"tools": [SaveTool().to_openai_schema()]})() - issues = invalid_native_tool_calls(LLMResponse(tool_calls=[_call(raw)]), llm) + issues = invalid_native_tool_calls(LLMResponse(tool_calls=[_call(raw)]), llm, mode) assert expected in issues[0]["reason"] assert issues[0]["raw_arguments"] == raw +def test_structural_mode_leaves_property_types_to_the_tool() -> None: + llm = type("Bound", (), {"tools": [SaveTool().to_openai_schema()]})() + response = LLMResponse(tool_calls=[_call('{"value": 1}')]) + assert invalid_native_tool_calls(response, llm) == [] + assert invalid_native_tool_calls(response, llm, "off") == [] + + +@pytest.mark.parametrize("raw", ["", " ", "{}"]) +def test_blank_arguments_are_an_empty_object_for_zero_arg_tools(raw: str) -> None: + llm = type("Bound", (), {"tools": [_PING_SCHEMA]})() + response = LLMResponse(tool_calls=[{ + "id": "tc1", "type": "function", "function": {"name": "ping", "arguments": raw}, + }]) + assert invalid_native_tool_calls(response, llm, "strict") == [] + + +def test_malformed_tool_schema_never_fails_the_call() -> None: + schema = {"type": "object", "properties": {"x": {"type": "any"}}} + assert validate_arguments({"x": 1}, schema) is None + llm = type("Bound", (), {"tools": [{"function": {"name": "odd", "parameters": schema}}]})() + response = LLMResponse(tool_calls=[{ + "id": "tc1", "type": "function", "function": {"name": "odd", "arguments": '{"x": 1}'}, + }]) + assert invalid_native_tool_calls(response, llm, "strict") == [] + + +def test_missing_native_ids_are_repaired_in_place() -> None: + response = LLMResponse(tool_calls=[ + {"id": "", "type": "function", "function": {"name": "save", "arguments": "{}"}}, + {"id": "keep", "type": "function", "function": {"name": "save", "arguments": "{}"}}, + ]) + assert ensure_tool_call_ids(response) == [0] + assert response.tool_calls[0]["id"].startswith("call_") + assert response.tool_calls[1]["id"] == "keep" + + def test_empty_object_is_valid_for_a_tool_without_required_fields() -> None: llm = type("Bound", (), {"tools": [{"function": { "name": "ping", "parameters": {"type": "object", "properties": {}} @@ -99,7 +145,7 @@ async def stream(self, messages, **kwargs): yield StreamDelta(finish_reason="tool_calls") client = Client() - response = await call_llm(client, [{"role": "user", "content": "save"}], + response = await call_llm(bind_tools(client, [SaveTool()]), [{"role": "user", "content": "save"}], timeout=10, max_retries=1, turn=1, stream=True) assert client.stream_calls == 2 assert response.tool_calls[0]["function"]["arguments"] == '{"value":"ok"}' @@ -136,3 +182,141 @@ async def stream(self, messages, **kwargs): assert client.stream_calls == 2 assert tool.calls == [] assert result.metadata["invalid_tool_calls"][0]["raw_arguments"] == "{}" + + +class PingTool: + name = "ping" + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def to_openai_schema(self) -> dict[str, Any]: + return _PING_SCHEMA + + async def ainvoke(self, args: dict[str, Any]) -> str: + self.calls.append(args) + return "pong" + + +class _OneToolCallChat: + model = "test" + + def __init__(self, call: dict[str, Any]) -> None: + self.call = call + self.chat_calls = 0 + + async def chat(self, messages, **kwargs): + self.chat_calls += 1 + if self.chat_calls == 1: + return LLMResponse(tool_calls=[self.call]) + return LLMResponse(content="done") + + +async def _run_once(llm: Any, tool: Any, **config: Any): + return await run_agent_loop( + system_prompt="system", user_message="start", llm=llm, tools=[tool], + config=LoopConfig(max_turns=2, max_llm_retries=1, + loop_policy=LoopPolicy(no_tool_behavior="stop"), **config), + ) + + +@pytest.mark.asyncio +async def test_zero_arg_tool_with_blank_arguments_runs_without_retry_on_stream() -> None: + tool = PingTool() + + class Client: + model = "test" + stream_calls = 0 + + async def stream(self, messages, **kwargs): + self.stream_calls += 1 + if self.stream_calls == 1: + yield StreamDelta(tool_call_deltas=[{ + "index": 0, "id": "tc1", "name": "ping", "arguments": "", + }]) + yield StreamDelta(finish_reason="tool_calls") + else: + yield StreamDelta(content="done") + yield StreamDelta(finish_reason="stop") + + client = Client() + await _run_once(client, tool, stream_transport=True) + assert tool.calls == [{}] + assert client.stream_calls == 2 # one per turn: no validation retry + + +@pytest.mark.asyncio +async def test_zero_arg_tool_with_blank_arguments_runs_on_chat() -> None: + tool = PingTool() + await _run_once(_OneToolCallChat({ + "id": "tc1", "type": "function", "function": {"name": "ping", "arguments": ""}, + }), tool) + assert tool.calls == [{}] + + +@pytest.mark.asyncio +async def test_type_mismatch_still_reaches_the_tool_unless_strict() -> None: + call = {"id": "tc1", "type": "function", "function": {"name": "save", "arguments": '{"value": 7}'}} + lenient = SaveTool() + await _run_once(_OneToolCallChat(call), lenient) + assert lenient.calls == [{"value": 7}] + + strict = SaveTool() + result = await _run_once(_OneToolCallChat(dict(call)), strict, tool_argument_validation="strict") + assert strict.calls == [] + assert "not of type 'string'" in result.metadata["invalid_tool_calls"][0]["reason"] + + +@pytest.mark.asyncio +async def test_text_mode_calls_are_not_blocked_by_schema_validation() -> None: + # Text-mode parameters are JSON-decoded, so a numeric-looking string + # parameter arrives as an int; strict mode must not reject it. + tool = SaveTool() + text_call = ( + "\n\n\n600519\n" + "\n\n" + ) + + class Client: + model = "test" + chat_calls = 0 + + async def chat(self, messages, **kwargs): + self.chat_calls += 1 + return LLMResponse(content=text_call if self.chat_calls == 1 else "done") + + await _run_once(Client(), tool, tool_argument_validation="strict") + assert tool.calls == [{"value": 600519}] + + +@pytest.mark.asyncio +async def test_native_call_without_id_gets_a_replayable_id() -> None: + tool = PingTool() + result = await _run_once(_OneToolCallChat({ + "type": "function", "function": {"name": "ping", "arguments": "{}"}, + }), tool) + assert tool.calls == [{}] + assistant = next(m for m in result.messages if m.get("role") == "assistant" and m.get("tool_calls")) + reply = next(m for m in result.messages if m.get("role") == "tool") + assert assistant["tool_calls"][0]["id"] + assert reply["tool_call_id"] == assistant["tool_calls"][0]["id"] + + +@pytest.mark.asyncio +async def test_observers_see_no_private_validation_keys() -> None: + seen: list[dict[str, Any]] = [] + + class Spy: + async def on_tool_call(self, ctx, tool_call): + seen.append(dict(tool_call)) + + tool = PingTool() + await run_agent_loop( + system_prompt="system", user_message="start", tools=[tool], + llm=_OneToolCallChat({"id": "tc1", "type": "function", + "function": {"name": "ping", "arguments": "{}"}}), + observers=[Spy()], + config=LoopConfig(max_turns=2, max_llm_retries=1, + loop_policy=LoopPolicy(no_tool_behavior="stop")), + ) + assert seen and not any(key.startswith("_") for call in seen for key in call)