Skip to content

feat(hooks): add before_response_emit hook for output policies#39203

Closed
zeroaltitude wants to merge 3 commits intoopenclaw:mainfrom
zeroaltitude:feat/hook-before-response-emit
Closed

feat(hooks): add before_response_emit hook for output policies#39203
zeroaltitude wants to merge 3 commits intoopenclaw:mainfrom
zeroaltitude:feat/hook-before-response-emit

Conversation

@zeroaltitude
Copy link
Copy Markdown
Contributor

TL;DR

Adds the before_response_emit modifying hook — fires before the final assistant response is delivered. Plugins can modify, redact, or block the response.

Split from #33916 (3 of 3). Depends on #39200 (after_llm_call) — merge that first.

Features

Single-message modification

return { content: redact(event.content) };

Multi-turn modification (full PII redaction)

return { allContent: event.allContent.map(redact) };

Rewrites all assistant messages from the current run — not just the last one.

Blocking

return { block: true, blockReason: "policy violation" };

Clears all current-run assistant content parts from session history.

Run-scoped rewrites

The hook only modifies messages from the current run. Prior history (messages before the user prompt that triggered this run) is untouched.

Compaction-safe: if auto-compaction invalidates the pre-run message count during the run, the handler falls back to a tail-based scan that finds assistant messages from the end of the snapshot.

Hook contract

Field Type Semantics
content string First-writer-wins, mutually exclusive with allContent
allContent string[] First-writer-wins, takes precedence over content
block boolean One-way latch
blockReason string First-writer-wins

Testing

  • 26 response emit handler tests
  • 4 merge semantics tests
  • Total: 58 tests across all 3 split PRs
  • pnpm check clean

AI-Assisted Development 🤖

Developed with AI assistance (Claude/OpenClaw agent "Tank"). All changes reviewed and directed by Eddie Abrams.

Adds the before_llm_call modifying hook that fires before every LLM API
call within the agent loop. Plugins can inspect/modify the message
context, filter the tool list, or block the call entirely.

Key design decisions:
- First-writer-wins for messages/systemPrompt (security plugins can't
  be overridden by lower-priority handlers)
- Intersection latch for tools (a later handler can only narrow, never
  widen, the tool allowlist)
- BeforeLlmCallBlockError sentinel class for graceful blocking without
  surfacing errors to the user
- streamFn wrapper pattern (outermost wrapper, same context as agentLoop)

Also moves clearInternalHooks() from server-startup.ts to server.impl.ts
so plugin hooks registered during loadGatewayPlugins are preserved when
bundled hooks load later.

Files:
- src/plugins/types.ts: BeforeLlmCallEvent/Result types
- src/plugins/hooks.ts: runBeforeLlmCall runner
- src/agents/pi-embedded-runner/run/hook-stream-wrapper.ts: streamFn wrapper
- src/agents/pi-embedded-runner/run/attempt.ts: wrapper setup + iteration tracking
- src/gateway/server-startup.ts, server.impl.ts: clearInternalHooks fix

Tests: 7 stream wrapper + 8 merge semantics = 15 tests
… gate

Adds the after_llm_call modifying hook — fires after the LLM response
is received, before tool execution. Plugins can block all tool calls
or filter specific tool calls by ID.

## Key design: deterministic Promise-based gate

Unlike the previous implementation which used a synchronous mutable ref
populated via .then() from a subscription callback (racy, best-effort),
this uses a Promise-based gate that's set synchronously from the streamFn
wrapper. The wrapper runs inside agentLoop's async context, so the Promise
is guaranteed to exist before executeToolCalls() starts.

The tool wrapper (pi-tools.before-tool-call.ts) awaits the gate Promise:
- First tool call blocks until the hook resolves
- Subsequent calls in the same turn get the cached result instantly
- Turn boundaries clear the gate via the turn_start subscription

This eliminates:
- Monotonic sequence counters
- Staleness detection logic
- The "best-effort" caveat

## Architecture

