Skip to content

Commit 2d77c87

Browse files
authored
Merge pull request #5617 from Agenta-AI/wp3-sdk-producer
feat(sdk): attachment block ingress, model capabilities, and the delivery chain
2 parents 752961e + caf7ab2 commit 2d77c87

26 files changed

Lines changed: 817 additions & 26 deletions

docs/design/agent-workflows/projects/agent-multi-modality/protocols/stage-1.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,58 @@ unrelated to this stage.
253253
`attachment_delivery` record, because the legacy path has no attachment id to key the event.
254254
Honest visibility for pasted images arrives with WP4, when the front end switches them to real
255255
attachments.
256+
257+
## WP3: the SDK and agent-service producer
258+
259+
### Implementation decisions worth knowing (beyond the plan)
260+
261+
- The stable failure code is SDK-owned (the runner result carries only a string today), named
262+
`failure_code` because the errors module already uses `code` for an integer HTTP status.
263+
- `attachment_delivery` events parse through the generic event path; the plan's dedicated parser
264+
branch would have duplicated what the generic path already preserves.
265+
- The model catalog's docstring now states that its `modalities` field feeds the runtime delivery
266+
gate through the connection resolver; the catalog itself still gates nothing.
267+
268+
### The review, and what it changed
269+
270+
The adversarial review's headline finding was verified empirically against the pinned AI SDK:
271+
the first implementation put the stable error code on the Vercel error frame, and the SDK
272+
validates that frame with a strict schema that rejects unknown keys, so every error-carrying
273+
stream would have aborted with an opaque parse failure instead of rendering the sanitized
274+
message. The code now travels in a `data-agent-error` part emitted before the standard two-key
275+
error frame, with per-site codes (`runner_error`, `no_output`, the exception's own code, or the
276+
default).
277+
278+
Second finding: the pinned SDK's file part has no top-level `size` field and validation strips
279+
extras, so the golden was pinning a value production could never send; `size` moved into the
280+
`providerMetadata.agenta` envelope beside the attachment id.
281+
282+
Third, the review settled the plan's open key-space question with an end-to-end trace: the Pi
283+
path resolves for the common case (`provider/model` ids match the catalog keys exactly), the
284+
Claude picker aliases resolve, but bare dated Anthropic ids missed, which would have silently
285+
gated every dated-id Claude run's uploads to workspace-only. Closed by falling back to the Pi
286+
catalog's `anthropic/<id>` entry, a second read of the same sourced fact, not a guess.
287+
288+
Fourth, a semantics defect at the WP2 seam, fixed on the WP2 lane: both catalogs only ever
289+
enumerate text and image, so the runner's gate treating a kind's absence as "unsupported"
290+
asserted a false negative for documents; absence now reads as unknown (workspace-only with the
291+
unknown reason), and the unsupported code is reserved for a catalog that can genuinely state
292+
negatives.
293+
294+
### The stack, restructured again for the same reason
295+
296+
The typed-failure work and the contract-test additions build on the sandbox-slug rename and the
297+
Pi-builtins changes in the same file regions, so those two parallel lanes were linearized into
298+
the train below WP3 (the same dependent-hunks refusal as WP2's case, caught the same way: the
299+
tool's partial-commit warning plus a tree-versus-tip diff). Consequence: PR #5597 now merges in
300+
the train after WP2; the sandbox-slug content already merged independently as #5585 and its lane
301+
dissolves on the next rebase.
302+
303+
### Forced routes to double-check
304+
305+
- **A catalog miss means workspace-only for that model's uploads.** The honesty rule's cost:
306+
a model absent from both catalogs delivers attachments to the workspace with a notice until
307+
the catalog data learns it. Closing a miss is a data addition, not a code change.
308+
- **The catalogs cannot express document or audio support today**, so native document delivery
309+
(Stage 2) will need the catalog schema to grow before the gate can ever say yes; the gate's
310+
absence-means-unknown rule is what keeps that honest in the meantime.

sdks/python/agenta/sdk/agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
to_messages,
8484
)
8585
from .errors import (
86+
AgentRunFailed,
8687
AgentRunnerConfigurationError,
8788
LocalSandboxNotAllowedError,
8889
SandboxNotAllowedError,
@@ -262,6 +263,7 @@
262263
"Environment",
263264
"Harness",
264265
# Errors
266+
"AgentRunFailed",
265267
"AgentRunnerConfigurationError",
266268
"SandboxNotAllowedError",
267269
"LocalSandboxNotAllowedError",

sdks/python/agenta/sdk/agents/adapters/vercel/messages.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
This adapter translates between the Vercel AI SDK ``UIMessage`` parts shape and the
44
neutral agent runtime ``Message`` / ``ContentBlock`` types. The neutral DTOs stay the port;
55
Vercel-specific part names live here.
6+
7+
Attachment references are ingress-only. A neutral ``attachment`` block has no URL, so
8+
``_block_to_parts`` cannot reconstruct a Vercel ``FileUIPart`` from it.
69
"""
710

811
from __future__ import annotations
912

13+
import re
1014
from typing import Any, Dict, List, Optional
1115

1216
from agenta.sdk.utils.logging import get_module_logger
@@ -29,6 +33,10 @@
2933
TOOL_OUTPUT_ERROR,
3034
TOOL_OUTPUT_DENIED,
3135
}
36+
# Lowercase-only is deliberate and pinned by the invalid-metadata test.
37+
_CANONICAL_UUID = re.compile(
38+
r"[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
39+
)
3240

3341

3442
def vercel_messages_to_agenta_messages(raw: Optional[List[Any]]) -> List[Message]:
@@ -68,17 +76,17 @@ def _part_to_blocks(part: Any) -> List[ContentBlock]:
6876
6977
Agenta's ``ContentBlock`` model is canonical. This mapper only ever produces
7078
``ContentBlock`` types Agenta already defines internally (``text``, ``image``,
71-
``resource``, ``tool_call``, ``tool_result``). To support a new Vercel part kind,
79+
``resource``, ``attachment``, ``tool_call``, ``tool_result``). To support a new Vercel part kind,
7280
first add first-class support for it in the Agenta ``ContentBlock`` model, then map
7381
it here — never fabricate an adapter-specific block type or pass an unmapped kind
7482
through opaquely. A part kind Agenta does not define is dropped (observably via a
7583
debug log), not fabricated.
7684
7785
Keep this channel symmetric: a kind must be handled in both directions or neither. If
7886
something is mapped inbound it must map outbound too (and vice versa); a kind dropped
79-
here must also be absent in ``_block_to_parts`` — never add one side alone. The only
80-
exception is a direction that explicitly cannot occur (e.g. the one-way live event
81-
stream in ``stream.py``, which has no inbound counterpart by design).
87+
here must also be absent in ``_block_to_parts`` — never add one side alone. Attachment
88+
references are the module-documented ingress-only exception because their neutral block
89+
has no URL.
8290
8391
Reasoning is such a stream-only concept: the live event stream maps the ``thought``
8492
event to Vercel ``reasoning`` frames. Stored ``UIMessage`` conversion has no reasoning
@@ -96,6 +104,32 @@ def _part_to_blocks(part: Any) -> List[ContentBlock]:
96104

97105
if ptype == "file":
98106
media = part.get("mediaType") or part.get("mimeType")
107+
provider_metadata = part.get("providerMetadata")
108+
agenta_metadata = (
109+
provider_metadata.get("agenta")
110+
if isinstance(provider_metadata, dict)
111+
else None
112+
)
113+
attachment_id = (
114+
agenta_metadata.get("attachmentId")
115+
if isinstance(agenta_metadata, dict)
116+
else None
117+
)
118+
if isinstance(attachment_id, str) and _CANONICAL_UUID.fullmatch(attachment_id):
119+
size = (
120+
agenta_metadata.get("size")
121+
if isinstance(agenta_metadata, dict)
122+
else None
123+
)
124+
return [
125+
ContentBlock(
126+
type="attachment",
127+
attachment_id=attachment_id,
128+
filename=part.get("filename"),
129+
mime_type=media,
130+
size=size,
131+
)
132+
]
99133
kind = (
100134
"image"
101135
if isinstance(media, str) and media.startswith("image/")
@@ -302,8 +336,8 @@ def _block_to_parts(block: ContentBlock) -> List[Dict[str, Any]]:
302336
Keep this channel symmetric: a kind must be handled in both directions or neither. If
303337
something is mapped here outbound it must map inbound too (and vice versa); a kind
304338
dropped in ``_part_to_blocks`` must also be absent here — never add one side alone. The
305-
only exception is a direction that explicitly cannot occur (e.g. the one-way live event
306-
stream in ``stream.py``, which has no inbound counterpart by design).
339+
module documents the ingress-only ``attachment`` case; its neutral block has no URL to
340+
render here.
307341
"""
308342
if block.type == "text":
309343
return [{"type": "text", "text": block.text or ""}]
@@ -334,6 +368,11 @@ def _block_to_parts(block: ContentBlock) -> List[Dict[str, Any]]:
334368
"output": block.output,
335369
}
336370
]
371+
if block.type == "attachment":
372+
log.debug(
373+
"vercel adapter: dropping outbound attachment block with no FileUIPart URL: %r",
374+
block.attachment_id,
375+
)
337376
return []
338377

339378

sdks/python/agenta/sdk/agents/adapters/vercel/stream.py

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from agenta.sdk.utils.logging import get_module_logger
1010

1111
from ...dtos import AgentResult
12+
from ...errors import AgentRunFailed
1213
from ...streaming import AgentStream
1314
from ...utils.wire import sanitize_runner_error
1415
from .messages import TOOL_APPROVAL_REQUEST
@@ -304,11 +305,17 @@ async def _agent_run_to_vercel_parts_impl(
304305
if _conform(file_part) is not None:
305306
content_parts_emitted += 1
306307
yield file_part
308+
elif etype == "attachment_delivery":
309+
content_parts_emitted += 1
310+
yield _attachment_delivery_part(data)
307311
elif etype == "usage":
308312
usage = _usage_metadata(data)
309313
elif etype == "error":
310314
error_emitted = True
311-
yield {"type": "error", "errorText": data.get("message", "")}
315+
for part in _error_parts(
316+
data.get("message", ""), failure_code="runner_error"
317+
):
318+
yield part
312319
elif etype == "done":
313320
# Last non-null stop reason wins; see the routing-layer twin's `done` note.
314321
reason = data.get("stopReason")
@@ -324,7 +331,8 @@ async def _agent_run_to_vercel_parts_impl(
324331
# exception is very often just that same failure resurfacing as a raised
325332
# `RuntimeError` (`result_from_wire`) -- yielding it too would duplicate the message
326333
# the user already saw under a second, "Agent run failed: ..."-prefixed frame.
327-
yield {"type": "error", "errorText": sanitize_runner_error(exc)}
334+
for part in _error_parts(sanitize_runner_error(exc), error=exc):
335+
yield part
328336
error_emitted = True
329337
finally:
330338
# 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(
349357
# "no output" frame on top of it would bury the actionable message (the swallowed-
350358
# provider-error path both streams a live error event AND fails the terminal result,
351359
# so this backstop must not double up on it).
352-
yield {"type": "error", "errorText": "The agent produced no output."}
360+
for part in _error_parts(
361+
"The agent produced no output.", failure_code="no_output"
362+
):
363+
yield part
353364
finish: Dict[str, Any] = {"type": "finish"}
354365
finish_reason = _map_finish_reason(stop_reason)
355366
if finish_reason is not None:
@@ -584,11 +595,17 @@ async def _agent_stream_to_vercel_stream_impl(
584595
if _conform(file_part) is not None:
585596
content_parts_emitted += 1
586597
yield file_part
598+
elif etype == "attachment_delivery":
599+
content_parts_emitted += 1
600+
yield _attachment_delivery_part(data)
587601
elif etype == "usage":
588602
usage = _usage_metadata(data)
589603
elif etype == "error":
590604
error_emitted = True
591-
yield {"type": "error", "errorText": data.get("message", "")}
605+
for part in _error_parts(
606+
data.get("message", ""), failure_code="runner_error"
607+
):
608+
yield part
592609
elif etype == "done":
593610
# Prefer the LAST non-null stop reason. The handler appends a corrective
594611
# terminal `done` after the runner's `done` when the authoritative result
@@ -605,7 +622,8 @@ async def _agent_stream_to_vercel_stream_impl(
605622
# out live this turn, so a swallowed-provider-error recovery (live error event, then
606623
# a failed terminal result raised as this same exception) doesn't duplicate the
607624
# user-facing message under a second, differently-worded frame.
608-
yield {"type": "error", "errorText": sanitize_runner_error(exc)}
625+
for part in _error_parts(sanitize_runner_error(exc), error=exc):
626+
yield part
609627
error_emitted = True
610628
finally:
611629
# 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(
617635
# note) -- a swallowed-provider-error turn both streams a live error event and fails
618636
# the terminal result, so this backstop must not double up on it and bury the real
619637
# message under "The agent produced no output."
620-
yield {"type": "error", "errorText": "The agent produced no output."}
638+
for part in _error_parts(
639+
"The agent produced no output.", failure_code="no_output"
640+
):
641+
yield part
621642
finish: Dict[str, Any] = {"type": "finish"}
622643
finish_reason = _map_finish_reason(stop_reason)
623644
if finish_reason is not None:
@@ -850,6 +871,32 @@ def _as_text(value: Any) -> str:
850871
return value if isinstance(value, str) else str(value)
851872

852873

874+
def _attachment_delivery_part(data: Dict[str, Any]) -> Dict[str, Any]:
875+
delivery = {
876+
key: data[key]
877+
for key in ("attachmentId", "outcome", "reasonCode", "workingPath")
878+
if data.get(key) is not None
879+
}
880+
return {"type": "data-attachment-delivery", "data": delivery}
881+
882+
883+
def _error_parts(
884+
error_text: Any,
885+
*,
886+
failure_code: Optional[str] = None,
887+
error: Optional[BaseException] = None,
888+
) -> Iterator[Dict[str, Any]]:
889+
resolved_code = failure_code or getattr(error, "failure_code", None)
890+
if not isinstance(resolved_code, str) or not resolved_code:
891+
resolved_code = AgentRunFailed.failure_code
892+
resolved_text = _as_text(error_text)
893+
yield {
894+
"type": "data-agent-error",
895+
"data": {"code": resolved_code, "errorText": resolved_text},
896+
}
897+
yield {"type": "error", "errorText": resolved_text}
898+
899+
853900
def _safe_result(run: AgentStream) -> Optional[AgentResult]:
854901
try:
855902
return run.result()

sdks/python/agenta/sdk/agents/connections/models.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from __future__ import annotations
1818

19-
from typing import Any, Dict, Literal, Optional
19+
from typing import Any, Dict, List, Literal, Optional
2020
from uuid import UUID
2121

2222
from pydantic import BaseModel, Field, field_serializer, model_validator
@@ -181,6 +181,7 @@ class ResolvedConnection(BaseModel):
181181
default_factory=dict, repr=False
182182
) # the ONLY secret channel
183183
endpoint: Optional[Endpoint] = None # NON-secret connection config only
184+
input_modalities: Optional[List[str]] = None
184185

185186
@field_serializer("env", when_used="always")
186187
def _mask_env(self, env: Dict[str, str]) -> Dict[str, str]:
@@ -204,6 +205,8 @@ def to_wire(self) -> Dict[str, Any]:
204205
endpoint_wire = self.endpoint.to_wire()
205206
if endpoint_wire:
206207
wire["endpoint"] = endpoint_wire
208+
if self.input_modalities is not None:
209+
wire["modelCapabilities"] = {"inputModalities": list(self.input_modalities)}
207210
return wire
208211

209212

sdks/python/agenta/sdk/agents/connections/resolver.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from typing import Any, Dict, Optional
1919

2020
from ..capabilities import PROVIDER_ENV_VARS
21+
from ..model_catalog import model_input_modalities
2122
from .errors import UnsupportedProviderError
2223
from .models import (
2324
Endpoint,
@@ -30,6 +31,13 @@
3031
_PROVIDER_ENV_VARS: Dict[str, str] = PROVIDER_ENV_VARS
3132

3233

34+
def _input_modalities(
35+
context: RuntimeAuthContext, *, provider: str, model: str
36+
) -> Optional[list[str]]:
37+
# A miss means workspace-only downstream; do not guess.
38+
return model_input_modalities(context.harness, model, provider=provider or None)
39+
40+
3341
class EnvConnectionResolver:
3442
"""Read the requested provider's api key from the current process environment.
3543
@@ -55,11 +63,15 @@ async def resolve(
5563
context: RuntimeAuthContext,
5664
) -> ResolvedConnection:
5765
if model.connection.mode == "self_managed":
66+
provider = model.provider or ""
5867
return ResolvedConnection(
59-
provider=model.provider or "",
68+
provider=provider,
6069
model=model.model,
6170
credential_mode="runtime_provided",
6271
env={},
72+
input_modalities=_input_modalities(
73+
context, provider=provider, model=model.model
74+
),
6375
)
6476

6577
provider = model.provider
@@ -77,13 +89,19 @@ async def resolve(
7789
model=model.model,
7890
credential_mode="env",
7991
env={env_var: key},
92+
input_modalities=_input_modalities(
93+
context, provider=provider, model=model.model
94+
),
8095
)
8196
# Absence is valid: inject nothing and let the harness use its own login/OAuth.
8297
return ResolvedConnection(
8398
provider=provider,
8499
model=model.model,
85100
credential_mode="runtime_provided",
86101
env={},
102+
input_modalities=_input_modalities(
103+
context, provider=provider, model=model.model
104+
),
87105
)
88106

89107

@@ -141,4 +159,7 @@ async def resolve(
141159
credential_mode="env" if env else "runtime_provided",
142160
env=env,
143161
endpoint=endpoint,
162+
input_modalities=_input_modalities(
163+
context, provider=provider, model=model.model
164+
),
144165
)

0 commit comments

Comments
 (0)