diff --git a/docs/contracts/README.md b/docs/contracts/README.md index 6e83f77..b050d22 100644 --- a/docs/contracts/README.md +++ b/docs/contracts/README.md @@ -32,6 +32,8 @@ The execution engine owns run execution and talks only to the control plane and intersects `platform_functions`, `allowed_tools`, and `tool_specs`. The engine maps the alias back to the canonical control-plane ID and sends the original model call ID; missing, duplicate, and invalid mappings fail closed. + The `acornops_fetch` alias for `http.fetch.get` uses this same platform-function + route; execution-engine does not make the external HTTP request itself. Provider-native `web_search` remains the only declaration sent through `native_tools`, while target and MCP tools retain their existing route. - Execution-engine never calls target agents, management-console, or external MCP servers directly. diff --git a/docs/contracts/manifest.json b/docs/contracts/manifest.json index b22e99f..5e1dd0a 100644 --- a/docs/contracts/manifest.json +++ b/docs/contracts/manifest.json @@ -55,6 +55,7 @@ "toolCallCompletedArtifactFields": ["id", "expires_at", "sha256", "uncompressed_bytes", "compressed_bytes", "content_type"], "toolCallCompletedResultMaxBytes": 12288, "platformNativeToolRouting": "A platform function is exposed under its provider-safe model_alias only when that alias intersects bootstrap platform_functions, allowed_tools, and tool_specs. Execution routes the alias back to the canonical control-plane ID with the stable tool-call ID. Missing, duplicate, or invalid mappings fail closed. native_tools and gateway JWT allowed_native_tools contain provider-native tools only; target MCP tools remain gateway/target-adapter owned.", + "fetchRouting": "http.fetch.get is exposed as acornops_fetch and dispatches through the existing platform-function client to the control plane; execution-engine never performs the external HTTP request. The platform-function read deadline exceeds Fetch's control-plane-owned 15-second deadline so timeout outcomes remain producer-authoritative. Durable tool-start events redact the URL, and completion or fallback events retain only status, content type, response size, retrieval time, and the untrusted-data marker, never the URL, query, or response body.", "commitStatusValues": ["completed", "failed", "cancelled"], "commitAssistantMessageFields": ["content", "format"], "commitUsageFields": ["input_tokens", "output_tokens", "tool_calls", "reasoning_tokens?"], diff --git a/execution_engine/orchestrator_client.py b/execution_engine/orchestrator_client.py index 5b7718b..7ae5c40 100644 --- a/execution_engine/orchestrator_client.py +++ b/execution_engine/orchestrator_client.py @@ -33,6 +33,7 @@ ) INTERNAL_CONTROL_PLANE_PREFIX = "/internal/v1" +PLATFORM_NATIVE_TOOL_READ_TIMEOUT_SECONDS = 20.0 def _retryable_orchestrator_error(exc: BaseException) -> bool: @@ -244,6 +245,12 @@ async def call_platform_native_tool( response = await self.client.post( url, json={"toolCallId": call_id, "arguments": arguments}, + timeout=httpx.Timeout( + connect=5.0, + read=PLATFORM_NATIVE_TOOL_READ_TIMEOUT_SECONDS, + write=10.0, + pool=5.0, + ), ) response.raise_for_status() orchestrator_requests_total.labels(endpoint="platform_native_tool", result="success").inc() diff --git a/execution_engine/worker.py b/execution_engine/worker.py index 5685a88..c39e287 100644 --- a/execution_engine/worker.py +++ b/execution_engine/worker.py @@ -36,7 +36,12 @@ start_event_manager, write_result_outcome_unknown, ) -from execution_engine.worker_tool_artifacts import persist_tool_result_artifact, tool_result_event_payload +from execution_engine.worker_tool_artifacts import ( + persist_tool_result_artifact, + tool_call_event_arguments, + tool_result_event_payload, + tool_result_event_summary, +) from execution_engine.worker_tool_authority import build_runtime_tool_client, provider_native_tools from execution_engine.worker_tool_sanitizer import sanitize_tool_spec_for_llm @@ -304,7 +309,7 @@ def finish_cancelled_run() -> None: emit_event("tool_call_started", { "call_id": pending_call_id, "tool": pending_tool_name, - "arguments": pending_arguments, + "arguments": tool_call_event_arguments(pending_tool_name, pending_arguments), }) tool_result = await tool_client.call_tool( pending_tool_name, @@ -466,7 +471,7 @@ async def load_skill_context(skill_ref: str) -> dict[str, object]: emit_event("tool_call_started", { "call_id": chunk["call_id"], "tool": chunk["tool"], - "arguments": chunk["arguments"] + "arguments": tool_call_event_arguments(chunk["tool"], chunk["arguments"]), }) elif chunk["type"] == "tool_result": artifact, artifact_unavailable = await persist_tool_result_artifact( @@ -475,7 +480,7 @@ async def load_skill_context(skill_ref: str) -> dict[str, object]: tool_result_events.append( { "tool": chunk["tool"], - "result": chunk["result"], + "result": tool_result_event_summary(chunk["tool"], chunk["result"]), "is_error": bool(chunk["is_error"]), } ) diff --git a/execution_engine/worker_tool_artifacts.py b/execution_engine/worker_tool_artifacts.py index 06dc41d..07655b6 100644 --- a/execution_engine/worker_tool_artifacts.py +++ b/execution_engine/worker_tool_artifacts.py @@ -14,6 +14,32 @@ UUID_V4_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", re.I) SHA256_RE = re.compile(r"^[0-9a-f]{64}$") RFC3339_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$") +FETCH_MODEL_ALIAS = "acornops_fetch" + + +def tool_call_event_arguments(tool_name: object, arguments: object) -> object: + """Remove Fetch URL paths and queries from durable tool-call events.""" + if str(tool_name) == FETCH_MODEL_ALIAS: + return {"url": "[redacted]"} + return arguments + + +def tool_result_event_summary(tool_name: object, supplied_result: object) -> object: + """Remove Fetch URLs and response bodies from durable events and fallbacks.""" + if str(tool_name) != FETCH_MODEL_ALIAS: + return supplied_result + structured = supplied_result.get("structuredContent") if isinstance(supplied_result, dict) else None + structured = structured if isinstance(structured, dict) else {} + return { + "status": structured.get("status"), + "contentType": structured.get("contentType"), + "responseSizeBytes": structured.get("responseSizeBytes"), + "retrievedAt": structured.get("retrievedAt"), + "untrustedExternalData": True, + } if structured else { + "code": supplied_result.get("code") if isinstance(supplied_result, dict) else None, + "untrustedExternalData": True, + } def _valid_artifact_metadata(value: Any) -> bool: @@ -69,7 +95,7 @@ def tool_result_event_payload( chunk: dict[str, Any], artifact: dict[str, Any] | None, artifact_unavailable: bool ) -> dict[str, Any]: """Build the compact-only durable tool completion event.""" - supplied_result = chunk.get("result") + supplied_result = tool_result_event_summary(chunk.get("tool"), chunk.get("result")) result = compact_tool_context(supplied_result) supplied_meta = chunk.get("context_meta") meta = supplied_meta if isinstance(supplied_meta, dict) else {} diff --git a/tests/test_supporting_components.py b/tests/test_supporting_components.py index 77e2699..2374d06 100644 --- a/tests/test_supporting_components.py +++ b/tests/test_supporting_components.py @@ -20,7 +20,12 @@ from execution_engine.agent.tools import GatewayToolClient, PlatformToolClient, ToolClientStub from execution_engine.gateway_client import GatewayLlmClient from execution_engine.readiness import DependencyStatus -from execution_engine.worker_tool_artifacts import persist_tool_result_artifact, tool_result_event_payload +from execution_engine.worker_tool_artifacts import ( + persist_tool_result_artifact, + tool_call_event_arguments, + tool_result_event_payload, + tool_result_event_summary, +) from execution_engine.worker_tool_authority import ( build_authorized_tool_routing, platform_function_mappings, @@ -62,15 +67,20 @@ def test_tool_routing_requires_an_exact_authorized_reference(): def test_platform_functions_require_all_snapshot_authorities(): assert platform_function_mappings( - ["acornops_generate_pdf_report", "web_search"], + ["acornops_fetch", "acornops_generate_pdf_report", "web_search"], [ + {"id": "http.fetch.get", "model_alias": "acornops_fetch"}, {"id": "reports.pdf.generate", "model_alias": "acornops_generate_pdf_report"}, ], [ + {"name": "acornops_fetch", "input_schema": {"type": "object"}}, {"name": "acornops_generate_pdf_report", "input_schema": {"type": "object"}}, {"name": "web_search"}, ], - ) == {"acornops_generate_pdf_report": "reports.pdf.generate"} + ) == { + "acornops_fetch": "http.fetch.get", + "acornops_generate_pdf_report": "reports.pdf.generate", + } @pytest.mark.parametrize( @@ -164,6 +174,34 @@ async def test_platform_tool_client_calls_control_plane_with_stable_call_id(): assert result["full_result"]["structuredContent"]["reportId"] == "report-1" +@pytest.mark.asyncio +async def test_fetch_uses_the_existing_platform_function_client(): + orchestrator = MagicMock() + orchestrator.call_platform_native_tool = AsyncMock(return_value={ + "content": [{"type": "text", "text": "{\"status\": 200}"}], + "structuredContent": {"status": 200, "data": {"ok": True}}, + "isError": False, + }) + client = PlatformToolClient( + ToolClientStub(), orchestrator, "run-1", + {"acornops_fetch": "http.fetch.get"}, + ) + + result = await client.call_tool( + "acornops_fetch", + {"url": "https://status.example.com/api/health"}, + call_id="call-fetch-1", + ) + + orchestrator.call_platform_native_tool.assert_awaited_once_with( + "run-1", + "http.fetch.get", + {"url": "https://status.example.com/api/health"}, + call_id="call-fetch-1", + ) + assert result["is_error"] is False + + @pytest.mark.asyncio async def test_platform_tool_client_requires_call_id(): client = PlatformToolClient( @@ -1096,6 +1134,49 @@ async def test_artifact_failure_keeps_full_result_out_of_durable_event(): assert "must-not-enter-events" not in str(payload) +def test_fetch_event_omits_url_query_and_response_body(): + chunk = { + "call_id": "call-fetch-1", + "tool": "acornops_fetch", + "result": { + "content": [{"type": "text", "text": "secret response body"}], + "structuredContent": { + "url": "https://api.example.com/search?secret=query-value", + "status": 200, + "contentType": "application/json", + "data": {"secret": "response body"}, + "responseSizeBytes": 42, + "retrievedAt": "2026-07-24T00:00:00.000Z", + }, + "isError": False, + }, + "full_result": {"secret": "artifact body"}, + "context_meta": {"context_bytes": 200}, + "artifact_eligible": False, + "is_error": False, + } + + payload = tool_result_event_payload(chunk, None, False) + + assert payload["result"] == { + "status": 200, + "contentType": "application/json", + "responseSizeBytes": 42, + "retrievedAt": "2026-07-24T00:00:00.000Z", + "untrustedExternalData": True, + } + assert "query-value" not in str(payload) + assert "response body" not in str(payload) + + assert tool_call_event_arguments( + "acornops_fetch", + {"url": "https://api.example.com/search?secret=query-value"}, + ) == {"url": "[redacted]"} + fallback_result = tool_result_event_summary("acornops_fetch", chunk["result"]) + assert "query-value" not in str(fallback_result) + assert "response body" not in str(fallback_result) + + @pytest.mark.asyncio async def test_invalid_artifact_receipt_is_reported_as_unavailable(): orchestrator = MagicMock() diff --git a/tests/test_unit.py b/tests/test_unit.py index 16ad59d..12da13b 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -622,6 +622,30 @@ async def handler(request: httpx.Request) -> httpx.Response: await client.close() +@pytest.mark.asyncio +async def test_orchestrator_client_allows_platform_native_tool_deadline_to_finish(): + async def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/internal/v1/runs/r1/native-tools/http.fetch.get/call" + assert request.extensions["timeout"]["read"] == 20.0 + return httpx.Response(200, json={"structuredContent": {"status": 200}}) + + client = OrchestratorClient() + await client.close() + client.base_url = "http://orchestrator" + client.client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + try: + result = await client.call_platform_native_tool( + "r1", + "http.fetch.get", + {"url": "https://status.example.com/health"}, + call_id="call-1", + ) + assert result["structuredContent"]["status"] == 200 + finally: + await client.close() + + @pytest.mark.asyncio async def test_orchestrator_client_encodes_skill_snapshot_path_params(): async def handler(request: httpx.Request) -> httpx.Response: