Skip to content

Commit 10ceaff

Browse files
stephentoubCopilot
andcommitted
Propagate request handler agent metadata
Thread agentId, parentAgentId, and interactionType from llmInference request-start frames into the public request handler contexts across SDK languages. Extend CAPI/BYOK and subagent request-handler tests to prove the metadata is observable, and fix .NET forwarding so GET/HEAD requests do not get an empty content body when forwarding headers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 44ec87f commit 10ceaff

21 files changed

Lines changed: 747 additions & 58 deletions

dotnet/src/CopilotRequestHandler.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ public CopilotRequestContext(CopilotRequestContext original)
4949
: this(original.RequestId, original.Url, original.Headers)
5050
{
5151
SessionId = original.SessionId;
52+
AgentId = original.AgentId;
53+
ParentAgentId = original.ParentAgentId;
54+
InteractionType = original.InteractionType;
5255
Transport = original.Transport;
5356
CancellationToken = original.CancellationToken;
5457
WebSocketResponse = original.WebSocketResponse;
@@ -67,6 +70,15 @@ internal CopilotRequestContext(string requestId, string url, IReadOnlyDictionary
6770
/// <summary>Runtime session id that triggered the request, if any.</summary>
6871
public string? SessionId { get; init; }
6972

73+
/// <summary>Stable per-agent-instance id for the agent trajectory that issued this request.</summary>
74+
public string? AgentId { get; init; }
75+
76+
/// <summary>Id of the parent agent when this request was issued by a subagent.</summary>
77+
public string? ParentAgentId { get; init; }
78+
79+
/// <summary>Runtime classification for the interaction that produced this request.</summary>
80+
public string? InteractionType { get; init; }
81+
7082
/// <summary>Transport the runtime would otherwise use.</summary>
7183
public CopilotRequestTransport Transport { get; init; }
7284

@@ -524,7 +536,7 @@ private static async Task<HttpRequestMessage> BuildHttpRequestAsync(LlmInference
524536
continue;
525537
}
526538

527-
if (!message.Headers.TryAddWithoutValidation(name, values))
539+
if (!message.Headers.TryAddWithoutValidation(name, values) && hasBody)
528540
{
529541
message.Content ??= new ByteArrayContent([]);
530542
message.Content.Headers.TryAddWithoutValidation(name, values);
@@ -870,6 +882,9 @@ public Task<LlmInferenceHttpRequestStartResult> HttpRequestStartAsync(LlmInferen
870882
exchange.Context = new CopilotRequestContext(request.RequestId, request.Url, ToReadOnlyHeaders(request.Headers))
871883
{
872884
SessionId = request.SessionId,
885+
AgentId = request.AgentId,
886+
ParentAgentId = request.ParentAgentId,
887+
InteractionType = request.InteractionType,
873888
Transport = transport,
874889
CancellationToken = exchange.Abort.Token,
875890
};

dotnet/test/E2E/CopilotRequestE2EProvider.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,13 @@ protected override async Task<HttpResponseMessage> SendRequestAsync(HttpRequestM
5656
#else
5757
: await request.Content.ReadAsStringAsync().ConfigureAwait(false);
5858
#endif
59-
_records.Enqueue(new InterceptedRequest(url, ctx.SessionId, bodyText));
59+
_records.Enqueue(new InterceptedRequest(
60+
url,
61+
ctx.SessionId,
62+
ctx.AgentId,
63+
ctx.ParentAgentId,
64+
ctx.InteractionType,
65+
bodyText));
6066

6167
return IsInferenceUrl(url)
6268
? BuildInferenceResponse(url, bodyText)
@@ -188,4 +194,10 @@ internal static HttpResponseMessage BuildNonInferenceResponse(string url)
188194
}
189195

190196
/// <summary>A single request the callback intercepted.</summary>
191-
internal sealed record InterceptedRequest(string Url, string? SessionId, string Body);
197+
internal sealed record InterceptedRequest(
198+
string Url,
199+
string? SessionId,
200+
string? AgentId,
201+
string? ParentAgentId,
202+
string? InteractionType,
203+
string Body);

dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@ public async Task Threads_The_Session_Id_Into_A_Capi_Session_Inference_Request()
5353

5454
var inference = provider.InferenceRequests;
5555
Assert.NotEmpty(inference);
56-
Assert.All(inference, r => Assert.Equal(capiSessionId, r.SessionId));
56+
Assert.All(inference, r =>
57+
{
58+
Assert.Equal(capiSessionId, r.SessionId);
59+
AssertAgentMetadata(r);
60+
});
5761

5862
// Validate the final assistant response arrived (guards against truncated captures)
5963
Assert.Contains("OK from the synthetic", content);
@@ -96,9 +100,19 @@ public async Task Threads_The_Session_Id_Into_A_Byok_Session_Inference_Request()
96100

97101
var inference = provider.InferenceRequests;
98102
Assert.NotEmpty(inference);
99-
Assert.All(inference, r => Assert.Equal(byokSessionId, r.SessionId));
103+
Assert.All(inference, r =>
104+
{
105+
Assert.Equal(byokSessionId, r.SessionId);
106+
AssertAgentMetadata(r);
107+
});
100108

101109
// Validate the final assistant response arrived (guards against truncated captures)
102110
Assert.Contains("OK from the synthetic", content);
103111
}
112+
113+
private static void AssertAgentMetadata(InterceptedRequest request)
114+
{
115+
Assert.False(string.IsNullOrEmpty(request.AgentId));
116+
Assert.False(string.IsNullOrEmpty(request.InteractionType));
117+
}
104118
}

dotnet/test/E2E/SubagentHooksE2ETests.cs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,31 @@
33
*--------------------------------------------------------------------------------------------*/
44

55
using System.Collections.Concurrent;
6+
using System.Net.Http;
67
using GitHub.Copilot.Test.Harness;
78
using Xunit;
89
using Xunit.Abstractions;
910

1011
namespace GitHub.Copilot.Test.E2E;
1112

13+
#pragma warning disable GHCP001 // The LLM inference surface is intentionally experimental.
14+
1215
public class SubagentHooksE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
1316
: E2ETestBase(fixture, "subagent_hooks", output)
1417
{
1518
[Fact]
1619
public async Task Should_Invoke_PreToolUse_And_PostToolUse_Hooks_For_Sub_Agent_Tool_Calls()
1720
{
1821
var hookLog = new ConcurrentBag<(string Kind, string ToolName, string SessionId)>();
22+
var requestHandler = new RecordingForwardingRequestHandler();
1923

2024
// Create a client with the session-based subagents feature flag
2125
var env = new Dictionary<string, string>(Ctx.GetEnvironment());
2226
env["COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS"] = "true";
23-
var client = Ctx.CreateClient(environment: env);
27+
var client = Ctx.CreateClient(new CopilotClientOptions
28+
{
29+
RequestHandler = requestHandler
30+
}, environment: env);
2431

2532
var session = await client.CreateSessionAsync(new SessionConfig
2633
{
@@ -69,5 +76,42 @@ await session.SendAndWaitAsync(
6976

7077
// input.SessionId distinguishes parent from sub-agent
7178
Assert.NotEqual(viewPre[0].SessionId, taskPre[0].SessionId);
79+
AssertSubagentRequestMetadata(requestHandler.InferenceRequests);
80+
}
81+
82+
private static void AssertSubagentRequestMetadata(IReadOnlyCollection<RequestRecord> records)
83+
{
84+
Assert.NotEmpty(records);
85+
var subagentRequest = records.FirstOrDefault(r => !string.IsNullOrEmpty(r.ParentAgentId));
86+
Assert.NotNull(subagentRequest);
87+
Assert.False(string.IsNullOrEmpty(subagentRequest.AgentId),
88+
"Sub-agent inference request should carry an agent id");
89+
Assert.False(string.IsNullOrEmpty(subagentRequest.InteractionType),
90+
"Sub-agent inference request should carry an interaction type");
91+
Assert.NotEqual(subagentRequest.ParentAgentId, subagentRequest.AgentId);
92+
}
93+
94+
private sealed class RecordingForwardingRequestHandler : CopilotRequestHandler
95+
{
96+
private readonly ConcurrentBag<RequestRecord> _records = [];
97+
98+
public IReadOnlyCollection<RequestRecord> InferenceRequests =>
99+
[.. _records.Where(r => RecordingRequestHandler.IsInferenceUrl(r.Url))];
100+
101+
protected override Task<HttpResponseMessage> SendRequestAsync(HttpRequestMessage request, CopilotRequestContext ctx)
102+
{
103+
_records.Add(new RequestRecord(
104+
request.RequestUri!.ToString(),
105+
ctx.AgentId,
106+
ctx.ParentAgentId,
107+
ctx.InteractionType));
108+
return base.SendRequestAsync(request, ctx);
109+
}
72110
}
111+
112+
private sealed record RequestRecord(
113+
string Url,
114+
string? AgentId,
115+
string? ParentAgentId,
116+
string? InteractionType);
73117
}

go/copilot_request_handler.go

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ var sharedHTTPTransport = func() http.RoundTripper {
5050
// CopilotRequestContext is the per-request context handed to every
5151
// [CopilotRequestHandler] seam.
5252
type CopilotRequestContext struct {
53-
RequestID string
54-
SessionID string
53+
RequestID string
54+
SessionID string
55+
AgentID string
56+
ParentAgentID string
57+
InteractionType string
5558
// Transport is "http" (covering plain HTTP and SSE) or "websocket".
5659
Transport string
5760
Method string
@@ -144,12 +147,12 @@ type CopilotWebSocketHandler interface {
144147

145148
// copilotContextKey is used to attach [CopilotRequestContext] to an
146149
// [http.Request] so custom [http.RoundTripper] implementations can access
147-
// metadata (e.g. SessionID) without additional parameters.
150+
// metadata (e.g. SessionID and AgentID) without additional parameters.
148151
type copilotContextKey struct{}
149152

150153
// RequestContextFrom returns the [CopilotRequestContext] attached to an
151154
// http.Request by the adapter, or nil if not present. Call this from a custom
152-
// [http.RoundTripper] to access metadata such as SessionID.
155+
// [http.RoundTripper] to access metadata such as SessionID and AgentID.
153156
func RequestContextFrom(r *http.Request) *CopilotRequestContext {
154157
v, _ := r.Context().Value(copilotContextKey{}).(*CopilotRequestContext)
155158
return v
@@ -194,7 +197,7 @@ func buildHTTPRequest(rctx *CopilotRequestContext) (*http.Request, error) {
194197
return nil, err
195198
}
196199
// Attach rctx so custom RoundTripper implementations can read metadata
197-
// (e.g. SessionID) via [RequestContextFrom].
200+
// (e.g. SessionID and AgentID) via [RequestContextFrom].
198201
httpReq = httpReq.WithContext(context.WithValue(httpReq.Context(), copilotContextKey{}, rctx))
199202
for name, values := range rctx.Headers {
200203
if isForbiddenRequestHeader(name) {
@@ -615,14 +618,17 @@ func (a *copilotRequestAdapter) HttpRequestStart(params *rpc.LlmInferenceHTTPReq
615618
}
616619

617620
rctx := &CopilotRequestContext{
618-
RequestID: params.RequestID,
619-
SessionID: sessionID,
620-
Method: params.Method,
621-
URL: params.URL,
622-
Headers: headers,
623-
Transport: transport,
624-
body: bodyCh,
625-
Context: ctx,
621+
RequestID: params.RequestID,
622+
SessionID: sessionID,
623+
AgentID: stringOrEmpty(params.AgentID),
624+
ParentAgentID: stringOrEmpty(params.ParentAgentID),
625+
InteractionType: stringOrEmpty(params.InteractionType),
626+
Method: params.Method,
627+
URL: params.URL,
628+
Headers: headers,
629+
Transport: transport,
630+
body: bodyCh,
631+
Context: ctx,
626632
}
627633
sink := &responseSink{requestID: params.RequestID, adapter: a, exchange: exchange}
628634
go a.runHandler(rctx, sink, exchange)
@@ -706,6 +712,13 @@ func (a *copilotRequestAdapter) removePending(requestID string) {
706712
a.mu.Unlock()
707713
}
708714

715+
func stringOrEmpty(value *string) string {
716+
if value == nil {
717+
return ""
718+
}
719+
return *value
720+
}
721+
709722
func decodeChunkData(data string, binary bool) ([]byte, error) {
710723
if binary {
711724
return base64.StdEncoding.DecodeString(data)

go/internal/e2e/copilot_request_session_id_e2e_test.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ import (
1616
)
1717

1818
type interceptedRequest struct {
19-
url string
20-
sessionID string
21-
body string
19+
url string
20+
sessionID string
21+
agentID string
22+
parentAgentID string
23+
interactionType string
24+
body string
2225
}
2326

2427
// recordingTransport intercepts every model-layer request, records its URL and
@@ -32,8 +35,14 @@ type recordingTransport struct {
3235
func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
3336
rctx := copilot.RequestContextFrom(req)
3437
sessionID := ""
38+
agentID := ""
39+
parentAgentID := ""
40+
interactionType := ""
3541
if rctx != nil {
3642
sessionID = rctx.SessionID
43+
agentID = rctx.AgentID
44+
parentAgentID = rctx.ParentAgentID
45+
interactionType = rctx.InteractionType
3746
}
3847
bodyBytes := []byte(nil)
3948
if req.Body != nil {
@@ -42,7 +51,14 @@ func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, erro
4251
bodyText := string(bodyBytes)
4352

4453
rt.mu.Lock()
45-
rt.records = append(rt.records, interceptedRequest{url: req.URL.String(), sessionID: sessionID, body: bodyText})
54+
rt.records = append(rt.records, interceptedRequest{
55+
url: req.URL.String(),
56+
sessionID: sessionID,
57+
agentID: agentID,
58+
parentAgentID: parentAgentID,
59+
interactionType: interactionType,
60+
body: bodyText,
61+
})
4662
rt.mu.Unlock()
4763

4864
if isInferenceURL(req.URL.String()) {
@@ -63,6 +79,16 @@ func (rt *recordingTransport) inferenceRecords() []interceptedRequest {
6379
return out
6480
}
6581

82+
func assertAgentMetadata(t *testing.T, r interceptedRequest) {
83+
t.Helper()
84+
if r.agentID == "" {
85+
t.Fatal("inference request must carry an agent id")
86+
}
87+
if r.interactionType == "" {
88+
t.Fatal("inference request must carry an interaction type")
89+
}
90+
}
91+
6692
func TestCopilotRequestSessionID(t *testing.T) {
6793
ctx := testharness.NewTestContext(t)
6894
transport := &recordingTransport{}
@@ -99,6 +125,7 @@ func TestCopilotRequestSessionID(t *testing.T) {
99125
if r.sessionID != capiSessionID {
100126
t.Fatalf("CAPI inference request must carry session id %q, got %q", capiSessionID, r.sessionID)
101127
}
128+
assertAgentMetadata(t, r)
102129
}
103130

104131
// Validate the final assistant response arrived (guards against truncated captures)
@@ -140,6 +167,7 @@ func TestCopilotRequestSessionID(t *testing.T) {
140167
if r.sessionID != byokSessionID {
141168
t.Fatalf("BYOK inference request must carry session id %q, got %q", byokSessionID, r.sessionID)
142169
}
170+
assertAgentMetadata(t, r)
143171
}
144172

145173
if byokSessionID == capiSessionID {

0 commit comments

Comments
 (0)