-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Python: [BREAKING] Require committed finish reason for tool calls #8305
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6eff2ba
2c3b994
118cc1f
481d5bb
1b15c40
62c2ff8
519b211
4507afd
32e6be0
95bdd73
f583414
2e4b7eb
25de4b6
f2ab412
cbdf396
dde9ffb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| --- | ||
| status: proposed | ||
| contact: eavanvalkenburg | ||
| date: 2026-09-11 | ||
| deciders: eavanvalkenburg | ||
| --- | ||
|
|
||
| # Authorize function calls with the finish reason | ||
|
|
||
| ## Context and Problem Statement | ||
|
|
||
| Streaming providers expose function-call content before they commit the complete model turn. Agent Framework already | ||
| waits for the provider stream to finish before local invocation, but iterator completion alone does not prove that the | ||
| provider emitted its terminal event or committed every call. A syntactically complete call fragment can therefore be | ||
| mistaken for an executable request after an interrupted or failed response. | ||
|
|
||
| ## Decision Drivers | ||
|
|
||
| - Execute local side effects only after an authoritative provider completion signal. | ||
| - Keep one provider-neutral authorization rule for streaming and non-streaming responses. | ||
| - Preserve incremental function-call updates for callers without adding buffering or another stream drain. | ||
| - Avoid adding a second response lifecycle field or changing the meaning of `informational_only`. | ||
| - Keep provider-specific protocol evidence in the provider adapter. | ||
|
|
||
| ## Considered Options | ||
|
|
||
| ### Add a response completion field | ||
|
|
||
| - Good: separates lifecycle state from generation finish reason. | ||
| - Bad: adds a public concept that overlaps existing finish-reason behavior. | ||
| - Bad: requires every response and update mapping to carry another field. | ||
|
|
||
| ### Mark speculative calls as informational | ||
|
|
||
| - Good: reuses the existing core execution filter. | ||
| - Bad: `informational_only` describes calls the framework does not own, not uncommitted local calls. | ||
| - Bad: changes the meaning of caller-visible function-call content. | ||
|
|
||
| ### Infer commitment from content or stream exhaustion | ||
|
|
||
| - Good: requires no provider changes. | ||
| - Bad: complete JSON and clean iterator exhaustion do not prove that the provider committed the turn. | ||
| - Bad: different providers expose different authoritative terminal events. | ||
|
|
||
| ### Authorize with `finish_reason == "tool_calls"` | ||
|
|
||
| - Good: uses the existing provider-neutral response field. | ||
| - Good: gives core one fail-closed rule for streaming and non-streaming responses. | ||
| - Good: lets providers continue yielding speculative content immediately and resolve the reason on their existing | ||
| terminal event. | ||
| - Bad: custom clients that return local function calls without `finish_reason="tool_calls"` must be updated. | ||
| - Bad: provider adapters must assign `"tool_calls"` only after their own protocol commitment evidence is satisfied. | ||
|
|
||
| ## Decision Outcome | ||
|
|
||
| Chosen option: "Authorize with `finish_reason == "tool_calls"`", because it establishes one explicit execution | ||
| boundary without another public lifecycle model. | ||
|
|
||
| Core approves or executes model-issued local calls only when the finalized response has | ||
| `finish_reason == "tool_calls"`. Missing and all other finish reasons are non-authorizing. | ||
|
|
||
| Each provider adapter owns its commitment evidence. Adapters that require correction use the same small local | ||
| `_resolve_finish_reason(...)` pattern: committed calls override the ordinary reason to `"tool_calls"`; calls without | ||
| commitment cannot retain or manufacture `"tool_calls"`; responses without calls preserve their ordinary reason. The | ||
| function remains local to each provider module because the evidence is protocol-specific and does not justify a shared | ||
| base class, mixin, registry, or cross-provider helper. | ||
|
|
||
| Streaming adapters resolve the finish reason on the provider's existing terminal event. They do not add another drain, | ||
| buffer speculative content, or wait after iterator exhaustion. Calls returned without authorization remain visible to | ||
| the caller, while every assistant message from that uncommitted provider turn is excluded from later model-bound replay. | ||
|
|
||
| ### Consequences | ||
|
|
||
| - Local tool bodies, approval requests, function middleware, function results, and follow-up model calls require an | ||
| authoritatively committed `"tool_calls"` response. | ||
| - Custom clients must set `finish_reason="tool_calls"` for completed local function-call turns. | ||
| - Provider adapters must not infer commitment from parseable arguments, observed call content, or iterator exhaustion. | ||
| - Interrupted call-bearing responses remain inspectable without becoming executable or replayable model history. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ | |
| ChatResponse, | ||
| ChatResponseUpdate, | ||
| Content, | ||
| FinishReason, | ||
| FunctionTool, | ||
| Message, | ||
| ResponseStream, | ||
|
|
@@ -58,6 +59,22 @@ | |
| logger: logging.Logger = logging.getLogger("agent_framework.ag_ui") | ||
|
|
||
|
|
||
| def _resolve_finish_reason( | ||
| provider_finish_reason: FinishReason | None, | ||
| *, | ||
| has_function_calls: bool, | ||
| function_calls_committed: bool, | ||
| ) -> FinishReason | None: | ||
| """Resolve the provider finish reason after function-call commitment.""" | ||
| if not has_function_calls: | ||
| return provider_finish_reason | ||
| if function_calls_committed: | ||
| return FinishReason("tool_calls") | ||
| if provider_finish_reason == "tool_calls": | ||
| return None | ||
| return provider_finish_reason | ||
|
|
||
|
|
||
| def _unwrap_server_function_call_contents(contents: MutableSequence[Content | dict[str, Any]]) -> None: | ||
| """Replace server_function_call instances with their underlying call content.""" | ||
| for idx, content in enumerate(contents): | ||
|
|
@@ -495,6 +512,8 @@ async def _streaming_impl( | |
| logger.debug(f"[AGUIChatClient] Client tool set: {client_tool_set}") | ||
|
|
||
| converter = AGUIEventConverter() | ||
| has_function_calls = False | ||
| open_client_tool_call_ids: set[str] = set() | ||
|
|
||
| available_interrupts = options.get("available_interrupts", options.get("availableInterrupts")) | ||
|
|
||
|
|
@@ -508,6 +527,14 @@ async def _streaming_impl( | |
| resume=_serialize_resume(options.get("resume")), | ||
| ): | ||
| logger.debug(f"[AGUIChatClient] Raw AG-UI event: {event}") | ||
| event_type = str(event.get("type", "")).upper() | ||
| event_tool_call_id = str(event.get("toolCallId") or event.get("tool_call_id") or "") | ||
| if event_type == "TOOL_CALL_START": | ||
| event_tool_name = event.get("toolName") or event.get("toolCallName") or event.get("tool_call_name") | ||
| if event_tool_name in client_tool_set: | ||
| open_client_tool_call_ids.add(event_tool_call_id) | ||
| elif event_type == "TOOL_CALL_END": | ||
| open_client_tool_call_ids.discard(event_tool_call_id) | ||
|
Comment on lines
+532
to
+537
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens when two client tool calls are interleaved? |
||
| update = converter.convert_event(event) | ||
| if update is not None: | ||
| logger.debug( | ||
|
|
@@ -522,6 +549,7 @@ async def _streaming_impl( | |
| ) | ||
| if content.name in client_tool_set: | ||
| # Client tool - let function invocation execute it | ||
| has_function_calls = True | ||
| if not content.additional_properties: | ||
| content.additional_properties = {} | ||
| content.additional_properties["agui_thread_id"] = thread_id | ||
|
|
@@ -531,4 +559,9 @@ async def _streaming_impl( | |
| self._register_server_tool_placeholder(content.name) # type: ignore[arg-type] | ||
| update.contents[i] = Content(type="server_function_call", function_call=content) # type: ignore | ||
|
|
||
| update.finish_reason = _resolve_finish_reason( | ||
| cast(FinishReason | None, update.finish_reason), | ||
| has_function_calls=has_function_calls, | ||
| function_calls_committed=event_type == "RUN_FINISHED" and not open_client_tool_call_ids, | ||
| ) | ||
| yield update | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This clears an open local call solely by ID, even when the ending event belongs to a duplicate or server-tool start that reused that ID. Its end can empty the set, so
RUN_FINISHEDauthorizes the still-unfinished local call; a parameterless or defaulted local tool can then execute prematurely. Please track starts and ends by local-call occurrence/type (or reject duplicate and empty IDs) and commit only after every local occurrence has ended.