Skip to content

.NET: [Bug]: AgentSkillsProvider approval-by-default + ToolApprovalAgent re-invoke causes O(rounds) context-provider overhead and timeouts. #6825

Description

@Cobra86

Description

After #6729 made all AgentSkillsProvider tools approval-required by default, the only
supported way to auto-approve them is ToolApprovalAgent + AllToolsAutoApprovalRule.
But ToolApprovalAgent re-invokes the whole inner agent once per approval batch, and
ChatClientAgent runs every AIContextProvider.InvokingAsync on each RunAsync. So a
turn that makes N tool rounds re-runs the entire context-provider stack N times.

In an app with non-trivial per-invocation providers (DB-backed memory, document RAG, a
summarization/compaction provider), this multiplies provider cost per round. A single
tool-heavy skill turn produced 70+ re-invocations and overran our streaming turn cap
(timeout). The same scenario also surfaces two related failure modes (see Additional Context):
a DisposeAsync that throws, and persisted dangling approval requests that permanently
break a reloaded session.

There is no way to auto-approve inline within FunctionInvokingChatClient's existing tool
loop — every approval forces a full re-invoke round-trip, regardless of how fast the rule
returns true.

What did you expect to happen?

Auto-approving skill tools should not change the cost model of a turn. With a "always
approve" rule, skill tools should execute inside FIC's normal multi-round loop — one pass
through the context providers per turn — exactly as they did before 1.11 when these tools
were not approval-gated.

Ideally one of:

  • an option to auto-approve within FIC's tool loop (no per-round agent re-invoke), or
  • a provider-level opt-out on AgentSkillsProvider (the removed ScriptApproval left no way
    to keep read-only tools un-gated), or
  • scoping the default approval requirement to run_skill_script only — load_skill /
    read_skill_resource are read-only.

Steps to reproduce the issue

  1. Create a ChatClientAgent with one or more AIContextProviders that do real
    per-invocation work (e.g. a provider that increments a counter / runs a query in
    InvokingAsync).
  2. Attach an AgentSkillsProvider and wrap the agent with
    .UseToolApproval(new ToolApprovalAgentOptions { AutoApprovalRules = [ToolApprovalAgent.AllToolsAutoApprovalRule] }).
  3. Run a skill/prompt that triggers several tool rounds.
  4. Observe that each tool round triggers a fresh RunAsync, so the context provider's
    InvokingAsync is invoked once per round instead of once per turn. With heavy providers,
    turn latency ≈ rounds × provider cost.

Code Sample

using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

// A context provider doing real per-invocation work. In a real app this is memory / RAG /
// summarization; here it just counts how many times it runs.
sealed class CountingContextProvider : AIContextProvider
{
    public int Invocations;
    public override ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken ct = default)
    {
        Interlocked.Increment(ref Invocations);
        // imagine an expensive DB query / summarization LLM call here
        return ValueTask.FromResult(new AIContext());
    }
}

var provider = new CountingContextProvider();
var skillsProvider = new AgentSkillsProvider(skillsDir, SubprocessScriptRunner.RunAsync);

AIAgent agent = chatClient
    .AsAIAgent(new ChatClientAgentOptions
    {
        ChatOptions = new() { Instructions = "Use the skill to complete the task." },
        AIContextProviders = [provider, skillsProvider],
    })
    .AsBuilder()
    .UseToolApproval(new ToolApprovalAgentOptions
    {
        AutoApprovalRules = [ToolApprovalAgent.AllToolsAutoApprovalRule],
    })
    .Build();

var session = await agent.CreateSessionAsync();
await agent.RunAsync("Run the skill end-to-end (it makes several tool calls).", session);

// EXPECTED: provider.Invocations == 1 (one provider pass for the turn)
// ACTUAL:   provider.Invocations == number of tool rounds (one full pass PER approval round)
Console.WriteLine($"Context-provider passes this turn: {provider.Invocations}");

Error Messages / Stack Traces

# (Related) If an approval-gated turn is interrupted, dangling ToolApprovalRequestContent are
# persisted in serialized history. On reload FIC throws on EVERY subsequent turn, permanently
# breaking the session with no framework-level recovery:
ToolApprovalRequestContent found with FunctionCall.CallId(s) 'call_...' that have no matching ToolApprovalResponseContent.

Package Versions

Microsoft.Agents.AI: 1.11.1 Microsoft.Agents.AI.OpenAI: 1.11.1 Microsoft.Agents.AI.Workflows: 1.11.1 Microsoft.Agents.AI.CopilotStudio: 1.11.1-preview.260625.1 Microsoft.Extensions.AI: 10.6.0 Microsoft.Extensions.AI.Abstractions: 10.6.0

.NET Version

.NET 10

Additional Context

This report bundles three related observations from the same scenario (server-side streaming
chat agent, ~129 tools, AgentSkillsProvider with inline skills only — no run_skill_script,
plus DB-memory / document-RAG / compaction context providers). Happy to split into separate
issues if preferred:

  1. (Primary, design) ToolApprovalAgent auto-approval re-invokes the agent per round, so
    per-invocation AIContextProviders run once per round instead of once per turn → O(rounds)
    overhead and timeouts on tool-heavy skills. No inline auto-approval path exists.

  2. (Robustness) Persisted, unanswered ToolApprovalRequestContent permanently brick a
    reloaded session (FIC hard-throws). Consider cleaning/tolerating dangling approval requests
    on load instead of throwing.

Workaround we adopted: a DelegatingChatClient placed above FIC that strips the
ApprovalRequiredAIFunction marker so FIC runs skill tools inline (single provider pass, no
re-invoke), plus a load-time pass that drops dangling ToolApprovalRequestContent. It works,
but it relies on framework internals (GetService<ApprovalRequiredAIFunction>(), and
ChatClientAgent skipping its own FIC when one is already present in the pipeline), so it's
fragile across versions — which is why we're reporting this.

Metadata

Metadata

Labels

.NETUsage: [Issues, PRs], Target: .Net

Type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions