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:
- Memory-backed Q&A:
examples/qa-agent/(search forBuildMemoryHooks)- Per-user memory in chat:
examples/travel-chat-agent/(search forBuildUserMemoryHooks)- DevOps chat + activity coordination:
examples/devops-chat-agent/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
- Why This Guide Exists
- What Are Pipeline Hooks?
- The Pipeline Lifecycle
- The Hook Stages
- PipelineContext and Enrichments
- Short-Circuiting the Pipeline
- Writing Your Own Hook
- Registering Hooks
- Execution Semantics and Guarantees
- Built-in Hooks Reference
- Testing Hooks
- Design Decisions and Trade-offs
- Troubleshooting
- Further Reading
Practical recipes:
- Allow or reject a plan with a required hook
- Report work performed by a hook
- Verify the result in a running agent
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
AfterPlanninghook. - 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 Stage | When It Runs | Can Mutate? | Can Short-Circuit? | Wired in live path? |
|---|---|---|---|---|
BeforePlanningHook | Before the planning phase begins | Enrichments | Yes — skips the entire pipeline | Yes |
AfterPlanningHook | After each phase reaches one final validated planner-produced plan | The plan | No | Yes |
AfterExecutionHook | After all tools finish executing | No (observe-only) | No | Yes |
AfterSynthesisHook | After the LLM produces its synthesis draft | Response text | No | Yes |
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
RequiredAfterPlanningHookinstead 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
AfterSynthesishooks run, the user has already seen the text. The returned (possibly mutated) string updates the finalOrchestratorResponse.Responseand is stored separately asStoredExecution.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 aBeforePlanninghook, 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. ABeforePlanninghook 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.
- Your hook writes
pctx.Enrichments[core.EnrichmentRAGContext] = "...retrieved docs...". - After
runBeforePlanningHooksfinishes,runRequeststores the map in the Go context withcore.WithPipelineEnrichments(state.Context, state.Pipeline.Enrichments). Seeorchestration/request_lifecycle.goand the enrichment helpers incore/context.go. buildPlanningPromptpreparesPromptInput.Metadatafromcore.GetPipelineEnrichments(ctx). The default builder emits known keys as tagged sections;preparePromptInputinorchestration/prompt_assembly.goapplies the configured prompt-value preparation to known string enrichments.
Default builder vs. custom
PromptBuilderBoth routes receive pipeline enrichments through the prepared
PromptInput.Metadatasnapshot. The default builder renders the well-known keys below; a customPromptBuildercan readinput.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:
| Constant | String value | Rendered as | Typical 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 wrapping | UserMemoryEnrichmentHook |
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 customPromptBuilderto read it (§5.2). The asymmetry worth remembering: auto-promotion (§5.4) only liftsconversation_historyandrag_contextout of requestMetadata, so putting an arbitrary key in request metadata does nothing unless a hook copies it intopctx.Enrichments.
Note: enrichments survive multi-phase planning
Enrichments are set once, before the first planning call, and stored in
ctx. In Phase 2+,buildContinuationPromptre-readsGetPipelineEnrichments(ctx)and prepares the known string enrichments throughprepareKnownPromptEnrichments. 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 viametadata[orchestration.MetadataConversationTurns]) and the framework promotes it for you. Hooks run after this step, so aBeforePlanninghook 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
BeforePlanninghooks — the first short-circuit wins. - Planning, execution, and synthesis are all skipped.
- A complete
OrchestratorResponseis built from the short-circuit, withConfidence: 1.0and any already-injected enrichments merged into the response metadata (buildShortCircuitResponseinorchestration/pipeline_hooks.go). - A bounded
orchestration.pipeline.short_circuit.decisionmetric records the provenance kind, decision reason, and status. Hook names andSourcevalues are intentionally excluded from metric labels; accepted decisions remain identifiable through the correlatedpipeline.short_circuit.decisiontrace event. - On the streaming path,
runRequestusesdeliverUTF8Chunksinorchestration/request_lifecycle.goto 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), orSetLLMDebugStore(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 time | Hook invocation | Effect audit-write |
|---|---|---|
| Submission accepted; write still running | succeeded after the hook returns | pending |
| Writer reports success | Still succeeded | succeeded |
| Writer later reports failure | Still succeeded | failed, with the writer's error |
| Submission fails immediately | failed; ordinary hook remains fail-open | failed |
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
WithHooksoption. Hooks are attached throughOrchestratorDependencies.PipelineHookswhen you create the orchestrator. (framework.goexposes 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
PipelineHooksis a flat slice, and slice order is execution order. Append enrichment hooks to the builder's output — your RAG hook andMemoryEnrichmentHookboth run atBeforePlanning, each accumulating intoEnrichments. But prepend a short-circuiting cache hook if you want it to pre-empt the builder's (expensive) enrichment hooks: the firstBeforePlanninghook 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:
| Example | Failure policy | Actual action | Reason |
|---|---|---|---|
| Optional callback returned an error | fail_open | continue | hook_error |
| Required after-planning callback returned an error | fail_closed | terminate | hook_error |
| Required after-planning mutation was accepted | fail_closed | continue | accepted |
| Cached response matched current provenance | fail_closed | short_circuit | cache_match |
| Cached response did not match | fail_closed | continue | cache_dimension_mismatch |
| Hook panicked | The boundary's ordinary error policy | propagate_panic | panic |
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.
| Type | File | Stage | What it does |
|---|---|---|---|
MemoryEnrichmentHook | orchestration/memory_hooks.go | BeforePlanning | Extracts entities from the request, queries shared episodic memory, and appends recent/related domain events into Enrichments[EnrichmentRAGContext]. Read/enrich. |
MemoryRecordHook | orchestration/memory_hooks.go | AfterExecution | Submits per-step events (entities, outcome, importance) into episodic memory. Write/record. |
UserMemoryEnrichmentHook | orchestration/user_memory_hooks.go | BeforePlanning | Reads Metadata["user_id"]; recalls per-user identity and query-relevant facts into Enrichments[EnrichmentUserProfile]. No-ops without a user ID. |
UserMemoryExtractionHook | orchestration/user_memory_extraction.go | AfterSynthesis | Extracts durable user facts from request+response via LLM and stores them per-user. Returns the response unchanged. Async or sync. |
KnowledgeExtractionHook | orchestration/knowledge_extraction_hook.go | AfterSynthesis | Asks an LLM for 0–3 reusable knowledge fragments, embeds and stores them. Runs in a goroutine; never mutates the response. |
ConversationHistoryHook | orchestration/conversation_history_hook.go | BeforePlanning | Adapter 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.) |
ActivityAnnouncementHook | orchestration/activity_hooks.go | BeforePlanning | Announces this agent's activity to the coordinator and injects concurrent activities into Enrichments[EnrichmentActivityCoordination]. |
ActivityCleanupHook | orchestration/activity_hooks.go | AfterSynthesis | Pass-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/boundary | Effect ID | Captured values |
|---|---|---|
Every BeforePlanning hook | planning_enrichment:<key> | Exact before/after JSON value and added, updated, or removed operation |
ActivityAnnouncementHook | activity_signal, activity_context | Submitted signal and TTL; discovered/selected signals. The exact injected text is carried once by planning_enrichment:<key>. |
MemoryEnrichmentHook | memory_planning_context | Agent 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>. |
MemoryEnrichmentHook | memory_episodic_read | Bounded recent-window read status (read_succeeded, read_failed, or legacy_unobserved), returned-event count and query limit |
MemoryEnrichmentHook | memory_digest_window | Digest 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 |
MemoryRecordHook | episodic_memory_records | Per-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. |
KnowledgeExtractionHook | knowledge_extraction | Pending async state, exact model extraction response, normalized fragments, and explicit per-fragment observed outcome/error |
| Managed request activity owner | activity_signal_renewal | Pending renewal-loop state followed by terminal submission/error counts after the loop is joined |
Managed request activity owner / direct ActivityCleanupHook | activity_signal_cleanup | Managed 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 owner | investigation_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
| Symptom | Likely cause | Fix |
|---|---|---|
| Hook never runs | Not in deps.PipelineHooks, or doesn't implement the stage interface you expect | Confirm 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 fires | Request short-circuited, no planner-produced plan was accepted, the request is a HITL resume, or an ordinary hook signature does not match | Confirm 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 it | Wrong key, or a custom PromptBuilder that doesn't read enrichments | Use a well-known key (§5) with the default builder, or have your custom builder call core.GetPipelineEnrichments(ctx) |
| Guardrail didn't block streamed text | AfterSynthesis runs after tokens stream | Move the check to BeforePlanning, a guarded prompt builder, or the non-streaming path |
| Optional hook error was ignored | Ordinary hooks are fail-open by design | Check the stage's WARN diagnostic; use RequiredAfterPlanningHook when a rejected plan mutation must stop before HITL and execution |
| Hook registration reports a nil entry | PipelineHooks contains a nil or typed-nil hook | Check 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 fails | Required marker is missing AfterPlanningHook, more than one required hook exists, or a later after-planning mutator follows it | Inspect InvalidRequiredAfterPlanningHookError.Reason (stage_drift, multiple_required, or required_not_terminal) and correct the hook slice |
| Hook ran but its work was overwritten | Another hook later in the slice mutated the same enrichment key or chained response | Reorder the slice; remember AfterSynthesis chains and BeforePlanning shares one Enrichments map |
| Can't find the hook in a trace | Telemetry provider not configured, or hook never matched its stage | Verify 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
- Enable execution recording (
TRUVAG3_EXECUTION_DEBUG_STORE_ENABLED=true) before constructing the orchestrator, and supply its execution backend. Register the hook throughdeps.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. - Deploy your modified agent using its
./setup.sh rebuild. Submit through its normal JSON or streaming endpoint, notExecutePlanWithSynthesis. - 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.
- 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.
- Open Registry Viewer, find the request in Execution DAG, and inspect
the
allowed-operationscard under Pre-Execution. Follow the trace link topipeline.hook.after_planning.allowed-operationsin Jaeger.
These are expected results, not captured live-run evidence:
| Evidence | Allowed plan | First-phase hook rejection |
|---|---|---|
| Hook invocation | succeeded | failed, with the application error |
| Recorded decision | fail_closed / continue / accepted | fail_closed / terminate / hook_error |
| Tools and HITL | May proceed normally | No tool call or checkpoint from this phase |
| Stored execution | Final status depends on later work | Failed request, zero executed steps, hook evidence retained |
| Client result | Normal result if later work succeeds | Error/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
- Adding Context to Your Agent — the scenario cookbook (RAG, semantic cache, conversation history, guardrails) built on these hooks
- Agent Memory User Guide — how the built-in memory hooks read and write shared episodic memory
- Conversation History Guide — the metadata path and
ConversationHistoryPreparerreferenced in §5 - Orchestration Modes Guide — how planning, execution, and synthesis fit together
- Error Handling Guide — how hook errors flow through the resilient error-handling system
- Distributed Tracing Guide — each hook gets its own span (e.g.
pipeline.hook.before_planning.rag-retriever) - Dev Tools Guide — Pre-Execution / Post-Execution tabs in the execution DAG viewer
- API Reference —
OrchestratorDependencies,CreateOrchestrator, and thePromptBuildercontract
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.