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
Original file line number Diff line number Diff line change
Expand Up @@ -1725,6 +1725,14 @@ 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 ``additional_properties["annotated_regions_coordinate_space"]`` is ``"source"`` to distinguish
these locations from generated-response character offsets. The Responses host does 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 +1797,8 @@ def _parse_citations_from_anthropic(
)
case _:
logger.debug(f"Unknown citation type encountered: {citation.type}")
if cit.get("annotated_regions"):
cit["additional_properties"] = {"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.get("additional_properties", {})
else:
assert annotation["annotated_regions"] == [
{"type": "text_span", "start_index": expected_span[0], "end_index": expected_span[1]}
]
assert annotation["additional_properties"]["annotated_regions_coordinate_space"] == "source"


@pytest.mark.flaky
@pytest.mark.integration
@skip_if_anthropic_integration_tests_disabled
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 `additional_properties["annotated_regions_coordinate_space"] == "source"` retain
source-document coordinates (for example, Anthropic character, page, or block locations); the host leaves
those annotations unchanged but does not emit their 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
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
add_usage_details,
get_namespaced_value,
)
from agent_framework import (
Annotation as FrameworkAnnotation,
)
from agent_framework._mcp import _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY # pyright: ignore[reportPrivateUsage]
from agent_framework._telemetry import mark_feature_used
from agent_framework.exceptions import AgentFrameworkException
Expand All @@ -63,12 +66,17 @@
from azure.ai.agentserver.responses._id_generator import IdGenerator
from azure.ai.agentserver.responses.aio import ResponseEventStream
from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost
from azure.ai.agentserver.responses.models import (
Annotation as ResponsesAnnotation,
)
from azure.ai.agentserver.responses.models import (
ComputerAction,
ComputerCallSafetyCheckParam,
ComputerScreenshotImage,
ContainerFileCitationBody,
CreateResponse,
FileCitationBody,
FilePath,
FunctionShellAction,
FunctionShellCallOutputContent,
FunctionShellCallOutputExitOutcome,
Expand All @@ -88,6 +96,7 @@
ResponseUsage,
ResponseUsageInputTokensDetails,
ResponseUsageOutputTokensDetails,
UrlCitationBody,
)
from azure.ai.agentserver.responses.streaming._builders import (
OutputItemBuilder,
Expand Down Expand Up @@ -272,6 +281,98 @@ def _container_file_citations_from_function_result(content: Content) -> list[_Co
return citations


def _annotations_to_output_text(
annotations: Sequence[FrameworkAnnotation] | None,
) -> list[ResponsesAnnotation]:
"""Convert framework citations into Responses output-text annotations.

Regions with ``additional_properties["annotated_regions_coordinate_space"] == "source"``
describe the source document, not output text, and are not emitted as output spans.
"""
if not annotations or not isinstance(annotations, Sequence) or isinstance(annotations, (str, bytes)):
return []

output_annotations: list[ResponsesAnnotation] = []
for annotation in annotations:
if not isinstance(annotation, Mapping):
continue
annotation_mapping = cast("Mapping[str, Any]", annotation)
if annotation_mapping.get("type") != "citation":
continue

raw_properties = annotation_mapping.get("additional_properties")
properties: Mapping[str, Any] = (
cast("Mapping[str, Any]", raw_properties) if isinstance(raw_properties, Mapping) else {}
)
raw_regions = annotation_mapping.get("annotated_regions")
if properties.get("annotated_regions_coordinate_space") == "source":
raw_regions = None
regions: list[Mapping[str, Any]] = []
if isinstance(raw_regions, Sequence) and not isinstance(raw_regions, (str, bytes)):
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":
regions.append(region)
file_id = annotation_mapping.get("file_id")
url = annotation_mapping.get("url")
title = annotation_mapping.get("title")
container_id = properties.get("container_id")
index = properties.get("index")

if isinstance(container_id, str) and container_id and isinstance(file_id, str) and file_id:
for region in regions:
start_index = region.get("start_index")
end_index = region.get("end_index")
if not isinstance(start_index, int) or not isinstance(end_index, int):
continue
output_annotations.append(
ContainerFileCitationBody(
type="container_file_citation",
container_id=container_id,
file_id=file_id,
filename=url or "",
start_index=start_index,
end_index=end_index,
)
)
elif isinstance(url, str) and url and not file_id:
for region in regions:
Comment thread
sophia-ramsey marked this conversation as resolved.
start_index = region.get("start_index")
end_index = region.get("end_index")
if not isinstance(start_index, int) or not isinstance(end_index, int):
continue
output_annotations.append(
UrlCitationBody(
type="url_citation",
url=url,
title=title or "",
start_index=start_index,
end_index=end_index,
)
)
elif isinstance(file_id, str) and file_id and isinstance(url, str) and url and isinstance(index, int):
output_annotations.append(
FileCitationBody(
type="file_citation",
file_id=file_id,
filename=url,
index=index,
)
)
elif isinstance(file_id, str) and file_id and isinstance(index, int):
output_annotations.append(
FilePath(
type="file_path",
file_id=file_id,
index=index,
)
)

return output_annotations


def _is_refusal_text_content(content: Content) -> bool:
return (
content.type == "text"
Expand Down Expand Up @@ -1763,7 +1864,9 @@ async def _handle_inner_agent(
for update in _agent_response_updates(response, context.response_id):
if context.shutdown.is_set() or cancellation_signal.is_set():
break
async for event in tracker.handle_update(update, approval_storage=approval_storage):
async for event in tracker.handle_update(
update, approval_storage=approval_storage, complete_message=True
):
yield event
if context.shutdown.is_set() or cancellation_signal.is_set():
continue
Expand Down Expand Up @@ -2423,7 +2526,8 @@ def __init__(
self._outstanding_computer_calls: set[str] = set()
self._oauth_consent_requests: set[tuple[str, str]] = set()
self._pending_container_file_citations: dict[str, _ContainerFileCitation] = {}
self._message_text_annotations: dict[int, list[ContainerFileCitationBody]] = {}
self._text_annotations: list[ResponsesAnnotation] = []
self._message_text_annotations: dict[int, list[ResponsesAnnotation]] = {}
# Set when an agent update reports the model stopped early (content filter, token
# limit); the response then ends as ``incomplete`` instead of ``completed`` so callers
# can tell a cut-short turn from a successful one. Mirrored into the stream's
Expand Down Expand Up @@ -2554,15 +2658,23 @@ async def handle_update(
update: AgentResponseUpdate,
*,
approval_storage: FunctionApprovalStore | None = None,
complete_message: bool = False,
) -> AsyncGenerator[ResponseStreamEvent]:
"""Process one agent update: note its finish reason, then handle each of its contents.

This is the single entry point for both the plain-agent and the workflow loops, so the
finish reason cannot be forgotten on one of them.
finish reason cannot be forgotten on one of them. Set ``complete_message`` when the
update contains a completed message's independent content parts rather than streaming
deltas; text and refusal parts then retain their original boundaries and citation offsets.
"""
self.record_finish_reason(update.finish_reason)
for content in update.contents:
async for event in self.handle(content, message_id=update.message_id, approval_storage=approval_storage):
async for event in self.handle(
content,
message_id=update.message_id,
approval_storage=approval_storage,
start_new_content_part=complete_message,
):
yield event

async def handle(
Expand All @@ -2571,6 +2683,7 @@ async def handle(
message_id: str | None = None,
*,
approval_storage: FunctionApprovalStore | None = None,
start_new_content_part: bool = False,
) -> AsyncGenerator[ResponseStreamEvent]:
"""Process a content item, yielding its events.

Expand All @@ -2582,20 +2695,25 @@ async def handle(
merged into it.
approval_storage: Used for content types that fall back to one-shot emission
(anything not recognized as a streaming delta type) to save/load approval requests.
start_new_content_part: Whether text and refusal content start a new independent
content part instead of continuing the current streaming occurrence.
"""
if _is_refusal_text_content(content) and content.text is not None:
for event in self._ensure_message_content("refusal", message_id):
for event in self._ensure_message_content("refusal", message_id, start_new_part=start_new_content_part):
yield event
self._active_message_id = message_id
delta = self._consume_text_update(content.text, content.merge_mode)
if delta and self._refusal_content is not None:
yield self._refusal_content.emit_delta(delta)

elif content.type == "text" and content.text is not None:
for event in self._ensure_message_content("text", message_id):
for event in self._ensure_message_content("text", message_id, start_new_part=start_new_content_part):
yield event
self._active_message_id = message_id
delta = self._consume_text_update(content.text, content.merge_mode)
for annotation in _annotations_to_output_text(content.annotations):
if annotation not in self._text_annotations:
self._text_annotations.append(annotation)
if delta and self._text_content is not None:
yield self._text_content.emit_delta(delta)

Expand Down Expand Up @@ -2905,11 +3023,13 @@ def _ensure_message_content(
self,
content_type: Literal["text", "refusal"],
message_id: str | None,
*,
start_new_part: bool = False,
) -> Generator[ResponseStreamEvent]:
message_changed = (
message_id is not None and self._active_message_id is not None and message_id != self._active_message_id
)
if self._active_type == content_type and not message_changed:
if self._active_type == content_type and not message_changed and not start_new_part:
return
if self._message_item is not None and self._active_type in {"text", "refusal"} and not message_changed:
yield from self._close_message_content()
Expand Down Expand Up @@ -2937,6 +3057,7 @@ def _open_message_content(
yield self._refusal_content.emit_added()
else:
self._text_content = self._message_item.add_text_content()
self._text_annotations.clear()
yield self._text_content.emit_added()

def _open_reasoning(self, content: Content) -> Generator[ResponseStreamEvent]:
Expand Down Expand Up @@ -3046,7 +3167,10 @@ def _close(self) -> Generator[ResponseStreamEvent]:
def _close_message_content(self) -> Generator[ResponseStreamEvent]:
accumulated = "".join(self._accumulated)
if self._active_type == "text" and self._text_content is not None:
annotations = self._container_file_annotations_for_text(accumulated)
annotations = list(self._text_annotations)
for annotation in self._container_file_annotations_for_text(accumulated):
if annotation not in annotations:
annotations.append(annotation)
yield self._text_content.emit_text_done(accumulated)
for annotation in annotations:
yield self._text_content.emit_annotation_added(annotation)
Expand All @@ -3058,6 +3182,7 @@ def _close_message_content(self) -> Generator[ResponseStreamEvent]:
self._message_text_annotations[self._text_content.content_index] = annotations
yield content_done
self._text_content = None
self._text_annotations.clear()
elif self._active_type == "refusal" and self._refusal_content is not None:
yield self._refusal_content.emit_refusal_done(accumulated)
yield self._refusal_content.emit_done()
Expand Down
Loading
Loading