Skip to content

Copilot SDK sub-agents fail with "Requested function \"task\" not found" — sub-agent tool set is empty #1174

Description

@JoshuaRowePhantom

Copilot SDK sub-agents fail with Requested function "task" not found — hosted sub-agent chat pipeline installs tool-running middleware that the Copilot SDK should own

Summary

When a Copilot-SDK-backed parent agent invokes the task tool to spawn a sub-agent, the child sub-agent's transcript renders:

Error: Requested function "task" not found.

The Copilot SDK owns tool running end-to-end for Copilot chats (the CLI drives the agentic loop and executes tools itself). Our parent Copilot SDK chat client (CopilotSdkChatClient) advertises this by implementing the marker ISelfInvokingToolChatClient, which causes the agent framework to use it as-is and skip its FunctionInvokingChatClient tool-running middleware. The hosted sub-agent chat client (CopilotSubAgentChatClient) does not implement or expose this marker. As a result, the agent framework wraps every hosted sub-agent chat with FunctionInvokingChatClient middleware — a tool-running layer that the Copilot SDK should own but the sub-agent stub does not opt out of. When CopilotSubAgentRouter.InjectToolCallPrompt pushes the parent's FunctionCallContent(Name="task", …) into the child's channel, that content flows through FunctionInvokingChatClient, which attempts to resolve task against the child's (empty) ChatOptions.Tools and throws Requested function "task" not found. The fix is to stop installing tool-running middleware on Copilot subagent SDK chats — mark CopilotSubAgentChatClient as ISelfInvokingToolChatClient so the agent framework uses it as-is, exactly as it already does for the parent CopilotSdkChatClient.

Root Cause

1. The error string is emitted by the SDK / MEAI, not by our code

A recursive text search of features\ for Requested function and not found yields no matches in Phantom.Workspaces.*. The string is emitted by the FunctionInvokingChatClient middleware in Microsoft.Extensions.AI 10.5.2 when it encounters a streamed FunctionCallContent whose Name does not resolve against the current ChatOptions.Tools.

SDK versions (from Directory.Packages.props:30,33):

  • Microsoft.Extensions.AI 10.5.2
  • GitHub.Copilot.SDK 1.0.0-beta.2

2. The agent framework wraps chat clients with FunctionInvokingChatClient unless they opt out via ISelfInvokingToolChatClient

Phantom.Workspaces.Llm.Core/ISelfInvokingToolChatClient.cs defines the opt-out marker:

// Marker for IChatClient implementations that invoke their own tools (for example the
// GitHub Copilot SDK, where the copilot CLI drives the agentic loop and executes tools itself).
//
// Such clients must be used by the agent framework as-is, without the framework adding its
// function-invoking middleware …
public interface ISelfInvokingToolChatClient { }

AgentChat.ResolveUseProvidedChatClientAsIs (AgentChat.cs:2106-2112) is the decision point:

internal static bool ResolveUseProvidedChatClientAsIs(bool hasClientOverride, IChatClient resolvedClient)
{
    ArgumentNullException.ThrowIfNull(resolvedClient);
    return hasClientOverride
        || resolvedClient is ISelfInvokingToolChatClient
        || resolvedClient.GetService(typeof(ISelfInvokingToolChatClient)) is not null;
}

Its result is fed into ChatClientAgentOptions.UseProvidedChatClientAsIs at AgentChat.cs:246-306:

var useProvidedChatClientAsIs = this.request.OverrideUseProvidedChatClientAsIs
    ?? ResolveUseProvidedChatClientAsIs(
        this.request.ClientOverride is not null,
        resolvedClient);this.chatOptions = new ChatClientAgentOptions
{
    ChatOptions = new ChatOptions(),
    ChatHistoryProvider = this.chatHistoryProvider,
    UseProvidedChatClientAsIs = useProvidedChatClientAsIs,
    RequirePerServiceCallChatHistoryPersistence = !useProvidedChatClientAsIs,
};this.chatClientAgent = new ChatClientAgent(streamingMiddleware, this.chatOptions);

ChatClientAgent (Microsoft.Extensions.AI.Agents) treats UseProvidedChatClientAsIs = false as an instruction to add its own tool-running middleware (FunctionInvokingChatClient) around the resolved client. UseProvidedChatClientAsIs = true skips that wrapping and passes the client through as-is.

