feat(hooks): add after_llm_call hook with deterministic Promise-based gate#39200
feat(hooks): add after_llm_call hook with deterministic Promise-based gate#39200zeroaltitude wants to merge 2 commits intoopenclaw:mainfrom
Conversation
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
Greptile SummaryThis PR introduces the Key findings:
Safe to merge after addressing the gate cleanup issue; the documentation gap on text-only responses should also be clarified before release. Confidence Score: 2/5
|
| if (toolCalls.length === 0) { | ||
| return; // No tools to gate — skip hook entirely. | ||
| } |
There was a problem hiding this comment.
after_llm_call silently skips text-only responses
fireAfterLlmCallGate returns early when toolCalls.length === 0, meaning the hook is never fired for LLM responses that contain no tool calls (e.g. a final text reply ending the conversation). The inline comment says "No tools to gate — skip hook entirely."
However, the hook is named after_llm_call and the documentation on runAfterLlmCall (hooks.ts line 748) says it "Fires after the LLM response is received, before tool execution" — without any caveat about tool-call-only responses.
This creates a silent behavioral gap: a plugin that registers after_llm_call to audit or log every LLM response will miss all text-only turns. If this is intentional (the hook is purely a gate for tool execution), it should be documented explicitly on PluginHookAfterLlmCallEvent in types.ts so plugin authors know not to rely on it for general observation.
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: 209-211
Comment:
**`after_llm_call` silently skips text-only responses**
`fireAfterLlmCallGate` returns early when `toolCalls.length === 0`, meaning the hook is never fired for LLM responses that contain no tool calls (e.g. a final text reply ending the conversation). The inline comment says "No tools to gate — skip hook entirely."
However, the hook is named `after_llm_call` and the documentation on `runAfterLlmCall` (hooks.ts line 748) says it "Fires after the LLM response is received, before tool execution" — without any caveat about tool-call-only responses.
This creates a silent behavioral gap: a plugin that registers `after_llm_call` to audit or log every LLM response will miss all text-only turns. If this is intentional (the hook is purely a gate for tool execution), it should be documented explicitly on `PluginHookAfterLlmCallEvent` in `types.ts` so plugin authors know not to rely on it for general observation.
How can I resolve this? If you propose a fix, please make it concise.| runLoopIterationStart: vi.fn(), | ||
| runLoopIterationEnd: vi.fn(), | ||
| runBeforeResponseEmit: vi.fn(), | ||
| getHookCount: vi.fn().mockReturnValue(0), |
There was a problem hiding this comment.
Mock includes methods not present on HookRunner
The mock helper makeMockHookRunner includes three methods — runLoopIterationStart, runLoopIterationEnd, and runBeforeResponseEmit — that do not exist on the HookRunner type returned by createHookRunner in hooks.ts. The as unknown as HookRunner cast hides this mismatch.
These extra entries are harmless today (they'll never be called by the code under test), but they signal that the mock may have drifted from a different/future version of the interface. Consider removing them to keep the mock aligned with the actual HookRunner shape:
| runLoopIterationStart: vi.fn(), | |
| runLoopIterationEnd: vi.fn(), | |
| runBeforeResponseEmit: vi.fn(), | |
| getHookCount: vi.fn().mockReturnValue(0), | |
| runAfterLlmCall: vi.fn(), | |
| getHookCount: vi.fn().mockReturnValue(0), | |
| hasHooks: vi.fn().mockReturnValue(false), | |
| ...overrides, | |
| } as unknown as HookRunner; |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/hook-stream-wrapper.test.ts
Line: 43-46
Comment:
**Mock includes methods not present on `HookRunner`**
The mock helper `makeMockHookRunner` includes three methods — `runLoopIterationStart`, `runLoopIterationEnd`, and `runBeforeResponseEmit` — that do not exist on the `HookRunner` type returned by `createHookRunner` in `hooks.ts`. The `as unknown as HookRunner` cast hides this mismatch.
These extra entries are harmless today (they'll never be called by the code under test), but they signal that the mock may have drifted from a different/future version of the interface. Consider removing them to keep the mock aligned with the actual `HookRunner` shape:
```suggestion
runAfterLlmCall: vi.fn(),
getHookCount: vi.fn().mockReturnValue(0),
hasHooks: vi.fn().mockReturnValue(false),
...overrides,
} as unknown as HookRunner;
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f4b78cbd99
ℹ️ 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".
|
|
||
| /** Helper to detect tool call content blocks in an assistant message. */ | ||
| function isToolCallBlockType(type: unknown): boolean { | ||
| return type === "toolCall" || type === "tool_call" || type === "tool_use"; |
There was a problem hiding this comment.
Handle toolUse/functionCall blocks in after_llm_call gate
isToolCallBlockType only recognizes toolCall, tool_call, and tool_use, so responses that use canonical toolUse/functionCall blocks are treated as having no tool calls. In that case fireAfterLlmCallGate returns early and never runs runAfterLlmCall, which leaves tool execution unfiltered even when plugins registered after_llm_call policies. This is a functional bypass for providers/models that emit camelCase tool-call blocks.
Useful? React with 👍 / 👎.
TL;DR
Adds the
after_llm_callmodifying hook — fires after the LLM response is received, before tool execution. Plugins can block all tool calls or filter specific calls by ID.Split from #33916 (2 of 3). Depends on #39195 (
before_llm_call) — merge that first.Key design: deterministic Promise-based gate
This PR completely redesigns the after_llm_call enforcement mechanism. The previous design (in #33916) used:
.then()from a subscription callbackNew design:
streamFnwrapper intercepts the LLM response completion (done/errorevent)runAfterLlmCall()and stores the Promise in the gate (synchronously, beforestreamAssistantResponsereturns)pi-tools.before-tool-call.ts) awaits the gate PromiseWhy it's deterministic:
The Promise is guaranteed to exist before
executeToolCalls()starts because both happen in the same agentLoop async context, sequentially.What was deleted (vs #33916)
after_llm_callhandling fromattempt.tshookTurnIteration,hookMessageEndSeqmonotonic counterseventIteration !== hookTurnIteration, etc.)Hook contract
blockbooleanblockReasonstringtoolCallsArray<{ id }>Testing
pnpm checkcleanAI-Assisted Development 🤖
Developed with AI assistance (Claude/OpenClaw agent "Tank"). All changes reviewed and directed by Eddie Abrams.