From 4757b40f799208f99752b2ae1d89f01a4e5daa3e Mon Sep 17 00:00:00 2001 From: SAURAV SINGLA Date: Fri, 25 Sep 2026 13:19:48 +0530 Subject: [PATCH 01/11] harden runtime result serialization and idempotency metadata --- agentweave/runtime_types.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/agentweave/runtime_types.py b/agentweave/runtime_types.py index b0c30de..3ee263e 100644 --- a/agentweave/runtime_types.py +++ b/agentweave/runtime_types.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from dataclasses import dataclass, field from typing import Any, Mapping @@ -18,6 +19,7 @@ class ToolSpec: source: str | None = None model_name: str | None = None risk_level: str = "standard" + idempotency: str = "auto" permissions: frozenset[str] = frozenset() scopes: frozenset[str] = frozenset() roles: frozenset[str] = frozenset() @@ -76,13 +78,29 @@ class ToolResult: metadata: Mapping[str, Any] = field(default_factory=dict) raw: Any = field(default=None, compare=False, repr=False) + @staticmethod + def _canonical_json(value: Any) -> str: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + default=str, + ) + def model_content(self) -> str: value = self.structured_content if value is None: value = self.content if value is None and self.error: value = {"error": self.error} - return str(value if value is not None else "") + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, (Mapping, list, tuple, bool, int, float)): + return self._canonical_json(value) + return str(value) @dataclass(frozen=True) From a7766a7fb7caf29cc768c53fe1c158dfe8812c2b Mon Sep 17 00:00:00 2001 From: SAURAV SINGLA Date: Fri, 25 Sep 2026 13:21:19 +0530 Subject: [PATCH 02/11] add hardened runtime execution ledger and authoritative provenance --- agentweave/runtime_hardened.py | 416 +++++++++++++++++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 agentweave/runtime_hardened.py diff --git a/agentweave/runtime_hardened.py b/agentweave/runtime_hardened.py new file mode 100644 index 0000000..d3e3dba --- /dev/null +++ b/agentweave/runtime_hardened.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +import hashlib +import json +import time +from typing import Any, Mapping, Sequence + +from .runtime import AgentWeaveRuntime as BaseAgentWeaveRuntime +from .runtime import RoutingPreview +from .runtime_types import ( + ModelResponse, + RunContext, + RuntimeResult, + RuntimeTelemetry, + ToolCall, + ToolResult, + ToolSpec, +) + + +class AgentWeaveRuntime(BaseAgentWeaveRuntime): + """Canonical runtime with replay protection and authoritative result provenance. + + Replay protection is scoped to one ``run`` invocation and happens only after the + existing schema-validation and authorization gates. ``ToolSpec.idempotency`` accepts + ``auto``, ``none``, ``call_id`` and ``arguments``. ``auto`` uses argument-signature + protection for high/critical-risk tools and call-ID protection for other tools. + Only successful executions are cached, so failed calls remain eligible for recovery. + """ + + _IDEMPOTENCY_MODES = frozenset({"auto", "none", "call_id", "arguments"}) + + @classmethod + def _idempotency_mode(cls, tool: ToolSpec) -> str: + mode = str(tool.idempotency or "auto").strip().lower() + if mode not in cls._IDEMPOTENCY_MODES: + raise ValueError( + f"unsupported idempotency mode {tool.idempotency!r} for tool {tool.key!r}" + ) + if mode == "auto": + return ( + "arguments" + if str(tool.risk_level).lower() in {"high", "critical"} + else "call_id" + ) + return mode + + @staticmethod + def _argument_signature(call: ToolCall, tool: ToolSpec) -> str: + payload = json.dumps( + { + "tool_key": tool.key, + "arguments": dict(call.arguments), + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + default=str, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + @classmethod + def _replay_key(cls, call: ToolCall, tool: ToolSpec) -> tuple[str | None, str]: + mode = cls._idempotency_mode(tool) + if mode == "none": + return None, mode + if mode == "arguments": + return f"arguments:{cls._argument_signature(call, tool)}", mode + if call.id: + return f"call_id:{tool.key}:{call.id}", mode + # Normalized model responses normally synthesize an ID. Fall back to an + # argument signature rather than silently dropping replay protection. + return f"arguments:{cls._argument_signature(call, tool)}", "arguments" + + @staticmethod + def _authoritative_result( + *, + result: ToolResult, + call: ToolCall, + tool: ToolSpec, + ) -> ToolResult: + """Bind executor output to the identity that passed runtime authorization.""" + reported = { + "tool_call_id": result.tool_call_id, + "name": result.name, + "model_name": result.model_name, + "tool_key": result.tool_key, + } + authoritative = { + "tool_call_id": call.id, + "name": tool.name, + "model_name": tool.exposed_name, + "tool_key": tool.key, + "provider": tool.provider, + "source": tool.source, + } + metadata = dict(result.metadata) + metadata["agentweave_runtime"] = authoritative + if any( + ( + reported["tool_call_id"] != call.id, + reported["name"] != tool.name, + reported["model_name"] not in {None, tool.exposed_name}, + reported["tool_key"] not in {None, tool.key}, + ) + ): + metadata["agentweave_executor_reported_identity"] = reported + return ToolResult( + tool_call_id=call.id, + name=tool.name, + model_name=tool.exposed_name, + tool_key=tool.key, + success=result.success, + content=result.content, + structured_content=result.structured_content, + error=result.error, + metadata=metadata, + raw=result.raw, + ) + + @staticmethod + def _replayed_result( + *, + cached: ToolResult, + call: ToolCall, + tool: ToolSpec, + mode: str, + ) -> ToolResult: + metadata = dict(cached.metadata) + metadata["agentweave_runtime"] = { + "tool_call_id": call.id, + "name": tool.name, + "model_name": tool.exposed_name, + "tool_key": tool.key, + "provider": tool.provider, + "source": tool.source, + } + metadata["agentweave_replay"] = { + "reused": True, + "mode": mode, + "source_tool_call_id": cached.tool_call_id, + } + return ToolResult( + tool_call_id=call.id, + name=tool.name, + model_name=tool.exposed_name, + tool_key=tool.key, + success=True, + content=cached.content, + structured_content=cached.structured_content, + error=None, + metadata=metadata, + raw=cached.raw, + ) + + @staticmethod + def _finish( + *, + status: str, + response: ModelResponse | None, + tool_results: Sequence[ToolResult], + preview: RoutingPreview | None, + recovery_attempts: int, + provenance: Mapping[str, Any], + telemetry: RuntimeTelemetry, + ) -> RuntimeResult: + return RuntimeResult( + status=status, + response=response, + tool_results=tuple(tool_results), + selected_tools=tuple(tool.name for tool in (preview.selected if preview else ())), + routing_confidence=preview.confidence if preview else None, + routing_abstained=preview.abstained if preview else False, + recovery_attempts=recovery_attempts, + provenance=provenance, + telemetry=telemetry.as_dict(), + ) + + async def run( + self, + text: str, + *, + context: RunContext | None = None, + messages: Sequence[Mapping[str, Any]] | None = None, + model_kwargs: Mapping[str, Any] | None = None, + ) -> RuntimeResult: + ctx = context or RunContext() + telemetry = RuntimeTelemetry() + + started = time.perf_counter() + source = self._dedupe(list(await self.catalog.list_tools(ctx))) + telemetry.record( + "catalog", + (time.perf_counter() - started) * 1000.0, + metadata={"catalog_size": len(source)}, + ) + + active_source = list(source) + conversation = list(messages or [{"role": "user", "content": text}]) + tool_results: list[ToolResult] = [] + execution_ledger: dict[str, ToolResult] = {} + recovery_attempts = 0 + last_response: ModelResponse | None = None + last_preview: RoutingPreview | None = None + failed_keys: set[str] = set() + run_provenance: dict[str, Any] = {"turns": [], "replays": []} + + for turn in range(self.max_model_turns): + candidates = [tool for tool in active_source if tool.key not in failed_keys] + + started = time.perf_counter() + preview = await self.preview_route(text, context=ctx, tools=candidates) + telemetry.record( + "scope_route", + (time.perf_counter() - started) * 1000.0, + metadata={ + "turn": turn + 1, + "candidate_count": len(candidates), + "selected_count": len(preview.selected), + "confidence": preview.confidence, + "abstained": preview.abstained, + }, + ) + last_preview = preview + + started = time.perf_counter() + response = await self._complete(conversation, preview.selected, model_kwargs) + telemetry.record( + "model", + (time.perf_counter() - started) * 1000.0, + metadata={ + "turn": turn + 1, + "tool_call_count": len(response.tool_calls), + "finish_reason": response.finish_reason, + "usage": dict(response.usage), + }, + ) + last_response = response + run_provenance["turns"].append( + { + "turn": turn + 1, + "selected_tools": [ + {"key": tool.key, "name": tool.name, "model_name": tool.exposed_name} + for tool in preview.selected + ], + "routing_confidence": preview.confidence, + "routing_abstained": preview.abstained, + "routing": dict(preview.provenance), + "tool_calls": [call.name for call in response.tool_calls], + } + ) + + if not response.tool_calls: + return self._finish( + status="completed", + response=response, + tool_results=tool_results, + preview=preview, + recovery_attempts=recovery_attempts, + provenance=run_provenance, + telemetry=telemetry, + ) + + conversation.append(self._assistant_tool_message(response)) + turn_failed = False + + for raw_call in response.tool_calls: + call, tool = self._resolve_call(raw_call, preview.selected) + if tool is None: + result = ToolResult( + tool_call_id=raw_call.id, + name=raw_call.name, + model_name=raw_call.name, + success=False, + error="authorization-denied:tool-not-model-visible", + ) + telemetry.record( + "authorization", + 0.0, + outcome="denied", + metadata={"model_name": raw_call.name, "reason": "tool-not-model-visible"}, + ) + tool_results.append(result) + conversation.append(self._tool_message(result)) + turn_failed = True + continue + + started = time.perf_counter() + validation_error = self._validate_arguments(call, tool) + telemetry.record( + "schema_validation", + (time.perf_counter() - started) * 1000.0, + outcome="denied" if validation_error else "ok", + metadata={"tool_key": tool.key, "tool": tool.name}, + ) + if validation_error: + result = ToolResult( + tool_call_id=call.id, + name=tool.name, + model_name=tool.exposed_name, + tool_key=tool.key, + success=False, + error=f"invalid-tool-arguments:{validation_error}", + ) + tool_results.append(result) + conversation.append(self._tool_message(result)) + turn_failed = True + continue + + started = time.perf_counter() + decision = await self._authorize(call, tool, ctx, preview.selected) + telemetry.record( + "authorization", + (time.perf_counter() - started) * 1000.0, + outcome="ok" if decision.allowed else "denied", + metadata={ + "tool_key": tool.key, + "tool": tool.name, + "reason": decision.reason_code, + }, + ) + if not decision.allowed: + result = ToolResult( + tool_call_id=call.id, + name=tool.name, + model_name=tool.exposed_name, + tool_key=tool.key, + success=False, + error=f"authorization-denied:{decision.reason_code}", + ) + failed_keys.add(tool.key) + else: + replay_key, replay_mode = self._replay_key(call, tool) + cached = execution_ledger.get(replay_key) if replay_key is not None else None + if cached is not None: + result = self._replayed_result( + cached=cached, + call=call, + tool=tool, + mode=replay_mode, + ) + telemetry.record( + "replay", + 0.0, + outcome="reused", + metadata={ + "tool_key": tool.key, + "tool": tool.name, + "mode": replay_mode, + "source_tool_call_id": cached.tool_call_id, + }, + ) + run_provenance["replays"].append( + { + "turn": turn + 1, + "tool_key": tool.key, + "tool": tool.name, + "model_name": tool.exposed_name, + "mode": replay_mode, + "source_tool_call_id": cached.tool_call_id, + "replayed_tool_call_id": call.id, + } + ) + else: + started = time.perf_counter() + executor_result = await self.executor.execute(call, ctx) + result = self._authoritative_result( + result=executor_result, + call=call, + tool=tool, + ) + telemetry.record( + "execution", + (time.perf_counter() - started) * 1000.0, + outcome="ok" if result.success else "error", + metadata={"tool_key": tool.key, "tool": tool.name}, + ) + if result.success and replay_key is not None: + execution_ledger[replay_key] = result + if not result.success: + failed_keys.add(tool.key) + + tool_results.append(result) + conversation.append(self._tool_message(result)) + if not result.success: + turn_failed = True + + if turn_failed: + recovery_attempts += 1 + telemetry.record( + "recovery", + 0.0, + outcome="retry" if recovery_attempts <= self.max_recovery_attempts else "exhausted", + metadata={"attempt": recovery_attempts}, + ) + if recovery_attempts > self.max_recovery_attempts: + return self._finish( + status="needs-review", + response=response, + tool_results=tool_results, + preview=preview, + recovery_attempts=recovery_attempts, + provenance=run_provenance, + telemetry=telemetry, + ) + continue + + return self._finish( + status="max-turns", + response=last_response, + tool_results=tool_results, + preview=last_preview, + recovery_attempts=recovery_attempts, + provenance=run_provenance, + telemetry=telemetry, + ) From ccafa080aa34394bb798842362cfd8d7a97bf4bc Mon Sep 17 00:00:00 2001 From: SAURAV SINGLA Date: Fri, 25 Sep 2026 13:21:36 +0530 Subject: [PATCH 03/11] route applications through hardened runtime --- agentweave/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentweave/application.py b/agentweave/application.py index 5ea9336..b869077 100644 --- a/agentweave/application.py +++ b/agentweave/application.py @@ -4,8 +4,8 @@ from typing import Any, Mapping from .config import RuntimeConfig, RuntimeFactory +from .runtime_hardened import AgentWeaveRuntime from .plugins import PluginManager -from .runtime import AgentWeaveRuntime from .safe_http import SafeHttpTransport From ad23b5858e2adc465e118e200a3861b8335f6a61 Mon Sep 17 00:00:00 2001 From: SAURAV SINGLA Date: Fri, 25 Sep 2026 13:21:56 +0530 Subject: [PATCH 04/11] export hardened runtime as canonical API --- agentweave/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentweave/__init__.py b/agentweave/__init__.py index 6d526fb..2b3697f 100644 --- a/agentweave/__init__.py +++ b/agentweave/__init__.py @@ -28,7 +28,6 @@ PluginManager, ) from .runtime import ( - AgentWeaveRuntime, CallableExecutor, CatalogProvider, DefaultScopePolicy, @@ -41,6 +40,7 @@ ToolSearchProvider, normalize_model_response, ) +from .runtime_hardened import AgentWeaveRuntime from .runtime_types import ( ModelResponse, RunContext, From 6f77648cf8afbf83164faad97f6ebbbd671aabd3 Mon Sep 17 00:00:00 2001 From: SAURAV SINGLA Date: Fri, 25 Sep 2026 13:22:29 +0530 Subject: [PATCH 05/11] build configured runtimes with hardening enabled --- agentweave/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentweave/config.py b/agentweave/config.py index 9e451ff..1f29f79 100644 --- a/agentweave/config.py +++ b/agentweave/config.py @@ -7,7 +7,7 @@ from typing import Any, Mapping from .plugins import ComponentRegistry -from .runtime import AgentWeaveRuntime +from .runtime_hardened import AgentWeaveRuntime from .safe_http import SafeHttpTransport From f558a2bf10160a3d4fca64fadcbfc59e85f7f44a Mon Sep 17 00:00:00 2001 From: SAURAV SINGLA Date: Fri, 25 Sep 2026 13:22:53 +0530 Subject: [PATCH 06/11] use hardened runtime in composition factories --- agentweave/composition.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/agentweave/composition.py b/agentweave/composition.py index 094f82a..6612def 100644 --- a/agentweave/composition.py +++ b/agentweave/composition.py @@ -4,7 +4,8 @@ import re from typing import Any, Mapping, Sequence -from .runtime import AgentWeaveRuntime, CatalogProvider, Executor +from .runtime import CatalogProvider, Executor +from .runtime_hardened import AgentWeaveRuntime from .runtime_types import RunContext, ToolCall, ToolResult, ToolSpec From 219bf7479dd157ddce090f2b7f16bde62b04f5ea Mon Sep 17 00:00:00 2001 From: SAURAV SINGLA Date: Fri, 25 Sep 2026 13:23:28 +0530 Subject: [PATCH 07/11] pin security scanner for deterministic CI --- constraints/ci.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/constraints/ci.txt b/constraints/ci.txt index 3b785cf..68a10c6 100644 --- a/constraints/ci.txt +++ b/constraints/ci.txt @@ -7,3 +7,4 @@ pytest==9.1.1 pytest-asyncio==1.4.0 ruff==0.16.8 mypy==2.3.1 +bandit==1.9.4 From 79ad578de04a85f6e29109fb3e1f256288a5f3b5 Mon Sep 17 00:00:00 2001 From: SAURAV SINGLA Date: Fri, 25 Sep 2026 13:23:49 +0530 Subject: [PATCH 08/11] add security scanner to development tooling --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cd63c5b..44a4fac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,7 @@ langgraph = ["langgraph>=1,<2"] autogen = ["autogen-agentchat>=0.7,<1", "autogen-core>=0.7,<1"] all-integrations = ["mcp>=2,<3", "langgraph>=1,<2", "autogen-agentchat>=0.7,<1", "autogen-core>=0.7,<1"] all = ["PyJWT[crypto]>=2.9", "cryptography>=42", "fastapi>=0.115", "starlette>=0.41", "uvicorn>=0.30", "a2a-sdk>=1.1.4,<2", "sse-starlette>=2.1", "grpcio>=1.70", "protobuf>=5.28", "pybind11>=2.13", "psycopg[binary]>=3.2", "boto3>=1.35", "opentelemetry-api>=1.27", "opentelemetry-sdk>=1.27", "opentelemetry-exporter-otlp>=1.27", "psutil>=6", "PyYAML>=6", "rdflib>=7.1", "mcp>=2,<3", "langgraph>=1,<2", "autogen-agentchat>=0.7,<1", "autogen-core>=0.7,<1"] -dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.16.8,<0.17", "mypy>=2.3.1,<2.4"] +dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.16.8,<0.17", "mypy>=2.3.1,<2.4", "bandit>=1.9,<2"] [project.scripts] agentweave = "agentweave.sdk:main" From 62eda954b61684033336767a50b2e44ed7208963 Mon Sep 17 00:00:00 2001 From: SAURAV SINGLA Date: Fri, 25 Sep 2026 13:24:18 +0530 Subject: [PATCH 09/11] strengthen runtime quality and security CI gates --- .github/workflows/ci.yml | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f91c039..9320e65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,8 +77,33 @@ jobs: - run: pip install -c constraints/ci.txt -e .[dev] - name: Ruff correctness gate run: ruff check agentweave agentweave_byom agentweave_security tests - - name: Mypy typed-surface gate - run: mypy agentweave/runtime_types.py agentweave_security/authorization.py + - name: Ruff security-sensitive bug-pattern gate + run: >- + ruff check + agentweave/runtime.py + agentweave/runtime_hardened.py + agentweave/runtime_types.py + agentweave/safe_http.py + agentweave_security/authorization.py + agentweave_security/scope_filter.py + --select E9,F63,F7,F82,B006,B008,B023 + - name: Mypy security-sensitive typed-surface gate + run: >- + mypy + agentweave/runtime_types.py + agentweave/runtime_hardened.py + agentweave/safe_http.py + agentweave_security/authorization.py + agentweave_security/scope_filter.py + - name: Bandit security gate + run: >- + bandit -q -ll + agentweave/runtime.py + agentweave/runtime_hardened.py + agentweave/runtime_types.py + agentweave/safe_http.py + agentweave_security/authorization.py + agentweave_security/scope_filter.py reproducible-python: runs-on: ubuntu-latest @@ -93,4 +118,8 @@ jobs: python-version: ${{ matrix.python-version }} - run: pip install -c constraints/ci.txt -e .[dev] - run: python -m pip check - - run: pytest -q tests/test_runtime_hardening_v07.py tests/test_security_hardening.py + - run: >- + pytest -q + tests/test_runtime_hardening_v07.py + tests/test_runtime_replay_provenance.py + tests/test_security_hardening.py From f76d46b313e49f75db5654734506e3a10b3afca5 Mon Sep 17 00:00:00 2001 From: SAURAV SINGLA Date: Fri, 25 Sep 2026 13:24:45 +0530 Subject: [PATCH 10/11] test replay protection provenance and canonical tool output --- tests/test_runtime_replay_provenance.py | 243 ++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 tests/test_runtime_replay_provenance.py diff --git a/tests/test_runtime_replay_provenance.py b/tests/test_runtime_replay_provenance.py new file mode 100644 index 0000000..e34ec46 --- /dev/null +++ b/tests/test_runtime_replay_provenance.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import json + +import pytest + +from agentweave import AgentWeaveRuntime, RunContext, StaticToolCatalog, ToolResult, ToolSpec +from agentweave_byom import ToolRoutingResult + + +class AllRouter: + version = "test-all" + + async def aroute(self, text, tools, *, max_tools=8): + return ToolRoutingResult( + selected=list(tools[:max_tools]), + filtered=list(tools[max_tools:]), + provenance={"router": self.version}, + confidence=1.0, + abstained=False, + ) + + +class RecoveryReplayModel: + def __init__(self): + self.turn = 0 + + async def complete(self, messages, *, tools=None, **kwargs): + self.turn += 1 + if self.turn == 1: + calls = [ + { + "id": "transfer-1", + "type": "function", + "function": {"name": "transfer", "arguments": '{"amount":100}'}, + }, + { + "id": "fail-1", + "type": "function", + "function": {"name": "unstable", "arguments": "{}"}, + }, + ] + return { + "choices": [ + { + "message": {"content": None, "tool_calls": calls}, + "finish_reason": "tool_calls", + } + ] + } + if self.turn == 2: + return { + "choices": [ + { + "message": { + "content": None, + "tool_calls": [ + { + "id": "transfer-2", + "type": "function", + "function": { + "name": "transfer", + "arguments": '{"amount":100}', + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ] + } + return { + "choices": [ + { + "message": {"content": "done", "tool_calls": []}, + "finish_reason": "stop", + } + ] + } + + +class SpoofingExecutor: + def __init__(self): + self.transfer_calls = 0 + self.unstable_calls = 0 + + async def execute(self, call, context): + if call.name == "unstable": + self.unstable_calls += 1 + return ToolResult( + tool_call_id="executor-fail", + name="executor-unstable", + model_name="wrong-visible-name", + tool_key="wrong:key", + success=False, + error="simulated-failure", + ) + self.transfer_calls += 1 + return ToolResult( + tool_call_id="executor-transfer", + name="executor-transfer-name", + model_name="wrong-visible-name", + tool_key="wrong:key", + success=True, + structured_content={"ok": True, "amount": call.arguments["amount"]}, + metadata={"executor": "spoofing-test"}, + ) + + +@pytest.mark.asyncio +async def test_high_risk_argument_replay_is_reused_and_executor_runs_once(): + executor = SpoofingExecutor() + runtime = AgentWeaveRuntime( + model=RecoveryReplayModel(), + catalog=StaticToolCatalog( + [ + ToolSpec( + id="payments:prod:transfer", + name="transfer", + risk_level="high", + input_schema={ + "type": "object", + "properties": {"amount": {"type": "number"}}, + "required": ["amount"], + "additionalProperties": False, + }, + ), + ToolSpec(id="test:unstable", name="unstable"), + ] + ), + executor=executor, + router=AllRouter(), + max_tools=2, + max_model_turns=4, + max_recovery_attempts=2, + ) + + result = await runtime.run( + "transfer 100 and verify the unstable step", + context=RunContext(human_approved=True), + ) + + assert result.status == "completed" + assert executor.transfer_calls == 1 + assert executor.unstable_calls == 1 + transfer_results = [item for item in result.tool_results if item.name == "transfer"] + assert len(transfer_results) == 2 + assert transfer_results[0].tool_call_id == "transfer-1" + assert transfer_results[1].tool_call_id == "transfer-2" + assert transfer_results[0].tool_key == "payments:prod:transfer" + assert transfer_results[1].tool_key == "payments:prod:transfer" + assert transfer_results[1].metadata["agentweave_replay"]["reused"] is True + assert transfer_results[1].metadata["agentweave_replay"]["mode"] == "arguments" + assert result.provenance["replays"][0]["source_tool_call_id"] == "transfer-1" + assert any( + event["stage"] == "replay" and event["outcome"] == "reused" + for event in result.telemetry["events"] + ) + + +@pytest.mark.asyncio +async def test_executor_identity_cannot_override_authorized_runtime_identity(): + executor = SpoofingExecutor() + + class OneShotModel: + def __init__(self): + self.called = False + + async def complete(self, messages, *, tools=None, **kwargs): + if self.called: + return {"choices": [{"message": {"content": "done", "tool_calls": []}}]} + self.called = True + return { + "choices": [ + { + "message": { + "content": None, + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": { + "name": "transfer", + "arguments": '{"amount":7}', + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ] + } + + runtime = AgentWeaveRuntime( + model=OneShotModel(), + catalog=StaticToolCatalog( + [ + ToolSpec( + id="payments:prod:transfer", + name="transfer", + input_schema={ + "type": "object", + "properties": {"amount": {"type": "number"}}, + "required": ["amount"], + }, + ) + ] + ), + executor=executor, + router=AllRouter(), + ) + + result = await runtime.run("transfer 7") + tool_result = result.tool_results[0] + assert tool_result.tool_call_id == "call-1" + assert tool_result.name == "transfer" + assert tool_result.model_name == "transfer" + assert tool_result.tool_key == "payments:prod:transfer" + assert tool_result.metadata["agentweave_runtime"]["tool_key"] == "payments:prod:transfer" + assert tool_result.metadata["agentweave_executor_reported_identity"] == { + "tool_call_id": "executor-transfer", + "name": "executor-transfer-name", + "model_name": "wrong-visible-name", + "tool_key": "wrong:key", + } + + +def test_structured_tool_output_is_canonical_json(): + result = ToolResult( + tool_call_id="c1", + name="lookup", + success=True, + structured_content={"z": [2, 1], "a": True}, + ) + rendered = result.model_content() + assert rendered == '{"a":true,"z":[2,1]}' + assert json.loads(rendered) == {"a": True, "z": [2, 1]} + + +def test_invalid_idempotency_mode_fails_closed_before_execution_keying(): + tool = ToolSpec(name="write", idempotency="sometimes") + with pytest.raises(ValueError, match="unsupported idempotency mode"): + AgentWeaveRuntime._idempotency_mode(tool) From ee740669e9f0e67247de84b9529e34c992c3f532 Mon Sep 17 00:00:00 2001 From: SAURAV SINGLA Date: Fri, 25 Sep 2026 13:25:32 +0530 Subject: [PATCH 11/11] preserve ToolSpec positional compatibility --- agentweave/runtime_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentweave/runtime_types.py b/agentweave/runtime_types.py index 3ee263e..729c0ee 100644 --- a/agentweave/runtime_types.py +++ b/agentweave/runtime_types.py @@ -19,13 +19,13 @@ class ToolSpec: source: str | None = None model_name: str | None = None risk_level: str = "standard" - idempotency: str = "auto" permissions: frozenset[str] = frozenset() scopes: frozenset[str] = frozenset() roles: frozenset[str] = frozenset() tenants: frozenset[str] = frozenset() environments: frozenset[str] = frozenset() metadata: Mapping[str, Any] = field(default_factory=dict) + idempotency: str = "auto" native: Any = field(default=None, compare=False, repr=False) @property