Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions conformance/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ the request still goes out.
field and adding it would change a public type and the generation-export mapping. Go
and Python cover the field in their own tests.

Media parts are absent, because only Go's `model.Part` can hold one and the server
has no `media` kind. All three SDKs drop a media part, and a message left with no
parts serializes as `"parts": []`.
Media parts are absent, because the server has no `media` kind. Go's `model.Part` and
Python's `Part` can hold a media part; the JS `MessagePart` union has no media member,
so JS has none to drop. Go and Python drop theirs, and a message left with no parts
serializes as `"parts": []` in all three SDKs.

## Comparison rule

Expand Down
9 changes: 5 additions & 4 deletions go/agento11y/hooks_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,10 +493,11 @@ func TestHooksRequestKeepsAnUnparsablePayload(t *testing.T) {
}

func TestHooksRequestDropsPartsTheServerCannotRead(t *testing.T) {
// Only Go's model.Part can hold a media part or a kind-less part. The server
// has no media kind, and its default branch would decode one as an empty text
// part. The hook serializer therefore drops any part without a payload the
// server can read, which is what Python and JS do. A message left with no parts
// The server has no media kind, and its default branch would decode a media part
// as an empty text part. The hook serializer therefore drops any part without a
// payload the server can read, which is what Python and JS do. Go and Python can
// both hold a media part; the JS MessagePart union has no media member, so JS has
// none to drop. A kind-less part is Go-only. A message left with no parts
// serializes as [] in all three SDKs.
payload, err := json.Marshal(newHookWireRequest(HookEvaluateRequest{
Phase: HookPhasePreflight,
Expand Down
7 changes: 4 additions & 3 deletions go/agento11y/redaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,10 @@ func sanitizeMessage(m *Message, mode textMode, includeEmail bool) {
}
}
case PartKindMedia:
// Media URLs and data URLs are generation content, but this sanitizer
// only redacts textual and JSON payloads. Metadata-only capture strips
// media URLs before export when content capture is disabled.
// A media URL is generation content, but this sanitizer only redacts text
// and JSON payloads. ContentCaptureModeMetadataOnly is the only mode that
// clears a media URL, and it runs instead of the sanitizer, so under every
// other mode the URL is exported as the caller set it.
continue
}
}
Expand Down
4 changes: 4 additions & 0 deletions python/agento11y/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
Generation,
GenerationMode,
GenerationStart,
Media,
Message,
MessageRole,
ModelRef,
Expand All @@ -105,6 +106,7 @@
TrialEvaluationStatus,
WorkflowStep,
assistant_text_message,
media_part,
text_part,
thinking_part,
tool_call_part,
Expand Down Expand Up @@ -171,6 +173,7 @@
"HooksConfig",
"hook_denied_from_response",
"MappingError",
"Media",
"Message",
"MessageRole",
"ModelRef",
Expand Down Expand Up @@ -207,6 +210,7 @@
"set_cache_diagnostics",
"conversation_id_from_context",
"conversation_title_from_context",
"media_part",
"text_part",
"thinking_part",
"tool_call_part",
Expand Down
7 changes: 6 additions & 1 deletion python/agento11y/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ def _strip_content(generation: Generation, error_category: str) -> None:


def _strip_message_content(message: Message) -> None:
"""Strips all content from message parts (text, thinking, tool call input, tool result)."""
"""Strips all content from message parts (text, thinking, tool call input, tool result, media URL)."""
for part in message.parts:
part.text = ""
part.thinking = ""
Expand All @@ -283,6 +283,11 @@ def _strip_message_content(message: Message) -> None:
if part.tool_result is not None:
part.tool_result.content = ""
part.tool_result.content_json = b""
if part.media is not None:
# A media URL is content: it can point at the payload or hold the bytes
# inline as a data: URI, and both forms are cleared. The kind, mime type,
# and name are references and stay.
part.media.url = ""


def _serialize_tool_result_payload(value: Any) -> tuple[str, bytes]:
Expand Down
24 changes: 24 additions & 0 deletions python/agento11y/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class PartKind(str, Enum):
THINKING = "thinking"
TOOL_CALL = "tool_call"
TOOL_RESULT = "tool_result"
MEDIA = "media"


class ContentCaptureMode(str, Enum):
Expand Down Expand Up @@ -170,6 +171,22 @@ class ToolResult:
is_error: bool = False


@dataclass(slots=True)
class Media:
"""Reference to a non-text payload in a message part.

`url` is required: export validation rejects a media part whose URL is empty
or blank. It can point at the payload or hold the bytes inline as a `data:`
URI. metadata_only capture clears `url` in either form and keeps `kind`,
`mime_type`, and `name`.
"""

kind: str = ""
url: str = ""
mime_type: str = ""
name: str = ""


@dataclass(slots=True)
class Part:
"""Typed message part."""
Expand All @@ -179,6 +196,7 @@ class Part:
thinking: str = ""
tool_call: ToolCall | None = None
tool_result: ToolResult | None = None
media: Media | None = None
metadata: PartMetadata = field(default_factory=PartMetadata)


Expand Down Expand Up @@ -485,6 +503,12 @@ def tool_result_part(tool_result: ToolResult) -> Part:
return Part(kind=PartKind.TOOL_RESULT, tool_result=tool_result)


def media_part(media: Media) -> Part:
"""Creates a media part."""

return Part(kind=PartKind.MEDIA, media=media)


def user_text_message(text: str) -> Message:
"""Creates a user message with one text part."""

Expand Down
21 changes: 19 additions & 2 deletions python/agento11y/proto_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def _map_generation_mode(mode: GenerationMode | None) -> int:

def _map_message(message: object) -> agento11y_pb2.Message:
role_value = message.role.value if hasattr(message.role, "value") else str(message.role)
parts = [_map_part(part) for part in message.parts]
parts = [mapped for mapped in (_map_part(part) for part in message.parts) if mapped is not None]
return agento11y_pb2.Message(
role=_map_message_role(role_value),
name=message.name,
Expand All @@ -184,7 +184,7 @@ def _map_message_role(role: str) -> int:
return agento11y_pb2.MESSAGE_ROLE_UNSPECIFIED


def _map_part(part: object) -> agento11y_pb2.Part:
def _map_part(part: object) -> agento11y_pb2.Part | None:
metadata = None
provider_type = getattr(part.metadata, "provider_type", "") if getattr(part, "metadata", None) is not None else ""
if provider_type:
Expand Down Expand Up @@ -215,6 +215,23 @@ def _map_part(part: object) -> agento11y_pb2.Part:
is_error=part.tool_result.is_error,
),
)
if kind_value == PartKind.MEDIA.value:
media = part.media
if media is None:
# A media part with no payload is dropped rather than sent as an empty
# part, which the server decodes as empty text. Go's codec.partsToProto
# skips this part the same way; the payload-less tool_call and
# tool_result parts it also skips raise here instead.
return None
return agento11y_pb2.Part(
metadata=metadata,
media=agento11y_pb2.Media(
kind=media.kind,
url=media.url,
mime_type=media.mime_type,
name=media.name,
),
)
return agento11y_pb2.Part(metadata=metadata)