3. CopilotSdkChatClient (parent) opts out — CopilotSubAgentChatClient (child) does not

The parent Copilot SDK client opts out:

// CopilotSdkChatClient.cs:32
public sealed class CopilotSdkChatClient : IChatClient, IAsyncDisposable,
    ISelfInvokingToolChatClient, SlashCommands.IModelSlashCommandClient

The hosted sub-agent client does not:

// CopilotSubAgentChatClient.cs:16
internal sealed class CopilotSubAgentChatClient
    : IChatClient, ICopilotSubAgentReceiver, IHostedAgentChatClient
{// CopilotSubAgentChatClient.cs:57-58 — only exposes the sub-agent receiver marker
    public object? GetService(Type serviceType, object? key = null) =>
        serviceType == typeof(ICopilotSubAgentReceiver) ? this : null;
}

The sub-agent chat client is constructed in AgentFactory.cs:226-229 without any wrapping:

if (provider == "github-copilot-subagent")
{
    return new ChatClientResult(new CopilotSubAgentChatClient(), "GitHub Copilot Sub-Agent");
}

Because the returned client is not ISelfInvokingToolChatClient and returns null for that service, ResolveUseProvidedChatClientAsIs returns false for every hosted sub-agent AgentChat, and ChatClientAgent therefore installs FunctionInvokingChatClient around it. That is the tool-running middleware the owner is calling out: the Copilot SDK does tool running; this middleware must not be present for Copilot subagent SDK chats.

4. Causal chain from injection to throw

The routing pipeline for a task invocation is (Phantom.Workspaces.Llm.Core/CopilotSubAgentRouter.cs):

  1. Parent turn emits FunctionCallContent(CallId="call_task_1", Name="task", …) on the root stream.
  2. BufferRootToolStart (CopilotSubAgentRouter.cs:172-195) stores it in bufferedToolStarts[…].
  3. The SDK emits SubagentStartedEvent for the child, carrying ParentToolCallId="call_task_1".
  4. HandleSubAgentStartedAsync (CopilotSubAgentRouter.cs:197-277) creates the child sink and calls InjectToolCallPrompt(entry, buffered) (L275).
  5. InjectToolCallPrompt (CopilotSubAgentRouter.cs:466-472) pushes the parent's FunctionCallContent into the child sink as a ChatRole.User update:
private static void InjectToolCallPrompt(ChildRoutingEntry entry, FunctionCallContent toolStart)
{
    entry.Push(new ChatResponseUpdate
    {
        Role = ChatRole.User,
        Contents = [toolStart],
    });
}

Because the hosted sub-agent's chat pipeline includes FunctionInvokingChatClient (step 3 above), that streamed FunctionCallContent(Name="task") is intercepted by the middleware, which performs a name-based lookup against the child's ChatOptions.Tools. That collection is empty for hosted sub-agents (AgentFactory.cs:226-229 constructs the stub without tools, matching the SubAgentDefinition at CopilotSubAgentRouter.cs:29-31), so the lookup fails and the middleware throws Requested function "task" not found. The error surfaces in the child's transcript because it is generated inside the child AgentChat's ChatClientAgent invocation.

The middleware — not the injection per se — is the defect: with the middleware absent (as it correctly is for the parent CopilotSdkChatClient), the injected FunctionCallContent would flow through the receive-only CopilotSubAgentChatClient channel and be persisted to the child's history/transcript without any function-registry lookup or throw.

Affected Files

File Role in bug / what changes
Phantom.Workspaces.Llm.Core/CopilotSubAgentChatClient.cs (L16, L57-58) Hosted sub-agent chat client stub. Change: implement ISelfInvokingToolChatClient (add the marker interface to the class declaration and optionally return this from GetService(typeof(ISelfInvokingToolChatClient))) so the agent framework recognises it as self-invoking and skips FunctionInvokingChatClient wrapping.
Phantom.Workspaces.Llm.Core/AgentChat.cs (L246-306, L2098-2112) ResolveUseProvidedChatClientAsIs + ChatClientAgentOptions.UseProvidedChatClientAsIs — the decision point that, once CopilotSubAgentChatClient is marked, will return true and cause ChatClientAgent to omit FunctionInvokingChatClient. No source change required here, but tests must assert the new outcome.
Phantom.Workspaces.Llm.Core/AgentFactory.cs (L226-229) github-copilot-subagent provider constructs the hosted stub; no wrapping change required. The stub's opt-out marker (above) is sufficient — the framework, not this factory, is what installs the middleware.
Phantom.Workspaces.Llm.Core/CopilotSubAgentRouter.cs (L172-195, L197-277, L466-472) BufferRootToolStart / HandleSubAgentStartedAsync / InjectToolCallPrompt push the parent's FunctionCallContent into the child sink. With middleware removed the injection no longer throws; no change required (see "Injection is now moot" below).
Phantom.Workspaces.Llm.Core/ISelfInvokingToolChatClient.cs Marker consumed by ResolveUseProvidedChatClientAsIs; no change.
Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs (L32) Parent Copilot SDK client already implements ISelfInvokingToolChatClient — the pattern being extended to the sub-agent stub.
Directory.Packages.props (L30, L33) Pins Microsoft.Extensions.AI 10.5.2 (source of FunctionInvokingChatClient and its "Requested function … not found" throw) and GitHub.Copilot.SDK 1.0.0-beta.2.

Design / Fix

Chosen fix — mark CopilotSubAgentChatClient as ISelfInvokingToolChatClient so the agent framework does not wrap it with tool-running middleware

The GitHub Copilot SDK owns tool running for Copilot chats. The Microsoft.Extensions.AI.Agents framework's ChatClientAgent adds FunctionInvokingChatClient (tool-running middleware) to the pipeline whenever ChatClientAgentOptions.UseProvidedChatClientAsIs is false. Our parent CopilotSdkChatClient opts out of that middleware by implementing ISelfInvokingToolChatClient; the hosted sub-agent CopilotSubAgentChatClient currently does not, so it silently receives the middleware even though the Copilot SDK — driving the parent — is the actual tool runner for the sub-agent's stream.

Change the sub-agent chat client to advertise the same opt-out:

// CopilotSubAgentChatClient.cs
internal sealed class CopilotSubAgentChatClient
    : IChatClient,
      ICopilotSubAgentReceiver,
      IHostedAgentChatClient,
      ISelfInvokingToolChatClient   // NEW — the Copilot SDK owns tool running for this stream
{public object? GetService(Type serviceType, object? key = null)
    {
        if (serviceType == typeof(ICopilotSubAgentReceiver)) return this;
        if (serviceType == typeof(ISelfInvokingToolChatClient)) return this;
        return null;
    }
}

Concretely, the pipeline changes for hosted sub-agents from:

before:   ChatClientAgent → FunctionInvokingChatClient → StreamingPersistenceMiddleware → CopilotSubAgentChatClient
                             ^^^^^^^^^^^^^^^^^^^^^^^^^^
                             throws Requested function "task" not found on the injected FunctionCallContent

to:

after:    ChatClientAgent → StreamingPersistenceMiddleware → CopilotSubAgentChatClient
          (UseProvidedChatClientAsIs = true; no tool-running middleware)

which matches the existing Copilot SDK parent pipeline (also UseProvidedChatClientAsIs = true).

InjectToolCallPrompt's FunctionCallContent injection is now moot for this bug

With FunctionInvokingChatClient removed from the child pipeline, the parent-sourced FunctionCallContent(Name="task", …) pushed into the child sink by CopilotSubAgentRouter.InjectToolCallPrompt (CopilotSubAgentRouter.cs:466-472) is never subjected to a function-registry lookup: the child's chat client (CopilotSubAgentChatClient) is a receive-only stub that only forwards updates to persistence/display, and no other component in the child's pipeline resolves function names against ChatOptions.Tools. The injection therefore no longer causes the Requested function "task" not found error and does not need to be rewritten as TextContent. It is preserved as-is so the child transcript continues to record the spawning tool call attribution for downstream consumers, matching current behaviour on the parent side.

Considered / Background — superseded

Considered — propagate the parent's tool set into the child's ChatOptions.Tools (original diagnosis). The idea was that the child's SDK session had no task registered, so populate its tool set with a trust-profile-filtered copy of the parent's tools. Superseded because the child sub-agent's model never emitted a task call — that FunctionCallContent originates on the parent — and because the Copilot SDK, not our framework, owns tool running. Populating the child's tools would only mask the middleware defect and would misleadingly attribute the parent's task execution to the child. Nested sub-agent spawning (the concern this option addresses) is a separate feature that should be tracked with an explicit trust-profile design (see docs/design/subagent-toolset.md).

Considered — convert the injected FunctionCallContent into TextContent in CopilotSubAgentRouter.InjectToolCallPrompt (previous chosen fix). The idea was to synthesise a plain user-text turn describing the spawn context so no FunctionCallContent referencing an unregistered function would enter the child's history. Superseded because it fixes the symptom at the wrong layer: the injection is legitimate (parent-tool-call attribution belongs in the child transcript); the actual defect is that the child's chat pipeline includes tool-running middleware the Copilot SDK should own. Once that middleware is removed (chosen fix above), the raw FunctionCallContent flows through the receive-only stub without any lookup or throw, and the injection can stay in its current form. This option is also rejected because rewriting the content type would degrade downstream consumers that rely on structured tool-call attribution in the child transcript.

Considered — strip unknown-function FunctionCallContent inside CopilotSubAgentChatClient before it reaches the middleware. A downstream filter that drops any FunctionCallContent whose Name is not in ChatOptions.Tools. Superseded because it hides a real semantic error (tool-running middleware is running where it should not be) and could accidentally drop legitimate content in future scenarios; the correct call-site is the middleware selection, not a downstream stripper.

Expected Tests

Follow the existing Subject_Scenario_ExpectedOutcome convention (e.g. AgentChatHostedSubAgentTests.AgentChat_HostedSubAgent_ProcessLoop_ConsumesChannelData_WithoutUserInput at Phantom.Workspaces.Llm.Core.Tests/AgentChatHostedSubAgentTests.cs:125, AgentChatTests.ResolveUseProvidedChatClientAsIs_TrueForOverride_SelfInvoking_OrServiceDiscovered at AgentChatTests.cs:995).

Test Name Class What It Verifies
CopilotSubAgentChatClient_ImplementsISelfInvokingToolChatClient AgentChatTests typeof(ISelfInvokingToolChatClient).IsAssignableFrom(typeof(CopilotSubAgentChatClient)) — mirrors the existing CopilotSdkChatClient_IsSelfInvokingToolChatClient assertion at AgentChatTests.cs:1012.
CopilotSubAgentChatClient_GetService_ReturnsSelfInvokingToolChatClientMarker AgentChatTests new CopilotSubAgentChatClient().GetService(typeof(ISelfInvokingToolChatClient)) is non-null so ResolveUseProvidedChatClientAsIs selects the as-is path even through DelegatingChatClient.GetService propagation.
ResolveUseProvidedChatClientAsIs_HostedSubAgentClient_ReturnsTrue AgentChatTests ResolveUseProvidedChatClientAsIs(hasClientOverride: false, new CopilotSubAgentChatClient()) returns true, i.e. the agent framework is instructed to use the sub-agent stub as-is (no FunctionInvokingChatClient wrapping).
AgentChat_HostedSubAgent_PipelineDoesNotWrapWithFunctionInvokingChatClient AgentChatHostedSubAgentTests Constructs a hosted sub-agent AgentChat and asserts that chatOptions.UseProvidedChatClientAsIs is true for the resolved CopilotSubAgentChatClient, so ChatClientAgent will not install a tool-running middleware layer around it.
AgentChat_HostedSubAgent_ParentTaskFunctionCallInjected_ChildTranscriptDoesNotThrowFunctionNotFound AgentChatHostedSubAgentTests End-to-end: parent turn buffers a FunctionCallContent(Name="task"), router injects it into the hosted sub-agent sink; the child AgentChat completes with no Requested function "task" not found error surfaced in its history or completion state.
AgentChat_HostedSubAgent_ParentTaskInvocation_TaskToolCallAttributedToParentTranscriptOnly AgentChatHostedSubAgentTests The parent's task FunctionCallContent / FunctionResultContent remain attributed to the parent transcript; the injected copy in the child sink is preserved in the child history but never triggers a function-registry lookup.

Related

This bug is a middleware-selection defect for hosted Copilot subagent chats and does not overlap with trust-profile-propagation work those related issues might imply.

Metadata

Metadata

Labels

bugSomething isn't workingdiagnosedRoot cause identifiednext-upverified-locallyImplementation has been verified locally

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions