Skip to content
Open
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
4 changes: 3 additions & 1 deletion python/packages/ag-ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ AG-UI protocol integration for building agent UIs with the AG-UI standard.
- Text annotations are surfaced as `CUSTOM` events with `name="annotations"` and
`value={"messageId": ..., "annotations": [...]}`. Annotation-only updates announce a text message if needed
without emitting empty text deltas. Provider `raw_representation` is omitted; `AGUIChatClient` restores the
annotations on text content. These live custom events are not replayed by `MESSAGES_SNAPSHOT`.
annotations on text content. Content-coordinate regions on text deltas are rebased to accumulated-message
offsets; source-coordinate regions are preserved unchanged. These live custom events are not replayed by
`MESSAGES_SNAPSHOT`.
- Inbound custom event aliases are accepted: `CUSTOM`, `CUSTOM_EVENT`, and `custom_event`.
- Multimodal user inputs support both legacy (`text`, `binary`) and draft-style (`image`, `audio`, `video`, `document`) shapes.
- Interrupted runs complete with `RUN_FINISHED.outcome.type == "interrupt"` and canonical `outcome.interrupts`; do not document or add new flows that depend on the legacy top-level `RUN_FINISHED.interrupt` field.
Expand Down
2 changes: 2 additions & 0 deletions python/packages/ag-ui/agent_framework_ag_ui/_run_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,8 @@ def _offset_annotation_text_spans(annotations: list[dict[str, Any]], offset: int
if offset == 0:
return
for annotation in annotations:
if annotation.get("annotated_regions_coordinate_space", "content") != "content":
continue
regions = annotation.get("annotated_regions")
if not isinstance(regions, list):
continue
Expand Down
17 changes: 17 additions & 0 deletions python/packages/ag-ui/tests/ag_ui/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,23 @@ def test_emit_text_rebases_delta_annotation_spans_and_deduplicates_full_replay()
assert replay_events == []


def test_emit_text_does_not_rebase_source_coordinate_annotation_spans() -> None:
"""Source-document locations remain independent of accumulated response text."""
flow = FlowState()
annotation = Annotation(
type="citation",
url="https://example.com/source",
annotated_regions=[{"type": "text_span", "start_index": 2, "end_index": 5}],
annotated_regions_coordinate_space="source",
)
_emit_text(Content.from_text("Prefix "), flow)

events = _emit_text(Content.from_text("target", annotations=[annotation]), flow)

citation_event = next(event for event in events if isinstance(event, CustomEvent))
assert citation_event.value["annotations"][0] == annotation


def test_emit_text_preserves_annotation_only_spans() -> None:
"""Annotation-only updates already use complete-message span indices."""
flow = FlowState()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1725,6 +1725,13 @@ def _parse_contents_from_anthropic(
def _parse_citations_from_anthropic(
self, content_block: BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock
) -> list[Annotation] | None:
"""Parse Anthropic citations into annotations.

Note:
Document and search-result citations retain their source coordinates in ``annotated_regions``.
Their ``annotated_regions_coordinate_space`` is ``"source"`` to distinguish these locations from
generated-content character offsets. Protocol adapters do not emit these regions as output-text spans.
"""
content_blocks = getattr(content_block, "citations", None)
if not content_blocks:
return None
Expand Down Expand Up @@ -1789,6 +1796,8 @@ def _parse_citations_from_anthropic(
)
case _:
logger.debug(f"Unknown citation type encountered: {citation.type}")
if cit.get("annotated_regions"):
cit["annotated_regions_coordinate_space"] = "source"
annotations.append(cit)
return annotations or None

Expand Down
44 changes: 44 additions & 0 deletions python/packages/anthropic/tests/test_anthropic_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4304,6 +4304,50 @@ def test_parse_citations_search_result_location(
assert len(result) > 0


@pytest.mark.parametrize(
("citation_type", "location_fields", "expected_span"),
[
("char_location", {"start_char_index": 2, "end_char_index": 12}, (2, 12)),
("page_location", {"start_page_number": 1, "end_page_number": 3}, (1, 3)),
("content_block_location", {"start_block_index": 0, "end_block_index": 2}, (0, 2)),
("search_result_location", {"start_block_index": 3, "end_block_index": 5}, (3, 5)),
("web_search_result_location", {}, None),
],
)
def test_parse_citations_marks_source_coordinate_space(
mock_anthropic_client: MagicMock,
citation_type: str,
location_fields: dict[str, int],
expected_span: tuple[int, int] | None,
) -> None:
client = create_test_anthropic_client(mock_anthropic_client)
citation = MagicMock(
type=citation_type,
title="Source",
document_title="Source",
cited_text="Cited text",
file_id=None,
url="https://example.com",
source="https://example.com",
)
citation.configure_mock(**location_fields)
block = MagicMock(type="text", text="Answer", citations=[citation])

result = client._parse_citations_from_anthropic(block)

assert result is not None
assert len(result) == 1
annotation = result[0]
if expected_span is None:
assert "annotated_regions" not in annotation
assert "annotated_regions_coordinate_space" not in annotation
else:
assert annotation["annotated_regions"] == [
{"type": "text_span", "start_index": expected_span[0], "end_index": expected_span[1]}
]
assert annotation["annotated_regions_coordinate_space"] == "source"


@pytest.mark.flaky
@pytest.mark.integration
@skip_if_anthropic_integration_tests_disabled
Expand Down
3 changes: 3 additions & 0 deletions python/packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ agent_framework/
`additional_properties` namespaces and streamed mergeable content uses `merge_mode`; response updates can use the
same field to replace all contents of one logical message; see
[spec 005](../../../docs/specs/005-python-content-identity-and-protocol-metadata.md).
- **Citation coordinates** - `Annotation.annotated_regions_coordinate_space` distinguishes spans in generated
`Content.text` (`"content"`, also the default when omitted) from locations in cited source material (`"source"`).
Protocol adapters must not interpret source-coordinate regions as generated-text offsets.
- **Computer use** - `ComputerSafetyCheck` and the `Content.from_computer_tool_call` / `from_computer_tool_result`
constructors are experimental under `COMPUTER_USE`; the rest of `Content` retains its existing stage. Computer
results can omit screenshots in core; OpenAI-based connectors require them when converting to Responses items.
Expand Down
46 changes: 46 additions & 0 deletions python/packages/core/agent_framework/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,10 +481,56 @@ class Annotation(TypedDict, total=False):
tool_name: str
snippet: str
annotated_regions: Sequence[TextSpanRegion]
annotated_regions_coordinate_space: Literal["content", "source"]
additional_properties: dict[str, Any]
raw_representation: Any


class _ValidatedTextSpanRegion(TypedDict):
type: Literal["text_span"]
start_index: int
end_index: int


def _get_annotation_text_span_regions( # pyright: ignore[reportUnusedFunction]
annotation: Mapping[str, Any],
*,
coordinate_space: Literal["content", "source"] = "content",
) -> list[_ValidatedTextSpanRegion]:
"""Return validated text spans in the requested coordinate space.

An absent coordinate space retains the legacy meaning that regions refer to the
annotated ``Content.text``. Source-coordinate regions refer to the cited source
and must not be interpreted as generated-content offsets.
"""
if annotation.get("annotated_regions_coordinate_space", "content") != coordinate_space:
return []

raw_regions = annotation.get("annotated_regions")
if not isinstance(raw_regions, Sequence) or isinstance(raw_regions, (str, bytes, bytearray)):
return []

regions: list[_ValidatedTextSpanRegion] = []
for raw_region in cast(Sequence[Any], raw_regions):
if not isinstance(raw_region, Mapping):
continue
region = cast(Mapping[str, Any], raw_region)
if region.get("type") != "text_span":
continue
start_index = region.get("start_index")
end_index = region.get("end_index")
if type(start_index) is not int or type(end_index) is not int or start_index < 0 or end_index < start_index:
continue
regions.append(
_ValidatedTextSpanRegion(
type="text_span",
start_index=start_index,
end_index=end_index,
)
)
return regions


@experimental(feature_id=ExperimentalFeature.COMPUTER_USE)
class ComputerSafetyCheck(TypedDict, total=False):
"""A provider warning that must be reviewed before running computer actions."""
Expand Down
34 changes: 33 additions & 1 deletion python/packages/core/tests/core/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from agent_framework._types import (
_MODEL_OUTPUT_KIND_KEY,
_append_instructions,
_get_annotation_text_span_regions,
_get_data_bytes,
_get_data_bytes_as_str,
_get_operation_state,
Expand Down Expand Up @@ -4426,7 +4427,13 @@ def test_text_content_with_annotations_serialization():
# Create multiple citations
citation1 = Annotation(type="citation", title="Citation 1", url="http://example.com/1", annotated_regions=[region1])

citation2 = Annotation(type="citation", title="Citation 2", url="http://example.com/2", annotated_regions=[region2])
citation2 = Annotation(
type="citation",
title="Citation 2",
url="http://example.com/2",
annotated_regions=[region2],
annotated_regions_coordinate_space="source",
)

# Create TextContent with multiple annotations
content = Content.from_text(text="Hello world", annotations=[citation1, citation2])
Expand All @@ -4438,6 +4445,7 @@ def test_text_content_with_annotations_serialization():
assert len(content_dict["annotations"]) == 2
assert content_dict["annotations"][0]["title"] == "Citation 1"
assert content_dict["annotations"][1]["title"] == "Citation 2"
assert content_dict["annotations"][1]["annotated_regions_coordinate_space"] == "source"

# Deserialize
reconstructed = Content.from_dict(content_dict)
Expand All @@ -4449,9 +4457,33 @@ def test_text_content_with_annotations_serialization():
assert all(isinstance(ann, dict) for ann in reconstructed.annotations) # type: ignore[union-attr] # pyrefly: ignore[not-iterable]
assert reconstructed.annotations[0]["title"] == "Citation 1" # type: ignore[index] # pyrefly: ignore[unsupported-operation]
assert reconstructed.annotations[1]["title"] == "Citation 2" # type: ignore[index] # pyrefly: ignore[unsupported-operation]
assert reconstructed.annotations[1]["annotated_regions_coordinate_space"] == "source" # type: ignore[index] # pyrefly: ignore[unsupported-operation]
assert all(isinstance(ann["annotated_regions"][0], dict) for ann in reconstructed.annotations) # type: ignore[union-attr] # pyrefly: ignore[not-iterable]


def test_get_annotation_text_span_regions_filters_coordinate_space_and_invalid_regions() -> None:
annotation = cast(
Annotation,
{
"type": "citation",
"annotated_regions_coordinate_space": "source",
"annotated_regions": [
{"type": "text_span", "start_index": 2, "end_index": 7},
{"type": "text_span", "start_index": True, "end_index": 7},
{"type": "text_span", "start_index": -1, "end_index": 7},
{"type": "text_span", "start_index": 8, "end_index": 7},
{"type": "source_block", "start_index": 0, "end_index": 1},
None,
],
},
)

assert _get_annotation_text_span_regions(annotation) == []
assert _get_annotation_text_span_regions(annotation, coordinate_space="source") == [
{"type": "text_span", "start_index": 2, "end_index": 7}
]


# region FunctionTool.parse_result with Pydantic models


Expand Down
12 changes: 12 additions & 0 deletions python/packages/foundry_hosting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,18 @@ output item ID without changing the provider's `call_id`.
Although screenshots are optional in shared `Content`, this Responses host requires them on computer results.
The computer-use `Content` constructors and `ComputerSafetyCheck` type are experimental Agent Framework APIs.

## Responses citation annotations

`ResponsesHostServer` serializes supported framework URL and file citations into `output_text.annotations`.
URL and container-file citation spans refer to character positions in the generated response text.
Annotations with `annotated_regions_coordinate_space == "source"` retain source-document coordinates
(for example, Anthropic character, page, or block locations); the host does not emit those regions as
output-text spans. Other supported citation metadata, such as file citations with an explicit output index,
is still converted.
Completed provider responses retain separate text content parts, keeping citation offsets relative to
their original part. Ordinary streaming deltas still merge within their existing text occurrence,
without shifting citation offsets.

## State store

### Local persistence
Expand Down
Loading
Loading