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
1 change: 1 addition & 0 deletions docs/specs/004-python-function-calling-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ that manually replay messages own the equivalent rule: do not resend an approval
| Middleware termination | Normal non-approval loop stops without a second model call. | `test_terminate_loop_single_function_call`, `test_terminate_loop_multiple_function_calls_one_terminates`, `test_terminate_loop_streaming_single_function_call` |
| Maximum iterations | No orphan calls; a final no-tool response or deterministic fallback is returned. | `test_max_iterations_limit`, `test_max_iterations_no_orphaned_function_calls`, `test_max_iterations_makes_final_toolchoice_none_call`, `test_max_iterations_blank_final_fallback_synthesizes_message`, streaming equivalents |
| Maximum function calls | Parallel overshoot is bounded after the batch; every executed result group counts even without a `function_result`; blank final responses get fallback content. | `test_max_function_calls_limits_parallel_invocations`, `test_max_function_calls_single_calls_per_iteration`, `test_user_input_request_multiple_contents_propagate`, `test_approval_resume_user_input_counts_toward_function_call_budget`, `test_max_function_calls_blank_final_fallback_synthesizes_message`, streaming equivalent |
| Provider tool content after an active limit | Locally actionable calls and local approval requests returned despite `tool_choice="none"` are removed in both response modes. Provider-executed informational call/result pairs, hosted approval requests, and metadata-only streaming updates remain visible; fallback text never replaces retained transcript content. | `test_function_invocation_limit_drops_unexecutable_tool_content`, `test_streaming_function_invocation_limit_drops_unexecutable_tool_content`, `test_streaming_function_invocation_limit_preserves_metadata_after_tool_content_is_dropped`, `test_function_invocation_limit_preserves_provider_executed_tool_pair`, `test_streaming_function_invocation_limit_preserves_provider_executed_tool_pair`, `test_function_invocation_limit_appends_fallback_after_provider_executed_tool_pair`, `test_streaming_function_invocation_limit_appends_fallback_after_provider_executed_tool_pair`, `test_function_invocation_limit_preserves_hosted_approval_request`, `test_streaming_function_invocation_limit_preserves_hosted_approval_request` |
| Conversation continuation | Conversation id updates between iterations and is cleared on stop where required. | `test_conversation_id_updated_in_options_between_tool_iterations`, `test_function_invocation_stop_clears_conversation_id_non_stream`, `test_streaming_function_invocation_stop_clears_conversation_id` |

### History and provider serialization
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 @@ -157,6 +157,9 @@ agent_framework/
in assistant-role messages, including mixed sibling batches.
- Function-call budget accounting counts one unit per executed result group, not per emitted `function_result`, so
executions that pause for user input still consume `max_function_calls`.
- Once an invocation limit disables local tools, locally actionable calls and local approval requests are removed
from streaming and final output. Provider-executed informational call/result pairs, hosted approval requests, and
metadata-only stream updates remain visible.
- `function_approval_request` and `function_approval_response` are control-plane contents. History providers may
retain them in their backing store for audit. The base `HistoryProvider.before_run` filters resolved wrappers from
later model replay, but preserves unresolved requests/responses until a terminal result or follow-up request closes
Expand Down
107 changes: 81 additions & 26 deletions python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1942,6 +1942,25 @@ def _clear_internal_conversation_id(response: ChatResponse[Any]) -> ChatResponse
return response


def _is_hosted_tool_approval(content: Any) -> bool:
"""Check if a function_approval_request/response is for a hosted tool (e.g. MCP).

Hosted tool approvals have a server_label in function_call.additional_properties
and should be passed through to the API untouched rather than processed locally.
"""
fc = getattr(content, "function_call", None)
if fc is None:
return False
ap = getattr(fc, "additional_properties", None)
return bool(ap and ap.get("server_label"))


def _is_unexecutable_local_tool_content(content: Content) -> bool:
if _is_actionable_function_call(content):
return True
return content.type == "function_approval_request" and not _is_hosted_tool_approval(content)


def _response_has_visible_content(response: ChatResponse[Any]) -> bool:
for message in response.messages:
for content in message.contents:
Expand All @@ -1953,19 +1972,36 @@ def _response_has_visible_content(response: ChatResponse[Any]) -> bool:
return False


def _ensure_function_invocation_limit_fallback_response(response: ChatResponse[Any]) -> ChatResponse[Any]:
if _response_has_visible_content(response):
return response
def _response_has_hosted_tool_approval(response: ChatResponse[Any]) -> bool:
return any(
content.type == "function_approval_request" and _is_hosted_tool_approval(content)
for message in response.messages
for content in message.contents
)


def _drop_unexecutable_tool_contents_from_response(response: ChatResponse[Any]) -> None:
for message in response.messages:
if any(_is_unexecutable_local_tool_content(content) for content in message.contents):
message.contents = [
content for content in message.contents if not _is_unexecutable_local_tool_content(content)
]


def _ensure_function_invocation_limit_fallback_response(response: ChatResponse[Any]) -> bool:
_drop_unexecutable_tool_contents_from_response(response)
if _response_has_visible_content(response) or _response_has_hosted_tool_approval(response):
return False

from ._types import Content, Message

fallback_content = Content.from_text(_FUNCTION_INVOCATION_LIMIT_FALLBACK_TEXT)
if response.messages:
if response.messages and not response.messages[-1].contents:
response.messages[-1].role = "assistant"
response.messages[-1].contents = [fallback_content]
else:
response.messages.append(Message(role="assistant", contents=[fallback_content]))
return response
return True


def _function_invocation_limit_fallback_update() -> ChatResponseUpdate:
Expand All @@ -1978,6 +2014,28 @@ def _function_invocation_limit_fallback_update() -> ChatResponseUpdate:
)


def _update_has_meaningful_metadata(update: ChatResponseUpdate) -> bool:
return any((
update.author_name is not None,
update.response_id is not None,
update.message_id is not None,
update.conversation_id is not None,
update.model is not None,
update.created_at is not None,
update.finish_reason is not None,
update.continuation_token is not None,
bool(update.additional_properties),
update.raw_representation is not None,
))


def _drop_unexecutable_tool_contents_from_update(update: ChatResponseUpdate) -> ChatResponseUpdate | None:
if not any(_is_unexecutable_local_tool_content(content) for content in update.contents):
return update
update.contents = [content for content in update.contents if not _is_unexecutable_local_tool_content(content)]
return update if update.contents or _update_has_meaningful_metadata(update) else None


def _extract_tools(
options: dict[str, Any] | None,
) -> ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None:
Expand All @@ -1992,19 +2050,6 @@ def _extract_tools(
return options.get("tools") if options else None


def _is_hosted_tool_approval(content: Any) -> bool:
"""Check if a function_approval_request/response is for a hosted tool (e.g. MCP).

Hosted tool approvals have a server_label in function_call.additional_properties
and should be passed through to the API untouched rather than processed locally.
"""
fc = getattr(content, "function_call", None)
if fc is None:
return False
ap = getattr(fc, "additional_properties", None)
return bool(ap and ap.get("server_label"))


def _get_tool_approval_state(invocation_session: AgentSession | None) -> dict[str, Any] | None:
"""Return the shared tool-approval state bag for the invocation session."""
if invocation_session is None:
Expand Down Expand Up @@ -2886,7 +2931,7 @@ async def _get_response_with_function_invocation(
if options.get("tool_choice") == "none" and _function_call_limit_reached(
total_function_calls, max_function_calls
):
response = _ensure_function_invocation_limit_fallback_response(response)
_ensure_function_invocation_limit_fallback_response(response)
aggregated_usage = add_usage_details(aggregated_usage, response.usage_details)
_update_continuation_state(
request_kwargs,
Expand Down Expand Up @@ -2937,7 +2982,7 @@ async def _get_response_with_function_invocation(
client_kwargs=request_kwargs,
),
)
response = _ensure_function_invocation_limit_fallback_response(response)
_ensure_function_invocation_limit_fallback_response(response)
aggregated_usage = add_usage_details(aggregated_usage, response.usage_details)
_update_continuation_state(
request_kwargs,
Expand Down Expand Up @@ -3011,16 +3056,24 @@ async def _stream_response_with_function_invocation(
),
)
await inner_stream
drop_unexecutable_calls = options.get("tool_choice") == "none" and _function_call_limit_reached(
total_function_calls,
max_function_calls,
)
async for update in inner_stream:
if drop_unexecutable_calls:
update = _drop_unexecutable_tool_contents_from_update(update)
if update is None:
continue
yield update

response = await inner_stream.get_final_response()
response_had_visible_content = _response_has_visible_content(response)
function_call_limit_reached = options.get("tool_choice") == "none" and _function_call_limit_reached(
total_function_calls, max_function_calls
)
fallback_added = False
if function_call_limit_reached:
response = _ensure_function_invocation_limit_fallback_response(response)
fallback_added = _ensure_function_invocation_limit_fallback_response(response)
_update_continuation_state(
request_kwargs,
response,
Expand All @@ -3033,7 +3086,7 @@ async def _stream_response_with_function_invocation(
for message in response.messages
for item in message.contents
):
if function_call_limit_reached and not response_had_visible_content:
if fallback_added:
yield _function_invocation_limit_fallback_update()
return

Expand Down Expand Up @@ -3082,17 +3135,19 @@ async def _stream_response_with_function_invocation(
)
await final_inner_stream
async for update in final_inner_stream:
update = _drop_unexecutable_tool_contents_from_update(update)
if update is None:
continue
yield update
final_response = await final_inner_stream.get_final_response()
final_response_had_visible_content = _response_has_visible_content(final_response)
final_response = _ensure_function_invocation_limit_fallback_response(final_response)
fallback_added = _ensure_function_invocation_limit_fallback_response(final_response)
_update_continuation_state(
request_kwargs,
final_response,
session=invocation_session,
options=options,
)
if not final_response_had_visible_content:
if fallback_added:
yield _function_invocation_limit_fallback_update()

@overload
Expand Down
Loading
Loading