docs(a2a): correct misleading v1-tolerance comments
Follow-up to PR #2509/#2510. The defensive v1-detection branches in extract_attached_files (Python) and extractFilesFromTask (TypeScript) were merged with comments claiming they fix a "v0→v1 silent-drop" bug that surfaced as the 2026-05-01 hongming "no text content" incident. Live test disproved that hypothesis: a2a-sdk's JSON-RPC layer validates inbound requests against the v0 Pydantic union, so v1 shapes are rejected at the request boundary — the v1 detection branch is unreachable on the JSON-RPC ingress path. The actual root cause of the hongming incident was the missing /workspace chown fixed by CP PR #381 + test #382. Update the comments to honestly describe these branches as defensive future-proofing (kept against an eventual SDK schema migration or in-process callers that construct Parts directly from protobuf), not as fixes for an observed bug. Also trims ChatTab.tsx's outbound-shape comment block from ~21 lines to a 3-line pointer to the SDK union. Comment-only change. No behavior change. 86 workspace tests + 91 canvas tests still pass.
This commit is contained in:
parent
ce0188d5b4
commit
fc33cf1131
@ -32,15 +32,11 @@ interface A2AFileRef {
|
|||||||
bytes?: string;
|
bytes?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
// A2A Part — outbound matches the v0 Pydantic discriminated-union
|
// Outbound shape matches a2a-sdk's JSON-RPC `SendMessageRequest`
|
||||||
// shape that a2a-sdk's JSON-RPC layer validates against (TextPart |
|
// Pydantic union (TextPart | FilePart | DataPart). The flat
|
||||||
// FilePart | DataPart). The v1 flat-protobuf shape `{url, filename,
|
// protobuf shape `{url, filename, mediaType}` is rejected at the
|
||||||
// mediaType}` is internal SDK serialization only; sending it on the
|
// request boundary with `Field required` errors — keep this
|
||||||
// wire fails Pydantic validation with `TextPart.text required,
|
// outbound shape unless a2a-sdk migrates the JSON-RPC schema.
|
||||||
// FilePart.file required, DataPart.data required` and never reaches
|
|
||||||
// the executor. Inbound also tolerates the v1 shape via
|
|
||||||
// message-parser.ts since the agent itself may serialize as v1 in
|
|
||||||
// some downstream tools.
|
|
||||||
interface A2APart {
|
interface A2APart {
|
||||||
kind: string;
|
kind: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
@ -511,18 +507,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
|
|
||||||
// A2A parts: text part (if any) + file parts (per attachment). The
|
// A2A parts: text part (if any) + file parts (per attachment). The
|
||||||
// agent sees both in a single turn, matching the A2A spec shape.
|
// agent sees both in a single turn, matching the A2A spec shape.
|
||||||
//
|
// Wire shape is v0 — see A2APart definition above.
|
||||||
// File parts use the v0 discriminated-union shape `{kind:"file",
|
|
||||||
// file:{...}}` because that's what a2a-sdk's JSON-RPC layer
|
|
||||||
// validates against (`SendMessageRequest.params.message.parts[]`
|
|
||||||
// → `TextPart | FilePart | DataPart` Pydantic union). Sending the
|
|
||||||
// v1 flat shape `{url, filename, mediaType}` returns
|
|
||||||
// `Invalid Request — TextPart.text required, FilePart.file
|
|
||||||
// required, DataPart.data required` and the message never
|
|
||||||
// reaches the executor. v1 protobuf is internal serialization
|
|
||||||
// only; the wire shape stays v0 until the SDK migrates the
|
|
||||||
// JSON-RPC schema. Text parts keep `{kind:"text", text}` for the
|
|
||||||
// same reason.
|
|
||||||
const parts: A2APart[] = [];
|
const parts: A2APart[] = [];
|
||||||
if (text) parts.push({ kind: "text", text });
|
if (text) parts.push({ kind: "text", text });
|
||||||
for (const att of uploaded) {
|
for (const att of uploaded) {
|
||||||
|
|||||||
@ -41,15 +41,15 @@ export interface ParsedFilePart {
|
|||||||
|
|
||||||
/** Extract file parts from an A2A response. Walks parts[] + artifacts[].
|
/** Extract file parts from an A2A response. Walks parts[] + artifacts[].
|
||||||
*
|
*
|
||||||
* Tolerates both A2A protocol generations:
|
* Hot path: v0 Pydantic shape `{ kind: "file", file: { name, mimeType,
|
||||||
* - v0 (Pydantic): `{ kind: "file", file: { name, mimeType, uri } }`
|
* uri } }` — what every current workspace runtime emits.
|
||||||
* - v1 (protobuf): `{ url, filename, mediaType }` — flat, no `kind`
|
|
||||||
* and no nested `file` object (the v1 Part's content oneof is
|
|
||||||
* `{text, raw, url, data}`; file metadata sits at top level).
|
|
||||||
*
|
*
|
||||||
* Without v1 tolerance, agents that emit the v1 shape (every workspace
|
* Defensive secondary path: v1 protobuf shape `{ url, filename,
|
||||||
* runtime since the SDK migration) silently drop file parts in chat —
|
* mediaType }` — flat, no `kind`, no nested `file`. Not currently
|
||||||
* the agent says "I sent the file" but the user never sees the chip.
|
* observed on the wire (a2a-sdk's JSON-RPC layer still validates
|
||||||
|
* against v0), but kept so a future SDK release that flips the wire
|
||||||
|
* shape, or a third-party agent that round-trips through protobuf
|
||||||
|
* serialization, doesn't silently lose file chips.
|
||||||
*
|
*
|
||||||
* We only surface parts that carry a URL — inline bytes would require
|
* We only surface parts that carry a URL — inline bytes would require
|
||||||
* a different renderer (data URL) and are out of scope for MVP. Names
|
* a different renderer (data URL) and are out of scope for MVP. Names
|
||||||
|
|||||||
@ -844,23 +844,27 @@ def resolve_attachment_uri(uri: str) -> str | None:
|
|||||||
def extract_attached_files(message: Any) -> list[dict[str, str]]:
|
def extract_attached_files(message: Any) -> list[dict[str, str]]:
|
||||||
"""Pull ``{name, mime_type, path}`` dicts out of an A2A message.
|
"""Pull ``{name, mime_type, path}`` dicts out of an A2A message.
|
||||||
|
|
||||||
Tolerates three Part shapes seen in the wild:
|
Tolerates three Part shapes:
|
||||||
|
|
||||||
1. a2a-sdk v0 Pydantic RootModel — ``part.root.kind == 'file'`` with
|
1. a2a-sdk v0 Pydantic RootModel — ``part.root.kind == 'file'`` with
|
||||||
``part.root.file.{uri,name,mimeType}``.
|
``part.root.file.{uri,name,mimeType}``. The hot path; this is
|
||||||
2. a2a-sdk v0 flatter shape — ``part.kind == 'file'`` with
|
what every current caller produces (canvas chat, A2A peer
|
||||||
``part.file.{uri,name,mimeType}`` (some hand-built callers).
|
delegations, agent self-attached files).
|
||||||
3. a2a-sdk v1 protobuf — ``part.url`` non-empty with
|
2. v0 flatter shape — ``part.kind == 'file'`` with
|
||||||
``part.filename`` + ``part.media_type``. The v1 ``Part`` proto
|
``part.file.{uri,name,mimeType}``. Some hand-built callers
|
||||||
has no ``kind`` field at all (the discriminator is now a oneof
|
(older test fixtures, third-party clients) emit this.
|
||||||
``content`` of {text, raw, url, data}). Without this branch a v1
|
3. v1 protobuf — ``part.url`` non-empty with ``part.filename`` +
|
||||||
file part — which is what a v1 server constructs from any caller
|
``part.media_type``. **Defensive future-proofing only.** The
|
||||||
that JSON-encodes the v1 shape — silently parses to an empty
|
v1 ``Part`` proto exists in a2a-sdk's ``a2a.types.a2a_pb2`` but
|
||||||
Part on the v0→v1 transition because protobuf json_format with
|
a2a-sdk's JSON-RPC layer still validates inbound requests
|
||||||
``ignore_unknown_fields=True`` drops the legacy ``kind`` and
|
against the v0 Pydantic discriminated union (TextPart |
|
||||||
``file`` keys, surfacing as the user-visible
|
FilePart | DataPart), so a v1 wire shape is rejected at the
|
||||||
"Error: message contained no text content" on image-only chats
|
request boundary today — this branch is unreachable on the
|
||||||
(2026-05-01 hongming incident).
|
JSON-RPC ingress path. Kept so a future SDK release that
|
||||||
|
flips the JSON-RPC schema doesn't silently regress this
|
||||||
|
helper, and so non-conformant in-process callers (e.g. a
|
||||||
|
template that constructs a Part directly from protobuf) get
|
||||||
|
handled correctly.
|
||||||
|
|
||||||
Non-file parts and files with unresolvable URIs are skipped — the
|
Non-file parts and files with unresolvable URIs are skipped — the
|
||||||
caller sees an empty list rather than a mix of valid and broken
|
caller sees an empty list rather than a mix of valid and broken
|
||||||
@ -884,10 +888,11 @@ def extract_attached_files(message: Any) -> list[dict[str, str]]:
|
|||||||
name = getattr(f, "name", "") or ""
|
name = getattr(f, "name", "") or ""
|
||||||
mime = getattr(f, "mimeType", None) or getattr(f, "mime_type", None) or ""
|
mime = getattr(f, "mimeType", None) or getattr(f, "mime_type", None) or ""
|
||||||
else:
|
else:
|
||||||
# v1 protobuf Part has no `kind`; detect by a non-empty
|
# Defensive v1 path (see docstring): v1 Part has no `kind`,
|
||||||
# `url` (the file/url-of-bytes oneof slot). Fall back to
|
# detect by a non-empty `url` (the file/url-of-bytes oneof
|
||||||
# `media_type` then `mimeType` for the camelCase Pydantic
|
# slot). Fall back from snake_case `media_type` to
|
||||||
# variant some adapters still hand us.
|
# camelCase `mediaType` for callers that hand us the
|
||||||
|
# Pydantic-style attribute name.
|
||||||
v1_url = getattr(part, "url", "") or ""
|
v1_url = getattr(part, "url", "") or ""
|
||||||
if not v1_url:
|
if not v1_url:
|
||||||
continue
|
continue
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user