Skip to content
Merged
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 @@ -246,7 +246,6 @@ def _handle_run_error(self, event: dict[str, Any]) -> ChatResponseUpdate:

return ChatResponseUpdate(
role="assistant",
finish_reason="content_filter",
contents=[
Content.from_error(
message=error_message,
Expand Down
2 changes: 1 addition & 1 deletion python/packages/ag-ui/tests/ag_ui/test_event_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ def test_run_error_event(self) -> None:

assert update is not None
assert update.role == "assistant"
assert update.finish_reason == "content_filter"
assert update.finish_reason is None
assert len(update.contents) == 1
assert update.contents[0].message == "Connection timeout"
assert update.contents[0].error_code == "RUN_ERROR"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ class BedrockChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], t
"max_tokens": "length",
"length": "length",
"content_filtered": "content_filter",
"guardrail_intervened": "content_filter",
"tool_use": "tool_calls",
}

Expand Down Expand Up @@ -760,10 +761,10 @@ def _parse_message_contents(self, content_blocks: Sequence[dict[str, Any]]) -> l
logger.debug("Ignoring unsupported Bedrock content block: %s", block)
return contents

def _map_finish_reason(self, reason: str | None) -> FinishReasonLiteral | None:
def _map_finish_reason(self, reason: str | None) -> str | None:
if not reason:
return None
return FINISH_REASON_MAP.get(reason.lower())
return FINISH_REASON_MAP.get(reason.lower(), reason)

def _prepare_output_config(self, response_format: Any | None) -> dict[str, Any] | None:
"""Convert response_format into the AWS Bedrock outputConfig wire format.
Expand Down
6 changes: 6 additions & 0 deletions python/packages/bedrock/tests/test_bedrock_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ def test_process_response_parses_tool_use_and_result() -> None:
assert chat_response.finish_reason == client._map_finish_reason("tool_use")


def test_map_finish_reason_maps_guardrail_intervention() -> None:
client = _build_client()

assert client._map_finish_reason("guardrail_intervened") == "content_filter"


def test_process_response_parses_tool_result() -> None:
client = _build_client()
response = {
Expand Down
12 changes: 11 additions & 1 deletion python/packages/claude/agent_framework_claude/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@

logger = logging.getLogger("agent_framework.claude")

FINISH_REASON_MAP: dict[str, str] = {
"end_turn": "stop",
"stop_sequence": "stop",
"max_tokens": "length",
"tool_use": "tool_calls",
"refusal": "content_filter",
}


# Name of the in-process MCP server that hosts Agent Framework tools.
# FunctionTool instances are converted to SDK MCP tools and served
Expand Down Expand Up @@ -843,7 +851,9 @@ async def _get_stream(
}.items()
if isinstance(value, int)
})
finish_reason = message.stop_reason
finish_reason = (
FINISH_REASON_MAP.get(message.stop_reason, message.stop_reason) if message.stop_reason else None
)
if usage_details or finish_reason:
yield AgentResponseUpdate(
contents=[Content.from_usage(usage_details, raw_representation=message)]
Expand Down
14 changes: 10 additions & 4 deletions python/packages/claude/tests/test_claude_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,13 @@ async def test_run_captures_session_id(self) -> None:
await agent.run("Hello", session=session)
assert session.service_session_id == "test-session-id"

async def test_run_captures_result_message_usage_and_finish_reason(self) -> None:
@pytest.mark.parametrize(
("stop_reason", "expected_finish_reason"),
[("end_turn", "stop"), ("pause_turn", "pause_turn")],
)
async def test_run_captures_result_message_usage_and_finish_reason(
self, stop_reason: str, expected_finish_reason: str
) -> None:
"""Test that ResultMessage metadata is propagated to the final AgentResponse."""
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock
from claude_agent_sdk.types import StreamEvent
Expand All @@ -296,7 +302,7 @@ async def test_run_captures_result_message_usage_and_finish_reason(self) -> None
is_error=False,
num_turns=1,
session_id="test-session-id",
stop_reason="end_turn",
stop_reason=stop_reason,
usage={
"input_tokens": 42,
"output_tokens": 18,
Expand All @@ -311,7 +317,7 @@ async def test_run_captures_result_message_usage_and_finish_reason(self) -> None
agent = ClaudeAgent()
response = await agent.run("Hello")

assert response.finish_reason == "end_turn"
assert response.finish_reason == expected_finish_reason
assert response.usage_details == {
"input_token_count": 42,
"output_token_count": 18,
Expand Down Expand Up @@ -465,7 +471,7 @@ async def test_run_stream_final_response_captures_usage_and_finish_reason(self)
pass
response = await stream.get_final_response()

assert response.finish_reason == "max_tokens"
assert response.finish_reason == "length"
assert response.usage_details == {
"input_token_count": 7,
"output_token_count": 9,
Expand Down
6 changes: 5 additions & 1 deletion python/packages/core/agent_framework/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -1582,11 +1582,15 @@ async def _finalize_stream() -> None:
and response.messages
and span.is_recording()
):
finish_reason = cast(
"FinishReason | None",
response.finish_reason if response.finish_reason in FINISH_REASON_MAP else None,
)
_capture_messages(
span=span,
provider_name=provider_name,
messages=response.messages,
finish_reason=response.finish_reason, # type: ignore[arg-type]
finish_reason=finish_reason,
output=True,
)
except Exception as exception:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -635,8 +635,11 @@ def usage_event_handler(event: SessionEvent) -> None:
parsed_usage_details = self._parse_usage_details_from_copilot(event.data)
if parsed_usage_details:
usage_details = add_usage_details(usage_details, parsed_usage_details)
if event.data.finish_reason:
finish_reason = event.data.finish_reason
event_finish_reason = (
"content_filter" if event.data.content_filter_triggered else event.data.finish_reason
)
if event_finish_reason:
finish_reason = event_finish_reason
if event.data.model:
model = event.data.model
else:
Expand Down Expand Up @@ -780,7 +783,7 @@ def event_handler(event: SessionEvent) -> None:
)
return
usage_details = self._parse_usage_details_from_copilot(event.data)
finish_reason = event.data.finish_reason or None
finish_reason = "content_filter" if event.data.content_filter_triggered else event.data.finish_reason
model = event.data.model or None
if usage_details or finish_reason or model:
update = AgentResponseUpdate(
Expand Down
14 changes: 11 additions & 3 deletions python/packages/github_copilot/tests/test_github_copilot_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,11 +488,18 @@ async def test_run_captures_assistant_usage_event(
input_tokens=5,
output_tokens=2,
finish_reason="length",
content_filter_triggered=True,
),
id=uuid4(),
timestamp=datetime.now(timezone.utc),
type=SessionEventType.ASSISTANT_USAGE,
)
empty_usage_event = SessionEvent(
data=AssistantUsageData(model="gpt-5.1-mini"),
id=uuid4(),
timestamp=datetime.now(timezone.utc),
type=SessionEventType.ASSISTANT_USAGE,
)
usage_handler: Any = None

def mock_on(handler: Any) -> Any:
Expand All @@ -503,6 +510,7 @@ def mock_on(handler: Any) -> Any:
async def mock_send_and_wait(*args: Any, **kwargs: Any) -> SessionEvent:
usage_handler(usage_event)
usage_handler(second_usage_event)
usage_handler(empty_usage_event)
return assistant_message_event

mock_session.on = mock_on
Expand All @@ -511,7 +519,7 @@ async def mock_send_and_wait(*args: Any, **kwargs: Any) -> SessionEvent:
agent = GitHubCopilotAgent(client=mock_client)
response = await agent.run("Hello")

assert response.finish_reason == "length"
assert response.finish_reason == "content_filter"
assert response.usage_details == {
"input_token_count": 125,
"output_token_count": 42,
Expand Down Expand Up @@ -596,7 +604,7 @@ async def test_run_streaming_captures_assistant_usage_event(
model="gpt-5.1-mini",
input_tokens=10,
output_tokens=4,
finish_reason="stop",
finish_reason="provider_specific_reason",
)
usage_event = SessionEvent(
data=usage_data,
Expand All @@ -620,7 +628,7 @@ def mock_on(handler: Any) -> Any:
response = await stream.get_final_response()

assert response.text == "Hello"
assert response.finish_reason == "stop"
assert response.finish_reason == "provider_specific_reason"
assert response.usage_details == {
"input_token_count": 10,
"output_token_count": 4,
Expand Down
10 changes: 8 additions & 2 deletions python/packages/ollama/agent_framework_ollama/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
ChatResponse,
ChatResponseUpdate,
Content,
FinishReason,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
FunctionTool,
Expand Down Expand Up @@ -542,6 +543,11 @@ def _parse_contents_from_ollama(self, response: OllamaChatResponse) -> list[Cont
contents.extend(tool_calls)
return contents

def _get_finish_reason_from_ollama(self, response: OllamaChatResponse) -> FinishReason | None:
if response.message.tool_calls:
return FinishReason("tool_calls")
return FinishReason(response.done_reason) if response.done_reason else None

def _parse_streaming_response_from_ollama(self, response: OllamaChatResponse) -> ChatResponseUpdate:
contents = self._parse_contents_from_ollama(response)
finish_reason = None
Expand All @@ -561,7 +567,7 @@ def _parse_streaming_response_from_ollama(self, response: OllamaChatResponse) ->
)
if usage_details:
contents.append(Content.from_usage(usage_details, raw_representation=response))
finish_reason = response.done_reason if response.done_reason in ("stop", "length") else None
finish_reason = self._get_finish_reason_from_ollama(response)
return ChatResponseUpdate(
contents=contents,
role="assistant",
Expand Down Expand Up @@ -590,7 +596,7 @@ def _parse_response_from_ollama(
if isinstance(value, int)
}
)
finish_reason = response.done_reason if response.done_reason in ("stop", "length") else None
finish_reason = self._get_finish_reason_from_ollama(response)

return ChatResponse(
messages=[Message(role="assistant", contents=contents)],
Expand Down
4 changes: 2 additions & 2 deletions python/packages/ollama/tests/test_ollama_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ async def test_cmc_maps_done_reason_to_finish_reason(


@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_leaves_unknown_done_reason_unset(
async def test_cmc_preserves_unknown_done_reason(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
Expand All @@ -305,7 +305,7 @@ async def test_cmc_leaves_unknown_done_reason_unset(
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history)

assert result.finish_reason is None
assert result.finish_reason == "load"


@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
Expand Down
21 changes: 20 additions & 1 deletion python/packages/openai/agent_framework_openai/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
ChatResponseUpdate,
Content,
ContinuationToken,
FinishReason,
Message,
ResponseStream,
Role,
Expand Down Expand Up @@ -2339,6 +2340,19 @@ def _parse_hosted_function_call_content(
)

# region Parse methods
def _get_finish_reason_from_openai_response(self, response: Any) -> FinishReason | None:
"""Get the framework finish reason from a terminal Responses API response."""
incomplete_reason = getattr(getattr(response, "incomplete_details", None), "reason", None)
if incomplete_reason == "content_filter":
return FinishReason("content_filter")
if incomplete_reason == "max_output_tokens":
return FinishReason("length")
if getattr(response, "status", None) != "completed":
return None
if any(getattr(item, "type", None) == "function_call" for item in getattr(response, "output", ())):
return FinishReason("tool_calls")
return FinishReason("stop")

def _parse_response_from_openai(
self,
response: OpenAIResponse | ParsedResponse[BaseModel],
Expand Down Expand Up @@ -2638,6 +2652,8 @@ def _parse_response_from_openai(
args["value"] = structured_response
elif response_format := options.get("response_format"):
args["response_format"] = response_format
if finish_reason := self._get_finish_reason_from_openai_response(response):
args["finish_reason"] = finish_reason
# Set continuation_token when background operation is still in progress
if response.status and response.status in ("in_progress", "queued"):
args["continuation_token"] = OpenAIContinuationToken(response_id=response.id)
Expand All @@ -2661,6 +2677,7 @@ def _parse_chunk_from_openai(
response_id: str | None = None
created_at: str | None = None
continuation_token: OpenAIContinuationToken | None = None
finish_reason: FinishReason | None = None
model = self.model
match event.type:
# types:
Expand Down Expand Up @@ -2837,13 +2854,14 @@ def _parse_chunk_from_openai(
response_id = event.response.id
conversation_id = self._get_conversation_id(event.response, options.get("store"))
continuation_token = OpenAIContinuationToken(response_id=event.response.id)
case "response.completed":
case "response.completed" | "response.incomplete" | "response.failed":
response_id = event.response.id
conversation_id = self._get_conversation_id(event.response, options.get("store"))
model = event.response.model
created_at = datetime.fromtimestamp(event.response.created_at, tz=timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
)
finish_reason = self._get_finish_reason_from_openai_response(event.response)
if event.response.usage:
usage = self._parse_usage_from_openai(event.response.usage)
if usage:
Expand Down Expand Up @@ -3185,6 +3203,7 @@ def _get_ann_value(key: str) -> Any:
model=model,
created_at=created_at,
continuation_token=continuation_token,
finish_reason=finish_reason,
additional_properties=metadata,
raw_representation=event,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@ def _parse_response_from_openai(self, response: ChatCompletion, options: Mapping
for choice in response.choices:
response_metadata.update(self._get_metadata_from_chat_choice(choice))
if choice.finish_reason:
finish_reason = choice.finish_reason # type: ignore[assignment]
finish_reason = "tool_calls" if choice.finish_reason == "function_call" else choice.finish_reason # type: ignore[assignment]
contents: list[Content] = []
if text_content := self._parse_text_from_openai(choice):
contents.append(text_content)
Expand Down Expand Up @@ -746,7 +746,7 @@ def _parse_response_update_from_openai(
for choice in chunk.choices:
chunk_metadata.update(self._get_metadata_from_chat_choice(choice))
if choice.finish_reason:
finish_reason = choice.finish_reason # type: ignore[assignment]
finish_reason = "tool_calls" if choice.finish_reason == "function_call" else choice.finish_reason # type: ignore[assignment]

# Some OpenAI-compatible providers (e.g. Azure) send `"delta": null`
# on finish chunks instead of the spec-compliant `"delta": {}`.
Expand Down
Loading
Loading