diff --git a/dotnet/src/CopilotRequestHandler.cs b/dotnet/src/CopilotRequestHandler.cs
index f26fc70e0..514d77da6 100644
--- a/dotnet/src/CopilotRequestHandler.cs
+++ b/dotnet/src/CopilotRequestHandler.cs
@@ -49,6 +49,9 @@ public CopilotRequestContext(CopilotRequestContext original)
: this(original.RequestId, original.Url, original.Headers)
{
SessionId = original.SessionId;
+ AgentId = original.AgentId;
+ ParentAgentId = original.ParentAgentId;
+ InteractionType = original.InteractionType;
Transport = original.Transport;
CancellationToken = original.CancellationToken;
WebSocketResponse = original.WebSocketResponse;
@@ -67,6 +70,15 @@ internal CopilotRequestContext(string requestId, string url, IReadOnlyDictionary
/// Runtime session id that triggered the request, if any.
public string? SessionId { get; init; }
+ /// Stable per-agent-instance id for the agent trajectory that issued this request.
+ public string? AgentId { get; init; }
+
+ /// Id of the parent agent when this request was issued by a subagent.
+ public string? ParentAgentId { get; init; }
+
+ /// Runtime classification for the interaction that produced this request.
+ public string? InteractionType { get; init; }
+
/// Transport the runtime would otherwise use.
public CopilotRequestTransport Transport { get; init; }
@@ -526,6 +538,12 @@ private static async Task BuildHttpRequestAsync(LlmInference
if (!message.Headers.TryAddWithoutValidation(name, values))
{
+#if NETSTANDARD2_0
+ if (!hasBody)
+ {
+ continue;
+ }
+#endif
message.Content ??= new ByteArrayContent([]);
message.Content.Headers.TryAddWithoutValidation(name, values);
}
@@ -870,6 +888,9 @@ public Task HttpRequestStartAsync(LlmInferen
exchange.Context = new CopilotRequestContext(request.RequestId, request.Url, ToReadOnlyHeaders(request.Headers))
{
SessionId = request.SessionId,
+ AgentId = request.AgentId,
+ ParentAgentId = request.ParentAgentId,
+ InteractionType = request.InteractionType,
Transport = transport,
CancellationToken = exchange.Abort.Token,
};
diff --git a/dotnet/test/E2E/CopilotRequestE2EProvider.cs b/dotnet/test/E2E/CopilotRequestE2EProvider.cs
index bade5c6e5..89826b4f8 100644
--- a/dotnet/test/E2E/CopilotRequestE2EProvider.cs
+++ b/dotnet/test/E2E/CopilotRequestE2EProvider.cs
@@ -56,7 +56,13 @@ protected override async Task SendRequestAsync(HttpRequestM
#else
: await request.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
- _records.Enqueue(new InterceptedRequest(url, ctx.SessionId, bodyText));
+ _records.Enqueue(new InterceptedRequest(
+ url,
+ ctx.SessionId,
+ ctx.AgentId,
+ ctx.ParentAgentId,
+ ctx.InteractionType,
+ bodyText));
return IsInferenceUrl(url)
? BuildInferenceResponse(url, bodyText)
@@ -188,4 +194,10 @@ internal static HttpResponseMessage BuildNonInferenceResponse(string url)
}
/// A single request the callback intercepted.
-internal sealed record InterceptedRequest(string Url, string? SessionId, string Body);
+internal sealed record InterceptedRequest(
+ string Url,
+ string? SessionId,
+ string? AgentId,
+ string? ParentAgentId,
+ string? InteractionType,
+ string Body);
diff --git a/dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs b/dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs
index e09c72c46..08dd07564 100644
--- a/dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs
+++ b/dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs
@@ -53,7 +53,11 @@ public async Task Threads_The_Session_Id_Into_A_Capi_Session_Inference_Request()
var inference = provider.InferenceRequests;
Assert.NotEmpty(inference);
- Assert.All(inference, r => Assert.Equal(capiSessionId, r.SessionId));
+ Assert.All(inference, r =>
+ {
+ Assert.Equal(capiSessionId, r.SessionId);
+ AssertAgentMetadata(r);
+ });
// Validate the final assistant response arrived (guards against truncated captures)
Assert.Contains("OK from the synthetic", content);
@@ -96,9 +100,19 @@ public async Task Threads_The_Session_Id_Into_A_Byok_Session_Inference_Request()
var inference = provider.InferenceRequests;
Assert.NotEmpty(inference);
- Assert.All(inference, r => Assert.Equal(byokSessionId, r.SessionId));
+ Assert.All(inference, r =>
+ {
+ Assert.Equal(byokSessionId, r.SessionId);
+ AssertAgentMetadata(r);
+ });
// Validate the final assistant response arrived (guards against truncated captures)
Assert.Contains("OK from the synthetic", content);
}
+
+ private static void AssertAgentMetadata(InterceptedRequest request)
+ {
+ Assert.False(string.IsNullOrEmpty(request.AgentId));
+ Assert.False(string.IsNullOrEmpty(request.InteractionType));
+ }
}
diff --git a/dotnet/test/E2E/SubagentHooksE2ETests.cs b/dotnet/test/E2E/SubagentHooksE2ETests.cs
index 4723c19b7..a718c56c1 100644
--- a/dotnet/test/E2E/SubagentHooksE2ETests.cs
+++ b/dotnet/test/E2E/SubagentHooksE2ETests.cs
@@ -3,12 +3,15 @@
*--------------------------------------------------------------------------------------------*/
using System.Collections.Concurrent;
+using System.Net.Http;
using GitHub.Copilot.Test.Harness;
using Xunit;
using Xunit.Abstractions;
namespace GitHub.Copilot.Test.E2E;
+#pragma warning disable GHCP001 // The LLM inference surface is intentionally experimental.
+
public class SubagentHooksE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
: E2ETestBase(fixture, "subagent_hooks", output)
{
@@ -16,11 +19,16 @@ public class SubagentHooksE2ETests(E2ETestFixture fixture, ITestOutputHelper out
public async Task Should_Invoke_PreToolUse_And_PostToolUse_Hooks_For_Sub_Agent_Tool_Calls()
{
var hookLog = new ConcurrentBag<(string Kind, string ToolName, string SessionId)>();
+ var requestHandler = new RecordingForwardingRequestHandler();
// Create a client with the session-based subagents feature flag
var env = new Dictionary(Ctx.GetEnvironment());
env["COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS"] = "true";
- var client = Ctx.CreateClient(environment: env);
+ var client = Ctx.CreateClient(new CopilotClientOptions
+ {
+ Connection = RuntimeConnection.ForStdio(),
+ RequestHandler = requestHandler
+ }, environment: env);
var session = await client.CreateSessionAsync(new SessionConfig
{
@@ -69,5 +77,42 @@ await session.SendAndWaitAsync(
// input.SessionId distinguishes parent from sub-agent
Assert.NotEqual(viewPre[0].SessionId, taskPre[0].SessionId);
+ AssertSubagentRequestMetadata(requestHandler.InferenceRequests);
+ }
+
+ private static void AssertSubagentRequestMetadata(IReadOnlyCollection records)
+ {
+ Assert.NotEmpty(records);
+ var subagentRequest = records.FirstOrDefault(r => !string.IsNullOrEmpty(r.ParentAgentId));
+ Assert.NotNull(subagentRequest);
+ Assert.False(string.IsNullOrEmpty(subagentRequest.AgentId),
+ "Sub-agent inference request should carry an agent id");
+ Assert.False(string.IsNullOrEmpty(subagentRequest.InteractionType),
+ "Sub-agent inference request should carry an interaction type");
+ Assert.NotEqual(subagentRequest.ParentAgentId, subagentRequest.AgentId);
+ }
+
+ private sealed class RecordingForwardingRequestHandler : CopilotRequestHandler
+ {
+ private readonly ConcurrentBag _records = [];
+
+ public IReadOnlyCollection InferenceRequests =>
+ [.. _records.Where(r => RecordingRequestHandler.IsInferenceUrl(r.Url))];
+
+ protected override Task SendRequestAsync(HttpRequestMessage request, CopilotRequestContext ctx)
+ {
+ _records.Add(new RequestRecord(
+ request.RequestUri!.ToString(),
+ ctx.AgentId,
+ ctx.ParentAgentId,
+ ctx.InteractionType));
+ return base.SendRequestAsync(request, ctx);
+ }
}
+
+ private sealed record RequestRecord(
+ string Url,
+ string? AgentId,
+ string? ParentAgentId,
+ string? InteractionType);
}
diff --git a/go/copilot_request_handler.go b/go/copilot_request_handler.go
index c1ac52bc2..ba8bb9b91 100644
--- a/go/copilot_request_handler.go
+++ b/go/copilot_request_handler.go
@@ -50,8 +50,11 @@ var sharedHTTPTransport = func() http.RoundTripper {
// CopilotRequestContext is the per-request context handed to every
// [CopilotRequestHandler] seam.
type CopilotRequestContext struct {
- RequestID string
- SessionID string
+ RequestID string
+ SessionID string
+ AgentID string
+ ParentAgentID string
+ InteractionType string
// Transport is "http" (covering plain HTTP and SSE) or "websocket".
Transport string
Method string
@@ -144,12 +147,12 @@ type CopilotWebSocketHandler interface {
// copilotContextKey is used to attach [CopilotRequestContext] to an
// [http.Request] so custom [http.RoundTripper] implementations can access
-// metadata (e.g. SessionID) without additional parameters.
+// metadata (e.g. SessionID and AgentID) without additional parameters.
type copilotContextKey struct{}
// RequestContextFrom returns the [CopilotRequestContext] attached to an
// http.Request by the adapter, or nil if not present. Call this from a custom
-// [http.RoundTripper] to access metadata such as SessionID.
+// [http.RoundTripper] to access metadata such as SessionID and AgentID.
func RequestContextFrom(r *http.Request) *CopilotRequestContext {
v, _ := r.Context().Value(copilotContextKey{}).(*CopilotRequestContext)
return v
@@ -194,7 +197,7 @@ func buildHTTPRequest(rctx *CopilotRequestContext) (*http.Request, error) {
return nil, err
}
// Attach rctx so custom RoundTripper implementations can read metadata
- // (e.g. SessionID) via [RequestContextFrom].
+ // (e.g. SessionID and AgentID) via [RequestContextFrom].
httpReq = httpReq.WithContext(context.WithValue(httpReq.Context(), copilotContextKey{}, rctx))
for name, values := range rctx.Headers {
if isForbiddenRequestHeader(name) {
@@ -615,14 +618,17 @@ func (a *copilotRequestAdapter) HttpRequestStart(params *rpc.LlmInferenceHTTPReq
}
rctx := &CopilotRequestContext{
- RequestID: params.RequestID,
- SessionID: sessionID,
- Method: params.Method,
- URL: params.URL,
- Headers: headers,
- Transport: transport,
- body: bodyCh,
- Context: ctx,
+ RequestID: params.RequestID,
+ SessionID: sessionID,
+ AgentID: stringOrEmpty(params.AgentID),
+ ParentAgentID: stringOrEmpty(params.ParentAgentID),
+ InteractionType: stringOrEmpty(params.InteractionType),
+ Method: params.Method,
+ URL: params.URL,
+ Headers: headers,
+ Transport: transport,
+ body: bodyCh,
+ Context: ctx,
}
sink := &responseSink{requestID: params.RequestID, adapter: a, exchange: exchange}
go a.runHandler(rctx, sink, exchange)
@@ -706,6 +712,13 @@ func (a *copilotRequestAdapter) removePending(requestID string) {
a.mu.Unlock()
}
+func stringOrEmpty(value *string) string {
+ if value == nil {
+ return ""
+ }
+ return *value
+}
+
func decodeChunkData(data string, binary bool) ([]byte, error) {
if binary {
return base64.StdEncoding.DecodeString(data)
diff --git a/go/internal/e2e/copilot_request_session_id_e2e_test.go b/go/internal/e2e/copilot_request_session_id_e2e_test.go
index e88b91c97..e3569d380 100644
--- a/go/internal/e2e/copilot_request_session_id_e2e_test.go
+++ b/go/internal/e2e/copilot_request_session_id_e2e_test.go
@@ -16,9 +16,12 @@ import (
)
type interceptedRequest struct {
- url string
- sessionID string
- body string
+ url string
+ sessionID string
+ agentID string
+ parentAgentID string
+ interactionType string
+ body string
}
// recordingTransport intercepts every model-layer request, records its URL and
@@ -32,8 +35,14 @@ type recordingTransport struct {
func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
rctx := copilot.RequestContextFrom(req)
sessionID := ""
+ agentID := ""
+ parentAgentID := ""
+ interactionType := ""
if rctx != nil {
sessionID = rctx.SessionID
+ agentID = rctx.AgentID
+ parentAgentID = rctx.ParentAgentID
+ interactionType = rctx.InteractionType
}
bodyBytes := []byte(nil)
if req.Body != nil {
@@ -42,7 +51,14 @@ func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, erro
bodyText := string(bodyBytes)
rt.mu.Lock()
- rt.records = append(rt.records, interceptedRequest{url: req.URL.String(), sessionID: sessionID, body: bodyText})
+ rt.records = append(rt.records, interceptedRequest{
+ url: req.URL.String(),
+ sessionID: sessionID,
+ agentID: agentID,
+ parentAgentID: parentAgentID,
+ interactionType: interactionType,
+ body: bodyText,
+ })
rt.mu.Unlock()
if isInferenceURL(req.URL.String()) {
@@ -63,6 +79,16 @@ func (rt *recordingTransport) inferenceRecords() []interceptedRequest {
return out
}
+func assertAgentMetadata(t *testing.T, r interceptedRequest) {
+ t.Helper()
+ if r.agentID == "" {
+ t.Fatal("inference request must carry an agent id")
+ }
+ if r.interactionType == "" {
+ t.Fatal("inference request must carry an interaction type")
+ }
+}
+
func TestCopilotRequestSessionID(t *testing.T) {
ctx := testharness.NewTestContext(t)
transport := &recordingTransport{}
@@ -99,6 +125,7 @@ func TestCopilotRequestSessionID(t *testing.T) {
if r.sessionID != capiSessionID {
t.Fatalf("CAPI inference request must carry session id %q, got %q", capiSessionID, r.sessionID)
}
+ assertAgentMetadata(t, r)
}
// Validate the final assistant response arrived (guards against truncated captures)
@@ -140,6 +167,7 @@ func TestCopilotRequestSessionID(t *testing.T) {
if r.sessionID != byokSessionID {
t.Fatalf("BYOK inference request must carry session id %q, got %q", byokSessionID, r.sessionID)
}
+ assertAgentMetadata(t, r)
}
if byokSessionID == capiSessionID {
diff --git a/go/internal/e2e/subagent_hooks_e2e_test.go b/go/internal/e2e/subagent_hooks_e2e_test.go
index c632b1e60..6a5000a2c 100644
--- a/go/internal/e2e/subagent_hooks_e2e_test.go
+++ b/go/internal/e2e/subagent_hooks_e2e_test.go
@@ -1,6 +1,7 @@
package e2e
import (
+ "net/http"
"os"
"path/filepath"
"sync"
@@ -10,10 +11,77 @@ import (
"github.com/github/copilot-sdk/go/internal/e2e/testharness"
)
+type subagentRequestRecord struct {
+ agentID string
+ parentAgentID string
+ interactionType string
+}
+
+type recordingForwardingTransport struct {
+ inner http.RoundTripper
+ mu sync.Mutex
+ records []subagentRequestRecord
+}
+
+func newRecordingForwardingTransport() *recordingForwardingTransport {
+ inner := http.DefaultTransport.(*http.Transport).Clone()
+ inner.DisableCompression = true
+ return &recordingForwardingTransport{inner: inner}
+}
+
+func (rt *recordingForwardingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ if isInferenceURL(req.URL.String()) {
+ rctx := copilot.RequestContextFrom(req)
+ record := subagentRequestRecord{}
+ if rctx != nil {
+ record.agentID = rctx.AgentID
+ record.parentAgentID = rctx.ParentAgentID
+ record.interactionType = rctx.InteractionType
+ }
+ rt.mu.Lock()
+ rt.records = append(rt.records, record)
+ rt.mu.Unlock()
+ }
+ return rt.inner.RoundTrip(req)
+}
+
+func (rt *recordingForwardingTransport) inferenceRecords() []subagentRequestRecord {
+ rt.mu.Lock()
+ defer rt.mu.Unlock()
+ out := make([]subagentRequestRecord, len(rt.records))
+ copy(out, rt.records)
+ return out
+}
+
+func assertSubagentRequestMetadata(t *testing.T, records []subagentRequestRecord) {
+ t.Helper()
+ if len(records) == 0 {
+ t.Fatal("request handler should observe inference requests")
+ }
+ for _, r := range records {
+ if r.parentAgentID == "" {
+ continue
+ }
+ if r.agentID == "" {
+ t.Fatal("sub-agent inference request should carry an agent id")
+ }
+ if r.interactionType == "" {
+ t.Fatal("sub-agent inference request should carry an interaction type")
+ }
+ if r.parentAgentID == r.agentID {
+ t.Fatal("sub-agent inference request should have distinct parent and child agent ids")
+ }
+ return
+ }
+ t.Fatal("sub-agent inference request should carry a parent agent id")
+}
+
func TestSubagentHooksE2E(t *testing.T) {
ctx := testharness.NewTestContext(t)
+ transport := newRecordingForwardingTransport()
client := ctx.NewClient(func(o *copilot.ClientOptions) {
o.Env = append(o.Env, "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS=true")
+ o.RequestHandler = &copilot.CopilotRequestHandler{Transport: transport}
})
t.Cleanup(func() { client.ForceStop() })
@@ -100,5 +168,6 @@ func TestSubagentHooksE2E(t *testing.T) {
if viewPre[0].sessionID == taskPre.sessionID {
t.Error("Sub-agent tool hooks should have a different sessionId than parent tool hooks")
}
+ assertSubagentRequestMetadata(t, transport.inferenceRecords())
})
}
diff --git a/java/src/main/java/com/github/copilot/CopilotRequestContext.java b/java/src/main/java/com/github/copilot/CopilotRequestContext.java
index 0948a24c2..610fb56c6 100644
--- a/java/src/main/java/com/github/copilot/CopilotRequestContext.java
+++ b/java/src/main/java/com/github/copilot/CopilotRequestContext.java
@@ -22,6 +22,12 @@ public final class CopilotRequestContext {
private final String requestId;
@Nullable
private final String sessionId;
+ @Nullable
+ private final String agentId;
+ @Nullable
+ private final String parentAgentId;
+ @Nullable
+ private final String interactionType;
private final CopilotRequestTransport transport;
private final String url;
private final Map> headers;
@@ -29,20 +35,25 @@ public final class CopilotRequestContext {
private LlmWebSocketResponseBridge webSocketResponse;
- CopilotRequestContext(String requestId, @Nullable String sessionId, CopilotRequestTransport transport, String url,
- Map> headers, CompletableFuture cancellation) {
+ CopilotRequestContext(String requestId, @Nullable String sessionId, @Nullable String agentId,
+ @Nullable String parentAgentId, @Nullable String interactionType, CopilotRequestTransport transport,
+ String url, Map> headers, CompletableFuture cancellation) {
this.requestId = requestId;
this.sessionId = sessionId;
+ this.agentId = agentId;
+ this.parentAgentId = parentAgentId;
+ this.interactionType = interactionType;
this.transport = transport;
this.url = url;
this.headers = headers;
this.cancellation = cancellation;
}
- private CopilotRequestContext(String requestId, @Nullable String sessionId, CopilotRequestTransport transport,
+ private CopilotRequestContext(String requestId, @Nullable String sessionId, @Nullable String agentId,
+ @Nullable String parentAgentId, @Nullable String interactionType, CopilotRequestTransport transport,
String url, Map> headers, CompletableFuture cancellation,
LlmWebSocketResponseBridge webSocketResponse) {
- this(requestId, sessionId, transport, url, headers, cancellation);
+ this(requestId, sessionId, agentId, parentAgentId, interactionType, transport, url, headers, cancellation);
this.webSocketResponse = webSocketResponse;
}
@@ -68,6 +79,39 @@ public String sessionId() {
return sessionId;
}
+ /**
+ * Gets the stable per-agent-instance id for the agent trajectory that issued
+ * this request, or {@code null} when no agent is in scope.
+ *
+ * @return the agent id, or {@code null}
+ */
+ @Nullable
+ public String agentId() {
+ return agentId;
+ }
+
+ /**
+ * Gets the id of the parent agent when this request was issued by a subagent,
+ * or {@code null} for root-agent and non-agent requests.
+ *
+ * @return the parent agent id, or {@code null}
+ */
+ @Nullable
+ public String parentAgentId() {
+ return parentAgentId;
+ }
+
+ /**
+ * Gets the runtime classification for the interaction that produced this
+ * request, or {@code null} when the runtime did not classify it.
+ *
+ * @return the interaction type, or {@code null}
+ */
+ @Nullable
+ public String interactionType() {
+ return interactionType;
+ }
+
/**
* Gets the transport the runtime would otherwise use.
*
@@ -103,8 +147,8 @@ public Map> headers() {
* @return the copied context
*/
public CopilotRequestContext withUrl(String url) {
- return new CopilotRequestContext(requestId, sessionId, transport, url, headers, cancellation,
- webSocketResponse);
+ return new CopilotRequestContext(requestId, sessionId, agentId, parentAgentId, interactionType, transport, url,
+ headers, cancellation, webSocketResponse);
}
/**
@@ -115,8 +159,8 @@ public CopilotRequestContext withUrl(String url) {
* @return the copied context
*/
public CopilotRequestContext withHeaders(Map> headers) {
- return new CopilotRequestContext(requestId, sessionId, transport, url, headers, cancellation,
- webSocketResponse);
+ return new CopilotRequestContext(requestId, sessionId, agentId, parentAgentId, interactionType, transport, url,
+ headers, cancellation, webSocketResponse);
}
/**
diff --git a/java/src/main/java/com/github/copilot/LlmInferenceAdapter.java b/java/src/main/java/com/github/copilot/LlmInferenceAdapter.java
index 9087df6c1..3e741a56b 100644
--- a/java/src/main/java/com/github/copilot/LlmInferenceAdapter.java
+++ b/java/src/main/java/com/github/copilot/LlmInferenceAdapter.java
@@ -65,6 +65,9 @@ private LlmInferenceExchange getOrCreateExchange(String requestId) {
private void handleRequestStart(JsonRpcClient rpc, String rpcId, JsonNode params) {
String requestId = params.get("requestId").asText();
String sessionId = textOrNull(params, "sessionId");
+ String agentId = textOrNull(params, "agentId");
+ String parentAgentId = textOrNull(params, "parentAgentId");
+ String interactionType = textOrNull(params, "interactionType");
String method = textOrNull(params, "method");
String url = textOrNull(params, "url");
CopilotRequestTransport transport = CopilotRequestTransport.fromWire(textOrNull(params, "transport"));
@@ -74,8 +77,8 @@ private void handleRequestStart(JsonRpcClient rpc, String rpcId, JsonNode params
// body — rather than dropping those frames.
LlmInferenceExchange exchange = getOrCreateExchange(requestId);
exchange.setMethod(method);
- exchange.setContext(
- new CopilotRequestContext(requestId, sessionId, transport, url, headers, exchange.cancellation()));
+ exchange.setContext(new CopilotRequestContext(requestId, sessionId, agentId, parentAgentId, interactionType,
+ transport, url, headers, exchange.cancellation()));
// Return from httpRequestStart immediately (after registering state) so the
// runtime's RPC reply is not gated on the consumer's I/O. The actual handler
diff --git a/java/src/test/java/com/github/copilot/CopilotRequestSessionIdE2ETest.java b/java/src/test/java/com/github/copilot/CopilotRequestSessionIdE2ETest.java
index daf524945..3025c64c3 100644
--- a/java/src/test/java/com/github/copilot/CopilotRequestSessionIdE2ETest.java
+++ b/java/src/test/java/com/github/copilot/CopilotRequestSessionIdE2ETest.java
@@ -10,6 +10,7 @@
import static com.github.copilot.CopilotRequestTestSupport.setupCapiAuth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -68,6 +69,7 @@ void threadsSessionIdForCapiAndByok() throws Exception {
assertFalse(capiInference.isEmpty(), "Expected at least one intercepted inference request");
for (InterceptedRequest r : capiInference) {
assertEquals(capiSessionId, r.sessionId(), "CAPI inference request must carry the session id");
+ assertAgentMetadata(r);
}
assertTrue(assistantText(capiResult).contains("OK from the synthetic"),
"Expected synthetic content in CAPI assistant reply, got " + assistantText(capiResult));
@@ -91,10 +93,18 @@ void threadsSessionIdForCapiAndByok() throws Exception {
assertTrue(byokInference.size() > before, "Expected at least one intercepted BYOK inference request");
for (InterceptedRequest r : byokInference.subList(before, byokInference.size())) {
assertEquals(byokSessionId, r.sessionId(), "BYOK inference request must carry the session id");
+ assertAgentMetadata(r);
}
assertNotEquals(capiSessionId, byokSessionId, "Expected per-session ids to differ between turns");
assertTrue(assistantText(byokResult).contains("OK from the synthetic"),
"Expected synthetic content in BYOK assistant reply, got " + assistantText(byokResult));
}
}
+
+ private static void assertAgentMetadata(InterceptedRequest request) {
+ assertNotNull(request.agentId(), "Inference request must carry an agent id");
+ assertFalse(request.agentId().isEmpty(), "Inference request must carry an agent id");
+ assertNotNull(request.interactionType(), "Inference request must carry an interaction type");
+ assertFalse(request.interactionType().isEmpty(), "Inference request must carry an interaction type");
+ }
}
diff --git a/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java b/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java
index 7a2e7f2f0..ecbf92068 100644
--- a/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java
+++ b/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java
@@ -473,7 +473,8 @@ static String assistantText(AssistantMessageEvent event) {
}
/** A single request the handler intercepted. */
- record InterceptedRequest(String url, String sessionId, String body) {
+ record InterceptedRequest(String url, String sessionId, String agentId, String parentAgentId,
+ String interactionType, String body) {
}
/**
@@ -509,7 +510,8 @@ protected HttpResponse sendRequest(HttpRequest request, CopilotRequ
throws Exception {
String url = request.uri().toString();
String body = requestBodyText(request);
- records.add(new InterceptedRequest(url, ctx.sessionId(), body));
+ records.add(new InterceptedRequest(url, ctx.sessionId(), ctx.agentId(), ctx.parentAgentId(),
+ ctx.interactionType(), body));
if (isInferenceUrl(url)) {
return buildInferenceResponse(url, body, text);
}
diff --git a/java/src/test/java/com/github/copilot/SubagentHooksE2ETest.java b/java/src/test/java/com/github/copilot/SubagentHooksE2ETest.java
new file mode 100644
index 000000000..c2ad45ff2
--- /dev/null
+++ b/java/src/test/java/com/github/copilot/SubagentHooksE2ETest.java
@@ -0,0 +1,125 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+package com.github.copilot;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.InputStream;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.util.HashMap;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Test;
+
+import com.github.copilot.rpc.CopilotClientOptions;
+import com.github.copilot.rpc.MessageOptions;
+import com.github.copilot.rpc.PermissionHandler;
+import com.github.copilot.rpc.PostToolUseHookOutput;
+import com.github.copilot.rpc.PreToolUseHookOutput;
+import com.github.copilot.rpc.SessionConfig;
+import com.github.copilot.rpc.SessionHooks;
+
+public class SubagentHooksE2ETest {
+
+ private static final String SNAPSHOT_NAME = "should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls";
+
+ @Test
+ void shouldInvokePreToolUseAndPostToolUseHooksForSubAgentToolCalls() throws Exception {
+ try (E2ETestContext ctx = E2ETestContext.create()) {
+ ctx.configureForTest("subagent_hooks", SNAPSHOT_NAME);
+
+ ConcurrentLinkedQueue hookLog = new ConcurrentLinkedQueue<>();
+ RecordingForwardingRequestHandler requestHandler = new RecordingForwardingRequestHandler();
+ HashMap env = new HashMap<>(ctx.getEnvironment());
+ env.put("COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS", "true");
+
+ try (CopilotClient client = ctx
+ .createClient(new CopilotClientOptions().setEnvironment(env).setRequestHandler(requestHandler))) {
+ CopilotSession session = client
+ .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)
+ .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> {
+ hookLog.add(new HookEntry("pre", input.getToolName(), input.getSessionId()));
+ return CompletableFuture.completedFuture(PreToolUseHookOutput.allow());
+ }).setOnPostToolUse((input, invocation) -> {
+ hookLog.add(new HookEntry("post", input.getToolName(), input.getSessionId()));
+ return CompletableFuture.completedFuture((PostToolUseHookOutput) null);
+ })))
+ .get();
+ try {
+ Files.writeString(ctx.getWorkDir().resolve("subagent-test.txt"), "Hello from subagent test!");
+ session.sendAndWait(new MessageOptions()
+ .setPrompt("Use the task tool to spawn an explore agent that reads the file "
+ + "subagent-test.txt in the current directory and reports its contents. "
+ + "You must use the task tool."))
+ .get(120, TimeUnit.SECONDS);
+
+ HookEntry taskPre = hookLog.stream()
+ .filter(h -> h.kind().equals("pre") && h.toolName().equals("task")).findFirst()
+ .orElse(null);
+ assertNotNull(taskPre, "preToolUse should fire for the parent's 'task' tool call");
+
+ List viewPre = hookLog.stream()
+ .filter(h -> h.kind().equals("pre") && h.toolName().equals("view")).toList();
+ List viewPost = hookLog.stream()
+ .filter(h -> h.kind().equals("post") && h.toolName().equals("view")).toList();
+ assertFalse(viewPre.isEmpty(), "preToolUse should fire for the sub-agent's 'view' tool call");
+ assertFalse(viewPost.isEmpty(), "postToolUse should fire for the sub-agent's 'view' tool call");
+ assertNotEquals(taskPre.sessionId(), viewPre.get(0).sessionId(),
+ "Sub-agent tool hooks should have a different sessionId than parent tool hooks");
+ assertSubagentRequestMetadata(requestHandler.inferenceRequests());
+ } finally {
+ session.close();
+ }
+ }
+ }
+ }
+
+ private static void assertSubagentRequestMetadata(List records) {
+ assertFalse(records.isEmpty(), "request handler should observe inference requests");
+ RequestRecord subagentRequest = records.stream()
+ .filter(r -> r.parentAgentId() != null && !r.parentAgentId().isEmpty()).findFirst().orElse(null);
+ assertNotNull(subagentRequest, "sub-agent inference request should carry a parentAgentId");
+ assertFalse(subagentRequest.agentId() == null || subagentRequest.agentId().isEmpty(),
+ "sub-agent inference request should carry an agentId");
+ assertFalse(subagentRequest.interactionType() == null || subagentRequest.interactionType().isEmpty(),
+ "sub-agent inference request should carry an interactionType");
+ assertNotEquals(subagentRequest.parentAgentId(), subagentRequest.agentId());
+ }
+
+ private static boolean isInferenceUrl(String url) {
+ String u = url.toLowerCase();
+ return u.endsWith("/chat/completions") || u.endsWith("/responses") || u.endsWith("/v1/messages")
+ || u.endsWith("/messages");
+ }
+
+ private record HookEntry(String kind, String toolName, String sessionId) {
+ }
+
+ private record RequestRecord(String url, String agentId, String parentAgentId, String interactionType) {
+ }
+
+ private static final class RecordingForwardingRequestHandler extends CopilotRequestHandler {
+ private final ConcurrentLinkedQueue records = new ConcurrentLinkedQueue<>();
+
+ List inferenceRequests() {
+ return records.stream().filter(r -> isInferenceUrl(r.url())).toList();
+ }
+
+ @Override
+ protected HttpResponse sendRequest(HttpRequest request, CopilotRequestContext ctx)
+ throws Exception {
+ records.add(new RequestRecord(request.uri().toString(), ctx.agentId(), ctx.parentAgentId(),
+ ctx.interactionType()));
+ return super.sendRequest(request, ctx);
+ }
+ }
+}
diff --git a/nodejs/src/copilotRequestHandler.ts b/nodejs/src/copilotRequestHandler.ts
index a949cecfd..ccfe6591c 100644
--- a/nodejs/src/copilotRequestHandler.ts
+++ b/nodejs/src/copilotRequestHandler.ts
@@ -33,6 +33,9 @@ type InternalContext = CopilotRequestContext & { [kBridge]: CopilotWebSocketResp
export interface CopilotRequestContext {
readonly requestId: string;
readonly sessionId?: string;
+ readonly agentId?: string;
+ readonly parentAgentId?: string;
+ readonly interactionType?: string;
readonly transport: "http" | "websocket";
url: string;
headers: LlmInferenceHeaders;
@@ -249,6 +252,9 @@ export class CopilotRequestHandler {
const ctx: InternalContext = {
requestId: exchange.requestId,
sessionId: exchange.sessionId,
+ agentId: exchange.agentId,
+ parentAgentId: exchange.parentAgentId,
+ interactionType: exchange.interactionType,
transport: exchange.transport,
url: exchange.url,
headers: exchange.headers,
@@ -456,6 +462,9 @@ interface BodyQueueItem {
class CopilotRequestExchange {
readonly requestId: string;
sessionId?: string;
+ agentId?: string;
+ parentAgentId?: string;
+ interactionType?: string;
method = "GET";
url = "";
headers: LlmInferenceHeaders = {};
@@ -478,6 +487,9 @@ class CopilotRequestExchange {
/** Fill in the request context once the matching start frame arrives. */
setContext(params: LlmInferenceHttpRequestStartRequest): void {
this.sessionId = params.sessionId;
+ this.agentId = params.agentId;
+ this.parentAgentId = params.parentAgentId;
+ this.interactionType = params.interactionType;
this.method = params.method;
this.url = params.url;
this.headers = params.headers;
diff --git a/nodejs/test/e2e/copilot_request_session_id.e2e.test.ts b/nodejs/test/e2e/copilot_request_session_id.e2e.test.ts
index 3f01475aa..bd070c20c 100644
--- a/nodejs/test/e2e/copilot_request_session_id.e2e.test.ts
+++ b/nodejs/test/e2e/copilot_request_session_id.e2e.test.ts
@@ -11,6 +11,9 @@ const SYNTHETIC_TEXT = "OK from the synthetic stream.";
interface InterceptedRequest {
url: string;
sessionId?: string;
+ agentId?: string;
+ parentAgentId?: string;
+ interactionType?: string;
}
function isInferenceUrl(url: string): boolean {
@@ -43,7 +46,13 @@ class RecordingRequestHandler extends CopilotRequestHandler {
ctx: CopilotRequestContext
): Promise {
const url = request.url;
- this.records.push({ url, sessionId: ctx.sessionId });
+ this.records.push({
+ url,
+ sessionId: ctx.sessionId,
+ agentId: ctx.agentId,
+ parentAgentId: ctx.parentAgentId,
+ interactionType: ctx.interactionType,
+ });
const bodyText = request.body ? await request.text() : "";
return isInferenceUrl(url)
? buildInferenceResponse(url, bodyText)
@@ -105,6 +114,11 @@ function buildNonInferenceResponse(url: string): Response {
return json("{}");
}
+function expectAgentMetadata(r: InterceptedRequest): void {
+ expect(r.agentId).toBeTruthy();
+ expect(r.interactionType).toBeTruthy();
+}
+
const RESPONSES_STREAM_EVENTS: string[] = [
`event: response.created\ndata: ${JSON.stringify({
type: "response.created",
@@ -273,6 +287,7 @@ describe("CopilotRequestHandler threads the runtime session id (CAPI + BYOK)", a
expect(r.sessionId, "CAPI inference request must carry the runtime session id").toBe(
session.sessionId
);
+ expectAgentMetadata(r);
}
// Validate the final assistant response arrived (guards against truncated captures)
@@ -313,6 +328,7 @@ describe("CopilotRequestHandler threads the runtime session id (CAPI + BYOK)", a
expect(r.sessionId, "BYOK inference request must carry the runtime session id").toBe(
byokSessionId
);
+ expectAgentMetadata(r);
}
// Session ids are per-session, so the two turns must differ — proves
diff --git a/nodejs/test/e2e/subagent_hooks.e2e.test.ts b/nodejs/test/e2e/subagent_hooks.e2e.test.ts
index ac0a694dc..dbc3ca673 100644
--- a/nodejs/test/e2e/subagent_hooks.e2e.test.ts
+++ b/nodejs/test/e2e/subagent_hooks.e2e.test.ts
@@ -6,20 +6,79 @@ import { writeFile } from "fs/promises";
import { join } from "path";
import { describe, expect, it } from "vitest";
import type {
+ CopilotRequestContext,
PreToolUseHookInput,
PreToolUseHookOutput,
PostToolUseHookInput,
PostToolUseHookOutput,
} from "../../src/index.js";
-import { approveAll } from "../../src/index.js";
+import { approveAll, CopilotRequestHandler } from "../../src/index.js";
import { createSdkTestContext, isCI } from "./harness/sdkTestContext.js";
+interface RequestRecord {
+ url: string;
+ agentId?: string;
+ parentAgentId?: string;
+ interactionType?: string;
+}
+
+class RecordingRequestHandler extends CopilotRequestHandler {
+ readonly records: RequestRecord[] = [];
+
+ protected override async sendRequest(
+ request: Request,
+ ctx: CopilotRequestContext
+ ): Promise {
+ this.records.push({
+ url: request.url,
+ agentId: ctx.agentId,
+ parentAgentId: ctx.parentAgentId,
+ interactionType: ctx.interactionType,
+ });
+ return super.sendRequest(request, ctx);
+ }
+}
+
+function isInferenceUrl(url: string): boolean {
+ const u = url.toLowerCase();
+ return (
+ u.endsWith("/chat/completions") ||
+ u.endsWith("/responses") ||
+ u.endsWith("/v1/messages") ||
+ u.endsWith("/messages")
+ );
+}
+
+function expectSubagentRequestMetadata(records: RequestRecord[]): void {
+ const inference = records.filter((r) => isInferenceUrl(r.url));
+ expect(inference.length, "request handler should observe inference requests").toBeGreaterThan(
+ 0
+ );
+
+ const subagentRequest = inference.find((r) => r.parentAgentId);
+ expect(
+ subagentRequest,
+ "sub-agent inference request should carry a parentAgentId"
+ ).toBeDefined();
+ expect(
+ subagentRequest!.agentId,
+ "sub-agent inference request should carry an agentId"
+ ).toBeTruthy();
+ expect(
+ subagentRequest!.interactionType,
+ "sub-agent inference request should carry an interactionType"
+ ).toBeTruthy();
+ expect(subagentRequest!.parentAgentId).not.toBe(subagentRequest!.agentId);
+}
+
describe("Subagent hooks", async () => {
// For snapshot recording (non-CI), use RECORD_GH_TOKEN if available
const recordToken = !isCI ? process.env.RECORD_GH_TOKEN : undefined;
+ const requestHandler = new RecordingRequestHandler();
const { copilotClient: client, workDir } = await createSdkTestContext({
copilotClientOptions: {
...(recordToken ? { gitHubToken: recordToken } : {}),
+ requestHandler,
env: { COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS: "true" },
},
});
@@ -75,6 +134,7 @@ describe("Subagent hooks", async () => {
// input.sessionId distinguishes parent from sub-agent: parent tools and
// sub-agent tools carry different sessionIds
expect(viewPre[0].sessionId).not.toBe(taskPre!.sessionId);
+ expectSubagentRequestMetadata(requestHandler.records);
await session.disconnect();
}, 120_000);
diff --git a/python/copilot/copilot_request_handler.py b/python/copilot/copilot_request_handler.py
index 54e71027c..e6465b7bb 100644
--- a/python/copilot/copilot_request_handler.py
+++ b/python/copilot/copilot_request_handler.py
@@ -96,6 +96,15 @@ class CopilotRequestContext:
"""Id of the runtime session that triggered this request, when in scope.
Absent for out-of-session requests (e.g. the startup model catalog)."""
+ agent_id: str | None = None
+ """Stable per-agent-instance id for the agent trajectory that issued this request."""
+
+ parent_agent_id: str | None = None
+ """Id of the parent agent when this request was issued by a subagent."""
+
+ interaction_type: str | None = None
+ """Runtime classification for the interaction that produced this request."""
+
_bridge: _CopilotWebSocketResponseBridge | None = field(default=None, repr=False)
@@ -253,6 +262,9 @@ async def _dispatch(self, exchange: _CopilotRequestExchange) -> None:
ctx = CopilotRequestContext(
request_id=exchange.request_id,
session_id=exchange.session_id,
+ agent_id=exchange.agent_id,
+ parent_agent_id=exchange.parent_agent_id,
+ interaction_type=exchange.interaction_type,
transport=exchange.transport,
url=exchange.url,
headers=exchange.headers,
@@ -382,6 +394,9 @@ def __init__(
) -> None:
self.request_id = request_id
self.session_id: str | None = None
+ self.agent_id: str | None = None
+ self.parent_agent_id: str | None = None
+ self.interaction_type: str | None = None
self.method: str = "GET"
self.url: str = ""
self.headers: dict[str, list[str]] = {}
@@ -397,6 +412,9 @@ def __init__(
def set_context(self, params: LlmInferenceHTTPRequestStartRequest) -> None:
"""Fill in the request context once the matching start frame arrives."""
self.session_id = params.session_id
+ self.agent_id = params.agent_id
+ self.parent_agent_id = params.parent_agent_id
+ self.interaction_type = params.interaction_type
self.method = params.method
self.url = params.url
self.headers = params.headers
diff --git a/python/e2e/test_copilot_request_session_id_e2e.py b/python/e2e/test_copilot_request_session_id_e2e.py
index e40af13a1..81624d73d 100644
--- a/python/e2e/test_copilot_request_session_id_e2e.py
+++ b/python/e2e/test_copilot_request_session_id_e2e.py
@@ -36,6 +36,9 @@
class _InterceptedRequest:
url: str
session_id: str | None
+ agent_id: str | None
+ parent_agent_id: str | None
+ interaction_type: str | None
class _SessionIdHandler(CopilotRequestHandler):
@@ -46,7 +49,15 @@ async def send_request(
self, request: httpx.Request, ctx: CopilotRequestContext
) -> httpx.Response:
url = str(request.url)
- self.records.append(_InterceptedRequest(url=url, session_id=ctx.session_id))
+ self.records.append(
+ _InterceptedRequest(
+ url=url,
+ session_id=ctx.session_id,
+ agent_id=ctx.agent_id,
+ parent_agent_id=ctx.parent_agent_id,
+ interaction_type=ctx.interaction_type,
+ )
+ )
if is_inference_url(url):
return build_inference_response(request)
# Force /responses transport so the inference URL is predictable.
@@ -56,6 +67,11 @@ async def send_request(
session_id_client = isolated_client_fixture(_SessionIdHandler)
+def _assert_agent_metadata(record: _InterceptedRequest) -> None:
+ assert record.agent_id
+ assert record.interaction_type
+
+
class TestCopilotRequestSessionId:
capi_session_id: str | None = None
@@ -78,6 +94,7 @@ async def test_threads_session_id_into_capi_session(self, session_id_client):
assert r.session_id == session.session_id, (
"CAPI inference request must carry the runtime session id"
)
+ _assert_agent_metadata(r)
# Validate the final assistant response arrived (guards against truncated captures)
assert "OK from the synthetic" in text
@@ -112,6 +129,7 @@ async def test_threads_session_id_into_byok_session(self, session_id_client):
assert r.session_id == byok_session_id, (
"BYOK inference request must carry the runtime session id"
)
+ _assert_agent_metadata(r)
# Session ids are per-session, so the two turns must differ.
assert byok_session_id != TestCopilotRequestSessionId.capi_session_id
diff --git a/python/e2e/test_subagent_hooks_e2e.py b/python/e2e/test_subagent_hooks_e2e.py
index 1ca2a54c1..da70265a0 100644
--- a/python/e2e/test_subagent_hooks_e2e.py
+++ b/python/e2e/test_subagent_hooks_e2e.py
@@ -3,10 +3,14 @@
fire for tool calls made by sub-agents spawned via the task tool.
"""
+from __future__ import annotations
+
import os
+import httpx
import pytest
+from copilot import CopilotRequestContext, CopilotRequestHandler
from copilot.client import CopilotClient, RuntimeConnection
from copilot.session import PermissionHandler
@@ -16,12 +20,56 @@
pytestmark = pytest.mark.asyncio(loop_scope="module")
+class _RecordingRequestHandler(CopilotRequestHandler):
+ def __init__(self) -> None:
+ self.records: list[dict[str, str | None]] = []
+
+ async def send_request(
+ self, request: httpx.Request, ctx: CopilotRequestContext
+ ) -> httpx.Response:
+ self.records.append(
+ {
+ "url": str(request.url),
+ "agent_id": ctx.agent_id,
+ "parent_agent_id": ctx.parent_agent_id,
+ "interaction_type": ctx.interaction_type,
+ }
+ )
+ return await super().send_request(request, ctx)
+
+
+def _is_inference_url(url: str) -> bool:
+ u = url.lower()
+ return (
+ u.endswith("/chat/completions")
+ or u.endswith("/responses")
+ or u.endswith("/v1/messages")
+ or u.endswith("/messages")
+ )
+
+
+def _assert_subagent_request_metadata(records: list[dict[str, str | None]]) -> None:
+ inference = [r for r in records if _is_inference_url(r["url"] or "")]
+ assert len(inference) > 0, "request handler should observe inference requests"
+
+ subagent_request = next((r for r in inference if r["parent_agent_id"]), None)
+ assert subagent_request is not None, (
+ "sub-agent inference request should carry a parent_agent_id"
+ )
+ assert subagent_request["agent_id"], "sub-agent inference request should carry an agent_id"
+ assert subagent_request["interaction_type"], (
+ "sub-agent inference request should carry an interaction_type"
+ )
+ assert subagent_request["parent_agent_id"] != subagent_request["agent_id"]
+
+
class TestSubagentHooks:
async def test_should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls(
self, ctx: E2ETestContext
):
"""Test that preToolUse/postToolUse hooks fire for sub-agent tool calls"""
hook_log = []
+ request_handler = _RecordingRequestHandler()
async def on_pre_tool_use(input_data, invocation):
hook_log.append(
@@ -54,6 +102,7 @@ async def on_post_tool_use(input_data, invocation):
working_directory=ctx.work_dir,
env=env,
github_token=github_token,
+ request_handler=request_handler,
)
session = await client.create_session(
@@ -87,6 +136,7 @@ async def on_post_tool_use(input_data, invocation):
assert view_pre[0]["sessionId"] != task_pre[0]["sessionId"], (
"Sub-agent tool hooks should have a different sessionId than parent tool hooks"
)
+ _assert_subagent_request_metadata(request_handler.records)
await session.disconnect()
await client.stop()
diff --git a/rust/src/copilot_request_handler.rs b/rust/src/copilot_request_handler.rs
index b686b6ead..961ae3876 100644
--- a/rust/src/copilot_request_handler.rs
+++ b/rust/src/copilot_request_handler.rs
@@ -140,6 +140,12 @@ pub struct CopilotRequestContext {
/// Id of the runtime session that triggered this request, or `None` when it
/// was issued outside any session (for example the startup model catalog).
pub session_id: Option,
+ /// Stable per-agent-instance id for the agent trajectory that issued this request.
+ pub agent_id: Option,
+ /// Id of the parent agent when this request was issued by a subagent.
+ pub parent_agent_id: Option,
+ /// Runtime classification for the interaction that produced this request.
+ pub interaction_type: Option,
/// Transport the runtime would otherwise use.
pub transport: CopilotRequestTransport,
/// Absolute request URL.
@@ -594,6 +600,9 @@ struct ResponseState {
#[derive(Default)]
struct RequestMeta {
session_id: Option,
+ agent_id: Option,
+ parent_agent_id: Option,
+ interaction_type: Option,
method: String,
url: String,
headers: HeaderMap,
@@ -630,6 +639,9 @@ impl CopilotRequestExchange {
fn set_context(&self, params: LlmInferenceHttpRequestStartRequest) {
let _ = self.meta.set(RequestMeta {
session_id: params.session_id.map(SessionId::into_inner),
+ agent_id: params.agent_id,
+ parent_agent_id: params.parent_agent_id,
+ interaction_type: params.interaction_type,
method: params.method,
url: params.url,
headers: headers_from_wire(¶ms.headers),
@@ -649,6 +661,9 @@ impl CopilotRequestExchange {
CopilotRequestContext {
request_id: self.request_id.clone(),
session_id: meta.session_id.clone(),
+ agent_id: meta.agent_id.clone(),
+ parent_agent_id: meta.parent_agent_id.clone(),
+ interaction_type: meta.interaction_type.clone(),
transport: meta.transport,
url: meta.url.clone(),
headers: meta.headers.clone(),
diff --git a/rust/tests/e2e/copilot_request_handler.rs b/rust/tests/e2e/copilot_request_handler.rs
index 6ca99393b..2dd141173 100644
--- a/rust/tests/e2e/copilot_request_handler.rs
+++ b/rust/tests/e2e/copilot_request_handler.rs
@@ -572,16 +572,25 @@ async fn services_http_and_websocket_via_handler() {
#[derive(Default)]
struct RecordingHandler {
- records: std::sync::Mutex)>>,
+ records: std::sync::Mutex>,
+}
+
+#[derive(Clone)]
+struct InterceptedRequest {
+ url: String,
+ session_id: Option,
+ agent_id: Option,
+ parent_agent_id: Option,
+ interaction_type: Option,
}
impl RecordingHandler {
- fn inference_records(&self) -> Vec<(String, Option)> {
+ fn inference_records(&self) -> Vec {
self.records
.lock()
.unwrap()
.iter()
- .filter(|(url, _)| is_inference_url(url))
+ .filter(|record| is_inference_url(&record.url))
.cloned()
.collect()
}
@@ -594,10 +603,13 @@ impl CopilotRequestHandler for RecordingHandler {
request: CopilotHttpRequest,
ctx: &CopilotRequestContext,
) -> Result {
- self.records
- .lock()
- .unwrap()
- .push((request.url.clone(), ctx.session_id.clone()));
+ self.records.lock().unwrap().push(InterceptedRequest {
+ url: request.url.clone(),
+ session_id: ctx.session_id.clone(),
+ agent_id: ctx.agent_id.clone(),
+ parent_agent_id: ctx.parent_agent_id.clone(),
+ interaction_type: ctx.interaction_type.clone(),
+ });
if is_inference_url(&request.url) {
Ok(synth_inference_response(
&request.url,
@@ -632,12 +644,13 @@ async fn threads_session_id_into_inference() {
!inference.is_empty(),
"expected at least one intercepted inference request"
);
- for (_, session_id) in &inference {
+ for record in &inference {
assert_eq!(
- session_id.as_deref(),
+ record.session_id.as_deref(),
Some(capi_session_id.as_str()),
"CAPI inference request must carry the session id"
);
+ assert_agent_metadata(record);
}
assert!(
assistant_text(&result).contains("OK from the synthetic"),
@@ -671,12 +684,13 @@ async fn threads_session_id_into_inference() {
inference.len() > before,
"expected at least one intercepted BYOK inference request"
);
- for (_, session_id) in &inference[before..] {
+ for record in &inference[before..] {
assert_eq!(
- session_id.as_deref(),
+ record.session_id.as_deref(),
Some(byok_session_id.as_str()),
"BYOK inference request must carry the session id"
);
+ assert_agent_metadata(record);
}
assert_ne!(
byok_session_id, capi_session_id,
@@ -694,6 +708,26 @@ async fn threads_session_id_into_inference() {
.await;
}
+fn assert_agent_metadata(record: &InterceptedRequest) {
+ assert!(
+ record.agent_id.as_deref().is_some_and(|id| !id.is_empty()),
+ "inference request must carry an agent id"
+ );
+ if let Some(parent_agent_id) = record.parent_agent_id.as_deref() {
+ assert!(
+ !parent_agent_id.is_empty(),
+ "parent agent id must be non-empty when present"
+ );
+ }
+ assert!(
+ record
+ .interaction_type
+ .as_deref()
+ .is_some_and(|kind| !kind.is_empty()),
+ "inference request must carry an interaction type"
+ );
+}
+
// ---------------------------------------------------------------------------
// Scenario 3a: errors — a handler that returns `Err` on an inference request
// surfaces a transport error rather than hanging the turn.
diff --git a/rust/tests/e2e/subagent_hooks.rs b/rust/tests/e2e/subagent_hooks.rs
index 99529c433..8a21169c4 100644
--- a/rust/tests/e2e/subagent_hooks.rs
+++ b/rust/tests/e2e/subagent_hooks.rs
@@ -5,6 +5,10 @@ use github_copilot_sdk::hooks::{
HookContext, PostToolUseInput, PostToolUseOutput, PreToolUseInput, PreToolUseOutput,
SessionHooks,
};
+use github_copilot_sdk::{
+ CopilotHttpRequest, CopilotHttpResponse, CopilotRequestContext, CopilotRequestError,
+ CopilotRequestHandler, forward_http,
+};
use parking_lot::Mutex;
use super::support::with_e2e_context;
@@ -24,16 +28,14 @@ async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls
.expect("write test file");
let hook_log = Arc::new(Mutex::new(Vec::::new()));
+ let request_log = Arc::new(RecordingRequestHandler::default());
- let mut opts = ctx.client_options();
- opts.env.push((
- "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS".into(),
- "true".into(),
- ));
-
- let client = github_copilot_sdk::Client::start(opts)
- .await
- .expect("start client");
+ let client = ctx
+ .start_llm_client(
+ Arc::clone(&request_log),
+ &[("COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS", "true")],
+ )
+ .await;
let session = client
.create_session(ctx.approve_all_session_config().with_hooks(Arc::new(
@@ -88,6 +90,7 @@ async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls
task_pre.unwrap().session_id,
"Sub-agent tool hooks should have a different sessionId than parent tool hooks"
);
+ assert_subagent_request_metadata(&request_log.inference_records());
session.disconnect().await.expect("disconnect session");
client.stop().await.expect("stop client");
@@ -104,6 +107,90 @@ struct HookEntry {
session_id: String,
}
+#[derive(Clone, Debug)]
+struct RequestEntry {
+ url: String,
+ agent_id: Option,
+ parent_agent_id: Option,
+ interaction_type: Option,
+}
+
+#[derive(Default)]
+struct RecordingRequestHandler {
+ log: Mutex>,
+}
+
+impl RecordingRequestHandler {
+ fn inference_records(&self) -> Vec {
+ self.log
+ .lock()
+ .iter()
+ .filter(|entry| is_inference_url(&entry.url))
+ .cloned()
+ .collect()
+ }
+}
+
+#[async_trait]
+impl CopilotRequestHandler for RecordingRequestHandler {
+ async fn send_request(
+ &self,
+ request: CopilotHttpRequest,
+ ctx: &CopilotRequestContext,
+ ) -> Result {
+ self.log.lock().push(RequestEntry {
+ url: request.url.clone(),
+ agent_id: ctx.agent_id.clone(),
+ parent_agent_id: ctx.parent_agent_id.clone(),
+ interaction_type: ctx.interaction_type.clone(),
+ });
+ forward_http(request).await
+ }
+}
+
+fn is_inference_url(url: &str) -> bool {
+ let url = url.to_lowercase();
+ url.ends_with("/chat/completions")
+ || url.ends_with("/responses")
+ || url.ends_with("/v1/messages")
+ || url.ends_with("/messages")
+}
+
+fn assert_subagent_request_metadata(records: &[RequestEntry]) {
+ assert!(
+ !records.is_empty(),
+ "request handler should observe inference requests"
+ );
+ let subagent_request = records
+ .iter()
+ .find(|entry| {
+ entry
+ .parent_agent_id
+ .as_deref()
+ .is_some_and(|id| !id.is_empty())
+ })
+ .expect("sub-agent inference request should carry a parentAgentId");
+ assert!(
+ subagent_request
+ .agent_id
+ .as_deref()
+ .is_some_and(|id| !id.is_empty()),
+ "sub-agent inference request should carry an agentId"
+ );
+ assert!(
+ subagent_request
+ .interaction_type
+ .as_deref()
+ .is_some_and(|kind| !kind.is_empty()),
+ "sub-agent inference request should carry an interactionType"
+ );
+ assert_ne!(
+ subagent_request.parent_agent_id.as_deref(),
+ subagent_request.agent_id.as_deref(),
+ "sub-agent inference request should have distinct parent and child agent ids"
+ );
+}
+
struct RecordingHooks {
log: Arc>>,
}