Skip to main content

Pipeline Hooks Guide

Managed-request activity announcements register pending renewal and cleanup effects before returning. The request owner renews the latest signal and joins renewal before all-exit cleanup, rather than relying on successful synthesis. Effect counters describe submissions/errors, not backend health. Execution records carry a separate optional lifecycle and monotonic record_revision; late effect updates merge into the newest full snapshot. Legacy records without lifecycle evidence have unknown request completion, regardless of step success.

The extension points that let you inject context, short-circuit work, and post-process responses at every stage of the orchestration pipeline — without forking the framework.

Working Example

The hooks described here are exercised by several shipped agents:

If you want a scenario-first cookbook (RAG, semantic caching, guardrails) rather than this mechanism reference, read Adding Context to Your Agent alongside this guide.

Table of Contents​

  1. Why This Guide Exists
  2. What Are Pipeline Hooks?
  3. The Pipeline Lifecycle
  4. The Hook Stages
  5. PipelineContext and Enrichments
  6. Short-Circuiting the Pipeline
  7. Writing Your Own Hook
  8. Registering Hooks
  9. Execution Semantics and Guarantees
  10. Built-in Hooks Reference
  11. Testing Hooks
  12. Design Decisions and Trade-offs
  13. Troubleshooting
  14. Further Reading

Practical recipes:


1. Why This Guide Exists​

Out of the box, a TruvaG3 orchestrator does four things in sequence: it plans (asks an LLM which tools to call), executes those tools, synthesizes a final answer, and returns it. That loop is intentionally closed — it has no opinion about your knowledge base, your conversation history, your cache, or your content policy.

Pipeline hooks are how you open that loop without touching framework internals. They let you:

  • Inject retrieved context (RAG, memory, conversation history) into the prompt before the planner runs.
  • Return a cached answer and skip the entire pipeline (short-circuit).
  • Reject a plan before tools run, using an explicitly required AfterPlanning hook.
  • Record execution outcomes to memory or analytics after tools finish.
  • Filter, redact, or log the final response after synthesis.

Memory is the framework's most prominent consumer of hooks — but it has no privileged status. Everything memory does, your own code can do through the same interfaces. This guide documents the mechanism itself: the contracts, where each stage fires, how data flows into the prompt, and the sharp edges to avoid.

Who this is for

Developers wiring custom context into an agent, or anyone debugging why a hook didn't fire (or fired but didn't reach the LLM). If you just want to add RAG or caching from a recipe, start with Adding Context to Your Agent and return here when you need the underlying detail.


2. What Are Pipeline Hooks?​

A pipeline hook is per-stage middleware: a small object that the orchestrator calls at a defined point in the request lifecycle. Every hook implements the base core.PipelineHook interface — a single Name() method used for logging and telemetry span names — plus one or more stage-specific interfaces.

// core/interfaces.go
type PipelineHook interface {
Name() string
}

There are four stage interfaces and one required-stage declaration. A single hook type can implement as many stages as it wants; the orchestrator discovers what an ordinary hook supports via type assertion at each stage, so partial implementations are normal and expected. RequiredAfterPlanningHook is not a fifth stage. It changes the failure contract for one AfterPlanningHook and is validated when the orchestrator is constructed.

Hook StageWhen It RunsCan Mutate?Can Short-Circuit?Wired in live path?
BeforePlanningHookBefore the planning phase beginsEnrichmentsYes — skips the entire pipelineYes
AfterPlanningHookAfter each phase reaches one final validated planner-produced planThe planNoYes
AfterExecutionHookAfter all tools finish executingNo (observe-only)NoYes
AfterSynthesisHookAfter the LLM produces its synthesis draftResponse textNoYes

Two properties define the whole system and are worth committing to memory:

  • Ordinary hook callback failures fail open. The documented stage fallback continues the request. A hook explicitly implementing RequiredAfterPlanningHook instead fails closed before HITL or execution. Invalid provenance-aware short-circuit contracts (missing payload or unknown kind) are also programming errors and fail the request rather than silently bypassing cache enforcement.
  • Hooks run sequentially, in registration order. There is no concurrent access to the shared context object, so hooks can read and write it without locking.

3. The Pipeline Lifecycle​

Here is where each stage fires relative to the orchestrator's core work. The single PipelineContext object (§5) threads through every stage of a request.

Request
│
▼
┌─────────────────────────────────────────────┐
│ prepareKnownEnrichments() │ ← auto-promotes conversation_history /
│ │ rag_context from Metadata (no hook needed)
└───────────────────────┬───────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ BeforePlanningHook(s) │ ← inject RAG / memory / history into Enrichments
│ ▶ may return a short-circuit decision ───────┼──▶ skip everything, return accepted Response
└───────────────────────┬───────────────────────┘
│ Enrichments copied into ctx via WithPipelineEnrichments()
▼
┌─────────────────────────────────────────────┐
│ Planning (LLM call) │ ← prompt builder reads enrichments back out
└───────────────────────┬───────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ AfterPlanningHook(s) │ ← copy-on-write mutation; full plan revalidation
└───────────────────────┬───────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ Execution (tool calls, possibly multi-phase) │
└───────────────────────┬───────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ AfterExecutionHook(s) │ ← record outcomes to memory / analytics
└───────────────────────┬───────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ Synthesis (LLM call) │
└───────────────────────┬───────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ AfterSynthesisHook(s) │ ← guardrails, redaction, memory write-back
└───────────────────────┬───────────────────────┘
▼
Response

All public request modes enter the shared lifecycle in orchestration/request_lifecycle.go. Phase planning and the AfterPlanningHook boundary live in orchestration/orchestrator.go; the hook runners and validation logic live in orchestration/pipeline_hooks.go.

Streaming caveat

On the streaming path, tokens are sent to the client during synthesis. By the time AfterSynthesis hooks run, the user has already seen the text. The returned (possibly mutated) string updates the final OrchestratorResponse.Response and is stored separately as StoredExecution.FinalResponse, but it cannot un-stream what was already delivered. The LLM debug synthesis interaction remains the streamed/raw model draft. If you need to block content before the user sees it, do it in a BeforePlanning hook, in a guarded prompt builder, or use the non-streaming path.


4. The Hook Stages​

All four stage contracts and the required-stage declaration are defined in core/interfaces.go. Each stage contract embeds PipelineHook; the required declaration embeds only PipelineHook so construction can detect stage-method drift independently.

4.1 BeforePlanningHook​

// core/interfaces.go
type BeforePlanningHook interface {
PipelineHook
BeforePlanning(ctx context.Context, pctx *PipelineContext) (*PipelineShortCircuit, error)
}

Runs before the planner sees the prompt. This is the only stage that can both enrich (write into pctx.Enrichments) and short-circuit (through the legacy payload or the provenance-aware decision contract). It is where RAG retrieval, memory recall, conversation-history injection, and semantic-cache lookups belong. New cache hooks should use BeforePlanningDecisionHook; legacy short-circuits are interpreted as authoritative responses.

Return (nil, nil) for the common "I added some context, carry on" case. Return a non-nil short-circuit only when you have a complete answer and want to bypass planning, execution, and synthesis entirely (§6).

4.2 AfterPlanningHook​

// core/interfaces.go
type AfterPlanningHook interface {
PipelineHook
AfterPlanning(ctx context.Context, pctx *PipelineContext, plan interface{}) (interface{}, error)
}

// RequiredAfterPlanningHook declares required intent independently from the
// stage method. Factory construction validates that the same hook also
// implements AfterPlanningHook.
type RequiredAfterPlanningHook interface {
PipelineHook
RequireAfterPlanningSuccess()
}

Runs exactly once for the final planner-produced plan in each phase, after the normalization/validation fixpoint and before HITL, persistence, or execution. HITL resume plans do not run this hook because they are approved checkpoint state rather than newly produced plans.

Mutation is copy-on-write and chained in registration order. Each hook must return a non-nil *orchestration.RoutingPlan. The framework applies terminal-synthesis normalization and runs the complete plan-validation gauntlet after each mutation. The model-only numeric-reference/dependency normalizer does not run here: hooks must supply canonical references and exact implicit_deps for referenced prior-phase step IDs. Missing, unreferenced, or same-phase entries in implicit_deps are rejected; same-phase references require depends_on instead. For an ordinary hook, a hook error, wrong return type, clone failure, or invalid mutation is rejected with bounded diagnostics and the last valid plan continues. It never triggers LLM regeneration and cannot corrupt that prior plan.

For a mandatory plan-governance boundary, implement both interfaces and include an explicit compile-time assertion:

type governedPlanHook struct{}

func (*governedPlanHook) Name() string { return "governed-plan" }
func (*governedPlanHook) RequireAfterPlanningSuccess() {}
func (*governedPlanHook) AfterPlanning(
ctx context.Context,
pctx *core.PipelineContext,
plan interface{},
) (interface{}, error) {
// Bind or validate trusted application state, then return the plan.
return plan, nil
}

var _ core.AfterPlanningHook = (*governedPlanHook)(nil)
var _ core.RequiredAfterPlanningHook = (*governedPlanHook)(nil)

This shows the interface shape only; it does not enforce a rule. Use the worked policy example for an actual rejection, registration, and caller-side error handling.

A required hook's error, wrong result type, clone failure, or invalid plan returns *orchestration.RequiredAfterPlanningError. The phase stops before its plan is persisted, before HITL is evaluated, and before any tool executes. Factory construction also enforces three composition rules: the required marker must implement AfterPlanningHook, only one required marker may be registered, and it must be the final hook that participates in AfterPlanning. Hooks for other stages may follow it because they cannot mutate the governed plan.

With execution recording enabled, the rejection is stored as an unsuccessful request using the configured error retention. If earlier phases completed, their plans and successful tool results remain in that record. The rejected candidate is not added as an accepted phase. A first-phase rejection has no accepted plan or tool steps, but still has a failed result and hook evidence. Later effect updates preserve this failure state. None of these diagnostic results is sent to synthesis or returned as a successful application response.

4.3 AfterExecutionHook​

The built-in MemoryRecordHook receives the existing request-local PipelineContext here. Its optional WithRecordRequestEntityExtractor (WithBuilderRecordRequestEntityExtractor in BuildMemoryHooks) extracts request identities for recording only, then merges distinct step identities. It does not modify execution results or dispatch parameters, rerun enrichment, or acquire new claims. Summary inputs include optional error facts; recorded events distinguish summary source and occurrence time from recording time. Managed claim cleanup still releases only execution-owned acquired handles.

// core/interfaces.go
type AfterExecutionHook interface {
PipelineHook
AfterExecution(ctx context.Context, pctx *PipelineContext, results interface{}) error
}

Runs after all tool execution (including multi-phase loops) completes, before synthesis. It receives the combined execution result and returns only an error — there is no way to change results. This is an observe-only stage: record the outcome to memory, emit metrics, log to an analytics pipeline. The built-in MemoryRecordHook lives here.

4.4 AfterSynthesisHook​

// core/interfaces.go
type AfterSynthesisHook interface {
PipelineHook
AfterSynthesis(ctx context.Context, pctx *PipelineContext, response string) (string, error)
}

Runs after the final response is synthesized. It receives the response string and returns a possibly-modified one; the output of one hook is fed as input to the next (chaining, §9). Use it for guardrails, PII redaction, response logging, and memory/knowledge write-back. Mind the Streaming caveat blockquote in §3 — on the streaming path the mutation is post-hoc.


5. PipelineContext and Enrichments​

5.1 The context object​

Every stage receives the same *PipelineContext for the request:

// core/interfaces.go — PipelineContext
type PipelineContext struct {
Request string // the raw user request
Metadata map[string]interface{} // caller-supplied request metadata (user_id, session, turns…)
Enrichments map[string]interface{} // the scratchpad hooks write into to reach the prompt
}
  • Request — the original user query, read-only in practice.
  • Metadata — whatever the caller attached to the request (e.g. user_id, conversation turns, session keys). Hooks read from this to decide what to do.
  • Enrichments — a mutable map, initialized empty by the orchestrator. A BeforePlanning hook writes into it; the orchestrator then makes it available to the prompt builder.

5.2 How enrichments reach the LLM​

This is the part that trips people up: writing to pctx.Enrichments is necessary but the magic that carries it into the prompt is a two-step handoff.

BeforePlanning hook request lifecycle prompt preparation/builder
───────────────────── ───────────────────────── ──────────────────────────
pctx.Enrichments[key] ──▶ stores enrichments in ctx ──▶ prepares input.Metadata
= value (WithPipelineEnrichments) → emits <agent_memory>, etc.
  1. Your hook writes pctx.Enrichments[core.EnrichmentRAGContext] = "...retrieved docs...".
  2. After runBeforePlanningHooks finishes, runRequest stores the map in the Go context with core.WithPipelineEnrichments(state.Context, state.Pipeline.Enrichments). See orchestration/request_lifecycle.go and the enrichment helpers in core/context.go.
  3. buildPlanningPrompt prepares PromptInput.Metadata from core.GetPipelineEnrichments(ctx). The default builder emits known keys as tagged sections; preparePromptInput in orchestration/prompt_assembly.go applies the configured prompt-value preparation to known string enrichments.

Default builder vs. custom PromptBuilder

Both routes receive pipeline enrichments through the prepared PromptInput.Metadata snapshot. The default builder renders the well-known keys below; a custom PromptBuilder can read input.Metadata[core.EnrichmentRAGContext]. The snapshot is a copy of the enrichment map, and known string values pass through configured prompt preparation. It may be nil when no enrichments exist. Arbitrary request metadata is not automatically forwarded. Adding Context to Your Agent documents the custom-builder path.

5.3 Well-known enrichment keys​

