feat: add agent:turn:end internal hook event#39596
Conversation
Greptile SummaryThis PR adds an Key changes:
Minor observation:
Confidence Score: 4/5
Last reviewed commit: 64194d7 |
| if (!context) { | ||
| return false; | ||
| } | ||
| return hasBooleanContextField(context, "success") && typeof context.durationMs === "number"; |
There was a problem hiding this comment.
Inconsistent validation helper for durationMs
The success field is validated via the existing hasBooleanContextField helper, but durationMs is checked inline with typeof context.durationMs === "number". For consistency with every other numeric-adjacent field validation in this file, a dedicated hasNumberContextField helper would be cleaner — or at minimum the inline check is fine, but it breaks the pattern established by hasStringContextField / hasBooleanContextField.
| return hasBooleanContextField(context, "success") && typeof context.durationMs === "number"; | |
| return hasBooleanContextField(context, "success") && hasBooleanContextField(context, "success") |
Since hasNumberContextField doesn't exist yet, the simplest consistent fix would be to add it alongside the existing helpers:
function hasNumberContextField<T extends Record<string, unknown>>(
context: Partial<T>,
key: keyof T,
): boolean {
return typeof context[key] === "number";
}…and then use it here:
return hasBooleanContextField(context, "success") && hasNumberContextField(context, "durationMs");Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hooks/internal-hooks.ts
Line: 392
Comment:
**Inconsistent validation helper for `durationMs`**
The `success` field is validated via the existing `hasBooleanContextField` helper, but `durationMs` is checked inline with `typeof context.durationMs === "number"`. For consistency with every other numeric-adjacent field validation in this file, a dedicated `hasNumberContextField` helper would be cleaner — or at minimum the inline check is fine, but it breaks the pattern established by `hasStringContextField` / `hasBooleanContextField`.
```suggestion
return hasBooleanContextField(context, "success") && hasBooleanContextField(context, "success")
```
Since `hasNumberContextField` doesn't exist yet, the simplest consistent fix would be to add it alongside the existing helpers:
```ts
function hasNumberContextField<T extends Record<string, unknown>>(
context: Partial<T>,
key: keyof T,
): boolean {
return typeof context[key] === "number";
}
```
…and then use it here:
```ts
return hasBooleanContextField(context, "success") && hasNumberContextField(context, "durationMs");
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Addressed in 5c4cd67 — added hasNumberContextField helper for consistency.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 64194d740a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| this.recordTurnCompletion({ | ||
| startedAt: turnStartedAt, | ||
| sessionKey, | ||
| }); | ||
| await this.setSessionState({ |
There was a problem hiding this comment.
Emit turn:end hook after state transition succeeds
recordTurnCompletion now emits agent:turn:end, but this call still happens before setSessionState(..., state: "idle"); if that state write fails (for example due session metadata I/O errors), the catch path calls recordTurnCompletion again with an error code, so one turn can produce two conflicting hook events (success: true then success: false). This can cause downstream hook consumers to double-count turns or process a false success signal, so the hook should be emitted once after the final outcome is known.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — addressed in b635138. Moved recordTurnCompletion after setSessionState on the success path, so the hook only fires once the state transition succeeds. If setSessionState were to throw, the catch block handles it with a single error-path call.
b635138 to
759debd
Compare
|
Rebased onto latest main to pick up upstream changes. The No code changes — the three original commits are identical in substance. |
Emit an agent:turn:end internal hook after each complete agent turn cycle. The hook fires from recordTurnCompletion on both success and error paths via fireAndForgetHook, ensuring it cannot block turn execution. Context includes: - success (boolean): whether the turn completed without error - durationMs (number): turn duration in milliseconds Adds AgentTurnEndHookContext, AgentTurnEndHookEvent type, and isAgentTurnEndEvent type guard following existing conventions.
… double-emit Move recordTurnCompletion after setSessionState on the success path. If setSessionState were to throw, the catch block handles it with a single error-path recordTurnCompletion call, eliminating any possibility of conflicting hook events for the same turn.
759debd to
03ca10b
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 03ca10bd80
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| handle, | ||
| text: input.text, | ||
| mode: input.mode, | ||
| requestId: input.requestId, | ||
| signal: combinedSignal, |
There was a problem hiding this comment.
Pass attachments through ACP turn execution
The runtime.runTurn call no longer forwards input.attachments, so any turn that includes media/files is downgraded to text-only input. That breaks attachment-dependent ACP workflows (for example image/audio/document processing) even though AcpRunTurnInput still accepts attachments, making this a functional regression rather than an API removal.
Useful? React with 👍 / 👎.
| input.signal && typeof AbortSignal.any === "function" | ||
| ? AbortSignal.any([input.signal, internalAbortController.signal]) | ||
| : internalAbortController.signal; | ||
| for await (const event of runtime.runTurn({ |
There was a problem hiding this comment.
Reinstate configured timeout wrapper around runtime turn loop
This now awaits runtime.runTurn directly in a for await loop with no timeout guard, so a hung backend can block the turn forever and keep the per-session actor queue stuck behind it. The previous timeout/cancel path enforced timeoutSeconds; removing it means queued work for that session can starve indefinitely when a turn never completes.
Useful? React with 👍 / 👎.
| const { | ||
| runtime, | ||
| handle: ensuredHandle, | ||
| meta: ensuredMeta, | ||
| } = await this.ensureRuntimeHandle({ |
There was a problem hiding this comment.
Move runTurn initialization under the error-handling block
ensureRuntimeHandle is executed before entering the try/catch, so initialization failures now bypass the turn error path that normalizes errors, records completion, and updates session state. In failure scenarios before the stream loop starts, callers can get inconsistent handling and session metadata is no longer transitioned through the same error path as turn failures.
Useful? React with 👍 / 👎.
| await runtime.ensureSession({ | ||
| sessionKey: params.sessionKey, | ||
| agent, | ||
| mode, | ||
| cwd, |
There was a problem hiding this comment.
Pass persisted resumeSessionId into ensureSession
The new ensureSession call omits resumeSessionId, so persistent sessions with previously resolved identity can no longer reattach to the existing backend session after process restart. That loses conversation continuity and defeats the stored identity/resume mechanism that was previously used for persistent ACP sessions.
Useful? React with 👍 / 👎.
| return await this.actorQueue.run(actorKey, async () => { | ||
| this.throwIfAborted(signal); | ||
| return await op(); |
There was a problem hiding this comment.
Reject aborted queued actor tasks before queue entry starts
withSessionActor now only checks signal.aborted when the queued callback eventually starts running. If a request is queued behind another turn and the caller aborts in the meantime, this promise remains pending until the queue advances instead of rejecting promptly, which regresses cancellation latency and can hold unnecessary queued work.
Useful? React with 👍 / 👎.
| input.allowBackendUnavailable && | ||
| (acpError.code === "ACP_BACKEND_MISSING" || | ||
| acpError.code === "ACP_BACKEND_UNAVAILABLE" || | ||
| this.isRecoverableAcpxExitError(acpError.message)) | ||
| (acpError.code === "ACP_BACKEND_MISSING" || acpError.code === "ACP_BACKEND_UNAVAILABLE") | ||
| ) { |
There was a problem hiding this comment.
Accept stale acpx-exit errors when backend-unavailable is allowed
When allowBackendUnavailable is true, the close-path fallback now only tolerates explicit ACP backend missing/unavailable codes and no longer handles stale "acpx exited with code N" failures. In that case closeSession throws instead of treating the dead backend as terminal and clearing cache state, which breaks the intended tolerant cleanup flow for stale runtime processes.
Useful? React with 👍 / 👎.
|
Closing to reopen with a cleaner implementation. The rebase over 3000 commits introduced unintended conflicts in manager.core.ts that stomped over upstream improvements (timeout guard, attachments forwarding, error handling). Will reopen with a fresh branch from current main with just the minimal hook change layered on top cleanly. |
Summary
Adds an
agent:turn:endinternal hook event that fires after each complete agent turn cycle, as requested in #37833.Changes
src/hooks/internal-hooks.tsAgentTurnEndHookContexttype withsuccess(boolean) anddurationMs(number)AgentTurnEndHookEventtype following existing conventionsisAgentTurnEndEvent()type guard matching the pattern of all other hook event guardssrc/acp/control-plane/manager.core.tsrecordTurnCompletionto acceptsessionKeyand emit the hookfireAndForgetHook— cannot block turn executioncreateInternalHookEvent+triggerInternalHookpattern (same asdeliver.ts)Design Decisions
agent:turn:endinternal hook event #37984successanddurationMs. TheerrorCodefield from the original proposal was dropped to keep the initial implementation simple (can be added later if needed)Testing
recordTurnCompletionstats logic is preserved identicallyCloses #37833