From 0371eebfde6acaba61f869d97933579bd85df064 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 1 Aug 2026 00:18:27 +0200 Subject: [PATCH 1/4] feat(sdk): attachment block ingress, model capabilities, and the delivery chain The Vercel adapter maps a file part whose providerMetadata.agenta carries a canonical-UUID attachmentId (and optionally size, since FileUIPart has no top-level size field) into the neutral attachment block; the mapping is ingress-only, because the neutral block carries no URL to rebuild a part from. The resolved connection puts the model's input modalities on the wire as modelCapabilities, sourced from the catalog at the resolver boundary; a lookup miss omits the field so the runner reads unknown, and bare dated Claude ids fall back to the same sourced fact in the Pi catalog. Run failures raise a typed AgentRunFailed with a stable failure_code and sanitized message; the Vercel stream keeps the error frame at exactly {type, errorText} (the pinned AI SDK validates it strictly) and carries the code in a preceding data-agent-error part; attachment_delivery events project as data-attachment-delivery in both stream twins. A new shared golden pins the attachment request shape in both contract suites; the two existing goldens are byte-identical. Claude-Session: https://claude.ai/code/session_01A1XQVjHPYJgVBHWSNUphtx --- sdks/python/agenta/sdk/agents/__init__.py | 2 + .../sdk/agents/adapters/vercel/messages.py | 51 ++++++- .../sdk/agents/adapters/vercel/stream.py | 59 +++++++- .../agenta/sdk/agents/connections/models.py | 5 +- .../agenta/sdk/agents/connections/resolver.py | 23 ++- sdks/python/agenta/sdk/agents/dtos.py | 19 ++- .../python/agenta/sdk/agents/model_catalog.py | 36 ++++- .../agenta/sdk/agents/platform/connections.py | 15 +- sdks/python/agenta/sdk/agents/utils/wire.py | 9 +- sdks/python/agenta/sdk/agents/wire_models.py | 12 ++ .../test_vercel_messages_roundtrip.py | 23 +++ .../test_vercel_stream_conformance.py | 26 ++++ .../test_vercel_stream_multimodality.py | 140 ++++++++++++++++++ .../agents/connections/test_model_catalog.py | 36 +++++ .../unit/agents/connections/test_models.py | 14 ++ .../unit/agents/connections/test_resolver.py | 12 ++ .../agents/golden/run_request.attachment.json | 43 ++++++ .../agents/platform/test_connections_http.py | 4 + .../unit/agents/test_dtos_content_blocks.py | 18 +++ .../unit/agents/test_result_multimodality.py | 37 +++++ .../pytest/unit/agents/test_ui_messages.py | 94 ++++++++++++ .../pytest/unit/agents/test_wire_models.py | 2 + .../runner/tests/unit/wire-contract.test.ts | 23 ++- 23 files changed, 677 insertions(+), 26 deletions(-) create mode 100644 sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_multimodality.py create mode 100644 sdks/python/oss/tests/pytest/unit/agents/golden/run_request.attachment.json create mode 100644 sdks/python/oss/tests/pytest/unit/agents/test_result_multimodality.py diff --git a/sdks/python/agenta/sdk/agents/__init__.py b/sdks/python/agenta/sdk/agents/__init__.py index e9cc5a41f3..d82d7089d1 100644 --- a/sdks/python/agenta/sdk/agents/__init__.py +++ b/sdks/python/agenta/sdk/agents/__init__.py @@ -83,6 +83,7 @@ to_messages, ) from .errors import ( + AgentRunFailed, AgentRunnerConfigurationError, LocalSandboxNotAllowedError, SandboxNotAllowedError, @@ -262,6 +263,7 @@ "Environment", "Harness", # Errors + "AgentRunFailed", "AgentRunnerConfigurationError", "SandboxNotAllowedError", "LocalSandboxNotAllowedError", diff --git a/sdks/python/agenta/sdk/agents/adapters/vercel/messages.py b/sdks/python/agenta/sdk/agents/adapters/vercel/messages.py index 86c3668c7c..a2f3706ad8 100644 --- a/sdks/python/agenta/sdk/agents/adapters/vercel/messages.py +++ b/sdks/python/agenta/sdk/agents/adapters/vercel/messages.py @@ -3,10 +3,14 @@ This adapter translates between the Vercel AI SDK ``UIMessage`` parts shape and the neutral agent runtime ``Message`` / ``ContentBlock`` types. The neutral DTOs stay the port; Vercel-specific part names live here. + +Attachment references are ingress-only. A neutral ``attachment`` block has no URL, so +``_block_to_parts`` cannot reconstruct a Vercel ``FileUIPart`` from it. """ from __future__ import annotations +import re from typing import Any, Dict, List, Optional from agenta.sdk.utils.logging import get_module_logger @@ -29,6 +33,10 @@ TOOL_OUTPUT_ERROR, TOOL_OUTPUT_DENIED, } +# Lowercase-only is deliberate and pinned by the invalid-metadata test. +_CANONICAL_UUID = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" +) def vercel_messages_to_agenta_messages(raw: Optional[List[Any]]) -> List[Message]: @@ -68,7 +76,7 @@ def _part_to_blocks(part: Any) -> List[ContentBlock]: Agenta's ``ContentBlock`` model is canonical. This mapper only ever produces ``ContentBlock`` types Agenta already defines internally (``text``, ``image``, - ``resource``, ``tool_call``, ``tool_result``). To support a new Vercel part kind, + ``resource``, ``attachment``, ``tool_call``, ``tool_result``). To support a new Vercel part kind, first add first-class support for it in the Agenta ``ContentBlock`` model, then map it here — never fabricate an adapter-specific block type or pass an unmapped kind through opaquely. A part kind Agenta does not define is dropped (observably via a @@ -76,9 +84,9 @@ def _part_to_blocks(part: Any) -> List[ContentBlock]: Keep this channel symmetric: a kind must be handled in both directions or neither. If something is mapped inbound it must map outbound too (and vice versa); a kind dropped - here must also be absent in ``_block_to_parts`` — never add one side alone. The only - exception is a direction that explicitly cannot occur (e.g. the one-way live event - stream in ``stream.py``, which has no inbound counterpart by design). + here must also be absent in ``_block_to_parts`` — never add one side alone. Attachment + references are the module-documented ingress-only exception because their neutral block + has no URL. Reasoning is such a stream-only concept: the live event stream maps the ``thought`` event to Vercel ``reasoning`` frames. Stored ``UIMessage`` conversion has no reasoning @@ -96,6 +104,32 @@ def _part_to_blocks(part: Any) -> List[ContentBlock]: if ptype == "file": media = part.get("mediaType") or part.get("mimeType") + provider_metadata = part.get("providerMetadata") + agenta_metadata = ( + provider_metadata.get("agenta") + if isinstance(provider_metadata, dict) + else None + ) + attachment_id = ( + agenta_metadata.get("attachmentId") + if isinstance(agenta_metadata, dict) + else None + ) + if isinstance(attachment_id, str) and _CANONICAL_UUID.fullmatch(attachment_id): + size = ( + agenta_metadata.get("size") + if isinstance(agenta_metadata, dict) + else None + ) + return [ + ContentBlock( + type="attachment", + attachment_id=attachment_id, + filename=part.get("filename"), + mime_type=media, + size=size, + ) + ] kind = ( "image" if isinstance(media, str) and media.startswith("image/") @@ -302,8 +336,8 @@ def _block_to_parts(block: ContentBlock) -> List[Dict[str, Any]]: Keep this channel symmetric: a kind must be handled in both directions or neither. If something is mapped here outbound it must map inbound too (and vice versa); a kind dropped in ``_part_to_blocks`` must also be absent here — never add one side alone. The - only exception is a direction that explicitly cannot occur (e.g. the one-way live event - stream in ``stream.py``, which has no inbound counterpart by design). + module documents the ingress-only ``attachment`` case; its neutral block has no URL to + render here. """ if block.type == "text": return [{"type": "text", "text": block.text or ""}] @@ -334,6 +368,11 @@ def _block_to_parts(block: ContentBlock) -> List[Dict[str, Any]]: "output": block.output, } ] + if block.type == "attachment": + log.debug( + "vercel adapter: dropping outbound attachment block with no FileUIPart URL: %r", + block.attachment_id, + ) return [] diff --git a/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py b/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py index c11e0f81fb..7abbe0ac1b 100644 --- a/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py +++ b/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py @@ -9,6 +9,7 @@ from agenta.sdk.utils.logging import get_module_logger from ...dtos import AgentResult +from ...errors import AgentRunFailed from ...streaming import AgentStream from ...utils.wire import sanitize_runner_error from .messages import TOOL_APPROVAL_REQUEST @@ -304,11 +305,17 @@ async def _agent_run_to_vercel_parts_impl( if _conform(file_part) is not None: content_parts_emitted += 1 yield file_part + elif etype == "attachment_delivery": + content_parts_emitted += 1 + yield _attachment_delivery_part(data) elif etype == "usage": usage = _usage_metadata(data) elif etype == "error": error_emitted = True - yield {"type": "error", "errorText": data.get("message", "")} + for part in _error_parts( + data.get("message", ""), failure_code="runner_error" + ): + yield part elif etype == "done": # Last non-null stop reason wins; see the routing-layer twin's `done` note. reason = data.get("stopReason") @@ -324,7 +331,8 @@ async def _agent_run_to_vercel_parts_impl( # exception is very often just that same failure resurfacing as a raised # `RuntimeError` (`result_from_wire`) -- yielding it too would duplicate the message # the user already saw under a second, "Agent run failed: ..."-prefixed frame. - yield {"type": "error", "errorText": sanitize_runner_error(exc)} + for part in _error_parts(sanitize_runner_error(exc), error=exc): + yield part error_emitted = True finally: # Every exit path — including the raw exception above — must still drain to a @@ -349,7 +357,10 @@ async def _agent_run_to_vercel_parts_impl( # "no output" frame on top of it would bury the actionable message (the swallowed- # provider-error path both streams a live error event AND fails the terminal result, # so this backstop must not double up on it). - yield {"type": "error", "errorText": "The agent produced no output."} + for part in _error_parts( + "The agent produced no output.", failure_code="no_output" + ): + yield part finish: Dict[str, Any] = {"type": "finish"} finish_reason = _map_finish_reason(stop_reason) if finish_reason is not None: @@ -584,11 +595,17 @@ async def _agent_stream_to_vercel_stream_impl( if _conform(file_part) is not None: content_parts_emitted += 1 yield file_part + elif etype == "attachment_delivery": + content_parts_emitted += 1 + yield _attachment_delivery_part(data) elif etype == "usage": usage = _usage_metadata(data) elif etype == "error": error_emitted = True - yield {"type": "error", "errorText": data.get("message", "")} + for part in _error_parts( + data.get("message", ""), failure_code="runner_error" + ): + yield part elif etype == "done": # Prefer the LAST non-null stop reason. The handler appends a corrective # terminal `done` after the runner's `done` when the authoritative result @@ -605,7 +622,8 @@ async def _agent_stream_to_vercel_stream_impl( # out live this turn, so a swallowed-provider-error recovery (live error event, then # a failed terminal result raised as this same exception) doesn't duplicate the # user-facing message under a second, differently-worded frame. - yield {"type": "error", "errorText": sanitize_runner_error(exc)} + for part in _error_parts(sanitize_runner_error(exc), error=exc): + yield part error_emitted = True finally: # Every exit path — including the raw exception above — must still drain to a @@ -617,7 +635,10 @@ async def _agent_stream_to_vercel_stream_impl( # note) -- a swallowed-provider-error turn both streams a live error event and fails # the terminal result, so this backstop must not double up on it and bury the real # message under "The agent produced no output." - yield {"type": "error", "errorText": "The agent produced no output."} + for part in _error_parts( + "The agent produced no output.", failure_code="no_output" + ): + yield part finish: Dict[str, Any] = {"type": "finish"} finish_reason = _map_finish_reason(stop_reason) if finish_reason is not None: @@ -850,6 +871,32 @@ def _as_text(value: Any) -> str: return value if isinstance(value, str) else str(value) +def _attachment_delivery_part(data: Dict[str, Any]) -> Dict[str, Any]: + delivery = { + key: data[key] + for key in ("attachmentId", "outcome", "reasonCode", "workingPath") + if data.get(key) is not None + } + return {"type": "data-attachment-delivery", "data": delivery} + + +def _error_parts( + error_text: Any, + *, + failure_code: Optional[str] = None, + error: Optional[BaseException] = None, +) -> Iterator[Dict[str, Any]]: + resolved_code = failure_code or getattr(error, "failure_code", None) + if not isinstance(resolved_code, str) or not resolved_code: + resolved_code = AgentRunFailed.failure_code + resolved_text = _as_text(error_text) + yield { + "type": "data-agent-error", + "data": {"code": resolved_code, "errorText": resolved_text}, + } + yield {"type": "error", "errorText": resolved_text} + + def _safe_result(run: AgentStream) -> Optional[AgentResult]: try: return run.result() diff --git a/sdks/python/agenta/sdk/agents/connections/models.py b/sdks/python/agenta/sdk/agents/connections/models.py index fcbbd4484a..d29cc2b821 100644 --- a/sdks/python/agenta/sdk/agents/connections/models.py +++ b/sdks/python/agenta/sdk/agents/connections/models.py @@ -16,7 +16,7 @@ from __future__ import annotations -from typing import Any, Dict, Literal, Optional +from typing import Any, Dict, List, Literal, Optional from uuid import UUID from pydantic import BaseModel, Field, field_serializer, model_validator @@ -181,6 +181,7 @@ class ResolvedConnection(BaseModel): default_factory=dict, repr=False ) # the ONLY secret channel endpoint: Optional[Endpoint] = None # NON-secret connection config only + input_modalities: Optional[List[str]] = None @field_serializer("env", when_used="always") def _mask_env(self, env: Dict[str, str]) -> Dict[str, str]: @@ -204,6 +205,8 @@ def to_wire(self) -> Dict[str, Any]: endpoint_wire = self.endpoint.to_wire() if endpoint_wire: wire["endpoint"] = endpoint_wire + if self.input_modalities is not None: + wire["modelCapabilities"] = {"inputModalities": list(self.input_modalities)} return wire diff --git a/sdks/python/agenta/sdk/agents/connections/resolver.py b/sdks/python/agenta/sdk/agents/connections/resolver.py index fbb56efdd3..9c519b9ce3 100644 --- a/sdks/python/agenta/sdk/agents/connections/resolver.py +++ b/sdks/python/agenta/sdk/agents/connections/resolver.py @@ -18,6 +18,7 @@ from typing import Any, Dict, Optional from ..capabilities import PROVIDER_ENV_VARS +from ..model_catalog import model_input_modalities from .errors import UnsupportedProviderError from .models import ( Endpoint, @@ -30,6 +31,13 @@ _PROVIDER_ENV_VARS: Dict[str, str] = PROVIDER_ENV_VARS +def _input_modalities( + context: RuntimeAuthContext, *, provider: str, model: str +) -> Optional[list[str]]: + # A miss means workspace-only downstream; do not guess. + return model_input_modalities(context.harness, model, provider=provider or None) + + class EnvConnectionResolver: """Read the requested provider's api key from the current process environment. @@ -55,11 +63,15 @@ async def resolve( context: RuntimeAuthContext, ) -> ResolvedConnection: if model.connection.mode == "self_managed": + provider = model.provider or "" return ResolvedConnection( - provider=model.provider or "", + provider=provider, model=model.model, credential_mode="runtime_provided", env={}, + input_modalities=_input_modalities( + context, provider=provider, model=model.model + ), ) provider = model.provider @@ -77,6 +89,9 @@ async def resolve( model=model.model, credential_mode="env", env={env_var: key}, + input_modalities=_input_modalities( + context, provider=provider, model=model.model + ), ) # Absence is valid: inject nothing and let the harness use its own login/OAuth. return ResolvedConnection( @@ -84,6 +99,9 @@ async def resolve( model=model.model, credential_mode="runtime_provided", env={}, + input_modalities=_input_modalities( + context, provider=provider, model=model.model + ), ) @@ -141,4 +159,7 @@ async def resolve( credential_mode="env" if env else "runtime_provided", env=env, endpoint=endpoint, + input_modalities=_input_modalities( + context, provider=provider, model=model.model + ), ) diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py index b7b733d2c3..17512a8e87 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -241,11 +241,14 @@ class ContentBlock(BaseModel): ``services/runner/src/protocol.ts``. """ - type: str # "text" | "image" | "resource" | "tool_call" | "tool_result" + type: str # "text" | "image" | "resource" | "attachment" | "tool_call" | "tool_result" text: Optional[str] = None data: Optional[str] = None # base64 payload, used when type != "text" mime_type: Optional[str] = None uri: Optional[str] = None + attachment_id: Optional[str] = None + filename: Optional[str] = None + size: Optional[int] = None # Tool-turn carriers (used by tool_call / tool_result blocks). tool_call_id: Optional[str] = None tool_name: Optional[str] = None @@ -263,6 +266,12 @@ def to_wire(self) -> Dict[str, Any]: block["mimeType"] = self.mime_type if self.uri is not None: block["uri"] = self.uri + if self.attachment_id is not None: + block["attachmentId"] = self.attachment_id + if self.filename is not None: + block["filename"] = self.filename + if self.size is not None: + block["size"] = self.size if self.tool_call_id is not None: block["toolCallId"] = self.tool_call_id if self.tool_name is not None: @@ -289,6 +298,9 @@ def from_raw(cls, raw: Any) -> "ContentBlock": data=raw.get("data"), mime_type=raw.get("mimeType") or raw.get("mime_type"), uri=raw.get("uri"), + attachment_id=raw.get("attachmentId") or raw.get("attachment_id"), + filename=raw.get("filename"), + size=raw.get("size"), tool_call_id=raw.get("toolCallId") or raw.get("tool_call_id"), tool_name=raw.get("toolName") or raw.get("tool_name"), input=raw.get("input"), @@ -360,8 +372,9 @@ def to_messages(raw: Optional[List[Any]]) -> List[Message]: class Event(BaseModel): """One structured event from a run, mapped from an ACP ``session/update``. - ``type`` is one of ``message``, ``thought``, ``tool_call``, ``tool_result``, ``usage``, - ``error``, ``done``. ``data`` carries the rest verbatim. + ``type`` is one of ``message``, ``thought``, ``tool_call``, ``tool_result``, ``data``, + ``file``, ``interaction_*``, ``attachment_delivery``, ``usage``, ``error``, or ``done``. + ``data`` carries the rest verbatim. """ type: str diff --git a/sdks/python/agenta/sdk/agents/model_catalog.py b/sdks/python/agenta/sdk/agents/model_catalog.py index 8ed49c65ad..e566bac9ab 100644 --- a/sdks/python/agenta/sdk/agents/model_catalog.py +++ b/sdks/python/agenta/sdk/agents/model_catalog.py @@ -5,7 +5,8 @@ ``provider`` — the join key to the accepted set), sourced facts (``name`` / ``pricing`` / ``context_window`` / ``modalities`` — objective, provenanced), and curated judgments (``label`` / ``description`` / ``ratings`` — subjective, human, sourced from current public info). The catalog -never gates selection; the runtime accepted set does. See +never gates selection; the runtime accepted set does. Its ``modalities`` fact feeds the runtime +delivery gate through the connection resolver, but the catalog still gates nothing itself. See ``docs/design/agent-workflows/projects/model-catalog-schema/design.md``. The data lives in JSON files under ``data/`` (owned by the ``sync-model-catalog`` skill), not in @@ -144,6 +145,39 @@ def claude_model_catalog() -> ModelCatalog: return _CLAUDE_CATALOG +def model_input_modalities( + harness: Optional[str], model_id: str, *, provider: Optional[str] = None +) -> Optional[List[str]]: + """Look up input modalities using the model id form accepted by ``harness``.""" + entry: Optional[ModelCatalogEntry] + if harness in ("pi_core", "pi_agenta"): + catalog = pi_model_catalog() + catalog_id = ( + model_id + if provider is None or model_id.startswith(f"{provider}/") + else f"{provider}/{model_id}" + ) + elif harness == "claude": + catalog = claude_model_catalog() + catalog_id = model_id + else: + return None + + entry = next((item for item in catalog.models if item.id == catalog_id), None) + if harness == "claude" and entry is None: + # Reuse the same sourced Anthropic fact from Pi's generated catalog; do not guess. + pi_catalog_id = ( + model_id if model_id.startswith("anthropic/") else f"anthropic/{model_id}" + ) + entry = next( + (item for item in pi_model_catalog().models if item.id == pi_catalog_id), + None, + ) + if entry is None or entry.modalities is None: + return None + return list(entry.modalities) + + def model_catalog_entries(harness: str) -> List[Dict[str, object]]: """The catalog entries for a harness, as plain JSON-able dicts (the published shape). diff --git a/sdks/python/agenta/sdk/agents/platform/connections.py b/sdks/python/agenta/sdk/agents/platform/connections.py index 5cd046e0f0..b7a95bc7e3 100644 --- a/sdks/python/agenta/sdk/agents/platform/connections.py +++ b/sdks/python/agenta/sdk/agents/platform/connections.py @@ -37,6 +37,7 @@ RuntimeAuthContext, UnsupportedConnectionModeError, ) +from ..model_catalog import model_input_modalities from .connection import PlatformConnection log = get_module_logger(__name__) @@ -498,11 +499,16 @@ def _resolve_from_secrets( if inferred: model = model.model_copy(update={"provider": inferred}) if connection.mode == "self_managed": + provider = model.provider or "" return ResolvedConnection( - provider=model.provider or "", + provider=provider, model=model.model, credential_mode="runtime_provided", env={}, + # A miss means workspace-only downstream; do not guess. + input_modalities=model_input_modalities( + harness, model.model, provider=provider or None + ), ) if connection.mode != "agenta": raise UnsupportedConnectionModeError(mode=str(connection.mode)) @@ -524,13 +530,18 @@ def _resolve_from_secrets( ): raise chosen.endpoint_resolution_error() env = chosen.resolved_env(provider) + resolved_model = chosen.selected_model_id(model) return ResolvedConnection( provider=provider, - model=chosen.selected_model_id(model), + model=resolved_model, deployment=chosen.deployment, credential_mode="env" if env else "runtime_provided", env=env, endpoint=chosen.endpoint, + # A miss means workspace-only downstream; do not guess. + input_modalities=model_input_modalities( + harness, resolved_model, provider=provider + ), ) diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py index d8e00e2111..ec89819547 100644 --- a/sdks/python/agenta/sdk/agents/utils/wire.py +++ b/sdks/python/agenta/sdk/agents/utils/wire.py @@ -24,6 +24,7 @@ from agenta.sdk.redaction.context import get_active_redactor from ..permission_rules import PermissionRule +from ..errors import AgentRunFailed from ..dtos import ( Event, AgentResult, @@ -162,14 +163,12 @@ def request_to_wire( def result_from_wire(data: Dict[str, Any]) -> AgentResult: """Parse a ``/run`` result JSON into an :class:`AgentResult`. - Raises ``RuntimeError`` when the runner reported a failure, so the caller surfaces a - clear message rather than handing the model an empty reply. The runner ``error`` is + Raises :class:`AgentRunFailed` when the runner reported a failure, so the caller gets a + stable code and a clear message rather than an empty reply. The runner ``error`` is sanitized at this boundary (one clean line, no stack/path leak); the full detail is logged. """ if not data.get("ok"): - raise RuntimeError( - f"Agent run failed: {sanitize_runner_error(data.get('error'))}" - ) + raise AgentRunFailed(sanitize_runner_error(data.get("error"))) messages: List[Message] = [] for raw in data.get("messages") or []: diff --git a/sdks/python/agenta/sdk/agents/wire_models.py b/sdks/python/agenta/sdk/agents/wire_models.py index 759f227a7a..fc22f188c5 100644 --- a/sdks/python/agenta/sdk/agents/wire_models.py +++ b/sdks/python/agenta/sdk/agents/wire_models.py @@ -83,6 +83,12 @@ class WireConnection(_WireModel): slug: Optional[str] = None +class WireModelCapabilities(_WireModel): + """Resolved model capabilities supplied to the runner.""" + + input_modalities: Optional[List[str]] = Field(default=None, alias="inputModalities") + + class WireContentBlock(_WireModel): """One content block of a message (mirrors ``ContentBlock.to_wire``).""" @@ -91,6 +97,9 @@ class WireContentBlock(_WireModel): data: Optional[str] = None mime_type: Optional[str] = Field(default=None, alias="mimeType") uri: Optional[str] = None + attachment_id: Optional[str] = Field(default=None, alias="attachmentId") + filename: Optional[str] = None + size: Optional[int] = None tool_call_id: Optional[str] = Field(default=None, alias="toolCallId") tool_name: Optional[str] = Field(default=None, alias="toolName") input: Optional[Any] = None @@ -417,6 +426,9 @@ class WireRunRequest(_WireModel): deployment: Optional[str] = None endpoint: Optional[WireEndpoint] = None credential_mode: Optional[str] = Field(default=None, alias="credentialMode") + model_capabilities: Optional[WireModelCapabilities] = Field( + default=None, alias="modelCapabilities" + ) # Turn. messages: Optional[List[WireChatMessage]] = None # Secrets injected as harness env (provider keys); never written to the agent filesystem. diff --git a/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_messages_roundtrip.py b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_messages_roundtrip.py index 9a44d7b77c..28d67541d4 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_messages_roundtrip.py +++ b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_messages_roundtrip.py @@ -94,6 +94,29 @@ def test_resource_block_roundtrips(self): assert block.mime_type == "application/pdf" +class TestOutboundAttachmentDropIsObservable: + def test_attachment_block_is_dropped_and_logged(self, caplog): + caplog.set_level(logging.DEBUG) + attachment_id = "01995d1a-2f83-7c4d-8a6b-123456789abc" + ui = message_to_vercel_ui_message( + Message( + role="user", + content=[ + ContentBlock( + type="attachment", + attachment_id=attachment_id, + filename="photo.png", + mime_type="image/png", + ) + ], + ) + ) + + assert ui["parts"] == [] + assert "dropping outbound attachment block" in caplog.text + assert attachment_id in caplog.text + + class TestInboundReasoningDroppedSymmetrically: def test_inbound_reasoning_part_is_dropped_and_logged(self, caplog): # Reasoning is stream-only (stream.py maps `thought` events to `reasoning` frames). diff --git a/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_conformance.py b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_conformance.py index 96fb6e98c2..19020704b5 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_conformance.py +++ b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_conformance.py @@ -47,6 +47,7 @@ # These chunks are strict objects with an EXACT allowed key set (no extra agenta-only fields may # leak onto them). `tool-output-denied` is `{type, toolCallId}` only — no errorText/output. _EXACT_KEYS = { + "error": {"type", "errorText"}, "tool-approval-request": {"type", "approvalId", "toolCallId"}, "tool-output-denied": {"type", "toolCallId"}, } @@ -75,6 +76,20 @@ def assert_conforms(part: Dict[str, Any]) -> None: ) +def assert_error_pair( + parts: List[Dict[str, Any]], *, code: str, error_text: str +) -> None: + error_index = next( + index for index, part in enumerate(parts) if part["type"] == "error" + ) + assert error_index > 0 + assert parts[error_index - 1] == { + "type": "data-agent-error", + "data": {"code": code, "errorText": error_text}, + } + assert parts[error_index] == {"type": "error", "errorText": error_text} + + async def _records(items: List[Dict[str, Any]]) -> AsyncIterator[Dict[str, Any]]: for item in items: yield item @@ -196,6 +211,9 @@ async def test_zero_content_run_emits_conforming_error_frame() -> None: for part in parts: assert_conforms(part) assert any(p["type"] == "error" for p in parts) + assert_error_pair( + parts, code="no_output", error_text="The agent produced no output." + ) @pytest.mark.asyncio @@ -216,6 +234,9 @@ async def test_dropped_only_content_part_still_triggers_zero_content_guard() -> p["type"] == "error" and p.get("errorText") == "The agent produced no output." for p in parts ) + assert_error_pair( + parts, code="no_output", error_text="The agent produced no output." + ) @pytest.mark.asyncio @@ -231,6 +252,9 @@ async def test_dropped_only_content_part_still_triggers_zero_content_guard_dev_t p["type"] == "error" and p.get("errorText") == "The agent produced no output." for p in parts ) + assert_error_pair( + parts, code="no_output", error_text="The agent produced no output." + ) @pytest.mark.asyncio @@ -277,6 +301,7 @@ async def _events_with_uncaught_failure(): f"expected exactly one error frame, got {error_parts!r}" ) assert error_parts[0]["errorText"] == real_error + assert_error_pair(parts, code="runner_error", error_text=real_error) assert not any(p.get("errorText") == "The agent produced no output." for p in parts) @@ -312,6 +337,7 @@ async def test_swallowed_provider_error_emits_exactly_one_error_frame_dev_twin() f"expected exactly one error frame, got {error_parts!r}" ) assert error_parts[0]["errorText"] == real_error + assert_error_pair(parts, code="runner_error", error_text=real_error) assert not any(p.get("errorText") == "The agent produced no output." for p in parts) diff --git a/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_multimodality.py b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_multimodality.py new file mode 100644 index 0000000000..019754671f --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_multimodality.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from typing import Any, AsyncIterator, Dict, List + +import pytest + +from agenta.sdk.agents import AgentRunFailed +from agenta.sdk.agents.adapters.vercel.stream import ( + agent_run_to_vercel_parts, + agent_stream_to_vercel_stream, +) +from agenta.sdk.agents.streaming import AgentStream + + +async def _records(items: List[Dict[str, Any]]) -> AsyncIterator[Dict[str, Any]]: + for item in items: + yield item + + +DELIVERY = { + "attachmentId": "01996b6c-7b6b-7000-8000-000000000001", + "outcome": "workspace_only", + "reasonCode": "model_modality_unknown", + "workingPath": "attachments/01996b6c-7b6b-7000-8000-000000000001/photo.png", +} + + +@pytest.mark.asyncio +async def test_live_twin_projects_attachment_delivery() -> None: + events = _records([{"type": "attachment_delivery", "data": DELIVERY}]) + + parts = [part async for part in agent_stream_to_vercel_stream(events)] + + delivery = next( + part for part in parts if part["type"] == "data-attachment-delivery" + ) + assert delivery == {"type": "data-attachment-delivery", "data": DELIVERY} + + +@pytest.mark.asyncio +async def test_dev_twin_projects_attachment_delivery() -> None: + records = [ + {"kind": "event", "event": {"type": "attachment_delivery", **DELIVERY}}, + {"kind": "result", "result": {"ok": True}}, + ] + run = AgentStream(_records(records)) + + parts = [part async for part in agent_run_to_vercel_parts(run)] + + delivery = next( + part for part in parts if part["type"] == "data-attachment-delivery" + ) + assert delivery == {"type": "data-attachment-delivery", "data": DELIVERY} + + +@pytest.mark.asyncio +async def test_attachment_delivery_omits_absent_fields() -> None: + events = _records( + [ + { + "type": "attachment_delivery", + "data": { + "attachmentId": "01996b6c-7b6b-7000-8000-000000000001", + "outcome": "native", + "reasonCode": None, + "workingPath": None, + }, + } + ] + ) + + parts = [part async for part in agent_stream_to_vercel_stream(events)] + + delivery = next( + part for part in parts if part["type"] == "data-attachment-delivery" + ) + assert delivery == { + "type": "data-attachment-delivery", + "data": { + "attachmentId": "01996b6c-7b6b-7000-8000-000000000001", + "outcome": "native", + }, + } + + +def _assert_error_pair( + parts: List[Dict[str, Any]], *, code: str, error_text: str +) -> None: + error_index = next( + index for index, part in enumerate(parts) if part["type"] == "error" + ) + assert parts[error_index - 1] == { + "type": "data-agent-error", + "data": {"code": code, "errorText": error_text}, + } + error = parts[error_index] + assert error == {"type": "error", "errorText": error_text} + assert set(error) == {"type", "errorText"} + + +class ProviderUnavailableFailure(AgentRunFailed): + failure_code = "provider_unavailable" + + +@pytest.mark.asyncio +async def test_live_twin_emits_failure_code_data_before_strict_error_frame() -> None: + async def _failed() -> AsyncIterator[Dict[str, Any]]: + if False: + yield {} + raise ProviderUnavailableFailure("provider unavailable") + + parts = [part async for part in agent_stream_to_vercel_stream(_failed())] + + _assert_error_pair( + parts, + code="provider_unavailable", + error_text="Agent run failed: provider unavailable", + ) + + +@pytest.mark.asyncio +async def test_dev_twin_emits_default_code_data_before_strict_error_frame() -> None: + run = AgentStream( + _records( + [ + { + "kind": "result", + "result": {"ok": False, "error": "provider unavailable"}, + } + ] + ) + ) + + parts = [part async for part in agent_run_to_vercel_parts(run)] + + _assert_error_pair( + parts, + code="agent_run_failed", + error_text="Agent run failed: provider unavailable", + ) diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_model_catalog.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_model_catalog.py index 5de074d8fe..29a7ddae81 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/connections/test_model_catalog.py +++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_model_catalog.py @@ -23,6 +23,7 @@ load_claude_model_catalog, load_pi_model_catalog, model_catalog_entries, + model_input_modalities, pi_model_catalog, ) @@ -155,6 +156,41 @@ def test_model_catalog_entries_helper_matches_the_published_field(): assert model_catalog_entries("some-future-harness") == [] +@pytest.mark.parametrize("harness", ["pi_core", "pi_agenta"]) +def test_pi_input_modalities_lookup_joins_resolved_provider_and_model(harness): + assert model_input_modalities(harness, "gpt-5.5", provider="openai") == [ + "text", + "image", + ] + + +def test_claude_input_modalities_lookup_uses_bare_alias(): + assert model_input_modalities("claude", "sonnet", provider="anthropic") == [ + "text", + "image", + ] + + +@pytest.mark.parametrize("model_id", ["claude-sonnet-4-6", "claude-opus-4-8"]) +def test_claude_dated_model_input_modalities_reuse_pi_catalog_fact(model_id): + assert model_input_modalities("claude", model_id, provider="anthropic") == [ + "text", + "image", + ] + + +def test_input_modalities_lookup_miss_returns_none(): + assert ( + model_input_modalities("pi_core", "workspace-only-model", provider="openai") + is None + ) + assert model_input_modalities("future-harness", "sonnet") is None + assert ( + model_input_modalities("claude", "claude-not-real", provider="anthropic") + is None + ) + + def test_claude_model_catalog_ids_match_the_models_map(): # For Claude the catalog id set equals the published models map (the accepted alias set), so a # picker reading either stays consistent. diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py index 6b2fddd7ba..c3ebc8c423 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py +++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py @@ -154,6 +154,19 @@ def test_resolved_connection_to_wire_excludes_env(): } +def test_resolved_connection_to_wire_emits_model_capabilities_when_set(): + resolved = ResolvedConnection( + provider="openai", + model="gpt-5.5", + credential_mode="runtime_provided", + input_modalities=["text", "image"], + ) + + assert resolved.to_wire()["modelCapabilities"] == { + "inputModalities": ["text", "image"] + } + + def test_resolved_connection_to_wire_omits_endpoint_when_absent(): resolved = ResolvedConnection( provider="openai", @@ -162,6 +175,7 @@ def test_resolved_connection_to_wire_omits_endpoint_when_absent(): ) wire = resolved.to_wire() assert "endpoint" not in wire + assert "modelCapabilities" not in wire assert wire["credentialMode"] == "runtime_provided" diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py index 10ebaaef03..75a89c0dd2 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py +++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py @@ -37,6 +37,18 @@ async def test_env_resolver_returns_only_the_requested_provider_var(): assert resolved.env == {"OPENAI_API_KEY": "sk-openai"} assert resolved.model == "gpt-5.5" assert resolved.provider == "openai" + assert resolved.input_modalities == ["text", "image"] + + +async def test_env_resolver_catalog_miss_leaves_modalities_unknown(): + resolver = EnvConnectionResolver(env={"OPENAI_API_KEY": "sk-openai"}) + resolved = await resolver.resolve( + model=ModelRef(provider="openai", model="workspace-only-model"), + context=_CTX, + ) + + assert resolved.input_modalities is None + assert "modelCapabilities" not in resolved.to_wire() async def test_env_resolver_reads_the_live_process_env(monkeypatch): diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.attachment.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.attachment.json new file mode 100644 index 0000000000..669a551fcb --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.attachment.json @@ -0,0 +1,43 @@ +{ + "harness": "pi_core", + "sandbox": "local", + "sessionId": "sess-attachment", + "agentsMd": "Use the attached file.", + "model": "claude-sonnet-4-6", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "attachment", + "mimeType": "image/png", + "attachmentId": "019c471b-5b91-71d2-9d4b-5486013e6e9b", + "filename": "photo.png", + "size": 482113 + }, + { + "type": "text", + "text": "Describe this image." + } + ] + } + ], + "secrets": {}, + "context": null, + "telemetry": null, + "tools": [], + "customTools": [], + "toolCallback": null, + "permissions": { + "default": "allow_reads" + }, + "provider": "anthropic", + "deployment": "direct", + "credentialMode": "runtime_provided", + "modelCapabilities": { + "inputModalities": [ + "text", + "image" + ] + } +} diff --git a/sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py b/sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py index d6478fce8f..60cdb2a18d 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py +++ b/sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py @@ -85,6 +85,7 @@ async def test_resolve_fetches_secrets_and_selects_one_key(fake_http, connection assert resolved.deployment == "direct" assert resolved.credential_mode == "env" assert resolved.env == {"OPENAI_API_KEY": "sk-prod"} + assert resolved.input_modalities == ["text", "image"] assert capture["method"] == "GET" assert capture["url"] == "https://api.x/api/secrets/" assert capture["headers"]["Authorization"] == "Access tok" @@ -100,6 +101,7 @@ async def test_self_managed_short_circuits_without_api_base(fake_http): ) assert resolved.credential_mode == "runtime_provided" assert resolved.env == {} + assert resolved.input_modalities == ["text", "image"] async def test_default_connection_requires_unique_provider_match(fake_http, connection): @@ -183,6 +185,7 @@ async def test_bare_claude_alias_resolves_to_anthropic(fake_http, connection): assert resolved.provider == "anthropic", alias assert resolved.model == alias, alias assert resolved.env == {"ANTHROPIC_API_KEY": "sk-ant"}, alias + assert resolved.input_modalities == ["text", "image"], alias async def test_bare_claude_dated_id_resolves_to_anthropic(fake_http, connection): @@ -250,6 +253,7 @@ async def test_known_direct_custom_provider_uses_direct_deployment( assert resolved.provider == provider assert resolved.deployment == "direct" assert resolved.model == model_id + assert resolved.input_modalities is None assert resolved.endpoint.base_url == endpoint if hasattr(resolved, "plaintext_environment"): environment = resolved.plaintext_environment() diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_dtos_content_blocks.py b/sdks/python/oss/tests/pytest/unit/agents/test_dtos_content_blocks.py index 5c8ba74ade..c91fdfe268 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_dtos_content_blocks.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_dtos_content_blocks.py @@ -40,6 +40,24 @@ def test_content_block_to_wire_omits_none_and_uses_camelcase(): assert "text" not in wire # None fields are omitted +def test_attachment_block_round_trips(): + wire = { + "type": "attachment", + "attachmentId": "01995d1a-2f83-7c4d-8a6b-123456789abc", + "filename": "photo.png", + "mimeType": "image/png", + "size": 482113, + } + + block = ContentBlock.from_raw(wire) + + assert block.attachment_id == wire["attachmentId"] + assert block.filename == "photo.png" + assert block.mime_type == "image/png" + assert block.size == 482113 + assert block.to_wire() == wire + + def test_text_block_round_trips(): assert ContentBlock(type="text", text="hi").to_wire() == { "type": "text", diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_result_multimodality.py b/sdks/python/oss/tests/pytest/unit/agents/test_result_multimodality.py new file mode 100644 index 0000000000..a4fb2cbde6 --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/agents/test_result_multimodality.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import pytest + +from agenta.sdk.agents import AgentRunFailed +from agenta.sdk.agents.utils.wire import result_from_wire + + +def test_result_failure_has_stable_code_and_sanitized_message() -> None: + with pytest.raises(AgentRunFailed) as excinfo: + result_from_wire( + { + "ok": False, + "error": "provider failed\n at run (/app/runner.ts:12:3)", + } + ) + + assert isinstance(excinfo.value, RuntimeError) + assert excinfo.value.failure_code == "agent_run_failed" + assert excinfo.value.message == "provider failed" + assert "/app/runner.ts" not in str(excinfo.value) + + +def test_result_parses_attachment_delivery_event() -> None: + raw_event = { + "type": "attachment_delivery", + "attachmentId": "01996b6c-7b6b-7000-8000-000000000001", + "outcome": "workspace_only", + "reasonCode": "model_modality_unknown", + "workingPath": "attachments/01996b6c-7b6b-7000-8000-000000000001/photo.png", + } + + result = result_from_wire({"ok": True, "events": [raw_event]}) + + assert len(result.events) == 1 + assert result.events[0].type == "attachment_delivery" + assert result.events[0].data == raw_event diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_ui_messages.py b/sdks/python/oss/tests/pytest/unit/agents/test_ui_messages.py index baa5453a52..1744602157 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_ui_messages.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_ui_messages.py @@ -72,6 +72,100 @@ def test_file_part_becomes_image_or_resource_block(self): assert [b.type for b in blocks] == ["text", "image"] assert blocks[1].uri == "data:..." assert blocks[1].mime_type == "image/png" + assert blocks[1].to_wire() == { + "type": "image", + "uri": "data:...", + "mimeType": "image/png", + } + + def test_file_part_with_attachment_metadata_becomes_attachment_block(self): + [message] = vercel_ui_messages_to_messages( + [ + { + "id": "m1", + "role": "user", + "parts": [ + { + "type": "file", + "url": "https://example.test/content", + "mediaType": "image/png", + "filename": "photo.png", + "providerMetadata": { + "agenta": { + "attachmentId": ( + "01995d1a-2f83-7c4d-8a6b-123456789abc" + ), + "size": 482113, + } + }, + } + ], + } + ] + ) + + [block] = message.content + assert block.to_wire() == { + "type": "attachment", + "attachmentId": "01995d1a-2f83-7c4d-8a6b-123456789abc", + "filename": "photo.png", + "mimeType": "image/png", + "size": 482113, + } + + def test_attachment_metadata_tolerates_absent_size(self): + [message] = vercel_ui_messages_to_messages( + [ + { + "id": "m1", + "role": "user", + "parts": [ + { + "type": "file", + "url": "https://example.test/content", + "mediaType": "application/pdf", + "filename": "report.pdf", + "providerMetadata": { + "agenta": { + "attachmentId": ( + "01995d1a-2f83-7c4d-8a6b-123456789abc" + ) + } + }, + } + ], + } + ] + ) + + [block] = message.content + assert block.to_wire() == { + "type": "attachment", + "attachmentId": "01995d1a-2f83-7c4d-8a6b-123456789abc", + "filename": "report.pdf", + "mimeType": "application/pdf", + } + + def test_invalid_attachment_metadata_preserves_file_mapping(self): + file_part = { + "type": "file", + "url": "data:...", + "mediaType": "image/png", + "filename": "photo.png", + "providerMetadata": { + "agenta": {"attachmentId": "01995D1A-2F83-7C4D-8A6B-123456789ABC"} + }, + } + [message] = vercel_ui_messages_to_messages( + [{"id": "m1", "role": "user", "parts": [file_part]}] + ) + + [block] = message.content + assert block.to_wire() == { + "type": "image", + "uri": "data:...", + "mimeType": "image/png", + } def test_tool_part_is_preserved_as_structured_blocks(self): # A resolved tool part -> a tool_call block plus a tool_result block, keyed by diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py index 596ddbf0ad..0142713d5c 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py @@ -80,6 +80,7 @@ def test_request_schema_properties_equal_known_request_keys(): [ ("run_request.pi_core.json", WireRunRequest), ("run_request.claude.json", WireRunRequest), + ("run_request.attachment.json", WireRunRequest), ("run_result.ok.json", WireRunResult), ("run_result.error.json", WireRunResult), ], @@ -96,6 +97,7 @@ def test_goldens_parse_into_the_wire_models(golden, golden_name, model): [ ("run_request.pi_core.json", "run_request"), ("run_request.claude.json", "run_request"), + ("run_request.attachment.json", "run_request"), ("run_result.ok.json", "run_result"), ("run_result.error.json", "run_result"), ], diff --git a/services/runner/tests/unit/wire-contract.test.ts b/services/runner/tests/unit/wire-contract.test.ts index 3890268b5a..f438ef0429 100644 --- a/services/runner/tests/unit/wire-contract.test.ts +++ b/services/runner/tests/unit/wire-contract.test.ts @@ -69,7 +69,11 @@ const _requestKeysExistOnType: readonly (keyof AgentRunRequest)[] = void _requestKeysExistOnType; describe("wire contract: requests (vs Python golden)", () => { - for (const name of ["run_request.pi_core.json", "run_request.claude.json"]) { + for (const name of [ + "run_request.pi_core.json", + "run_request.claude.json", + "run_request.attachment.json", + ]) { it(`${name}: every top-level key is known to AgentRunRequest`, () => { const req = loadGolden(name) as Record; for (const key of Object.keys(req)) { @@ -81,6 +85,23 @@ describe("wire contract: requests (vs Python golden)", () => { }); } + it("attachment request: carries the resource handle and resolved modalities", () => { + const req = loadGolden("run_request.attachment.json") as AgentRunRequest; + assert.deepEqual(req.modelCapabilities, { + inputModalities: ["text", "image"], + }); + assert.ok(Array.isArray(req.messages?.[0]?.content)); + const content = req.messages[0].content; + assert.deepEqual(content[0], { + type: "attachment", + attachmentId: "019c471b-5b91-71d2-9d4b-5486013e6e9b", + filename: "photo.png", + mimeType: "image/png", + size: 482113, + }); + assert.equal(resolvePromptText(req), "Describe this image."); + }); + it("pi request: shape, tool axes, and the runner helpers", () => { const req = loadGolden("run_request.pi_core.json") as AgentRunRequest; assert.equal(req.harness, "pi_core"); From 9c3563cc708ab35aee5af206bd4d5cace26f495a Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 1 Aug 2026 00:20:19 +0200 Subject: [PATCH 2/4] feat(sdk): typed run failure and contract-test coverage over the linearized base errors.py and the wire-contract suite build on the sandbox-slug rename and the Pi-builtins changes in the same regions, which is why those two lanes now sit below this one in the stack. Claude-Session: https://claude.ai/code/session_01A1XQVjHPYJgVBHWSNUphtx --- sdks/python/agenta/sdk/agents/errors.py | 11 ++++ .../pytest/unit/agents/test_wire_contract.py | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/sdks/python/agenta/sdk/agents/errors.py b/sdks/python/agenta/sdk/agents/errors.py index 62d00b870c..6de1a8d895 100644 --- a/sdks/python/agenta/sdk/agents/errors.py +++ b/sdks/python/agenta/sdk/agents/errors.py @@ -10,6 +10,7 @@ from .tools.errors import ToolResolutionError __all__ = [ + "AgentRunFailed", "AgentRunnerConfigurationError", "SandboxNotAllowedError", "LocalSandboxNotAllowedError", @@ -38,6 +39,16 @@ class AgentRunnerConfigurationError(RuntimeError): """Raised when a runner-backed adapter lacks a usable transport configuration.""" +class AgentRunFailed(RuntimeError): + """A runner-reported terminal failure with a stable machine-readable code.""" + + failure_code: str = "agent_run_failed" + + def __init__(self, message: str) -> None: + self.message = message + super().__init__(f"Agent run failed: {message}") + + class SandboxNotAllowedError(ErrorStatus): """A sandbox provider not in `AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS`; maps to HTTP 403.""" diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py index 1c3620867c..ea6b600425 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py @@ -23,6 +23,7 @@ AgentaAgentTemplate, AgentTemplate, ClaudeAgentTemplate, + ContentBlock, Endpoint, HarnessKind, Message, @@ -57,6 +58,7 @@ "sessionId", "agentsMd", "model", + "modelCapabilities", "provider", "connection", "deployment", @@ -213,6 +215,40 @@ def _agenta_payload(): ) +def _attachment_payload(): + config = PiAgentTemplate( + agents_md="Use the attached file.", + model="anthropic/claude-sonnet-4-6", + resolved_connection=ResolvedConnection( + provider="anthropic", + model="claude-sonnet-4-6", + credential_mode="runtime_provided", + input_modalities=["text", "image"], + ), + ) + return request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=config, + messages=[ + Message( + role="user", + content=[ + ContentBlock( + type="attachment", + attachment_id="019c471b-5b91-71d2-9d4b-5486013e6e9b", + filename="photo.png", + mime_type="image/png", + size=482113, + ), + ContentBlock(type="text", text="Describe this image."), + ], + ) + ], + session_id="sess-attachment", + ) + + def test_request_to_wire_agenta_carries_skills_and_pi_shape(): payload = _agenta_payload() assert set(payload) <= KNOWN_REQUEST_KEYS @@ -314,6 +350,20 @@ def test_request_to_wire_pi_matches_golden(golden): assert "harnessFiles" not in payload +def test_request_to_wire_attachment_matches_golden(golden): + payload = _attachment_payload() + assert payload == golden("run_request.attachment.json") + assert set(payload) <= KNOWN_REQUEST_KEYS + assert payload["modelCapabilities"] == {"inputModalities": ["text", "image"]} + assert payload["messages"][0]["content"][0] == { + "type": "attachment", + "attachmentId": "019c471b-5b91-71d2-9d4b-5486013e6e9b", + "filename": "photo.png", + "mimeType": "image/png", + "size": 482113, + } + + async def test_default_template_grants_pi_default_builtins_on_the_wire(make_env): """A default-derived agent must reach the runner with Pi's built-ins granted (issue #5590). From 2244f633c175c649dddc5eaf104758ff45f5200d Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 1 Aug 2026 00:22:43 +0200 Subject: [PATCH 3/4] doc(agent-workflows): record the WP3 review trail in the stage protocol Claude-Session: https://claude.ai/code/session_01A1XQVjHPYJgVBHWSNUphtx --- .../agent-multi-modality/protocols/stage-1.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/docs/design/agent-workflows/projects/agent-multi-modality/protocols/stage-1.md b/docs/design/agent-workflows/projects/agent-multi-modality/protocols/stage-1.md index 83d2820c16..3177045129 100644 --- a/docs/design/agent-workflows/projects/agent-multi-modality/protocols/stage-1.md +++ b/docs/design/agent-workflows/projects/agent-multi-modality/protocols/stage-1.md @@ -253,3 +253,58 @@ unrelated to this stage. `attachment_delivery` record, because the legacy path has no attachment id to key the event. Honest visibility for pasted images arrives with WP4, when the front end switches them to real attachments. + +## WP3: the SDK and agent-service producer + +### Implementation decisions worth knowing (beyond the plan) + +- The stable failure code is SDK-owned (the runner result carries only a string today), named + `failure_code` because the errors module already uses `code` for an integer HTTP status. +- `attachment_delivery` events parse through the generic event path; the plan's dedicated parser + branch would have duplicated what the generic path already preserves. +- The model catalog's docstring now states that its `modalities` field feeds the runtime delivery + gate through the connection resolver; the catalog itself still gates nothing. + +### The review, and what it changed + +The adversarial review's headline finding was verified empirically against the pinned AI SDK: +the first implementation put the stable error code on the Vercel error frame, and the SDK +validates that frame with a strict schema that rejects unknown keys, so every error-carrying +stream would have aborted with an opaque parse failure instead of rendering the sanitized +message. The code now travels in a `data-agent-error` part emitted before the standard two-key +error frame, with per-site codes (`runner_error`, `no_output`, the exception's own code, or the +default). + +Second finding: the pinned SDK's file part has no top-level `size` field and validation strips +extras, so the golden was pinning a value production could never send; `size` moved into the +`providerMetadata.agenta` envelope beside the attachment id. + +Third, the review settled the plan's open key-space question with an end-to-end trace: the Pi +path resolves for the common case (`provider/model` ids match the catalog keys exactly), the +Claude picker aliases resolve, but bare dated Anthropic ids missed, which would have silently +gated every dated-id Claude run's uploads to workspace-only. Closed by falling back to the Pi +catalog's `anthropic/` entry, a second read of the same sourced fact, not a guess. + +Fourth, a semantics defect at the WP2 seam, fixed on the WP2 lane: both catalogs only ever +enumerate text and image, so the runner's gate treating a kind's absence as "unsupported" +asserted a false negative for documents; absence now reads as unknown (workspace-only with the +unknown reason), and the unsupported code is reserved for a catalog that can genuinely state +negatives. + +### The stack, restructured again for the same reason + +The typed-failure work and the contract-test additions build on the sandbox-slug rename and the +Pi-builtins changes in the same file regions, so those two parallel lanes were linearized into +the train below WP3 (the same dependent-hunks refusal as WP2's case, caught the same way: the +tool's partial-commit warning plus a tree-versus-tip diff). Consequence: PR #5597 now merges in +the train after WP2; the sandbox-slug content already merged independently as #5585 and its lane +dissolves on the next rebase. + +### Forced routes to double-check + +- **A catalog miss means workspace-only for that model's uploads.** The honesty rule's cost: + a model absent from both catalogs delivers attachments to the workspace with a notice until + the catalog data learns it. Closing a miss is a data addition, not a code change. +- **The catalogs cannot express document or audio support today**, so native document delivery + (Stage 2) will need the catalog schema to grow before the gate can ever say yes; the gate's + absence-means-unknown rule is what keeps that honest in the meantime. From caf7ab2412c66310531076cc9ad27400aa8fb78b Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 1 Aug 2026 17:11:52 +0200 Subject: [PATCH 4/4] fix(sdk): make the capability catalog lookup case-insensitive on provider --- .../python/agenta/sdk/agents/model_catalog.py | 25 +++++++++++++------ .../agents/connections/test_model_catalog.py | 15 +++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/sdks/python/agenta/sdk/agents/model_catalog.py b/sdks/python/agenta/sdk/agents/model_catalog.py index e566bac9ab..360094d90b 100644 --- a/sdks/python/agenta/sdk/agents/model_catalog.py +++ b/sdks/python/agenta/sdk/agents/model_catalog.py @@ -145,6 +145,21 @@ def claude_model_catalog() -> ModelCatalog: return _CLAUDE_CATALOG +def _catalog_id(provider: Optional[str], model_id: str) -> str: + """Build the ``provider/model`` join key. + + Catalog ids carry a lowercase provider, and the rest of the system (environment resolver, + connection matching) treats provider names case-insensitively, so a caller-supplied + ``"OpenAI"`` must still join. + """ + head, separator, tail = model_id.partition("/") + if provider is None: + return f"{head.lower()}/{tail}" if separator else model_id + if separator and head.lower() == provider.lower(): + return f"{provider.lower()}/{tail}" + return f"{provider.lower()}/{model_id}" + + def model_input_modalities( harness: Optional[str], model_id: str, *, provider: Optional[str] = None ) -> Optional[List[str]]: @@ -152,11 +167,7 @@ def model_input_modalities( entry: Optional[ModelCatalogEntry] if harness in ("pi_core", "pi_agenta"): catalog = pi_model_catalog() - catalog_id = ( - model_id - if provider is None or model_id.startswith(f"{provider}/") - else f"{provider}/{model_id}" - ) + catalog_id = _catalog_id(provider, model_id) elif harness == "claude": catalog = claude_model_catalog() catalog_id = model_id @@ -166,9 +177,7 @@ def model_input_modalities( entry = next((item for item in catalog.models if item.id == catalog_id), None) if harness == "claude" and entry is None: # Reuse the same sourced Anthropic fact from Pi's generated catalog; do not guess. - pi_catalog_id = ( - model_id if model_id.startswith("anthropic/") else f"anthropic/{model_id}" - ) + pi_catalog_id = _catalog_id("anthropic", model_id) entry = next( (item for item in pi_model_catalog().models if item.id == pi_catalog_id), None, diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_model_catalog.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_model_catalog.py index 29a7ddae81..df5e4bcf11 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/connections/test_model_catalog.py +++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_model_catalog.py @@ -164,6 +164,21 @@ def test_pi_input_modalities_lookup_joins_resolved_provider_and_model(harness): ] +def test_input_modalities_lookup_is_case_insensitive_on_provider(): + # Provider names are matched case-insensitively everywhere else (environment resolver, + # connection matching); a mixed-case provider must not silently drop the modality fact. + assert model_input_modalities( + "pi_core", "gpt-5.5", provider="OpenAI" + ) == model_input_modalities("pi_core", "gpt-5.5", provider="openai") + assert model_input_modalities("pi_core", "OpenAI/gpt-5.5", provider="OpenAI") == [ + "text", + "image", + ] + assert model_input_modalities( + "claude", "claude-sonnet-4-6", provider="Anthropic" + ) == ["text", "image"] + + def test_claude_input_modalities_lookup_uses_bare_alias(): assert model_input_modalities("claude", "sonnet", provider="anthropic") == [ "text",