Expand Down
6 changes: 6 additions & 0 deletions python/agento11y/redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,12 @@ def _sanitize_part(part: Part, redactor: _SecretRedactor, default_text_mode: str
if len(part.tool_call.input_json) > 0:
part.tool_call.input_json = redactor.redact(part.tool_call.input_json.decode("utf-8")).encode("utf-8")
return
if part.kind == PartKind.MEDIA:
# A media URL is generation content, but this sanitizer only redacts text
# and JSON payloads. metadata_only capture is the only mode that clears a
# media URL, and it runs instead of the sanitizer, so under every other
# mode the URL is exported as the caller set it.
return
if part.kind == PartKind.TOOL_RESULT and part.tool_result is not None:
part.tool_result.content = redactor.redact(part.tool_result.content)
if len(part.tool_result.content_json) > 0:
Expand Down
23 changes: 19 additions & 4 deletions python/agento11y/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def _validate_part(
PartKind.THINKING.value,
PartKind.TOOL_CALL.value,
PartKind.TOOL_RESULT.value,
PartKind.MEDIA.value,
):
raise ValueError(f"{path}[{message_index}].parts[{part_index}].kind is invalid")

Expand All @@ -125,6 +126,8 @@ def _validate_part(
field_count += 1
if getattr(part, "tool_result", None) is not None:
field_count += 1
if getattr(part, "media", None) is not None:
field_count += 1

# Stripped text/thinking parts have empty payloads — that's expected.
stripped_text_or_thinking = content_stripped and kind in (PartKind.TEXT.value, PartKind.THINKING.value)
Expand All @@ -151,7 +154,19 @@ def _validate_part(
raise ValueError(f"{path}[{message_index}].parts[{part_index}].tool_call.name is required")
return

if role != MessageRole.TOOL.value:
raise ValueError(f"{path}[{message_index}].parts[{part_index}].tool_result only allowed for tool role")
if getattr(part, "tool_result", None) is None:
raise ValueError(f"{path}[{message_index}].parts[{part_index}].tool_result is required")
if kind == PartKind.TOOL_RESULT.value:
if role != MessageRole.TOOL.value:
raise ValueError(f"{path}[{message_index}].parts[{part_index}].tool_result only allowed for tool role")
if getattr(part, "tool_result", None) is None:
raise ValueError(f"{path}[{message_index}].parts[{part_index}].tool_result is required")
return

if kind == PartKind.MEDIA.value:
# Media is allowed on every role, unlike thinking and tool_call (assistant
# only) and tool_result (tool only).
media = getattr(part, "media", None)
if media is None:
raise ValueError(f"{path}[{message_index}].parts[{part_index}].media is required")
if not content_stripped and getattr(media, "url", "").strip() == "":
raise ValueError(f"{path}[{message_index}].parts[{part_index}].media.url is required")
return
29 changes: 28 additions & 1 deletion python/tests/test_content_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
Generation,
GenerationExportConfig,
GenerationStart,
Media,
Message,
MessageRole,
ModelRef,
Expand All @@ -36,6 +37,7 @@
ToolDefinition,
ToolExecutionStart,
ToolResult,
media_part,
validate_generation,
)
from agento11y.context import content_capture_mode_from_context, with_content_capture_mode
Expand Down Expand Up @@ -193,7 +195,20 @@ def _full_generation() -> Generation:
return Generation(
system_prompt="You are helpful.",
input=[
Message(role=MessageRole.USER, parts=[Part(kind=PartKind.TEXT, text="What is the weather?")]),
Message(
role=MessageRole.USER,
parts=[
Part(kind=PartKind.TEXT, text="What is the weather?"),
media_part(
Media(
kind="image",
url="data:image/png;base64,abc123",
mime_type="image/png",
name="weather-map.png",
)
),
],
),
Message(
role=MessageRole.TOOL,
parts=[
Expand Down Expand Up @@ -319,6 +334,7 @@ def test_metadata_only_strips_sensitive_content(self):
assert gen.output[0].parts[2].text == ""
assert gen.input[1].parts[0].tool_result.content == ""
assert gen.input[1].parts[0].tool_result.content_json == b""
assert gen.input[0].parts[1].media.url == ""
assert gen.tools[0].description == ""
assert gen.tools[0].input_schema_json == b""
assert gen.conversation_title == ""
Expand All @@ -334,6 +350,10 @@ def test_metadata_only_strips_sensitive_content(self):
assert gen.output[0].parts[1].tool_call.id == "call_1"
assert gen.input[1].parts[0].tool_result.tool_call_id == "call_1"
assert gen.input[1].parts[0].tool_result.name == "weather"
assert gen.input[0].parts[1].kind == PartKind.MEDIA
assert gen.input[0].parts[1].media.kind == "image"
assert gen.input[0].parts[1].media.mime_type == "image/png"
assert gen.input[0].parts[1].media.name == "weather-map.png"
assert gen.tools[0].name == "weather"
assert gen.usage.input_tokens == 120
assert gen.usage.output_tokens == 42
Expand Down Expand Up @@ -1618,6 +1638,9 @@ def test_generation_proto_and_span(self, expect: _ModeExpect):
assert gen.input[1].parts[0].tool_result.content_json == (
b"" if expect.proto_content_stripped else b'{"temp":18}'
)
assert gen.input[0].parts[1].media.url == (
"" if expect.proto_content_stripped else "data:image/png;base64,abc123"
)
assert gen.tools[0].description == ("" if expect.proto_content_stripped else "Get weather info")
assert gen.tools[0].input_schema_json == (b"" if expect.proto_content_stripped else b'{"type":"object"}')
# Conversation title lives only in metadata (no top-level proto
Expand All @@ -1626,7 +1649,11 @@ def test_generation_proto_and_span(self, expect: _ModeExpect):
# Structural fields (counts, names, IDs, roles) always preserved.
assert len(gen.input) == 2
assert len(gen.output) == 1
assert len(gen.input[0].parts) == 2
assert len(gen.output[0].parts) == 3
assert gen.input[0].parts[1].media.kind == "image"
assert gen.input[0].parts[1].media.mime_type == "image/png"
assert gen.input[0].parts[1].media.name == "weather-map.png"
assert gen.output[0].parts[1].tool_call.name == "weather"
assert gen.output[0].parts[1].tool_call.id == "call_1"
assert gen.tools[0].name == "weather"
Expand Down
Loading
Loading