The default prompt builder recognizes four keys. Using these keys means your injected context reaches the LLM with zero extra wiring:

ConstantString valueRendered asTypical writer
core.EnrichmentRAGContext"rag_context"wrapped in <agent_memory>…</agent_memory>RAG / MemoryEnrichmentHook
core.EnrichmentConversationHistory"conversation_history"wrapped in <conversation_history>…</conversation_history>chat history
core.EnrichmentActivityCoordination"activity_coordination"wrapped in <agent_coordination>…</agent_coordination>ActivityAnnouncementHook
core.EnrichmentUserProfile"user_profile"inserted verbatim — no tag wrappingUserMemoryEnrichmentHook

The constants are defined in core/interfaces.go. Three are wrapped in their own XML section by DefaultPromptBuilder.BuildPlanningPrompt; user_profile is the exception — the builder writes it as-is, so a custom user-profile hook must supply its own preformatted <user_profile>…</user_profile> block, exactly as UserMemoryEnrichmentHook does.

Custom keys: enrichments are the only channel

You can put any key into Enrichments, but only these four are rendered by the default builder — a custom key needs a custom PromptBuilder to read it (§5.2). The asymmetry worth remembering: auto-promotion (§5.4) only lifts conversation_history and rag_context out of request Metadata, so putting an arbitrary key in request metadata does nothing unless a hook copies it into pctx.Enrichments.

Note: enrichments survive multi-phase planning

Enrichments are set once, before the first planning call, and stored in ctx. In Phase 2+, buildContinuationPrompt re-reads GetPipelineEnrichments(ctx) and prepares the known string enrichments through prepareKnownPromptEnrichments. You inject once; each phase receives the request-start snapshot.

5.4 Auto-promotion: the zero-hook path​

Skills activation/resource selectors also consume the four known string enrichments through buildSkillSelectorContext. Each value is cut to its first 1,000 UTF-8-safe bytes (plus ... when truncated). Put scope and historical-origin warnings first; verify this view separately from the full planner prompt. Built-in memory detail puts its event-detail/v2-record-facts tuple legend, when selected, immediately after the scope notice, before any event record. Sparse histories retain named fields when they would not repay the legend's cost. Both the notice and any legend are included in the view's byte budget and fit within this selector prefix when memory is the sole RAG source. Other hooks' preceding RAG text retains its existing order and can consume that prefix; this change does not reprioritize another hook's contribution. Memory enrichment is a request-start snapshot reused across phases, so current execution progress belongs in phase-updated step results rather than this text.

Before any hook runs, the orchestrator calls prepareKnownEnrichments in orchestration/pipeline_hooks.go. It copies conversation_history and rag_context from request Metadata into Enrichments, and runs conversation turns through the configured ConversationHistoryPreparer. The practical consequence:

If you can attach your context as request metadata, you may not need a hook at all. Pass metadata[core.EnrichmentRAGContext] (or conversation turns via metadata[orchestration.MetadataConversationTurns]) and the framework promotes it for you. Hooks run after this step, so a BeforePlanning hook can still override or augment the prepared values. Reach for a hook when retrieval requires logic (a vector query, a cache lookup) rather than data you already hold.


6. Short-Circuiting the Pipeline​

A provenance-aware BeforePlanningDecisionHook can end the request immediately:

type BeforePlanningDecisionHook interface {
PipelineHook
BeforePlanningDecision(
ctx context.Context,
pctx *PipelineContext,
gate PipelineGate,
) (*PipelineShortCircuitDecision, error)
}

type PipelineShortCircuitDecision struct {
ShortCircuit *PipelineShortCircuit
Kind PipelineShortCircuitKind // authoritative or cache
CachedAgainst map[string]string // variation map stored with a cache entry
}

Use PipelineShortCircuitAuthoritative for policy denials, rate limits, and other answers that remain valid regardless of cache state. Use PipelineShortCircuitCache only for a cache entry. For cache decisions, CachedAgainst must be the variation map persisted when that entry was written, not gate.CacheVary() read at lookup time. Echoing current values would make the freshness check meaningless.

The framework accepts a cached response only when cache reads are enabled and every orchestration-reserved dimension matches symmetrically: a value present on only one side is also a mismatch. The foundation exposes the generic gate; its variation map can be empty until an installed orchestration feature owns a reserved dimension. Unknown kinds and missing payloads fail the request as hook contract errors. Other rejected cache decisions continue through planning.

When a decision is accepted:

  • The orchestrator stops running further BeforePlanning hooks — the first short-circuit wins.
  • Planning, execution, and synthesis are all skipped.
  • A complete OrchestratorResponse is built from the short-circuit, with Confidence: 1.0 and any already-injected enrichments merged into the response metadata (buildShortCircuitResponse in orchestration/pipeline_hooks.go).
  • A bounded orchestration.pipeline.short_circuit.decision metric records the provenance kind, decision reason, and status. Hook names and Source values are intentionally excluded from metric labels; accepted decisions remain identifiable through the correlated pipeline.short_circuit.decision trace event.
  • On the streaming path, runRequest uses deliverUTF8Chunks in orchestration/request_lifecycle.go to deliver the cached response through the caller's callback.

The canonical cache use case is a semantic cache: hash or embed the request, look for a hit, and return the cached answer without paying for an LLM round trip. None of the shipped hooks short-circuit.

func (h *SemanticCacheHook) BeforePlanningDecision(
ctx context.Context,
pctx *core.PipelineContext,
gate core.PipelineGate,
) (*core.PipelineShortCircuitDecision, error) {
entry, ok, err := h.cache.Lookup(ctx, pctx.Request)
if err != nil {
return nil, err // fail open — logged & skipped, pipeline continues normally
}
if !ok || gate.ResponseCacheReadDisabled() {
return nil, nil // cache miss — carry on with planning
}
return &core.PipelineShortCircuitDecision{
ShortCircuit: &core.PipelineShortCircuit{
Response: entry.Response,
Source: "semantic_cache",
},
Kind: core.PipelineShortCircuitCache,
CachedAgainst: entry.VariationDimensions, // stored alongside Response
}, nil
}

The legacy BeforePlanningHook remains source-compatible. A legacy PipelineShortCircuit is treated as authoritative and emits a diagnostic when reserved cache dimensions exist. Cache implementations should migrate to the decision hook; policy/guardrail hooks may remain legacy or declare PipelineShortCircuitAuthoritative explicitly.


7. Writing Your Own Hook​

A hook is any struct with a Name() method plus at least one stage method. Nothing else is required — no registration interface, no base class to embed.

7.1 The smallest possible hook​

This is the minimal shape — a BeforePlanning hook that injects a static enrichment. It mirrors the enrichmentHook test helper in pipeline_hooks_test.go.

package hooks

import (
"context"

"github.com/truvaagents/truva-g3/core"
)

type StaticEnrichmentHook struct {
key string
value interface{}
}

func (h *StaticEnrichmentHook) Name() string { return "static-" + h.key }

func (h *StaticEnrichmentHook) BeforePlanning(ctx context.Context, pctx *core.PipelineContext) (*core.PipelineShortCircuit, error) {
pctx.Enrichments[h.key] = h.value
return nil, nil
}

7.2 A realistic RAG hook​

A BeforePlanning hook that queries a vector store and injects the results under the well-known RAG key, so the default prompt builder renders them as <agent_memory>:

// rag_hook.go
package hooks

import (
"context"
"fmt"
"strings"

"github.com/truvaagents/truva-g3/core"
)

type RAGHook struct {
retriever VectorRetriever // your interface: Search(ctx, query) ([]Doc, error)
topK int
logger core.Logger
}

func NewRAGHook(retriever VectorRetriever, topK int) *RAGHook {
return &RAGHook{retriever: retriever, topK: topK}
}

func (h *RAGHook) Name() string { return "rag-retriever" }

func (h *RAGHook) BeforePlanning(ctx context.Context, pctx *core.PipelineContext) (*core.PipelineShortCircuit, error) {
docs, err := h.retriever.Search(ctx, pctx.Request)
if err != nil {
return nil, fmt.Errorf("rag search: %w", err) // fail open
}
if len(docs) == 0 {
return nil, nil
}

var b strings.Builder
for i, d := range docs {
if i >= h.topK {
break
}
fmt.Fprintf(&b, "- %s\n", d.Text)
}
// Well-known key → rendered as <agent_memory> in the planning prompt.
pctx.Enrichments[core.EnrichmentRAGContext] = b.String()
return nil, nil
}

// SetLogger is optional — the factory injects the framework logger if present.
func (h *RAGHook) SetLogger(l core.Logger) { h.logger = l }

7.3 A guardrail hook​

An AfterSynthesis hook that redacts the response. Remember the streaming caveat — on streaming paths this is post-hoc.

func (h *RedactionHook) Name() string { return "pii-redaction" }

func (h *RedactionHook) AfterSynthesis(ctx context.Context, pctx *core.PipelineContext, response string) (string, error) {
return h.redactor.Scrub(response), nil
}

Optional setter interfaces

If your hook implements any of SetLogger(core.Logger), SetTelemetry(core.Telemetry), or SetLLMDebugStore(LLMDebugStore), the factory detects them by type assertion and injects the framework's instances after registration (factory.go:364-384). These are entirely optional — implement them only if your hook needs to log, trace, or record LLM calls.

Tip: assert the contract at compile time

Ordinary stages are discovered by type assertion, so a typo in a method signature can silently skip that stage. A registered required after-planning marker instead makes stage mismatch a construction error. Add compile-time assertions in either case to catch mistakes earlier. Several built-in hooks do this (e.g. ConversationHistoryHook, UserMemoryEnrichmentHook):

var _ core.BeforePlanningHook = (*RAGHook)(nil)

7.4 Recipe: allow or reject a plan​

Suppose an agent may read a stock quote, but must not submit a trade. The LLM proposes operations; application code decides which operations are permitted. The rule must use trusted application configuration, not an allow-list supplied in the user's prompt or in planner-generated metadata.

This example checks the tool namespace, registered service name, and capability together. These identifiers are illustrative: replace them with the exact identities shown in your service registry. A matching name does not prove that a tool is safe; the application owner must trust the registered implementation.

Put this code in an application file, for example plan_policy.go. It uses the same core and orchestration modules as your agent; no new service is needed.

package main

import (
"context"
"fmt"

"github.com/truvaagents/truva-g3/core"
"github.com/truvaagents/truva-g3/orchestration"
)

type Operation struct {
Namespace string
Agent string
Capability string
}

type CapabilityPolicy struct {
allowed map[Operation]bool
}

func NewCapabilityPolicy(allowed []Operation) *CapabilityPolicy {
// Copy the configuration once. Concurrent requests only read this map.
policy := &CapabilityPolicy{allowed: make(map[Operation]bool, len(allowed))}
for _, operation := range allowed {
policy.allowed[operation] = true
}
return policy
}

func (*CapabilityPolicy) Name() string { return "allowed-operations" }
func (*CapabilityPolicy) RequireAfterPlanningSuccess() {}

func (h *CapabilityPolicy) AfterPlanning(
ctx context.Context, pctx *core.PipelineContext, candidate interface{},
) (interface{}, error) {
plan, ok := candidate.(*orchestration.RoutingPlan)
if !ok || plan == nil {
return nil, fmt.Errorf("expected a routing plan")
}
for _, step := range plan.Steps {
capability, ok := step.Metadata["capability"].(string)
operation := Operation{step.Namespace, step.AgentName, capability}
if !ok || capability == "" || !h.allowed[operation] {
return nil, fmt.Errorf("step %s uses an operation not allowed by this application", step.StepID)
}
}
return plan, nil
}

var _ core.AfterPlanningHook = (*CapabilityPolicy)(nil)
var _ core.RequiredAfterPlanningHook = (*CapabilityPolicy)(nil)

At startup, add it to the dependency slice before calling the factory. Here config and deps are the configuration and dependencies your application already uses to construct its orchestrator:

policy := NewCapabilityPolicy([]Operation{
{Namespace: "demo-tools", Agent: "stock-service", Capability: "stock_quote"},
})
deps.PipelineHooks = append(deps.PipelineHooks, policy)
orch, err := orchestration.CreateOrchestrator(config, deps)
if err != nil {
return err // Invalid registration must prevent application startup.
}

Append after all ordinary AfterPlanning mutators. Do not append a second required hook. Other mandatory checks belong inside this one terminal hook. Hooks that participate only in other stages may follow it.

At your normal request boundary, distinguish a policy failure from a successful answer. This block needs the standard-library errors import:

response, err := orch.ProcessRequest(ctx, request, metadata)
var rejected *orchestration.RequiredAfterPlanningError
if errors.As(err, &rejected) {
// Map this to your application's denial/error response, not HTTP 200 success.
// HookName identifies the boundary; Cause retains the underlying failure.
return fmt.Errorf("request stopped by %s: %w", rejected.HookName, err)
}
if err != nil {
return err
}
// Only this branch may deliver response.Response as a successful answer.
_ = response

Not every required-hook error is a deliberate policy denial: an internal hook error or invalid mutation also stops the request. If your API distinguishes denials from internal faults, return an application-defined typed error from the hook and inspect it through the error chain. Do not expose internal causes to an untrusted client without an application decision to do so.

You can first check the rule without Redis or an LLM. Save this as plan_policy_test.go beside the hook and run go test -run TestCapabilityPolicy:

package main

import (
"context"
"testing"

"github.com/truvaagents/truva-g3/core"
"github.com/truvaagents/truva-g3/orchestration"
)

