Skip to content

Python: Enforce tool selection before dispatch - #8852

Open
Roger Barreto (rogerbarreto) wants to merge 1 commit into
mainfrom
i922r502-tool-policy
Open

Roger Barreto (rogerbarreto) wants to merge 1 commit into
mainfrom
i922r502-tool-policy

Conversation

@rogerbarreto

Copy link
Copy Markdown
Member

Motivation & Context

Some provider protocols cannot represent every tool_choice restriction. The shared function invocation loop therefore needs to preserve those restrictions at the local dispatch boundary so behavior remains consistent across providers and response modes.

Description & Review Guide

  • What are the major changes? Revalidate fresh model-returned function calls against none, required_function_name, and allowed_tools before local execution. Excluded calls receive correlated error results without executing or consuming the function-call budget. Streaming, non-streaming, and approval-resume regressions cover the behavior, and the function-loop specification maps the new scenarios.
  • What is the impact of these changes? Local tool dispatch now consistently honors the active selection policy even when a provider cannot enforce the full shape. Existing permitted calls and session-bound approval resumes retain their behavior.
  • What do you want reviewers to focus on? The separation between fresh model-call policy enforcement and recorded approval replay, plus execution-budget accounting for rejected calls.

Related Issue

N/A. This maintenance change is not linked to a public issue.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

Some provider protocols cannot represent every tool-selection constraint.
Revalidate fresh calls at the local dispatch boundary while preserving
correlated results, execution budgets, and approval resumes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Approval-resume tests remain permissive and do not verify separation from fresh-call policy filtering.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Enforces tool_choice restrictions at Python’s local function-dispatch boundary.

Changes:

  • Rejects disallowed fresh function calls with correlated errors.
  • Excludes rejected calls from execution-budget accounting.
  • Adds streaming, non-streaming, approval-resume tests and specification mapping.
File Description
python/​packages/​core/​agent_framework/​_tools.py Filters fresh calls before dispatch.
python/​packages/​core/​tests/​core/​test_function_invocation_logic.py Adds policy and budget regressions.
docs/​specs/​004-python-function-calling-loop.md Documents tool-selection behavior.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

options={"tools": [guarded_stream_tool]},
options={
"tools": [guarded_stream_tool],
"tool_choice": {"mode": "auto", "allowed_tools": ["guarded_stream_tool"]},
@github-code-quality

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: Python

Python / code-coverage/python

The overall line coverage in commit c040c47 in the i922r502-tool-policy branch is 92%. Line coverage data for the main branch is not yet available.

Show a line coverage summary of the most covered files.
File main i922r502-tool-policy c040c47 +/-
packages/core/a...ework/_tools.py — 96% —
packages/core/a...ework/_types.py — 95% —
packages/core/a...work/_skills.py — 95% —
packages/openai..._chat_client.py — 94% —
packages/core/a.../_compaction.py — 94% —
packages/core/a...ork/_vectors.py — 93% —
packages/core/a...bservability.py — 93% —
packages/core/a...amework/_mcp.py — 92% —
packages/ag-ui/...i/_agent_run.py — 90% —
packages/core/a...ork/security.py — 89% —

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAF Automated Review — Iteration 1

Result: Findings reported
Scope: full PR (1 commit(s)): c040c47161d6
Model: gpt-5.6-sol-fast

Overview

The PR adds a shared pre-dispatch filter that blocks fresh model calls excluded by tool_choice, preserves call/result ordering, and excludes synthetic rejections from the execution budget. The new streaming and non-streaming tests cover the immediate mixed-call and budget cases, while existing approval binding keeps recorded approval resumes separate. Two required-mode transitions still discard active restrictions and can allow a later model turn to execute a forbidden registered tool.

Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
2 verified findings remained after source verification (2 high) across 1 file. Details are attached to the affected lines below.

Affected areas: python/packages/core/agent_framework/_tools.py

execution = await execute_function_calls(
function_calls=function_calls,
options=options,
allowed_calls, blocked_call_ids = _fresh_function_calls_allowed_by_tool_choice(function_calls, options)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a provider returns only calls excluded by required_function_name, this rejects the batch with zero executions, but both outer loops still clear required mode before the next model turn. A provider that ignores the restriction can then return the same forbidden tool again and it executes locally without the policy. Keep required mode active until at least one permitted call actually executes in both streaming and non-streaming paths.

allowed_names = set()
elif required_name := tool_mode.get("required_function_name"):
allowed_names = {required_name}
elif (configured_names := tool_mode.get("allowed_tools")) is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allowlist is enforced only for the current round when mode is required: after an allowed call executes, both loops clear the entire tool_choice, so a later model turn can dispatch any registered tool. That defeats the local authorization boundary for providers that do not enforce allowed_tools. Once the required-call obligation is satisfied, preserve the allowlist by transitioning to auto mode rather than dropping it.

Comment on lines +4885 to +4890
allowed_result_groups = iter(execution.result_groups)
execution.result_groups = [
[_tool_choice_rejection_result(function_call)]
if id(function_call) in blocked_call_ids
else next(allowed_result_groups)
for function_call in function_calls

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when the allowed batch contains an approval-required call plus a session-deferred executable sibling and also includes a blocked call? _try_execute_function_call_groups returns only the visible approval group, but this comprehension consumes one group for every allowed call, so the deferred sibling hits StopIteration and both streaming and non-streaming requests fail before returning the approval prompt. Could the reassembly key result groups by input occurrence, or preserve an explicit slot for every allowed call?

This branch was successfully deployed

1 active deployment
github-app-auth — c040c471 Deployed Sep 29, 2026 by rogerbarreto via team_check #5536
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants