梦兽编程
AI_SUITE

Callbacks and Plugins: Lifecycle Hooks and Extension Mechanism

Detailed explanation of Callbacks and Plugins concepts in ADK Go—Agent lifecycle hooks, plugin mechanism, achieving deep customization.

Callbacks and Plugins are the most powerful extension points in the ADK Go framework. If Tools extend the Agent’s “capability boundary” and Skills extend its “knowledge depth”, then Callbacks and Plugins extend the Agent’s “control surface”—they allow developers to insert custom logic at every key lifecycle point of Agent execution, enabling logging, security auditing, dynamic routing, result post-processing, and other cross-cutting concerns.

Architectural Perspective: Why Lifecycle Hooks Are Needed

In a production-grade Agent system, pure business logic (reasoning, Tool calls, output generation) often accounts for only 30%-40% of the overall code. The rest is consumed by observability, security, compliance, performance optimization, and other infrastructure needs. If these needs are tightly coupled with business logic, the result is:

  1. Code bloat: Every Agent must repeatedly implement logging, monitoring, rate limiting, etc.
  2. Difficult maintenance: Changes to infrastructure logic require modifying every Agent.
  3. Complex testing: Business-logic tests become polluted by infrastructure dependencies.
  4. Low reuse: Generic cross-cutting logic cannot be shared across Agents.

Callbacks and Plugins solve these problems through aspect-oriented programming (AOP) thinking. They decouple infrastructure logic from business logic and “weave” it into the Agent lifecycle declaratively.

Callbacks: Fine-Grained Lifecycle Hooks

Callbacks are ADK Go’s lightest-weight extension mechanism. They are registered as functions on specific Agent lifecycle events and are suitable for simple, stateless cross-cutting logic.

Complete Lifecycle Event Map

ADK Go defines the following lifecycle events covering the full trajectory of Agent execution:

EventTriggerTypical Use
BeforeRunBefore the Agent starts processing user inputInput validation, sensitive-word filtering, permission checks
BeforeModelCallBefore sending a request to the LLMPrompt modification, context injection, cost estimation
AfterModelCallAfter receiving the LLM responseResponse parsing, content moderation, result caching
BeforeToolCallBefore executing a ToolTool-parameter validation, permission checks, logging
AfterToolCallAfter a Tool finishes executingResult processing, error recovery, metrics reporting
OnErrorWhen an error occurs at any stageError classification, alerting, degradation handling
AfterRunAfter the Agent has completed all processingResult formatting, session persistence, follow-up actions
OnStreamChunkWhen each streaming chunk is receivedReal-time rendering, sensitive-content interception, typewriter effect

Production-Grade Callback Configuration Example

Below is a Callback configuration that includes full observability, security, and resilience design:

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "strings"
    "time"

    "google.golang.org/adk/agent"
    "google.golang.org/adk/callback"
    "google.golang.org/adk/llm"
)

func main() {
    ctx := context.Background()

    model, err := llm.NewGeminiModel(ctx, llm.GeminiConfig{
        APIKey: os.Get...Y"),
        Model:  "gemini-2.0-flash",
    })
    if err != nil {
        log.Fatalf("Failed to initialize model: %v", err)
    }

    agent, err := agent.New(agent.Config{
        Name:  "production-agent",
        Model: model,
        Instruction: "You are a helpful assistant.",
        Callbacks: []callback.Callback{
            // 1. Input security audit
            {
                Type: callback.BeforeRun,
                Handler: func(ctx context.Context, input *callback.RunInput) error {
                    // Log user input (note: PII must be masked in production)
                    log.Printf("[AUDIT] user=%s session=%s input_len=%d",
                        input.UserID, input.SessionID, len(input.Text))

                    // Sensitive-word filtering
                    blockedWords := []string{"password", "secret", "token", "key"}
                    for _, word := range blockedWords {
                        if strings.Contains(strings.ToLower(input.Text), word) {
                            // Do not block, but mark as high risk
                            input.Metadata["risk_level"] = "high"
                            input.Metadata["sensitive_words"] = append(
                                input.Metadata["sensitive_words"].([]string), word)
                        }
                    }

                    // Input length limit (prevent Prompt Injection via overly long input consuming tokens)
                    if len(input.Text) > 10000 {
                        return fmt.Errorf("input too long: %d characters, maximum allowed 10000", len(input.Text))
                    }

                    return nil
                },
            },

            // 2. Prompt enhancement and cost estimation
            {
                Type: callback.BeforeModelCall,
                Handler: func(ctx context.Context, call *callback.ModelCallInput) error {
                    // Inject current time (mitigate model knowledge-cutoff issue)
                    call.Prompt += fmt.Sprintf("

[System hint] Current time: %s", time.Now().Format(time.RFC3339))

                    // Rough token-consumption estimate
                    estimatedTokens := len(call.Prompt) * 3 / 2  // conservative for CJK characters
                    if estimatedTokens > 8000 {
                        log.Printf("[WARN] Large context request: %d tokens", estimatedTokens)
                    }

                    // Record model-call metrics
                    metrics.IncrementCounter("model_calls", metrics.Tag{"model", call.ModelName})

                    return nil
                },
            },

            // 3. Response post-processing and content moderation
            {
                Type: callback.AfterModelCall,
                Handler: func(ctx context.Context, call *callback.ModelCallOutput) error {
                    // Latency logging
                    metrics.RecordHistogram("model_latency_ms", float64(call.Duration.Milliseconds()))

                    // Output PII detection
                    if containsPII(call.Response.Text) {
                        log.Printf("[ALERT] Model output contains PII; it has been masked")
                        call.Response.Text = maskPII(call.Response.Text)
                    }

                    // Cache high-frequency responses
                    if call.Response.Metadata["cacheable"] == true {
                        cache.Set(call.CacheKey, call.Response, 5*time.Minute)
                    }

                    return nil
                },
            },

            // 4. Tool-call monitoring and circuit breaker
            {
                Type: callback.BeforeToolCall,
                Handler: func(ctx context.Context, call *callback.ToolCallInput) error {
                    // Check Tool circuit-breaker state
                    if circuitBreaker.IsOpen(call.ToolName) {
                        return fmt.Errorf("Tool %s is currently unavailable (circuit breaker open)", call.ToolName)
                    }

                    // Log Tool call
                    log.Printf("[TOOL] name=%s args=%v", call.ToolName, call.Arguments)

                    // Parameter security check
                    if err := validateToolArgs(call.ToolName, call.Arguments); err != nil {
                        return fmt.Errorf("Tool parameter validation failed: %w", err)
                    }

                    return nil
                },
            },

            {
                Type: callback.AfterToolCall,
                Handler: func(ctx context.Context, call *callback.ToolCallOutput) error {
                    duration := time.Since(call.StartTime)
                    metrics.RecordHistogram("tool_latency_ms", float64(duration.Milliseconds()),
                        metrics.Tag{"tool", call.ToolName})

                    if call.Error != nil {
                        metrics.IncrementCounter("tool_errors", metrics.Tag{"tool", call.ToolName})
                        // Record error for circuit-breaker decision
                        circuitBreaker.RecordFailure(call.ToolName)
                    } else {
                        circuitBreaker.RecordSuccess(call.ToolName)
                    }

                    return nil
                },
            },

            // 5. Error handling and degradation
            {
                Type: callback.OnError,
                Handler: func(ctx context.Context, err *callback.ErrorEvent) error {
                    log.Printf("[ERROR] phase=%s error=%v", err.Phase, err.Error)

                    // Classify error and trigger different handling strategies
                    switch classifyError(err.Error) {
                    case ErrorTypeRateLimit:
                        // Rate limit: trigger exponential backoff retry
                        err.RetryAfter = calculateBackoff(err.Attempt)
                    case ErrorTypeModelUnavailable:
                        // Model unavailable: switch to fallback model
                        err.FallbackModel = "gemini-1.5-flash"
                    case ErrorTypeToolFailure:
                        // Tool failure: try cached result or return friendly message
                        if cached, ok := cache.Get(err.ToolName); ok {
                            err.RecoveryData = cached
                        }
                    }

                    // Send alert for severe errors
                    if err.Severity == callback.SeverityCritical {
                        alert.Send(fmt.Sprintf("Agent critical error: %v", err.Error))
                    }

                    return nil
                },
            },

            // 6. Streaming output processing
            {
                Type: callback.OnStreamChunk,
                Handler: func(ctx context.Context, chunk *callback.StreamChunk) error {
                    // Real-time sensitive-word detection (especially important in streaming)
                    if containsSensitiveContent(chunk.Text) {
                        chunk.Text = maskSensitiveContent(chunk.Text)
                    }

                    // Record streaming metrics
                    metrics.IncrementCounter("stream_chunks")

                    return nil
                },
            },
        },
    })
    if err != nil {
        log.Fatalf("Failed to create Agent: %v", err)
    }

    // Use the Agent...
}

If Callbacks are “function-level hooks”, then Plugins are “module-level extensions”. Plugins can:

  • Register new Callbacks
  • Add new Tools
  • Modify Agent configuration
  • Access and modify internal state
  • Implement cross-request state sharing

Plugin Interface

type Plugin interface {
    Name() string
    Initialize(ctx context.Context, config PluginConfig) error
    RegisterCallbacks(registry *callback.Registry) error
    RegisterTools(registry *tool.Registry) error
    Shutdown(ctx context.Context) error
}

Production-Grade Plugin Example: Distributed Tracing

Below is a Plugin that implements OpenTelemetry distributed tracing, demonstrating how a Plugin can deeply integrate into every corner of the Agent:

package tracing

import (
    "context"
    "fmt"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/trace"
    "google.golang.org/adk/agent"
    "google.golang.org/adk/callback"
    "google.golang.org/adk/plugin"
    "google.golang.org/adk/tool"
)

// TracingPlugin provides OpenTelemetry distributed tracing for the Agent
type TracingPlugin struct {
    tracer trace.Tracer
    config TracingConfig
}

type TracingConfig struct {
    ServiceName      string
    ExporterEndpoint string
    SamplingRate     float64
}

func (p *TracingPlugin) Name() string {
    return "opentelemetry-tracing"
}

func (p *TracingPlugin) Initialize(ctx context.Context, config plugin.PluginConfig) error {
    p.config = config.(TracingConfig)

    // Initialize OTel TracerProvider
    tp, err := initTracerProvider(p.config)
    if err != nil {
        return fmt.Errorf("failed to initialize TracerProvider: %w", err)
    }

    otel.SetTracerProvider(tp)
    p.tracer = tp.Tracer(p.config.ServiceName)

    return nil
}

func (p *TracingPlugin) RegisterCallbacks(registry *callback.Registry) error {
    // Create a Trace Span for each Agent Run
    registry.Register(callback.BeforeRun, func(ctx context.Context, input *callback.RunInput) error {
        ctx, span := p.tracer.Start(ctx, "agent.run",
            trace.WithAttributes(
                attribute.String("agent.name", input.AgentName),
                attribute.String("session.id", input.SessionID),
                attribute.String("user.id", input.UserID),
                attribute.Int("input.length", len(input.Text)),
            ),
        )
        // Store span in context for later callbacks
        input.Context = trace.ContextWithSpan(ctx, span)
        return nil
    })

    registry.Register(callback.AfterRun, func(ctx context.Context, output *callback.RunOutput) error {
        span := trace.SpanFromContext(ctx)

        span.SetAttributes(
            attribute.Int("output.length", len(output.Text)),
            attribute.Int("tool.calls", output.ToolCallCount),
            attribute.Int("model.calls", output.ModelCallCount),
        )

        if output.Error != nil {
            span.SetStatus(codes.Error, output.Error.Error())
            span.RecordError(output.Error)
        }

        span.End()
        return nil
    })

    // Create a child Span for each model call
    registry.Register(callback.BeforeModelCall, func(ctx context.Context, call *callback.ModelCallInput) error {
        ctx, span := p.tracer.Start(ctx, "llm.call",
            trace.WithAttributes(
                attribute.String("model.name", call.ModelName),
                attribute.Int("prompt.tokens", call.PromptTokens),
            ),
        )
        call.Context = ctx
        return nil
    })

    registry.Register(callback.AfterModelCall, func(ctx context.Context, call *callback.ModelCallOutput) error {
        span := trace.SpanFromContext(ctx)
        span.SetAttributes(
            attribute.Int("response.tokens", call.ResponseTokens),
            attribute.Float64("duration.ms", float64(call.Duration.Milliseconds())),
        )
        span.End()
        return nil
    })

    // Create a child Span for each Tool call
    registry.Register(callback.BeforeToolCall, func(ctx context.Context, call *callback.ToolCallInput) error {
        ctx, span := p.tracer.Start(ctx, fmt.Sprintf("tool.%s", call.ToolName),
            trace.WithAttributes(
                attribute.String("tool.name", call.ToolName),
            ),
        )
        call.Context = ctx
        return nil
    })

    registry.Register(callback.AfterToolCall, func(ctx context.Context, call *callback.ToolCallOutput) error {
        span := trace.SpanFromContext(ctx)
        span.SetAttributes(
            attribute.Bool("tool.success", call.Error == nil),
            attribute.Float64("duration.ms", float64(call.Duration.Milliseconds())),
        )
        if call.Error != nil {
            span.RecordError(call.Error)
        }
        span.End()
        return nil
    })

    return nil
}

func (p *TracingPlugin) RegisterTools(registry *tool.Registry) error {
    // This Plugin does not register new Tools
    return nil
}

func (p *TracingPlugin) Shutdown(ctx context.Context) error {
    // Graceful shutdown to ensure all spans are exported
    if tp, ok := otel.GetTracerProvider().(*sdktrace.TracerProvider); ok {
        return tp.Shutdown(ctx)
    }
    return nil
}

Using a Plugin

func main() {
    tracingPlugin := &tracing.TracingPlugin{}

    agent, err := agent.New(agent.Config{
        Name: "traced-agent",
        Plugins: []plugin.Plugin{
            tracingPlugin,
            &logging.Plugin{},
            &metrics.Plugin{},
        },
    })

    // Ensure Plugins shut down gracefully on application exit
    defer func() {
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        for _, p := range agent.Plugins() {
            if err := p.Shutdown(ctx); err != nil {
                log.Printf("Plugin %s shutdown failed: %v", p.Name(), err)
            }
        }
    }()
}

Callbacks vs Plugins: How to Choose

ScenarioRecommended MechanismReason
Simple loggingCallbackLightweight, declarative, no extra dependencies
Input/output filteringCallbackSimple logic, stateless
Cross-request state sharingPluginCan maintain internal state
Adding new ToolsPluginNeeds access to Tool Registry
Complex observability integrationPluginNeeds to initialize external clients (OTel, Prometheus, etc.)
Dynamic configuration updatesPluginCan implement hot-reload logic
A/B testing frameworkPluginNeeds to maintain experiment state and traffic splitting

Advanced Pattern: Plugin Chains and Priority

When multiple Plugins are registered, their execution order and interactions must be carefully designed. ADK Go supports Plugin priority and chain execution:

agent, err := agent.New(agent.Config{
    Plugins: []plugin.Plugin{
        // High priority: security-related Plugins run first
        &security.Plugin{Priority: 100},

        // Medium priority: observability Plugins
        &tracing.Plugin{Priority: 50},
        &metrics.Plugin{Priority: 50},

        // Low priority: business-enhancement Plugins
        &enhancement.Plugin{Priority: 10},
    },
})

Plugin chains follow the Onion Model:

Request enters
[Security Plugin] BeforeRun
[Tracing Plugin] BeforeRun
[Metrics Plugin] BeforeRun
[Enhancement Plugin] BeforeRun
[Core business logic]
[Enhancement Plugin] AfterRun
[Metrics Plugin] AfterRun
[Tracing Plugin] AfterRun
[Security Plugin] AfterRun
Response returned

This design ensures that security policies are always on the outermost layer (first Before, last After), while business-enhancement logic is on the innermost layer.

Production Pitfalls and Debugging Tips

Pitfall 1: Blocking Operations in Callbacks

Callbacks run on the Agent’s main execution path. If a Callback performs blocking I/O (such as a database query or HTTP request), it directly increases Agent response latency.

Solution: Make non-critical operations asynchronous

{
    Type: callback.AfterRun,
    Handler: func(ctx context.Context, output *callback.RunOutput) error {
        // Log asynchronously without blocking the main flow
        go func() {
            auditLog.Write(output)
        }()
        return nil
    },
}

Pitfall 2: Error Propagation in Callback Chains

A Callback returning an error may cause the entire Agent execution to fail. You need a clear error-handling strategy:

{
    Type: callback.BeforeRun,
    Handler: func(ctx context.Context, input *callback.RunInput) error {
        err := doSomething()
        if err != nil {
            // Strategy A: block execution
            // return err

            // Strategy B: log error but continue
            log.Printf("Non-fatal error: %v", err)
            return nil

            // Strategy C: degrade gracefully
            input.Metadata["degraded"] = true
            return nil
        }
        return nil
    },
}

Pitfall 3: Plugin Initialization Failure Blocks Agent Startup

In production, the failure of an observability Plugin (such as Tracing) should not block core business. Implement graceful degradation:

func (p *TracingPlugin) Initialize(ctx context.Context, config plugin.PluginConfig) error {
    err := p.doInit(config)
    if err != nil {
        // Log error but mark the Plugin as noop
        log.Printf("Tracing initialization failed, degraded: %v", err)
        p.noop = true
    }
    return nil  // do not return error to avoid blocking Agent startup
}

Debugging Tip: Callback Execution Tracing

When Agent behavior is abnormal, locating which Callback caused the problem can be difficult. Implement Callback execution tracing:

func tracedCallback(cb callback.Callback) callback.Callback {
    return callback.Callback{
        Type: cb.Type,
        Handler: func(ctx context.Context, event interface{}) error {
            start := time.Now()
            err := cb.Handler(ctx, event)
            duration := time.Since(start)

            log.Printf("[CALLBACK] type=%s duration=%v error=%v",
                cb.Type, duration, err)

            return err
        },
    }
}

Summary

Module 9 is complete. We have deeply explored four advanced topics:

  • Grounding: Search-enhanced generation that anchors Agent answers to the real world, solving LLM knowledge-cutoff and hallucination problems.
  • Artifacts: A structured content-generation mechanism that upgrades Agent output from free text to typed, machine-parseable data units.
  • Skills: Preset professional capability modules that give Agents systematic thinking frameworks and best practices in specific domains.
  • Callbacks and Plugins: Lifecycle hooks and extension mechanisms that enable elegant separation and deep customization of cross-cutting concerns.

Together, these mechanisms form the “infrastructure layer” of a production-grade Agent system—they do not directly participate in business reasoning, but they determine the system’s reliability, maintainability, and scalability.

Next we enter the final module: Real-World Comprehensive—end-to-end project practice.

Skills for Agents | End-to-End Project →


Want to learn more Go ADK hands-on? Follow the “Full-Stack Peak — Mengshou Programming” WeChat account for weekly Go / AI programming practice updates.

Frequently Asked Questions

What are Callbacks and Plugins?

Callbacks are fine-grained lifecycle hooks, and Plugins are fuller extension modules; both let you insert custom logic at key Agent execution points.

Why do production Agents need lifecycle hooks?

Hooks let you implement logging, monitoring, security auditing, and dynamic routing as cross-cutting concerns centrally without tightly coupling them to business logic.

What lifecycle events are available?

Typical events include before/after invocation, before/after tool call, on error, and on completion.

How do I choose between a Callback and a Plugin?

Use Callbacks for simple, stateless cross-cutting logic; use Plugins when you need state management or complex functionality.

What are best practices for Callbacks and Plugins in production?

Keep them idempotent, low-latency, fault-isolated, and non-blocking so they do not hinder the core Agent reasoning flow.