Skip to content

Commit 82ef5da

Browse files
belaltaher8Copilot
andauthored
sdk: Expose AgentStop session hook across languages (#2054)
* nodejs: Expose onAgentStop session hook The runtime already fires the top-level agent's `agentStop` hook and registers it for SDK callback sessions (REGISTERED_CALLBACK_EVENT_NAMES), with a working block/continue re-prompt loop, but the Node SDK never exposed it: SessionHooks had no `onAgentStop` and `_handleHooksInvoke` had no dispatch entry, so `agentStop` callbacks were silently dropped. Add the AgentStopHookInput/Output/Handler types, the `onAgentStop` field on SessionHooks, and the `agentStop` entry in the hook dispatcher. Returning `{ decision: "block", reason }` keeps the agent running with `reason` enqueued as a follow-up message (e.g. to remediate findings a handler surfaced); returning nothing lets the agent stop. This unblocks the Copilot cloud agent restoring its post-completion security-tool hooks (dependabot / secret scanning) on the proper platform hook rather than ad-hoc per-commit hooks. Note: parallel changes for the Python/Go/.NET SDKs are follow-ups. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2031e02b-7fe5-4075-9d75-eb20eb29f407 * Expose AgentStop hook across SDKs Add AgentStop types and dispatch support for Python, Go, .NET, Rust, and Java, and normalize the Node stop_hook_active wire field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f4d61ce-6d2f-4b72-a2da-52f60300df59 * Address AgentStop review feedback Export Node hook types, document the hook, cover wire-field normalization, add Go and .NET E2E block-continuation coverage, and format the Python README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f4d61ce-6d2f-4b72-a2da-52f60300df59 * Clarify AgentStop input naming by language Document the public input member names for Node, Python, Go, .NET, Rust, and Java. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f4d61ce-6d2f-4b72-a2da-52f60300df59 * test: cover AgentStop E2E in every SDK Add Node, Python, Rust, and Java replay tests for natural-stop callback delivery and block-driven continuation using the shared hooks_extended snapshot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: format Python AgentStop E2E Apply Ruff formatting to the new replay test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: apply Java and Rust formatting Match Spotless and nightly rustfmt output for the AgentStop replay tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2031e02b-7fe5-4075-9d75-eb20eb29f407 Copilot-Session: 4f4d61ce-6d2f-4b72-a2da-52f60300df59
1 parent 0d48467 commit 82ef5da

32 files changed

Lines changed: 1218 additions & 15 deletions

docs/hooks/hooks-overview.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Hooks allow you to intercept and customize the behavior of Copilot sessions at k
1919
| [`onSessionStart`](./session-lifecycle.md#session-start) | Session begins | Add context, configure session |
2020
| [`onSessionEnd`](./session-lifecycle.md#session-end) | Session ends | Cleanup, analytics |
2121
| [`onErrorOccurred`](./error-handling.md) | Error happens | Custom error handling |
22+
| [`onAgentStop`](./session-lifecycle.md#agent-stop) | Top-level agent naturally stops | Validate completion or request another turn |
2223

2324
## Quick start
2425

@@ -263,6 +264,7 @@ const session = await client.createSession({
263264
* **[Post-Tool Use Hook](./post-tool-use.md)** - Transform tool results
264265
* **[User Prompt Submitted Hook](./user-prompt-submitted.md)** - Modify user prompts
265266
* **[Session Lifecycle Hooks](./session-lifecycle.md)** - Session start and end
267+
* **[Agent Stop Hook](./session-lifecycle.md#agent-stop)** - Validate completion before the agent stops
266268
* **[Error Handling Hook](./error-handling.md)** - Custom error handling
267269

268270
## See also

docs/hooks/session-lifecycle.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,42 @@ Session Summary:
540540
});
541541
```
542542

543+
## Agent stop hook {#agent-stop}
544+
545+
The agent stop hook runs when the top-level agent naturally reaches the end of a turn. It is separate from `onSessionEnd`: the session remains active, and the hook can request another agent turn.
546+
547+
| Language | Handler |
548+
|----------|---------|
549+
| Node.js / TypeScript | `onAgentStop` |
550+
| Python | `on_agent_stop` |
551+
| Go | `OnAgentStop` |
552+
| .NET | `OnAgentStop` |
553+
| Rust | `on_agent_stop` |
554+
| Java | `setOnAgentStop` |
555+
556+
### Input
557+
558+
The public member names follow each language's casing conventions:
559+
560+
| Meaning | Node.js / Python | Go / .NET | Rust | Java |
561+
|---------|------------------|-----------|------|------|
562+
| Why the agent stopped, such as `end_turn` | `stopReason` | `StopReason` | `stop_reason` | `getStopReason()` |
563+
| Path to the on-disk session transcript | `transcriptPath` | `TranscriptPath` | `transcript_path` | `getTranscriptPath()` |
564+
| Whether an earlier block decision already forced this continuation | `stopHookActive` | `StopHookActive` | `stop_hook_active` | `getStopHookActive()` |
565+
566+
### Output
567+
568+
Return no output to let the agent stop. Return a block decision to enqueue another user message and continue:
569+
570+
```json
571+
{
572+
"decision": "block",
573+
"reason": "Run the final validation and fix any failures."
574+
}
575+
```
576+
577+
Use the active-stop member listed above to avoid repeatedly blocking an agent that has already continued because of this hook. The runtime also caps consecutive block decisions.
578+
543579
## Best practices
544580

545581
1. **Keep `onSessionStart` fast** - Users are waiting for the session to be ready.

dotnet/src/Client.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,7 +1095,8 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
10951095
config.Hooks.OnUserPromptSubmitted != null ||
10961096
config.Hooks.OnSessionStart != null ||
10971097
config.Hooks.OnSessionEnd != null ||
1098-
config.Hooks.OnErrorOccurred != null);
1098+
config.Hooks.OnErrorOccurred != null ||
1099+
config.Hooks.OnAgentStop != null);
10991100

11001101
var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage);
11011102

@@ -1320,7 +1321,8 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
13201321
config.Hooks.OnUserPromptSubmitted != null ||
13211322
config.Hooks.OnSessionStart != null ||
13221323
config.Hooks.OnSessionEnd != null ||
1323-
config.Hooks.OnErrorOccurred != null);
1324+
config.Hooks.OnErrorOccurred != null ||
1325+
config.Hooks.OnAgentStop != null);
13241326

13251327
var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage);
13261328

dotnet/src/Session.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1620,6 +1620,11 @@ internal void RegisterHooks(SessionHooks hooks)
16201620
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.ErrorOccurredHookInput)!,
16211621
invocation)
16221622
: null,
1623+
"agentStop" => hooks.OnAgentStop != null
1624+
? await hooks.OnAgentStop(
1625+
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.AgentStopHookInput)!,
1626+
invocation)
1627+
: null,
16231628
_ => null
16241629
};
16251630
}
@@ -1987,6 +1992,8 @@ internal void ThrowIfDisposed()
19871992
AllowOutOfOrderMetadataProperties = true,
19881993
NumberHandling = JsonNumberHandling.AllowReadingFromString,
19891994
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
1995+
[JsonSerializable(typeof(AgentStopHookInput))]
1996+
[JsonSerializable(typeof(AgentStopHookOutput))]
19901997
[JsonSerializable(typeof(AutoModeSwitchRequest))]
19911998
[JsonSerializable(typeof(AutoModeSwitchResponse))]
19921999
[JsonSerializable(typeof(Dictionary<string, SystemMessageTransformSection>))]

dotnet/src/Types.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1871,6 +1871,67 @@ public sealed class ErrorOccurredHookOutput
18711871
public string? UserNotification { get; set; }
18721872
}
18731873

1874+
/// <summary>
1875+
/// Input for an agent-stop hook.
1876+
/// </summary>
1877+
public sealed class AgentStopHookInput
1878+
{
1879+
/// <summary>
1880+
/// The runtime session ID of the session that triggered the hook.
1881+
/// </summary>
1882+
[JsonPropertyName("sessionId")]
1883+
public string SessionId { get; set; } = string.Empty;
1884+
1885+
/// <summary>
1886+
/// Unix timestamp in milliseconds when the agent stopped.
1887+
/// </summary>
1888+
[JsonPropertyName("timestamp")]
1889+
[JsonConverter(typeof(UnixMillisecondsDateTimeOffsetConverter))]
1890+
public DateTimeOffset Timestamp { get; set; }
1891+
1892+
/// <summary>
1893+
/// Current working directory of the session.
1894+
/// </summary>
1895+
[JsonPropertyName("cwd")]
1896+
public string WorkingDirectory { get; set; } = string.Empty;
1897+
1898+
/// <summary>
1899+
/// Reason the agent stopped.
1900+
/// </summary>
1901+
[JsonPropertyName("stopReason")]
1902+
public string? StopReason { get; set; }
1903+
1904+
/// <summary>
1905+
/// Path to the on-disk session transcript.
1906+
/// </summary>
1907+
[JsonPropertyName("transcriptPath")]
1908+
public string? TranscriptPath { get; set; }
1909+
1910+
/// <summary>
1911+
/// Whether this stop follows a previous block decision from the hook.
1912+
/// </summary>
1913+
[JsonPropertyName("stop_hook_active")]
1914+
public bool? StopHookActive { get; set; }
1915+
}
1916+
1917+
/// <summary>
1918+
/// Output for an agent-stop hook.
1919+
/// </summary>
1920+
public sealed class AgentStopHookOutput
1921+
{
1922+
/// <summary>
1923+
/// Set to <c>"block"</c> to keep the agent running.
1924+
/// </summary>
1925+
[JsonPropertyName("decision")]
1926+
public string? Decision { get; set; }
1927+
1928+
/// <summary>
1929+
/// Follow-up instruction supplied when the stop is blocked.
1930+
/// </summary>
1931+
[JsonPropertyName("reason")]
1932+
public string? Reason { get; set; }
1933+
}
1934+
18741935
/// <summary>
18751936
/// Hook handlers configuration for a session.
18761937
/// </summary>
@@ -1917,6 +1978,11 @@ public sealed class SessionHooks
19171978
/// Handler called when an error occurs.
19181979
/// </summary>
19191980
public Func<ErrorOccurredHookInput, HookInvocation, Task<ErrorOccurredHookOutput?>>? OnErrorOccurred { get; set; }
1981+
1982+
/// <summary>
1983+
/// Handler called when the top-level agent reaches a natural stop.
1984+
/// </summary>
1985+
public Func<AgentStopHookInput, HookInvocation, Task<AgentStopHookOutput?>>? OnAgentStop { get; set; }
19201986
}
19211987

19221988
/// <summary>

dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace GitHub.Copilot.Test.E2E;
1111
/// <summary>
1212
/// E2E coverage for every handler exposed on <see cref="SessionHooks"/>:
1313
/// OnPreToolUse, OnPostToolUse, OnPostToolUseFailure, OnUserPromptSubmitted,
14-
/// OnSessionStart, OnSessionEnd, OnErrorOccurred. Output-shape behavior
14+
/// OnSessionStart, OnSessionEnd, OnErrorOccurred, OnAgentStop. Output-shape behavior
1515
/// (modifiedPrompt / additionalContext / errorHandling / modifiedArgs /
1616
/// modifiedResult / sessionSummary) is asserted alongside hook invocation. If a
1717
/// new handler is added to <c>SessionHooks</c>, add a corresponding test here.
@@ -255,6 +255,45 @@ await session.SendAndWaitAsync(new MessageOptions
255255
Assert.NotNull(session.SessionId);
256256
}
257257

258+
[Fact]
259+
public async Task Should_Invoke_AgentStop_Hook_And_Apply_Block_Response()
260+
{
261+
var inputs = new List<AgentStopHookInput>();
262+
var session = await CreateSessionAsync(new SessionConfig
263+
{
264+
Hooks = new SessionHooks
265+
{
266+
OnAgentStop = (input, invocation) =>
267+
{
268+
inputs.Add(input);
269+
Assert.False(string.IsNullOrWhiteSpace(invocation.SessionId));
270+
if (inputs.Count == 1)
271+
{
272+
return Task.FromResult<AgentStopHookOutput?>(new AgentStopHookOutput
273+
{
274+
Decision = "block",
275+
Reason = "Reply with exactly: AGENT_STOP_CONTINUED",
276+
});
277+
}
278+
279+
return Task.FromResult<AgentStopHookOutput?>(null);
280+
},
281+
},
282+
});
283+
284+
var response = await session.SendAndWaitAsync(new MessageOptions
285+
{
286+
Prompt = "Reply with exactly: AGENT_STOP_INITIAL",
287+
});
288+
289+
Assert.Equal(2, inputs.Count);
290+
Assert.NotEqual(true, inputs[0].StopHookActive);
291+
Assert.True(inputs[1].StopHookActive);
292+
Assert.Equal("end_turn", inputs[0].StopReason);
293+
Assert.False(string.IsNullOrWhiteSpace(inputs[0].TranscriptPath));
294+
Assert.Contains("AGENT_STOP_CONTINUED", response?.Data.Content ?? string.Empty);
295+
}
296+
258297
[Fact]
259298
public async Task Should_Allow_PreToolUse_To_Return_ModifiedArgs_And_SuppressOutput()
260299
{

dotnet/test/Unit/SerializationTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,48 @@ public void PermissionDecision_SerializesBaseDiscriminator_WithSdkOptions()
790790
Assert.Equal("approve-once", document.RootElement.GetProperty("kind").GetString());
791791
}
792792

793+
[Fact]
794+
public void AgentStopHookInput_DeserializesWireFields_WithSdkOptions()
795+
{
796+
var options = GetSerializerOptions();
797+
var input = JsonSerializer.Deserialize<AgentStopHookInput>(
798+
"""
799+
{
800+
"sessionId": "session-1",
801+
"timestamp": 1700000000000,
802+
"cwd": "/repo",
803+
"stopReason": "end_turn",
804+
"transcriptPath": "/tmp/transcript.jsonl",
805+
"stop_hook_active": true
806+
}
807+
""",
808+
options);
809+
810+
Assert.NotNull(input);
811+
Assert.Equal("session-1", input.SessionId);
812+
Assert.Equal("/repo", input.WorkingDirectory);
813+
Assert.Equal("end_turn", input.StopReason);
814+
Assert.Equal("/tmp/transcript.jsonl", input.TranscriptPath);
815+
Assert.True(input.StopHookActive);
816+
Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1700000000000), input.Timestamp);
817+
}
818+
819+
[Fact]
820+
public void AgentStopHookOutput_SerializesBlockDecision_WithSdkOptions()
821+
{
822+
var options = GetSerializerOptions();
823+
var output = new AgentStopHookOutput
824+
{
825+
Decision = "block",
826+
Reason = "finish the remaining work"
827+
};
828+
829+
var json = JsonSerializer.SerializeToElement(output, options);
830+
831+
Assert.Equal("block", json.GetProperty("decision").GetString());
832+
Assert.Equal("finish the remaining work", json.GetProperty("reason").GetString());
833+
}
834+
793835
[Fact]
794836
public void HooksInvokeResponse_SerializesPreMcpToolCallHookOutput_WithMetaToUse()
795837
{

go/client.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -870,7 +870,8 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
870870
config.Hooks.OnUserPromptSubmitted != nil ||
871871
config.Hooks.OnSessionStart != nil ||
872872
config.Hooks.OnSessionEnd != nil ||
873-
config.Hooks.OnErrorOccurred != nil) {
873+
config.Hooks.OnErrorOccurred != nil ||
874+
config.Hooks.OnAgentStop != nil) {
874875
req.Hooks = Bool(true)
875876
}
876877
if config.OnPermissionRequest != nil {
@@ -1151,7 +1152,8 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
11511152
config.Hooks.OnUserPromptSubmitted != nil ||
11521153
config.Hooks.OnSessionStart != nil ||
11531154
config.Hooks.OnSessionEnd != nil ||
1154-
config.Hooks.OnErrorOccurred != nil) {
1155+
config.Hooks.OnErrorOccurred != nil ||
1156+
config.Hooks.OnAgentStop != nil) {
11551157
req.Hooks = Bool(true)
11561158
}
11571159
req.WorkingDirectory = config.WorkingDirectory

go/internal/e2e/hooks_extended_e2e_test.go

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515
//
1616
// Covers each handler exposed on copilot.SessionHooks: OnPreToolUse,
1717
// OnPostToolUse, OnPostToolUseFailure, OnUserPromptSubmitted, OnSessionStart,
18-
// OnSessionEnd, OnErrorOccurred. Output-shape behavior (modifiedPrompt /
18+
// OnSessionEnd, OnErrorOccurred, OnAgentStop. Output-shape behavior (modifiedPrompt /
1919
// additionalContext / errorHandling / modifiedArgs / modifiedResult /
2020
// sessionSummary) is asserted alongside hook invocation. If a new handler is
2121
// added to SessionHooks, add a corresponding test here.
@@ -215,6 +215,66 @@ func TestHooksExtendedE2E(t *testing.T) {
215215
}
216216
})
217217

218+
t.Run("should invoke agentStop hook and apply block response", func(t *testing.T) {
219+
ctx.ConfigureForTest(t)
220+
221+
var (
222+
mu sync.Mutex
223+
inputs []copilot.AgentStopHookInput
224+
)
225+
226+
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
227+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
228+
Hooks: &copilot.SessionHooks{
229+
OnAgentStop: func(input copilot.AgentStopHookInput, invocation copilot.HookInvocation) (*copilot.AgentStopHookOutput, error) {
230+
mu.Lock()
231+
inputs = append(inputs, input)
232+
callCount := len(inputs)
233+
mu.Unlock()
234+
if invocation.SessionID == "" {
235+
t.Error("Expected non-empty session ID in invocation")
236+
}
237+
if callCount == 1 {
238+
return &copilot.AgentStopHookOutput{
239+
Decision: "block",
240+
Reason: "Reply with exactly: AGENT_STOP_CONTINUED",
241+
}, nil
242+
}
243+
return nil, nil
244+
},
245+
},
246+
})
247+
if err != nil {
248+
t.Fatalf("Failed to create session: %v", err)
249+
}
250+
251+
response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{
252+
Prompt: "Reply with exactly: AGENT_STOP_INITIAL",
253+
})
254+
if err != nil {
255+
t.Fatalf("Failed to send message: %v", err)
256+
}
257+
258+
mu.Lock()
259+
defer mu.Unlock()
260+
if len(inputs) != 2 {
261+
t.Fatalf("Expected two agentStop hook invocations, got %+v", inputs)
262+
}
263+
if inputs[0].StopHookActive {
264+
t.Error("Expected first agentStop invocation to not be a continuation")
265+
}
266+
if !inputs[1].StopHookActive {
267+
t.Error("Expected second agentStop invocation to be a continuation")
268+
}
269+
if inputs[0].StopReason != "end_turn" || inputs[0].TranscriptPath == "" {
270+
t.Errorf("Unexpected first agentStop input: %+v", inputs[0])
271+
}
272+
assistantMessage, ok := response.Data.(*copilot.AssistantMessageData)
273+
if !ok || !strings.Contains(assistantMessage.Content, "AGENT_STOP_CONTINUED") {
274+
t.Errorf("Expected final response to contain AGENT_STOP_CONTINUED, got %v", response.Data)
275+
}
276+
})
277+
218278
t.Run("should allow preToolUse to return modifiedArgs and suppressOutput", func(t *testing.T) {
219279
ctx.ConfigureForTest(t)
220280

go/session.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,17 @@ func (s *Session) handleHooksInvoke(hookType string, rawInput json.RawMessage) (
808808
return nil, fmt.Errorf("invalid hook input: %w", err)
809809
}
810810
return hooks.OnErrorOccurred(input, invocation)
811+
812+
case "agentStop":
813+
if hooks.OnAgentStop == nil {
814+
return nil, nil
815+
}
816+
var input AgentStopHookInput
817+
if err := json.Unmarshal(rawInput, &input); err != nil {
818+
return nil, fmt.Errorf("invalid hook input: %w", err)
819+
}
820+
return hooks.OnAgentStop(input, invocation)
821+
811822
default:
812823
return nil, nil
813824
}

0 commit comments

Comments
 (0)