func TestCapabilityPolicy(t *testing.T) {
hook := NewCapabilityPolicy([]Operation{
{Namespace: "demo-tools", Agent: "stock-service", Capability: "stock_quote"},
})
for _, capability := range []string{"stock_quote", "execute_trade"} {
t.Run(capability, func(t *testing.T) {
plan := &orchestration.RoutingPlan{Steps: []orchestration.RoutingStep{{
StepID: "step-1", Namespace: "demo-tools", AgentName: "stock-service",
Metadata: map[string]interface{}{"capability": capability},
}}}
_, err := hook.AfterPlanning(context.Background(), &core.PipelineContext{}, plan)
wantDenied := capability == "execute_trade"
if (err != nil) != wantDenied {
t.Fatalf("capability=%s error=%v; want denied=%v", capability, err, wantDenied)
}
})
}
}

This tests the application's rule only. It does not test the factory, planner, or fail-closed request boundary. Verify those with the running-agent exercise in §13.1.

The hook checks the proposed operation, not every possible parameter or later change in authority. Enforce parameter-level permissions and time-sensitive authorization in the application/tool as well. In particular, an approved HITL checkpoint resumes without running AfterPlanning again.

7.5 Recipe: report a hook effect​

An invocation record answers "Did the hook return successfully?" An effect record answers "What work did the hook attempt, and what result did it observe?" These are different when a hook submits background work.

The following application hook submits an audit write. Submit is an application-owned worker/queue function, not a new framework API. Its contract is: return an error if submission failed; otherwise call done when the write finishes. It must handle bounded admission, cancellation, and shutdown. The hook copies the small amount of data the writer needs, so background work does not retain a mutable framework result.

package main

import (
"context"
"encoding/json"
"fmt"
"sync"
"time"

"github.com/truvaagents/truva-g3/core"
"github.com/truvaagents/truva-g3/orchestration"
)

type AuditRecord struct {
StepCount int `json:"step_count"`
}

type AuditHook struct {
Submit func(context.Context, AuditRecord, func(error)) error
}

func (*AuditHook) Name() string { return "application-audit" }

func (h *AuditHook) AfterExecution(ctx context.Context, pctx *core.PipelineContext, value interface{}) error {
execution, ok := value.(*orchestration.ExecutionResult)
if !ok || execution == nil || h.Submit == nil {
return fmt.Errorf("audit hook requires execution results and a submitter")
}
record := AuditRecord{StepCount: len(execution.Steps)}
data, err := json.Marshal(record)
if err != nil {
return err
}
started := time.Now()
effect := core.PipelineHookEffect{
EffectID: "audit-write", SchemaVersion: 1, Name: "Application audit write",
Status: core.PipelineHookEffectPending, Data: data, StartedAt: started,
}
// Announce before returning; later callbacks may update this same effect ID.
_ = core.ReportPipelineHookEffect(ctx, effect)
var once sync.Once
done := func(writeErr error) {
once.Do(func() {
final := effect
final.Duration = time.Since(started)
final.Status = core.PipelineHookEffectSucceeded
final.Summary = "Audit writer acknowledged completion"
if writeErr != nil {
final.Status = core.PipelineHookEffectFailed
final.Error = writeErr.Error()
final.Summary = "Audit writer reported failure"
}
_ = core.ReportPipelineHookEffect(ctx, final)
})
}
if err := h.Submit(ctx, record, done); err != nil {
done(err)
return err
}
return nil
}

var _ core.AfterExecutionHook = (*AuditHook)(nil)

Register &AuditHook{Submit: yourSubmitter} in deps.PipelineHooks. A simple synchronous submitter may perform the write and call done before returning; an asynchronous one may complete later. Keeping ctx for the effect reporter does not require a background write to inherit request cancellation. Your worker owns that choice. Drain accepted work before closing the orchestrator and its stores; an in-memory callback does not survive a process crash. If the worker creates tracing spans, follow the linked-span pattern in the Distributed Tracing Guide.

Expected evidence:

Point in timeHook invocationEffect audit-write
Submission accepted; write still runningsucceeded after the hook returnspending
Writer reports successStill succeededsucceeded
Writer later reports failureStill succeededfailed, with the writer's error
Submission fails immediatelyfailed; ordinary hook remains fail-openfailed

The framework records what the writer reports; it does not independently prove the audit database committed the write. Effect-reporting errors are diagnostic and must not change the business outcome. Capture them through your application's diagnostic logger if needed. Payloads and errors are stored as supplied: choose what may be retained before reporting them. With execution recording disabled, reporting is a no-op.


8. Registering Hooks​

Registration is factory-only. There is no framework-level WithHooks option. Hooks are attached through OrchestratorDependencies.PipelineHooks when you create the orchestrator. (framework.go exposes config options but nothing about pipeline hooks.)

A nil or empty PipelineHooks slice is valid and registers no hooks. A nil or typed-nil entry in that slice is invalid, whether the hook is optional or required. The factory rejects it before serving requests, with an error wrapping ErrInvalidOrchestratorConfig that identifies the entry's zero-based index. Remove the entry or supply an initialized hook; do not use nil placeholders. This is a general configuration error, not an InvalidRequiredAfterPlanningHookError or one of its required-hook reason codes.

8.1 The direct path​

deps := orchestration.OrchestratorDependencies{
Discovery: discovery,
AIClient: agent.AI,
Logger: agent.Logger,
Telemetry: telemetry.GetTelemetryProvider(),
PipelineHooks: []core.PipelineHook{ // ← registration order = execution order
NewSemanticCacheHook(cache), // BeforePlanning (may short-circuit)
NewRAGHook(retriever, 5), // BeforePlanning (enrich)
&RedactionHook{redactor: r}, // AfterSynthesis
},
}

orch, err := orchestration.CreateOrchestrator(config, deps)

The factory assigns the slice to the orchestrator and runs the optional-setter injection described in §7 (factory.go:354-385). This is exactly what examples/qa-agent/qa_agent.go:220-230 does.

8.2 The builder path (memory)​

You rarely assemble memory hooks by hand. The BuildMemoryHooks builder returns a ready-ordered []core.PipelineHook (announcement → enrichment → record → knowledge-extraction → cleanup) plus an ActivityCoordinator and construction error, gated on which backends you configured:

// examples/qa-agent/main.go
memHooks, activityCoord, err := orchestration.BuildMemoryHooks(
memBackends.ToDeps(), agent.AI, agent.Logger,
)
if err != nil {
return fmt.Errorf("configure memory hooks: %w", err)
}
// …then later…
deps.PipelineHooks = memHooks
deps.ActivityCoordinator = activityCoord

For per-user memory, BuildUserMemoryHooks plays the same role — see examples/travel-chat-agent/chat_agent.go. Behavioral knobs (lookback window, retrieval weights, entity extractor) are passed as options to the builder; see Adding Context to Your Agent and the Agent Memory User Guide.

Mixing custom and built-in hooks

PipelineHooks is a flat slice, and slice order is execution order. Append enrichment hooks to the builder's output — your RAG hook and MemoryEnrichmentHook both run at BeforePlanning, each accumulating into Enrichments. But prepend a short-circuiting cache hook if you want it to pre-empt the builder's (expensive) enrichment hooks: the first BeforePlanning hook to short-circuit wins (§9).

When a required after-planning hook is present, place it after every ordinary AfterPlanningHook. The factory rejects two required hooks or any later after-planning mutator. Compose several mandatory checks inside one terminal required hook rather than relying on order between multiple required mutators.


9. Execution Semantics and Guarantees​

The runners in orchestration/pipeline_hooks.go all share one shape: iterate pipelineHooks in order, type-assert to the stage interface (skip hooks that don't implement it), open a telemetry span named pipeline.hook.<stage>.<Name()>, call the hook.

Ordering. Hooks fire in registration (slice) order within a stage. A hook that implements multiple stages is invoked once per stage, at the appropriate point in the lifecycle.

Type filtering. Implementing BeforePlanningHook does not obligate you to implement the others. At each stage the runner does h, ok := hook.(core.AfterSynthesisHook) and silently skips non-matching ordinary hooks. This is why partial hooks are idiomatic. Silent filtering does not apply to a registered RequiredAfterPlanningHook: factory construction fails if that marker does not also implement AfterPlanningHook, so method-signature drift cannot silently downgrade a mandatory control.

Failure behavior. Ordinary pipeline hooks are fail-open and retain their documented stage fallback. A required after-planning hook is a first-class fail-closed lifecycle control: its error or rejected mutation returns RequiredAfterPlanningError and prevents all downstream work for that phase. Use the required contract for a mandatory plan-governance boundary; use an ordinary hook for optional transformation or enrichment.

Mutation chaining. AfterSynthesis and AfterPlanning chain: the accepted output of hook N is the input to hook N+1. Order matters. Invalid optional after-planning output leaves the last valid plan in place; invalid required output stops the phase. The required hook is the unique terminal after-planning participant, so no later hook can rewrite its governed result. AfterExecution is observe-only and BeforePlanning accumulates into the shared Enrichments map, so neither "chains" a return value.

Short-circuit precedence. The first accepted before-planning short-circuit wins; subsequent before-planning hooks are not called. An explicit cache decision must pass cache-read and provenance checks first; a rejected cached value is not served, and later hooks and normal planning may still run. Put your cache hook early if you want it to pre-empt expensive enrichment hooks.

Execution-debug evidence. When an ExecutionStore is configured, every eligible hook invocation is also appended to the request's StoredExecution.PipelineHooks record. Each entry contains the hook name, lifecycle phase, terminal invocation outcome, phase-local sequence, optional iterative-plan phase, start time, duration, and exact failure text. This is the authoritative, provider-neutral source for troubleshooting which hooks ran; it does not require Redis specifically or a tracing backend. Workflow-mode ExecutePlanWithSynthesis does not run application pipeline hooks and therefore does not populate this field.

Each completed boundary also records a Decision, separate from its invocation status and effect results:

ExampleFailure policyActual actionReason
Optional callback returned an errorfail_opencontinuehook_error
Required after-planning callback returned an errorfail_closedterminatehook_error
Required after-planning mutation was acceptedfail_closedcontinueaccepted
Cached response matched current provenancefail_closedshort_circuitcache_match
Cached response did not matchfail_closedcontinuecache_dimension_mismatch
Hook panickedThe boundary's ordinary error policypropagate_panicpanic

Here, fail-closed cache evaluation means that an unverified cached response is not served; it does not mean that planning must stop. A missing Decision means no decision was recorded, not that the hook succeeded. The same fields appear on the existing hook trace span even without execution recording. Panics are not ordinary hook errors: even a fail-open hook stops execution if it panics. The framework records the failed invocation, closes its trace span, and propagates the original panic value. It preserves completed earlier phases in the failed request record and closes the request/active phase spans. Native streaming also closes the enclosing synthesis span when an AfterSynthesis hook panics; tokens already sent to the client cannot be withdrawn. This does not recover a panic in a separate goroutine started by application code, or guarantee that buffered debug writes survive process termination.

The successful native synthesis model call is saved before AfterSynthesis runs. If that hook panics, LLM Calls can still show the raw model output while the request is failed and has no final application response. Model success and application success are different outcomes.

Registry Viewer shows Pipeline consequence separately from invocation and effect status. Decision details exposes the stored policy, action, and reason; older records without them say not recorded.

Invalid explicit before-planning decisions retain a failed zero-step result. If planning fails in a later phase after a hook ran, completed steps and admitted plans remain in the failed terminal record. These failures use the configured error retention, including later effect updates. An ordinary HITL suspension keeps its checkpoint/interruption semantics.

Invocation outcome and effect outcome are intentionally separate. Orchestration automatically records exact added, changed, or removed BeforePlanning enrichment values as versioned PipelineHookEffect entries. The built-in activity and shared-memory hooks also report submitted writes, observed reads, retrieval metadata, submitted episodic events, extracted knowledge fragments, and cleanup calls. A custom hook may call core.ReportPipelineHookEffect. It must announce a new pending effect before the invocation returns; detached work may then update the same EffectID to a terminal state. Success, request failure, partial native-stream delivery, and HITL suspension all establish the same terminal rewrite path. Concurrent detached completions are revision-gated before per-request store serialization, so a stale snapshot cannot overwrite a newer one. This keeps shutdown accounting race-free and avoids duplicate cards.

Effect status is the producer's observation, not independent proof of durable persistence. In particular, the memory module deliberately fails open: a backend can log an internal runtime failure and return a neutral result so the request continues. Built-in payloads therefore say submitted_event, provider_call_status, or observed_outcome rather than claiming that a write was durably committed. Correlate an unexpected result with memory logs when the provider contract cannot expose a stronger acknowledgement. Exact injected context appears only in the automatically captured enrichment effect; the hook-specific effect retains retrieval metadata without duplicating it.

The recording effect also carries summary_coverage. Its optional built-in batch.entity_fields counts describe retained summaries after duplicate handling: observed, missing, empty, valid, invalid, mixed, accepted_entities and rejected_entities. These do not redefine invocation status, legacy batch outcome, entity grounding or backend success. Custom/heuristic paths omit the built-in batch observation. Exact entity identities remain in existing effect payloads; the corresponding trace events contain counts rather than identities.

Effects use json.RawMessage so their producer owns a versioned structured schema without widening the hook interfaces. The execution-debug boundary is an explicit exact-fidelity opt-in: the framework does not redact, truncate, or summarize effect data or errors. Do not emit secrets unless your execution store and Viewer access policy are designed to retain them.

Telemetry. Each hook invocation is independently wrapped in its own span (when a telemetry provider is configured), so tracing systems can provide a distributed timing view — e.g. pipeline.hook.before_planning.rag-retriever. The span covers the complete framework-observed boundary, including after-planning result validation, and errors are recorded on it. Built-in provider failures use bounded trace/log classifications while their exact provider observations remain in the opt-in effect record. KnowledgeExtractionHook runs its detached work under a fresh pipeline.hook.async.knowledge-extraction root span linked to the scheduling span, with copied request baggage but independent cancellation. Trace spans are optional correlation evidence, not the source of truth for hook ownership or execution. See the Distributed Tracing Guide.

Concurrency. Hooks for a single request run sequentially with no concurrent access to PipelineContext (defined in core/interfaces.go), so you don't need locks around reads/writes of Enrichments. Hooks across different requests run on different PipelineContext instances; any shared state your hook holds (a cache client, a retriever) must be safe for concurrent use.


10. Built-in Hooks Reference​

These ship in the orchestration module and are the best worked examples to read. Most are assembled for you by BuildMemoryHooks / BuildUserMemoryHooks (§8.2) — the exception is ConversationHistoryHook, which you construct directly via NewConversationHistoryHook and add to the slice yourself (the builders do not wire conversation history). None of them short-circuit.

TypeFileStageWhat it does
MemoryEnrichmentHookorchestration/memory_hooks.goBeforePlanningExtracts entities from the request, queries shared episodic memory, and appends recent/related domain events into Enrichments[EnrichmentRAGContext]. Read/enrich.
MemoryRecordHookorchestration/memory_hooks.goAfterExecutionSubmits per-step events (entities, outcome, importance) into episodic memory. Write/record.
UserMemoryEnrichmentHookorchestration/user_memory_hooks.goBeforePlanningReads Metadata["user_id"]; recalls per-user identity and query-relevant facts into Enrichments[EnrichmentUserProfile]. No-ops without a user ID.
UserMemoryExtractionHookorchestration/user_memory_extraction.goAfterSynthesisExtracts durable user facts from request+response via LLM and stores them per-user. Returns the response unchanged. Async or sync.
KnowledgeExtractionHookorchestration/knowledge_extraction_hook.goAfterSynthesisAsks an LLM for 0–3 reusable knowledge fragments, embeds and stores them. Runs in a goroutine; never mutates the response.
ConversationHistoryHookorchestration/conversation_history_hook.goBeforePlanningAdapter that injects memory-backed conversation history into Enrichments[EnrichmentConversationHistory]. Constructed manually via NewConversationHistoryHook — not wired by the builders. (For raw turns, prefer the metadata path in §5.4.)
ActivityAnnouncementHookorchestration/activity_hooks.goBeforePlanningAnnounces this agent's activity to the coordinator and injects concurrent activities into Enrichments[EnrichmentActivityCoordination].
ActivityCleanupHookorchestration/activity_hooks.goAfterSynthesisPass-through in managed requests: the request owner joins renewal and cleans up on completion/failure/cancellation. In direct hook use without that owner, submits signal removal after synthesis.

The built-in effect IDs are stable within each invocation:

Hook/boundaryEffect IDCaptured values
Every BeforePlanning hookplanning_enrichment:<key>Exact before/after JSON value and added, updated, or removed operation
ActivityAnnouncementHookactivity_signal, activity_contextSubmitted signal and TTL; discovered/selected signals. The exact injected text is carried once by planning_enrichment:<key>.
MemoryEnrichmentHookmemory_planning_contextAgent domain, extracted entity references, section count and view report (version, format, byte budget/usage, outcome and section counts). The exact bounded context is carried once by planning_enrichment:<key>.
MemoryEnrichmentHookmemory_episodic_readBounded recent-window read status (read_succeeded, read_failed, or legacy_unobserved), returned-event count and query limit
MemoryEnrichmentHookmemory_digest_windowDigest path, cache_entry_found (compatible decoded entry with window metadata, not raw key existence), returned-event count and coverage (returned_window, limited_window, or unknown); entry presence alone does not prove reuse
MemoryRecordHookepisodic_memory_recordsPer-step submitted event envelope, provider-call observation/error, independent backend_assigns_event_id/backend_assigns_timestamp flags, and claim-release call observations. Built-in summary coverage separates batch_excluded from parsing outcomes; excluded events carry batch_limit. Shortened heuristic causes carry summary_cause_shortened=true.
KnowledgeExtractionHookknowledge_extractionPending async state, exact model extraction response, normalized fragments, and explicit per-fragment observed outcome/error
Managed request activity owneractivity_signal_renewalPending renewal-loop state followed by terminal submission/error counts after the loop is joined
Managed request activity owner / direct ActivityCleanupHookactivity_signal_cleanupManaged removal after joining renewal, or direct-hook removal; captures whether the provider returned an explicit error, not an independent durability/health check
Managed request resource ownerinvestigation_lease_cleanup:<ordinal>Acquired entity identity and cleanup outcome (released, absent, not_owned, or unknown); private acquisition tokens are excluded

Effect status belongs to the observed effect, not just the hook callback's return. Managed activity effects can remain pending after BeforePlanning succeeds and are completed by the request owner. A provider returning nil confirms its call contract, not independently verified backend storage or health. See the tracing guide for the related read, renewal and cleanup events.

Notice the symmetry: the BeforePlanning hooks read/enrich, the AfterExecution/AfterSynthesis hooks write/record. Several also declare the compile-time conformance assertion recommended in §7 — a habit worth adopting in your own hooks.


11. Testing Hooks​

This section covers unit-testing a hook in isolation. For diagnosing a hook that misbehaves inside a running agent (didn't fire, fired but didn't reach the LLM), jump to §13 Troubleshooting.

Hooks are plain structs, so they're trivial to unit-test in isolation: construct a *core.PipelineContext, call the stage method, assert on the returned value and the mutated Enrichments.

func TestRAGHook_InjectsContext(t *testing.T) {
h := NewRAGHook(stubRetriever{docs: []Doc{{Text: "Paris is the capital of France."}}}, 5)
pctx := &core.PipelineContext{
Request: "What is the capital of France?",
Metadata: map[string]interface{}{},
Enrichments: map[string]interface{}{},
}

sc, err := h.BeforePlanning(context.Background(), pctx)
require.NoError(t, err)
require.Nil(t, sc) // no short-circuit
require.Contains(t, pctx.Enrichments[core.EnrichmentRAGContext], "Paris")
}

The framework's own contract tests are worth reading as a reference for expected behavior. orchestration/pipeline_hooks_test.go proves type filtering (only matching hooks fire), short-circuit precedence (first wins, rest skipped), and optional fail-open behavior. orchestration/required_after_planning_test.go proves construction validation, all required rejection classes, the no-HITL/no-execution boundary, and failed-record preservation across buffered requests, streaming entry points, and later effect updates. It also checks configured normal/error retention using local store fixtures.

For compact exemplar hooks, see pipeline_hooks_test.go:

  • enrichmentHook — the minimal one-stage hook shown in §7.
  • allStagesHook — implements all four stages with call counters and injectable errors/mutations; a good template for an exhaustive test double.
  • beforeOnlyHook — demonstrates the partial-implementation pattern.

There is no exported NoOp hook in non-test code; if you want one for tests, the enrichmentHook shape above is the smallest thing that compiles. To stub a hook's dependencies (an embedding client, a memory backend), the core module ships NoOp* and Mock* doubles — see Adding Context to Your Agent §11 for the conventions.


12. Design Decisions and Trade-offs​

Why per-stage interfaces instead of one big callback? Type assertion lets a hook declare exactly the stages it cares about. A guardrail hook implements only AfterSynthesis; the orchestrator skips it everywhere else with no boilerplate on your side. It also keeps each method signature honest about what data is available and what can change at that point.

Why do ordinary hooks fail open? Context injection is usually an enhancement. A vector store being briefly unreachable should degrade the answer's quality, not turn the request into a 500. Mandatory plan governance is different: use RequiredAfterPlanningHook when continuing with the planner's prior valid plan would violate an application invariant. The explicit marker keeps optional enrichment resilient while making the selected control fail closed.

Why can only BeforePlanning short-circuit? Short-circuiting means "I already have the answer, skip the work." That only makes sense before any work has happened. After planning or execution, you've already paid the cost, so there's nothing to save.

Why is AfterExecution observe-only? Letting a hook rewrite raw tool results mid-pipeline invites subtle, hard-to-trace corruption of the data the synthesizer reasons over. Recording and metrics are safe; mutation is intentionally withheld. Shape the response in AfterSynthesis instead.

Why metadata auto-promotion and hooks? Two audiences. Simple agents that already hold the context (chat turns, a precomputed snippet) shouldn't have to write a hook — they pass metadata. Agents that must compute context (a vector query, a cache lookup) need code, and that's a hook. The two compose: auto-promotion runs first, hooks can override.


13. Troubleshooting​

SymptomLikely causeFix
Hook never runsNot in deps.PipelineHooks, or doesn't implement the stage interface you expectConfirm it's in the slice and that the method signature exactly matches the interface (pointer vs value receiver matters for the type assertion)
AfterPlanning hook never firesRequest short-circuited, no planner-produced plan was accepted, the request is a HITL resume, or an ordinary hook signature does not matchConfirm the request reached planning and look for pipeline.hook.after_planning.<name> plus after_planning_hook diagnostics. A drifted required hook prevents construction instead of being skipped
Enrichment set but LLM ignores itWrong key, or a custom PromptBuilder that doesn't read enrichmentsUse a well-known key (§5) with the default builder, or have your custom builder call core.GetPipelineEnrichments(ctx)
Guardrail didn't block streamed textAfterSynthesis runs after tokens streamMove the check to BeforePlanning, a guarded prompt builder, or the non-streaming path
Optional hook error was ignoredOrdinary hooks are fail-open by designCheck the stage's WARN diagnostic; use RequiredAfterPlanningHook when a rejected plan mutation must stop before HITL and execution
Hook registration reports a nil entryPipelineHooks contains a nil or typed-nil hookCheck errors.Is(err, orchestration.ErrInvalidOrchestratorConfig) and the zero-based index in the error; remove or initialize that entry. A nil or empty slice is valid
Required-hook registration failsRequired marker is missing AfterPlanningHook, more than one required hook exists, or a later after-planning mutator follows itInspect InvalidRequiredAfterPlanningHookError.Reason (stage_drift, multiple_required, or required_not_terminal) and correct the hook slice
Hook ran but its work was overwrittenAnother hook later in the slice mutated the same enrichment key or chained responseReorder the slice; remember AfterSynthesis chains and BeforePlanning shares one Enrichments map
Can't find the hook in a traceTelemetry provider not configured, or hook never matched its stageVerify telemetry is initialized; look for span pipeline.hook.<stage>.<name>

To verify a hook fired, look in Jaeger for its span (pipeline.hook.before_planning.<name>). Successful hooks need not emit a log line. The DAG/registry-viewer debug UI surfaces BeforePlanning and AfterPlanning activity under Pre-Execution, and AfterExecution and AfterSynthesis activity under Post-Execution — see the Dev Tools Guide.

13.1 Verify an allowed and a rejected request​

  1. Enable execution recording (TRUVAG3_EXECUTION_DEBUG_STORE_ENABLED=true) before constructing the orchestrator, and supply its execution backend. Register the hook through deps.PipelineHooks; recording configuration alone does not register it. Enable tracing through the agent's normal telemetry setup. LLM-debug recording is optional and separate from hook recording.
  2. Deploy your modified agent using its ./setup.sh rebuild. Submit through its normal JSON or streaming endpoint, not ExecutePlanWithSynthesis.
  3. Ask for an allowed operation, such as a stock quote. Save the request ID and confirm the actual planner-selected identity matches your allow-list.
  4. Ask for a disallowed operation that is discoverable and can produce a valid plan. A planner refusal or an unavailable tool does not exercise the hook. Use a harmless mock tool for this exercise, never a live trade or destructive operation. A temporary deny-all application configuration can also exercise rejection against the same read-only operation.
  5. Open Registry Viewer, find the request in Execution DAG, and inspect the allowed-operations card under Pre-Execution. Follow the trace link to pipeline.hook.after_planning.allowed-operations in Jaeger.

These are expected results, not captured live-run evidence:

EvidenceAllowed planFirst-phase hook rejection
Hook invocationsucceededfailed, with the application error
Recorded decisionfail_closed / continue / acceptedfail_closed / terminate / hook_error
Tools and HITLMay proceed normallyNo tool call or checkpoint from this phase
Stored executionFinal status depends on later workFailed request, zero executed steps, hook evidence retained
Client resultNormal result if later work succeedsError/denial, not a synthesized success answer

A later-phase rejection preserves earlier completed work; it cannot undo it. The rejected candidate is not an accepted executable plan, so do not expect a tool-node DAG for it. The hook failure should remain visible. If using the audit recipe, also inspect Post-Execution before and after its callback finishes: the same effect should change status, not create a second effect card. An AfterExecution hook will not run when rejection prevents execution entirely.

This hook pipeline applies to the normal ProcessRequest paths. ExecutePlanWithSynthesis is workflow mode and currently does not invoke these hooks or store a post-hook FinalResponse; its synthesis record is model output only.

That's the whole surface: four stages, one shared context, fail-open ordinary extensions, and one explicit fail-closed after-planning boundary. Wire a hook, confirm its span shows up in a trace, and build out from there. Happy hooking!


14. Further Reading​

Event-summary timeout ownership​

The builtin memory recorder still completes AfterExecution before synthesis. Only its builtin summarizer AI invocation is bounded (2m default, TRUVAG3_SHARED_MEMORY_EVENT_SUMMARIZER_TIMEOUT through BuildMemoryHooks). The child timeout uses the existing heuristic fallback; parent cancellation emits a failed hook effect and stops writes. Cleanup remains request-lifecycle-owned; no detached background recording or additional model retry is introduced.