Skip to content

feat: agent-loop hooks — before/after LLM & tool calls + wrap_tool_call#255

Merged
frostming merged 10 commits into
mainfrom
feat/agent-loop-hooks
Jul 7, 2026
Merged

feat: agent-loop hooks — before/after LLM & tool calls + wrap_tool_call#255
frostming merged 10 commits into
mainfrom
feat/agent-loop-hooks

Conversation

@frostming

@frostming frostming commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #253.

Adds four hook points inside the agent loop (per the discussion in #253 and a use-case sweep of LangChain's middleware reference):

Hook Semantics
before_llm_call chained request modification (each impl sees the previous impl's request); may return LlmCallDecision.finish(text) to skip the provider call (cost guards / call limits)
after_llm_call observe-only terminal outcome — fires exactly once per call (normal, error, aclose(), cancellation); streaming observed at accumulated final state
before_tool_call per-invocation policy: proceed(args) folds argument changes, replace(result) / deny(message) short-circuit (enables #249-style plugins)
after_tool_call observe-only terminal outcome incl. deny/replace/error

Semantics decisions

  • Fault isolation: hook impls never break a turn (raising impls are logged + skipped); blocking is only expressible through decision objects.
  • Ordering follows pluggy LIFO (last registered runs first).
  • Rewritten requests are honored end-to-end: effective model/max_tokens reach the provider and the tape records the effective model.
  • run_id is threaded through all payloads for correlation with tape entries.
  • A continuation-style wrap_tool_call was prototyped and removed per review direction; it can return as a separate proposal if retry/HITL use cases press. wrap_model_call likewise deferred (bub's model call streams events mid-flight; native candidate fallback already covers model fallback).

Wiring: contracts in bub/agent_hooks.py; AgentHooks facade in hook_runtime.py; BubFramework.get_agent_hooks()ModelRunner(hooks=…)ToolExecutor(hooks=…). ToolExecutor() without hooks is unchanged.

Tests: 15 hook tests + 5 review-regression tests (rewritten model/max_tokens reach provider+tape, exactly-once on success/early-close, chaining order, short-circuit, isolation). Full suite 243 passed; ruff + mypy clean.

🤖 Generated with Claude Code

…ll (#253)

Adds the missing interception points inside the agent loop:

- before_llm_call: chain-modifiable request (model/messages), or
  LlmCallDecision.finish(text) to short-circuit (cost guards / call limits)
- after_llm_call: observe-only terminal outcome (text/tool_calls/usage/
  error/duration), fires once per call incl. error paths; streaming
  observed at accumulated final state, not per chunk
- before_tool_call: per-invocation ToolCallDecision — proceed(args) folds
  argument changes, replace(result) / deny(message) short-circuit
- after_tool_call: observe-only terminal outcome incl. deny/replace
- wrap_tool_call: middleware-style continuation (call_next zero/one/many
  times) enabling retry, caching, human-in-the-loop — cf. LangChain
  middleware wrap_tool_call

Design notes:
- payload contracts in bub/agent_hooks.py; execution semantics in
  AgentHooks facade (bub/hook_runtime.py); wired via
  BubFramework.get_agent_hooks() -> ModelRunner/ToolExecutor
- observer/chain hooks are fault-isolated (raising plugin logged+skipped,
  never fatal; veto only via decision objects); wrappers own the
  execution path so their exceptions surface as tool errors
- ordering follows pluggy LIFO: last registered runs first / outermost
- tool errors are normalized to BubError inside the continuation so
  wrappers catch a stable type; ToolExecutor() without hooks is unchanged

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 6, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
bub d795a84 Commit Preview URL

Branch Preview URL
Jul 07 2026, 12:47 AM

Rubick and others added 9 commits July 6, 2026 20:10
…e after_llm_call, effective-call observation

Review findings from PR #255 round 1:

1. before_llm_call rewrites now reach the provider AND the tape:
   completion_response accepts the effective max_tokens; both success-path
   record_chat calls record request.model instead of the pre-hook model.
2. after_llm_call is exactly-once on every exit path: the guard now
   catches BaseException so consumer aclose()/cancellation (GeneratorExit,
   CancelledError) still produce a terminal observation.
3. after_tool_call observes the effective invocation: the most recent
   ToolCall passed to call_next (wrappers may rewrite arguments or retry);
   wrappers that never invoke call_next are observed with the pre-wrap
   call. Contract documented in the wrap_tool_call hookspec.
4. Doc consistency: wrap nesting is pluggy LIFO (last registered =
   outermost) everywhere; fixed a literal backslash-n typo in the
   before_llm_call docstring and the misnamed nesting test.

Adds 5 regression tests (rewritten model/max_tokens reach provider+tape,
exactly-once on success and on early aclose, effective-call observation
with and without call_next).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keeps the four before/after hooks (incl. LlmCallDecision.finish and the
exactly-once / effective-request fixes). The continuation-style wrapper
can return later as a separate proposal if retry/HITL use cases press.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review direction: observers get the raised exception object instead
of a stringified form — ToolCallResult.error is the original BubError
(kind/message/details intact), LlmCallResult.error is the original
BaseException (incl. GeneratorExit/CancelledError on close/cancel).
Tests pin the preserved types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ned to BaseException

Mirrors the LLM-side exactly-once guarantee: a cancelled tool invocation
(CancelledError, BaseException) now produces a terminal after_tool_call
observation with the original exception, then re-raises unwrapped.
Regression: real Task.cancel() on a blocking async tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ld recipes (en/zh)

- reference/hooks: four new table rows; 'Agent-loop hooks (AgentHooks)'
  section covering LIFO chaining, decision-only veto + fault isolation,
  exactly-once terminal observation (incl. aclose/cancellation), original
  exception preservation, run_id<->tape correlation, end-to-end request
  rewriting, and the removed wrap_tool_call boundary note
- build/hooks: recipe section with model-routing/cost-guard,
  tool deny/replace policy, and terminal-observation metrics examples
- zh-cn mirrors for both pages; website build verified

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
state persists across turns, so the counter is a session budget; the
example now names it session_llm_calls, says so in the finish text, and
notes how to build an actual per-turn budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bypasses by design

Per review direction: catching BaseException for terminal observation is
dropped on both the LLM and tool paths. Cancellation / consumer close
(CancelledError, GeneratorExit) intentionally does not fire after hooks;
success, Exception failure and deny/replace still observe exactly once.
Result error types narrowed back to Exception; tests inverted to pin the
not-observed semantics; en/zh reference and build docs updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@frostming frostming merged commit 0d7aaf0 into main Jul 7, 2026
6 checks passed
@frostming frostming deleted the feat/agent-loop-hooks branch July 7, 2026 07:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Missing hook points for LLM input/output interception inside the agent loop

1 participant