1. streamFn wrapper intercepts response completion (done/error event)
2. Extracts tool calls from the final message
3. Fires runAfterLlmCall, stores the Promise in the gate (synchronously)
4. Tool wrapper awaits the gate before tool.execute()

Files:
- src/plugins/types.ts: AfterLlmCallEvent/Result types
- src/plugins/hooks.ts: runAfterLlmCall runner
- src/agents/pi-embedded-runner/run/hook-stream-wrapper.ts: response interception
- src/agents/pi-embedded-runner/run/after-llm-call-gate.ts: Promise-based gate
- src/agents/pi-tools.before-tool-call.ts: async gate check

Tests: 9 gate + 4 merge semantics + 7 stream wrapper = 20 new tests
Adds the before_response_emit modifying hook — fires before the final
assistant response is delivered. Plugins can modify, redact, or block
the response entirely.

Key features:
- Single-message modification via `content`
- Multi-turn modification via `allContent` (full PII redaction across
  tool-loop iterations)
- Run-scoped rewrites (only current-run messages, not prior history)
- Compaction-safe fallback (tail-based scan when preRunMessageCount is
  invalidated)
- Session history scrubbing on block (clears all current-run assistant
  content parts)

Merge semantics:
- content/allContent: first-writer-wins, mutually exclusive
- block: one-way latch
- blockReason: first-writer-wins

Files:
- src/plugins/types.ts: BeforeResponseEmitEvent/Result types
- src/plugins/hooks.ts: runBeforeResponseEmit runner
- src/agents/pi-embedded-runner/run/hook-response-emit.ts: implementation
- src/agents/pi-embedded-runner/run/attempt.ts: integration

Tests: 26 response emit + 4 merge semantics = 30 new tests
Total: 58 tests across all 3 PRs
@openclaw-barnacle
Copy link
Copy Markdown

Closing this PR because the author has more than 10 active PRs in this repo. Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.

@openclaw-barnacle openclaw-barnacle Bot closed this Mar 7, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 7, 2026

Greptile Summary

This PR adds the before_response_emit modifying hook (the third in the series split from #33916), plus ships the before_llm_call / after_llm_call infrastructure (hook-stream-wrapper.ts, after-llm-call-gate.ts) that the sibling PR (#39200) depends on. The overall design is solid: a Promise-based gate guarantees after_llm_call decisions are available before tool execution begins, and the tail-scan approach in getRunScopedMessages is compaction-safe. The merge semantics (first-writer-wins, intersection latch, one-way block latch) are well-reasoned and consistently tested.

Key items to address before merge:

  • Memory leakafter-llm-call-gate.ts module-level gates map never has entries removed when a session ends. The finally block in attempt.ts must also call clearAfterLlmCallGate(params.sessionId).
  • Fail-open on before_response_emit error — a hook crash silently delivers the unmodified (potentially sensitive) response. For a hook whose primary use case is PII redaction this should be fail-closed, or at minimum escalated to log.error.
  • Fail-closed fallback over-reach — when getRunScopedMessages returns an empty slice (compaction edge case), the block path clears all assistant messages in the session including prior-run history. This is documented, but the log level should be log.error to ensure it surfaces in production alerting.

Confidence Score: 3/5

  • Not safe to merge as-is — a memory leak in the gate map and fail-open behavior on the PII redaction hook need to be resolved first.
  • The design and test coverage are thorough, but there are two substantive issues: a module-level map leak (gates accumulate indefinitely after session end) and fail-open semantics on the before_response_emit hook, which is the primary mechanism for PII redaction. The over-reaching fail-closed fallback (clearing all prior-run assistant history) is a third concern. These need to be addressed before this merges to production.
  • Pay close attention to src/agents/pi-embedded-runner/run/attempt.ts (missing gate cleanup in finally block, fail-open error handling) and src/agents/pi-embedded-runner/run/hook-response-emit.ts (fail-closed fallback clearing all session history).

Comments Outside Diff (3)

  1. src/agents/pi-embedded-runner/run/attempt.ts, line 1375-1377 (link)

    before_response_emit error silently fails open — PII may leak

    When applyBeforeResponseEmitHook throws (e.g. a policy hook crashes), the catch block logs a warning and falls through, delivering the original unmodified response to the user. For the primary stated use-case of this hook — PII redaction — this means a hook crash allows sensitive content to escape.

    The other "blocking" hooks (before_llm_call, after_llm_call) adopt fail-open semantics for availability reasons, but before_response_emit is the last line of defence before output reaches the user. Consider whether a failed hook here should suppress the response entirely (fail-closed) rather than emit it unchanged. At minimum, the log level should be log.error rather than log.warn to make these incidents visible in production alerting.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agents/pi-embedded-runner/run/attempt.ts
    Line: 1375-1377
    
    Comment:
    **`before_response_emit` error silently fails open — PII may leak**
    
    When `applyBeforeResponseEmitHook` throws (e.g. a policy hook crashes), the `catch` block logs a warning and falls through, delivering the original unmodified response to the user. For the primary stated use-case of this hook — PII redaction — this means a hook crash allows sensitive content to escape.
    
    The other "blocking" hooks (`before_llm_call`, `after_llm_call`) adopt fail-open semantics for availability reasons, but `before_response_emit` is the *last line of defence* before output reaches the user. Consider whether a failed hook here should suppress the response entirely (fail-closed) rather than emit it unchanged. At minimum, the log level should be `log.error` rather than `log.warn` to make these incidents visible in production alerting.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/agents/pi-embedded-runner/run/hook-response-emit.ts, line 1025-1037 (link)

    Fail-closed fallback clears all assistant history indiscriminately

    When getRunScopedMessages returns an empty slice (couldn't identify run boundaries — compaction edge case), the block handler falls through to:

    clearAllAssistantContent(activeSession.messages);

    This clears every assistant message in the session, including prior-run history that may be long-lived and trusted. A PII block on the current run should not silently corrupt prior conversation history that has already been persisted or displayed to the user.

    If this path is truly unavoidable, the log should be at log.error level and the caller should be notified (return a distinct result code). The current log.warn makes it easy to miss in production.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agents/pi-embedded-runner/run/hook-response-emit.ts
    Line: 1025-1037
    
    Comment:
    **Fail-closed fallback clears all assistant history indiscriminately**
    
    When `getRunScopedMessages` returns an empty slice (couldn't identify run boundaries — compaction edge case), the block handler falls through to:
    
    ```typescript
    clearAllAssistantContent(activeSession.messages);
    ```
    
    This clears **every** assistant message in the session, including prior-run history that may be long-lived and trusted. A PII block on the current run should not silently corrupt prior conversation history that has already been persisted or displayed to the user.
    
    If this path is truly unavoidable, the log should be at `log.error` level and the caller should be notified (return a distinct result code). The current `log.warn` makes it easy to miss in production.
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. src/agents/pi-embedded-runner/run/hook-stream-wrapper.ts, line 1589-1593 (link)

    [Symbol.asyncIterator] replacement makes stream single-use

    (responseStream as unknown as Record<symbol, () => AsyncIterator<AssistantMessageEvent>>)[
      Symbol.asyncIterator
    ] = () => wrappedIterator;

    The replacement always returns the same wrappedIterator object, so if anything downstream calls [Symbol.asyncIterator]() a second time (e.g. for error recovery or dual consumption), both iterators share state and one will see a partially-advanced or exhausted cursor. Standard AsyncIterable semantics require each call to [Symbol.asyncIterator]() to return a fresh, independent iterator.

    If only a single iteration path is guaranteed by the runtime, a comment explaining that assumption would make this safe by design rather than safe by convention.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agents/pi-embedded-runner/run/hook-stream-wrapper.ts
    Line: 1589-1593
    
    Comment:
    **`[Symbol.asyncIterator]` replacement makes stream single-use**
    
    ```typescript
    (responseStream as unknown as Record<symbol, () => AsyncIterator<AssistantMessageEvent>>)[
      Symbol.asyncIterator
    ] = () => wrappedIterator;
    ```
    
    The replacement always returns the *same* `wrappedIterator` object, so if anything downstream calls `[Symbol.asyncIterator]()` a second time (e.g. for error recovery or dual consumption), both iterators share state and one will see a partially-advanced or exhausted cursor. Standard `AsyncIterable` semantics require each call to `[Symbol.asyncIterator]()` to return a fresh, independent iterator.
    
    If only a single iteration path is guaranteed by the runtime, a comment explaining that assumption would make this safe by design rather than safe by convention.
    
    How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 22a884d

Comment on lines 2063 to 2064
);
}
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.

Gate not cleared on session end — memory leak

The gates map in after-llm-call-gate.ts is a module-level singleton. clearAfterLlmCallGate is called at turn_start (to clear the previous turn's gate before a new one) and inside wrapStreamFnWithHooks before setting a fresh gate. Neither of these paths fires after the final turn of a session.

Because this finally block only calls hookIterationUnsub?.() (which just unsubscribes the turn_start listener) and unsubscribe(), the resolved Promise<GateDecision> entry for the last turn is left in the map indefinitely. For long-running servers handling many sessions with unique session IDs, these entries accumulate forever.

The fix is one extra call here:

Suggested change
);
}
try {
hookIterationUnsub?.();
if (params.sessionId && hookRunner?.hasHooks("after_llm_call")) {
const { clearAfterLlmCallGate } = await import("./after-llm-call-gate.js");
clearAfterLlmCallGate(params.sessionId);
}
unsubscribe();
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.ts
Line: 2063-2064

Comment:
**Gate not cleared on session end — memory leak**

The `gates` map in `after-llm-call-gate.ts` is a module-level singleton. `clearAfterLlmCallGate` is called at `turn_start` (to clear the *previous* turn's gate before a new one) and inside `wrapStreamFnWithHooks` before setting a fresh gate. Neither of these paths fires after the *final* turn of a session.

Because this `finally` block only calls `hookIterationUnsub?.()` (which just unsubscribes the `turn_start` listener) and `unsubscribe()`, the resolved `Promise<GateDecision>` entry for the last turn is left in the map indefinitely. For long-running servers handling many sessions with unique session IDs, these entries accumulate forever.

The fix is one extra call here:
```suggestion
        try {
          hookIterationUnsub?.();
          if (params.sessionId && hookRunner?.hasHooks("after_llm_call")) {
            const { clearAfterLlmCallGate } = await import("./after-llm-call-gate.js");
            clearAfterLlmCallGate(params.sessionId);
          }
          unsubscribe();
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

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

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: 22a884db65

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


// Scope to only current-run messages so we never corrupt prior history.
// Uses tail-based scan (last N assistant messages) which is compaction-safe.
const runMessages = getRunScopedMessages(activeSession.messages, assistantTexts.length);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use assistant-message count for run scoping

assistantTexts.length is treated as the number of assistant turns here, but in block-reply/chunking flows assistantTexts is populated per emitted chunk (see src/agents/pi-embedded-subscribe.ts where each chunk is pushed), so a single assistant message can increment it multiple times. In that case getRunScopedMessages() cannot find enough assistant messages and returns empty, which drives the block path into the fallback that clears all assistant content in the session, wiping prior history unexpectedly whenever before_response_emit blocks a chunked response.

Useful? React with 👍 / 👎.

return { blocked: false } as GateDecision;
});

gates.set(sessionId, gatePromise);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear gate entries when sessions finish

This stores a per-session promise in a process-wide Map, but entries are only removed on a later turn_start or another LLM stream for the same session; if a session ends after a tool-using turn and is never resumed, its gate stays resident forever. Over time (many unique session IDs), this creates unbounded retained entries (promises + allowlist sets) in the gateway process, so the gate should be explicitly cleared during run/session teardown.

Useful? React with 👍 / 👎.

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

Labels

agents Agent runtime and tooling gateway Gateway runtime r: too-many-prs Auto-close: author has more than twenty active PRs. size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant