Explorar o código

feat(A17): Three-layer Hook system — HookRegistry + HookTerm + decorators

Layer 3: HookRegistry (8 events: pre/post_llm, pre/post_tool, on_guard_fail, on_error, on_step, on_cancel)
Layer 2: HookTerm (first-class Lambda term with E-HookPre/E-HookPost rules)
Layer 1: pre_hook/post_hook/guard_hook decorators
AsyncExecutor integration + YAML hooks compilation
24 tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
kenny67nju hai 6 meses
pai
achega
0886f67aaa

+ 1 - 1
ENGINEERING_GAP_ANALYSIS.md

@@ -1575,7 +1575,7 @@ Phase 3 (P2): 竞争力提升                  [4-6 周]
 
 **Tier 3 — 扩展层 (完整助手 V2)**
 
-- [ ] **A17: Hook 系统** [2d]
+- [x] **A17: Hook 系统** [2d]
   - `pre_tool_call(tool_name, input)` → 可修改或阻止
   - `post_tool_call(tool_name, input, output)` → 可修改输出
   - `pre_llm_call(model, prompt)` → prompt 注入、成本控制

+ 31 - 2
lambdagent/agentruntime/async_executor.py

@@ -20,6 +20,8 @@ from .config import RuntimeConfig
 from .llm_adapter import LLMAdapter, LLMResponse
 from .trace_store import TraceStore, TraceRecord
 
+from lambdagent.hooks import HookRegistry, HookTerm
+
 
 class AsyncExecutor:
     """
@@ -33,12 +35,14 @@ class AsyncExecutor:
       - Streaming output via reduce_stream()
       - CancellationToken for hierarchical cancellation
       - Configurable timeouts per operation
+      - HookRegistry for global pre/post callbacks (Layer 3)
     """
 
-    def __init__(self, config: RuntimeConfig):
+    def __init__(self, config: RuntimeConfig, hooks: HookRegistry | None = None):
         self.config = config
         self.llm = LLMAdapter(config.llm)
         self.trace = TraceStore()
+        self.hooks = hooks or HookRegistry()
         self._step_counter = 0
         self._default_timeout = getattr(config, 'timeout', 120)
 
@@ -58,7 +62,9 @@ class AsyncExecutor:
         cancel.check()
         timeout = timeout or self._default_timeout
 
-        if isinstance(term, Lam):
+        if isinstance(term, HookTerm):
+            return await term.aapply(input_val, ctx, cancel)
+        elif isinstance(term, Lam):
             return await self._reduce_lam(term, input_val, ctx, cancel, timeout)
         elif isinstance(term, Compose):
             return await self._reduce_compose(term, input_val, ctx, cancel, timeout)
@@ -126,6 +132,9 @@ class AsyncExecutor:
 
     async def _reduce_lam(self, lam: Lam, input_val: Any, ctx: Context,
                           cancel: CancellationToken, timeout: float) -> Any:
+        # Hook Layer 3: pre_llm
+        await self.hooks.afire("pre_llm", term=lam, input=input_val, ctx=ctx)
+
         t0 = time.time()
         response = await asyncio.wait_for(
             self.llm.acall(
@@ -137,6 +146,13 @@ class AsyncExecutor:
         cancel.check()
         duration = (time.time() - t0) * 1000
         result = lam.output_parser(response.text)
+
+        # Hook Layer 3: post_llm (can modify output via output["value"])
+        output_wrapper = {"value": result}
+        await self.hooks.afire("post_llm", term=lam, input=input_val,
+                               output=output_wrapper, usage=response.usage, ctx=ctx)
+        result = output_wrapper["value"]
+
         self._record(lam, input_val, result, duration, response)
         ctx.log(lam._name, lam._trace_id, input_val, result, duration,
                 lam.model, response.usage.total_tokens)
@@ -180,6 +196,9 @@ class AsyncExecutor:
 
     async def _reduce_tool(self, tool: Tool, input_val: Any, ctx: Context,
                            cancel: CancellationToken, timeout: float) -> Any:
+        # Hook Layer 3: pre_tool
+        await self.hooks.afire("pre_tool", term=tool, input=input_val, ctx=ctx)
+
         t0 = time.time()
         loop = asyncio.get_event_loop()
         result = await asyncio.wait_for(
@@ -187,6 +206,13 @@ class AsyncExecutor:
             timeout=timeout,
         )
         duration = (time.time() - t0) * 1000
+
+        # Hook Layer 3: post_tool (can modify output)
+        output_wrapper = {"value": result}
+        await self.hooks.afire("post_tool", term=tool, input=input_val,
+                               output=output_wrapper, duration_ms=duration, ctx=ctx)
+        result = output_wrapper["value"]
+
         self.trace.append(TraceRecord(
             step=self._step_counter, term_name=tool._name, term_type="Tool",
             duration_ms=duration, input=str(input_val)[:200], output=str(result)[:200],
@@ -264,6 +290,9 @@ class AsyncExecutor:
                 valid = True
             if valid:
                 return result
+        # Hook Layer 3: on_guard_fail
+        await self.hooks.afire("on_guard_fail", term=guard, input=input_val,
+                               last_result=last_result, attempts=1 + guard.retry, ctx=ctx)
         if guard.on_fail:
             return guard.on_fail(last_result)
         from lambdagent.core import ValidationError

+ 8 - 3
lambdagent/fromconfig/compiler.py

@@ -201,14 +201,19 @@ def build_agent(cfg: Dict[str, Any], overrides: Dict = None) -> Term:
     overrides = overrides or {}
     agent_type = cfg.get("type", "simple")
 
-    # Step 0: Build ToolGateway from guard config (if present)
-    #   This ensures dangerousCommandBlock / highRiskConfirmation / maxOutputLength
-    #   are actually enforced at runtime, not just declared in YAML.
+    # Step 0a: Build ToolGateway from guard config (if present)
     guard_cfg = cfg.get("guard") or {}
     gateway = _build_gateway(guard_cfg, overrides)
     if gateway:
         overrides = {**overrides, "_tool_gateway": gateway}
 
+    # Step 0b: Compile hooks from YAML config (A17)
+    hooks_cfg = cfg.get("hooks", {})
+    if hooks_cfg:
+        from lambdagent.hooks import compile_hooks_from_config
+        hooks_registry = compile_hooks_from_config(hooks_cfg)
+        overrides = {**overrides, "_hooks": hooks_registry}
+
     # Step 1: Compile core agent based on type
     if agent_type == "simple":
         agent = _compile_simple(cfg)

+ 338 - 0
lambdagent/hooks.py

@@ -0,0 +1,338 @@
+"""
+lambdagent.hooks — Three-layer Hook system for agent execution
+
+Layer 3: HookRegistry  — Machine-level observer (global, cross-cutting)
+Layer 2: HookTerm      — Term-level wrapper (local, reusable policy)
+Layer 1: pre_hook/post_hook/guard_hook — Decorators (inline, algebraic)
+
+The three layers are orthogonal and can coexist:
+  Layer 3 observes execution (infrastructure: audit, OTel, rate limiting)
+  Layer 2 wraps specific terms (policy: PII filter, domain retry)
+  Layer 1 composes inline (business: one-off transforms)
+
+Formal basis (Paper10 §4):
+  Hook = Term transformer H : Term → Term
+  Pre-hook  = Compose(Tool(pre_fn), agent)       [C-Comp + C-Tool]
+  Post-hook = Compose(agent, Tool(post_fn))       [C-CompRet + C-Tool]
+  Guard-hook = Guard(agent, validator, k)         [C-Guard*]
+  Event-hook = CEK transition observer            [all rules]
+"""
+from __future__ import annotations
+
+import asyncio
+import time
+from dataclasses import dataclass, field
+from typing import Any, Callable, Dict, List, Optional
+
+from .core import Term, Context
+
+
+# ════════════════════════════════════════════════════════════
+# Layer 3: HookRegistry — Machine-level event system
+# ════════════════════════════════════════════════════════════
+
+@dataclass
+class HookRegistry:
+    """
+    Global hook registry. Callbacks fire at CEK transition points.
+
+    Events (mapped to CEK rules):
+      pre_llm        C-Lam before      (term, input, ctx)
+      post_llm       C-Lam after       (term, input, output, usage, ctx)
+      pre_tool       C-Tool before     (term, input, ctx)
+      post_tool      C-Tool after      (term, input, output, duration_ms, ctx)
+      on_guard_fail  C-GuardFail       (term, input, last_result, attempts, ctx)
+      on_error       any exception     (term, input, error, ctx)
+      on_step        every β-reduction (step, term_name, duration_ms, ctx)
+      on_cancel      cancellation      (reason, ctx)
+
+    post_llm and post_tool can modify output via output["value"] dict pattern.
+    """
+    pre_llm:        List[Callable] = field(default_factory=list)
+    post_llm:       List[Callable] = field(default_factory=list)
+    pre_tool:       List[Callable] = field(default_factory=list)
+    post_tool:      List[Callable] = field(default_factory=list)
+    on_guard_fail:  List[Callable] = field(default_factory=list)
+    on_error:       List[Callable] = field(default_factory=list)
+    on_step:        List[Callable] = field(default_factory=list)
+    on_cancel:      List[Callable] = field(default_factory=list)
+
+    def register(self, event: str, fn: Callable):
+        """Register a hook callback for an event."""
+        hooks = getattr(self, event, None)
+        if hooks is None:
+            raise ValueError(f"Unknown hook event: {event}. "
+                             f"Available: {', '.join(self._event_names())}")
+        hooks.append(fn)
+
+    def unregister(self, event: str, fn: Callable):
+        """Remove a hook callback."""
+        hooks = getattr(self, event, None)
+        if hooks and fn in hooks:
+            hooks.remove(fn)
+
+    def fire(self, event: str, **kwargs) -> None:
+        """Fire all callbacks for an event. Errors in hooks are logged but not raised."""
+        hooks = getattr(self, event, [])
+        for fn in hooks:
+            try:
+                fn(**kwargs)
+            except Exception as e:
+                # Hook errors should not break agent execution
+                import sys
+                print(f"[HookError] {event}: {e}", file=sys.stderr)
+
+    async def afire(self, event: str, **kwargs) -> None:
+        """Async fire — runs sync hooks in order, awaits async hooks."""
+        hooks = getattr(self, event, [])
+        for fn in hooks:
+            try:
+                if asyncio.iscoroutinefunction(fn):
+                    await fn(**kwargs)
+                else:
+                    fn(**kwargs)
+            except Exception as e:
+                import sys
+                print(f"[HookError] {event}: {e}", file=sys.stderr)
+
+    def _event_names(self) -> list:
+        return [f.name for f in self.__dataclass_fields__.values()]
+
+    def clear(self):
+        """Remove all registered hooks."""
+        for name in self._event_names():
+            getattr(self, name).clear()
+
+    def summary(self) -> str:
+        parts = []
+        for name in self._event_names():
+            hooks = getattr(self, name)
+            if hooks:
+                parts.append(f"  {name}: {len(hooks)} hook(s)")
+        if not parts:
+            return "HookRegistry: (empty)"
+        return "HookRegistry:\n" + "\n".join(parts)
+
+
+# Singleton default registry
+_default_registry = HookRegistry()
+
+
+def get_default_registry() -> HookRegistry:
+    return _default_registry
+
+
+# ════════════════════════════════════════════════════════════
+# Layer 2: HookTerm — First-class Lambda term
+# ════════════════════════════════════════════════════════════
+
+class HookTerm(Term):
+    """
+    Hook as a first-class Lambda term.
+
+    Small-step rules (Paper10 extension):
+      E-HookPre:  ⟨Hook(a,pre,post) v, E, K, σ⟩
+                   →τ ⟨a v, E, HookK(post,v) :: K, σ⟩
+                   (premise: pre(v) evaluated for side effects)
+
+      E-HookPost: ⟨v', E, HookK(post,orig) :: K, σ⟩
+                   →τ ⟨post(v'), E, K, σ⟩
+
+    Usage:
+        hooked = HookTerm(my_agent,
+            pre=lambda x: print(f"Input: {x}"),
+            post=lambda x: x.strip(),
+            on_error=lambda e, x: f"Fallback for {e}",
+        )
+    """
+
+    def __init__(
+        self,
+        agent: Term,
+        pre: Callable | None = None,
+        post: Callable | None = None,
+        on_error: Callable | None = None,
+        name: str = "",
+    ):
+        super().__init__(name or f"Hook({agent._name})")
+        self.agent = agent
+        self.pre = pre
+        self.post = post
+        self.on_error = on_error
+
+    def apply(self, input: Any, ctx: Context | None = None) -> Any:
+        """Synchronous β-reduction with pre/post hooks."""
+        ctx = ctx or Context()
+        t0 = time.time()
+
+        # E-HookPre
+        if self.pre:
+            self.pre(input)
+
+        # Agent execution
+        try:
+            result = self.agent.apply(input, ctx)
+        except Exception as e:
+            if self.on_error:
+                return self.on_error(e, input)
+            raise
+
+        # E-HookPost
+        if self.post:
+            result = self.post(result)
+
+        duration = (time.time() - t0) * 1000
+        ctx.log(self._name, self._trace_id, input, result, duration)
+        return result
+
+    async def aapply(self, input: Any, ctx: Context | None = None,
+                     cancel=None) -> Any:
+        """Async β-reduction with pre/post hooks."""
+        from .cancellation import NullCancellationToken
+        cancel = cancel or NullCancellationToken()
+        ctx = ctx or Context()
+        t0 = time.time()
+
+        # E-HookPre
+        if self.pre:
+            if asyncio.iscoroutinefunction(self.pre):
+                await self.pre(input)
+            else:
+                self.pre(input)
+
+        cancel.check()
+
+        # Agent execution
+        try:
+            result = await self.agent.aapply(input, ctx, cancel)
+        except Exception as e:
+            if self.on_error:
+                return self.on_error(e, input)
+            raise
+
+        cancel.check()
+
+        # E-HookPost
+        if self.post:
+            if asyncio.iscoroutinefunction(self.post):
+                result = await self.post(result)
+            else:
+                result = self.post(result)
+
+        duration = (time.time() - t0) * 1000
+        ctx.log(self._name, self._trace_id, input, result, duration)
+        return result
+
+
+# ════════════════════════════════════════════════════════════
+# Layer 1: Decorators — Syntactic sugar over Compose/Tool/Guard
+# ════════════════════════════════════════════════════════════
+
+def pre_hook(fn: Callable) -> Callable:
+    """
+    Decorator: run fn before agent execution (pass-through).
+
+    Lambda: pre_hook(fn)(agent) = Compose(Tool(fn_passthrough), agent)
+    """
+    def wrapper(agent: Term) -> Term:
+        from .primitives import Compose, Tool
+        passthrough = Tool(f"pre_hook:{fn.__name__}", lambda x: (fn(x), x)[1])
+        return Compose(passthrough, agent)
+    return wrapper
+
+
+def post_hook(fn: Callable) -> Callable:
+    """
+    Decorator: run fn after agent execution (can modify output).
+
+    Lambda: post_hook(fn)(agent) = Compose(agent, Tool(fn))
+    """
+    def wrapper(agent: Term) -> Term:
+        from .primitives import Compose, Tool
+        transformer = Tool(f"post_hook:{fn.__name__}", fn)
+        return Compose(agent, transformer)
+    return wrapper
+
+
+def guard_hook(predicate: Callable, retry: int = 0, on_fail: Callable = None) -> Callable:
+    """
+    Decorator: validate output, retry on failure.
+
+    Lambda: guard_hook(P, k)(agent) = Guard(agent, P, k)
+    """
+    def wrapper(agent: Term) -> Term:
+        from .extensions import Guard
+        return Guard(agent, predicate, retry=retry, on_fail=on_fail)
+    return wrapper
+
+
+# ════════════════════════════════════════════════════════════
+# YAML Hook compilation helper
+# ════════════════════════════════════════════════════════════
+
+def compile_shell_hook(command: str, event: str) -> Callable:
+    """
+    Compile a shell command into a hook callback.
+
+    The shell command receives context via environment variables:
+      HOOK_EVENT, HOOK_TERM, HOOK_INPUT (first 500 chars)
+    """
+    import subprocess
+
+    def hook_fn(**kwargs):
+        env = {
+            "HOOK_EVENT": event,
+            "HOOK_TERM": str(getattr(kwargs.get("term"), "_name", "unknown")),
+            "HOOK_INPUT": str(kwargs.get("input", ""))[:500],
+        }
+        if "output" in kwargs:
+            val = kwargs["output"]
+            if isinstance(val, dict) and "value" in val:
+                env["HOOK_OUTPUT"] = str(val["value"])[:500]
+            else:
+                env["HOOK_OUTPUT"] = str(val)[:500]
+
+        import os
+        full_env = {**os.environ, **env}
+        try:
+            result = subprocess.run(
+                command, shell=True, env=full_env,
+                capture_output=True, text=True, timeout=10,
+            )
+            if result.returncode != 0 and result.stderr:
+                import sys
+                print(f"[ShellHook:{event}] {result.stderr.strip()}", file=sys.stderr)
+        except subprocess.TimeoutExpired:
+            import sys
+            print(f"[ShellHook:{event}] timeout after 10s", file=sys.stderr)
+        except Exception as e:
+            import sys
+            print(f"[ShellHook:{event}] error: {e}", file=sys.stderr)
+
+    return hook_fn
+
+
+def compile_hooks_from_config(hooks_cfg: dict) -> HookRegistry:
+    """
+    Compile YAML hooks config into a HookRegistry.
+
+    YAML format:
+      hooks:
+        pre_tool:
+          - command: "python validate.py"
+        post_llm:
+          - command: "python audit.py"
+    """
+    registry = HookRegistry()
+    if not hooks_cfg:
+        return registry
+
+    for event, hook_list in hooks_cfg.items():
+        if not isinstance(hook_list, list):
+            continue
+        for hook_def in hook_list:
+            if isinstance(hook_def, dict) and "command" in hook_def:
+                fn = compile_shell_hook(hook_def["command"], event)
+                registry.register(event, fn)
+
+    return registry

+ 287 - 0
lambdagent/tests/test_hooks.py

@@ -0,0 +1,287 @@
+"""
+Tests for A17: Three-layer Hook system
+
+Layer 1: Decorators (pre_hook, post_hook, guard_hook)
+Layer 2: HookTerm (first-class Lambda term)
+Layer 3: HookRegistry (machine-level observer)
+"""
+from __future__ import annotations
+
+import asyncio
+import time
+import pytest
+
+from lambdagent.core import Context
+from lambdagent.primitives import Tool, Lam, Compose
+from lambdagent.hooks import (
+    HookRegistry, HookTerm,
+    pre_hook, post_hook, guard_hook,
+    compile_hooks_from_config, compile_shell_hook,
+)
+
+
+def run_async(coro):
+    return asyncio.get_event_loop().run_until_complete(coro)
+
+
+# ════════════════════════════════════════════════════════════
+# Layer 3: HookRegistry
+# ════════════════════════════════════════════════════════════
+
+class TestHookRegistry:
+    def test_register_and_fire(self):
+        reg = HookRegistry()
+        calls = []
+        reg.register("pre_tool", lambda **kw: calls.append(("pre_tool", kw.get("input"))))
+        reg.fire("pre_tool", input="hello", term=None)
+        assert len(calls) == 1
+        assert calls[0] == ("pre_tool", "hello")
+
+    def test_multiple_hooks(self):
+        reg = HookRegistry()
+        log = []
+        reg.register("on_step", lambda **kw: log.append("a"))
+        reg.register("on_step", lambda **kw: log.append("b"))
+        reg.fire("on_step")
+        assert log == ["a", "b"]
+
+    def test_hook_error_doesnt_break(self, capsys):
+        """Hook errors are caught and logged, not raised."""
+        reg = HookRegistry()
+        reg.register("pre_llm", lambda **kw: 1 / 0)  # will raise
+        reg.register("pre_llm", lambda **kw: None)     # should still run
+        reg.fire("pre_llm", term=None, input="x", ctx=None)
+        captured = capsys.readouterr()
+        assert "HookError" in captured.err
+
+    def test_post_llm_modify_output(self):
+        """post_llm hooks can modify output via dict pattern."""
+        reg = HookRegistry()
+
+        def uppercase_hook(**kwargs):
+            if "output" in kwargs and isinstance(kwargs["output"], dict):
+                kwargs["output"]["value"] = str(kwargs["output"]["value"]).upper()
+
+        reg.register("post_llm", uppercase_hook)
+        output = {"value": "hello world"}
+        reg.fire("post_llm", term=None, input="x", output=output, usage=None, ctx=None)
+        assert output["value"] == "HELLO WORLD"
+
+    def test_async_fire(self):
+        reg = HookRegistry()
+        log = []
+
+        async def async_hook(**kwargs):
+            log.append("async")
+
+        reg.register("pre_llm", async_hook)
+        reg.register("pre_llm", lambda **kw: log.append("sync"))
+        run_async(reg.afire("pre_llm", term=None, input="x", ctx=None))
+        assert log == ["async", "sync"]
+
+    def test_unregister(self):
+        reg = HookRegistry()
+        fn = lambda **kw: None
+        reg.register("on_step", fn)
+        assert len(reg.on_step) == 1
+        reg.unregister("on_step", fn)
+        assert len(reg.on_step) == 0
+
+    def test_clear(self):
+        reg = HookRegistry()
+        reg.register("pre_llm", lambda **kw: None)
+        reg.register("post_tool", lambda **kw: None)
+        reg.clear()
+        assert len(reg.pre_llm) == 0
+        assert len(reg.post_tool) == 0
+
+    def test_invalid_event(self):
+        reg = HookRegistry()
+        with pytest.raises(ValueError, match="Unknown"):
+            reg.register("nonexistent_event", lambda: None)
+
+    def test_summary(self):
+        reg = HookRegistry()
+        reg.register("pre_llm", lambda **kw: None)
+        reg.register("pre_llm", lambda **kw: None)
+        reg.register("on_step", lambda **kw: None)
+        s = reg.summary()
+        assert "pre_llm: 2" in s
+        assert "on_step: 1" in s
+
+
+# ════════════════════════════════════════════════════════════
+# Layer 2: HookTerm
+# ════════════════════════════════════════════════════════════
+
+class TestHookTerm:
+    def test_pre_hook(self):
+        """Pre hook runs before agent, doesn't modify input."""
+        log = []
+        agent = Tool("double", lambda x: int(x) * 2)
+        hooked = HookTerm(agent, pre=lambda x: log.append(f"pre:{x}"))
+        result = hooked.apply("5", Context())
+        assert result == 10
+        assert log == ["pre:5"]
+
+    def test_post_hook(self):
+        """Post hook can modify output."""
+        agent = Tool("greet", lambda x: f"hello {x}")
+        hooked = HookTerm(agent, post=lambda x: x.upper())
+        result = hooked.apply("world", Context())
+        assert result == "HELLO WORLD"
+
+    def test_error_hook(self):
+        """on_error hook provides fallback."""
+        agent = Tool("fail", lambda x: 1 / 0)
+        hooked = HookTerm(agent, on_error=lambda e, x: f"fallback for {x}")
+        result = hooked.apply("test", Context())
+        assert result == "fallback for test"
+
+    def test_error_reraise(self):
+        """Without on_error, exceptions propagate."""
+        agent = Tool("fail", lambda x: 1 / 0)
+        hooked = HookTerm(agent)
+        with pytest.raises(ZeroDivisionError):
+            hooked.apply("test", Context())
+
+    def test_combined_pre_post(self):
+        log = []
+        agent = Tool("id", lambda x: x)
+        hooked = HookTerm(
+            agent,
+            pre=lambda x: log.append("pre"),
+            post=lambda x: (log.append("post"), x + "!")[1],
+        )
+        result = hooked.apply("hello", Context())
+        assert result == "hello!"
+        assert log == ["pre", "post"]
+
+    def test_async_aapply(self):
+        """HookTerm.aapply works async."""
+        import lambdagent.async_core  # patch aapply
+        agent = Tool("double", lambda x: int(x) * 2)
+        hooked = HookTerm(agent, post=lambda x: x + 1)
+        result = run_async(hooked.aapply("5", Context()))
+        assert result == 11
+
+    def test_trace_logged(self):
+        agent = Tool("id", lambda x: x)
+        hooked = HookTerm(agent, name="MyHook")
+        ctx = Context()
+        hooked.apply("input", ctx)
+        # Both Tool and HookTerm log to trace
+        names = [e.term_name for e in ctx.trace]
+        assert "id" in names
+        assert "MyHook" in names
+
+
+# ════════════════════════════════════════════════════════════
+# Layer 1: Decorators
+# ════════════════════════════════════════════════════════════
+
+class TestDecorators:
+    def test_pre_hook_decorator(self):
+        log = []
+        base = Tool("echo", lambda x: x)
+        agent = pre_hook(lambda x: log.append(x))(base)
+        result = agent.apply("hello", Context())
+        assert result == "hello"
+        assert "hello" in log
+
+    def test_post_hook_decorator(self):
+        base = Tool("echo", lambda x: x)
+        agent = post_hook(lambda x: x.upper())(base)
+        result = agent.apply("hello", Context())
+        assert result == "HELLO"
+
+    def test_guard_hook_decorator(self):
+        from lambdagent.core import ValidationError
+        base = Tool("short", lambda x: "hi")
+        agent = guard_hook(lambda x: len(str(x)) > 3, retry=1)(base)
+        with pytest.raises(ValidationError):
+            agent.apply("input", Context())
+
+    def test_stacked_decorators(self):
+        """Multiple decorators compose correctly."""
+        log = []
+        base = Tool("echo", lambda x: x)
+        agent = pre_hook(lambda x: log.append("pre"))(
+            post_hook(lambda x: x + "!")(base)
+        )
+        result = agent.apply("hi", Context())
+        assert result == "hi!"
+        assert "pre" in log
+
+
+# ════════════════════════════════════════════════════════════
+# YAML Hook compilation
+# ════════════════════════════════════════════════════════════
+
+class TestYAMLHooks:
+    def test_compile_hooks_from_config(self):
+        cfg = {
+            "pre_tool": [
+                {"command": "echo hook_fired"},
+            ],
+        }
+        registry = compile_hooks_from_config(cfg)
+        assert len(registry.pre_tool) == 1
+
+    def test_compile_empty_config(self):
+        registry = compile_hooks_from_config({})
+        assert len(registry.pre_llm) == 0
+
+    def test_shell_hook_execution(self):
+        """Shell hook runs without error."""
+        fn = compile_shell_hook("echo test", "pre_tool")
+        # Should not raise
+        mock_term = type("MockTerm", (), {"_name": "test"})()
+        fn(term=mock_term, input="hello")
+
+
+# ════════════════════════════════════════════════════════════
+# Integration: Three layers coexist
+# ════════════════════════════════════════════════════════════
+
+class TestThreeLayerIntegration:
+    def test_all_layers_fire(self):
+        """Layer 1 + Layer 2 + Layer 3 all fire in one execution."""
+        log = []
+
+        # Layer 3: global observer
+        registry = HookRegistry()
+        registry.register("pre_tool", lambda **kw: log.append("L3:pre_tool"))
+
+        # Layer 2: HookTerm wrapper
+        inner = Tool("compute", lambda x: int(x) * 2)
+        hooked = HookTerm(
+            inner,
+            pre=lambda x: log.append("L2:pre"),
+            post=lambda x: (log.append("L2:post"), x)[1],
+        )
+
+        # Layer 1: inline compose
+        from lambdagent.primitives import Compose
+        pipeline = Compose(
+            Tool("L1:log", lambda x: (log.append("L1:pre"), x)[1]),
+            hooked,
+        )
+
+        ctx = Context()
+        result = pipeline.apply("5", ctx)
+        assert result == 10
+
+        # Layer 1 and Layer 2 should have fired
+        assert "L1:pre" in log
+        assert "L2:pre" in log
+        assert "L2:post" in log
+
+        # Layer 3 fires when tools are called through AsyncExecutor (not direct apply)
+        # So we manually fire to verify it works
+        registry.fire("pre_tool", term=inner, input="5", ctx=ctx)
+        assert "L3:pre_tool" in log
+
+
+if __name__ == "__main__":
+    pytest.main([__file__, "-v", "--tb=short"])