Skip to content

feat: add agent:turn:end internal hook event#39596

Closed
mantisai-bot wants to merge 3 commits into
openclaw:mainfrom
mantisai-bot:feature/agent-turn-end-hook
Closed

feat: add agent:turn:end internal hook event#39596
mantisai-bot wants to merge 3 commits into
openclaw:mainfrom
mantisai-bot:feature/agent-turn-end-hook

Conversation

@mantisai-bot

Copy link
Copy Markdown

Summary

Adds an agent:turn:end internal hook event that fires after each complete agent turn cycle, as requested in #37833.

Changes

src/hooks/internal-hooks.ts

  • AgentTurnEndHookContext type with success (boolean) and durationMs (number)
  • AgentTurnEndHookEvent type following existing conventions
  • isAgentTurnEndEvent() type guard matching the pattern of all other hook event guards

src/acp/control-plane/manager.core.ts

  • Extended recordTurnCompletion to accept sessionKey and emit the hook
  • Hook fires on both success and error paths via fireAndForgetHook — cannot block turn execution
  • Uses existing createInternalHookEvent + triggerInternalHook pattern (same as deliver.ts)

Design Decisions

Testing

  • TypeScript compiles clean (no errors in modified files)
  • No behavioral changes to existing code paths — the recordTurnCompletion stats logic is preserved identically

Closes #37833

@greptile-apps

greptile-apps Bot commented Mar 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an agent:turn:end internal hook event that fires after each completed agent turn cycle (both successful and failed), providing subscribers with success and durationMs context. The implementation is clean and closely mirrors existing hook patterns in the codebase.

Key changes:

  • New AgentTurnEndHookContext / AgentTurnEndHookEvent types and isAgentTurnEndEvent type guard in src/hooks/internal-hooks.ts
  • recordTurnCompletion in manager.core.ts extended with sessionKey and wired to emit the hook via fireAndForgetHook — the existing stats logic is preserved identically
  • The original early-return pattern was correctly replaced with if/else so the hook fires on both success and error paths without altering counter semantics

Minor observation:

  • In isAgentTurnEndEvent, the durationMs field is validated with an inline typeof context.durationMs === "number" check rather than through a helper (e.g. a hasNumberContextField function), while the success field correctly uses the existing hasBooleanContextField helper. This is a style inconsistency — not a bug — but a hasNumberContextField helper would keep the pattern uniform.

Confidence Score: 4/5

  • This PR is safe to merge — it adds a well-scoped new hook event with no behavioral changes to existing code paths.
  • The implementation faithfully follows existing hook patterns, stats logic is preserved identically, the hook is fire-and-forget so it cannot block the turn lifecycle, and triggerInternalHook already catches handler errors internally. The only issue is a minor style inconsistency in the type guard that does not affect correctness or runtime behavior.
  • No files require special attention.

Last reviewed commit: 64194d7

Comment thread src/hooks/internal-hooks.ts Outdated
if (!context) {
return false;
}
return hasBooleanContextField(context, "success") && typeof context.durationMs === "number";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 5c4cd67 — added hasNumberContextField helper for consistency.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/acp/control-plane/manager.core.ts Outdated
Comment on lines 723 to 727
this.recordTurnCompletion({
startedAt: turnStartedAt,
sessionKey,
});
await this.setSessionState({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mantisai-bot
mantisai-bot force-pushed the feature/agent-turn-end-hook branch from b635138 to 759debd Compare March 12, 2026 06:27
@mantisai-bot

Copy link
Copy Markdown
Author

Rebased onto latest main to pick up upstream changes. The check CI job was failing since March 8 due to a stale HostEnvSecurityPolicy.generated.swift — resolved by the rebase. All 28 checks now pass.

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.
@mantisai-bot
mantisai-bot force-pushed the feature/agent-turn-end-hook branch from 759debd to 03ca10b Compare March 28, 2026 05:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +704 to +708
handle,
text: input.text,
mode: input.mode,
requestId: input.requestId,
signal: combinedSignal,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +653 to +657
const {
runtime,
handle: ensuredHandle,
meta: ensuredMeta,
} = await this.ensureRuntimeHandle({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +995 to +999
await runtime.ensureSession({
sessionKey: params.sessionKey,
agent,
mode,
cwd,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +1336 to 1338
return await this.actorQueue.run(actorKey, async () => {
this.throwIfAborted(signal);
return await op();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines 923 to 925
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")
) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@mantisai-bot

Copy link
Copy Markdown
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: agent:turn:end internal hook event

1 participant