Skip to content

Feature Request: Support Custom Agent Hooks via forwardedProps #901

@RotEveryDay

Description

@RotEveryDay

Feature Request: Support Custom Agent Hooks via forwardedProps

Is your feature request related to a problem? Please describe.

Currently, the forwardedProps field in RunAgentInput is only used for extracting the agentId parameter (AguiRequestProcessor.java:142-148). The remaining properties in forwardedProps are simply passed through but never actually utilized.

This represents a missed opportunity for extensibility. Many use cases require custom preprocessing or postprocessing of agent execution based on runtime parameters, such as:

  • Adding custom context/metadata before agent execution
  • Modifying agent behavior dynamically (e.g., temperature, max tokens)
  • Injecting custom tools or modifying tool configurations
  • Logging/auditing with custom metadata
  • Multi-tenant scenarios requiring tenant-specific configurations
  • A/B testing different agent configurations

Describe the solution you'd like

I propose implementing a Hook Mechanism that allows developers to register custom hooks that can access and utilize forwardedProps to modify agent execution behavior.

Proposed Architecture

1. Define Hook Interfaces

/**
 * Hook interface for preprocessing AG-UI requests
 */
public interface AguiRequestHook {
    /**
     * Called before agent resolution
     * @param input The original request input
     * @param context Execution context (mutable)
     */
    void preResolve(RunAgentInput input, AguiExecutionContext context);
    
    /**
     * Called after agent resolution but before execution
     * @param agent The resolved agent
     * @param input The request input
     * @param context Execution context (mutable)
     */
    void preExecute(Agent agent, RunAgentInput input, AguiExecutionContext context);
}

/**
 * Hook interface for postprocessing AG-UI responses
 */
public interface AguiResponseHook {
    /**
     * Called after agent execution completes
     * @param event The final event
     * @param context Execution context
     */
    void onComplete(AguiEvent event, AguiExecutionContext context);
    
    /**
     * Called when an error occurs
     * @param error The error
     * @param context Execution context
     */
    void onError(Throwable error, AguiExecutionContext context);
}

2. Execution Context

/**
 * Mutable context object passed through hook chain
 */
public class AguiExecutionContext {
   private final String threadId;
   private final String runId;
   private final Map<String, Object> forwardedProps;
    
    // Mutable state
   private Map<String, Object> attributes = new ConcurrentHashMap<>();
   private List<Msg> modifiedMessages;
   private ToolMergeMode overrideToolMergeMode;
    
    // Getters and setters...
}

3. Hook Registry

/**
 * Registry for managing AG-UI hooks
 */
public class AguiHookRegistry {
   private final List<AguiRequestHook> requestHooks = new CopyOnWriteArrayList<>();
   private final List<AguiResponseHook> responseHooks = new CopyOnWriteArrayList<>();
    
   public void registerRequestHook(AguiRequestHook hook) { ... }
   public void registerResponseHook(AguiResponseHook hook) { ... }
}

4. Integration in AguiRequestProcessor

public class AguiRequestProcessor {
   private final AgentResolver agentResolver;
   private final AguiAdapterConfig config;
   private final AguiHookRegistry hookRegistry; // New
    
   public ProcessResult process(RunAgentInput input, String headerAgentId, String pathAgentId) {
       String threadId = input.getThreadId();
       String agentId = resolveAgentId(input, headerAgentId, pathAgentId);
        
        // Create execution context
        AguiExecutionContext context = new AguiExecutionContext(threadId, input);
        
        // Invoke preResolve hooks
       if (hookRegistry != null) {
            hookRegistry.getRequestHooks().forEach(hook -> 
                hook.preResolve(input, context));
        }
        
        // Resolve agent (may be modified by hooks)
        Agent agent = agentResolver.resolveAgent(agentId, threadId);
        
        // Invoke preExecute hooks
       if (hookRegistry != null) {
            hookRegistry.getResponseHooks().forEach(hook -> 
                hook.preExecute(agent, input, context));
        }
        
        // Apply modifications from context
        RunAgentInput effectiveInput = applyModifications(input, context);
        
        // Execute agent
        AguiAgentAdapter adapter = new AguiAgentAdapter(agent, config);
        Flux<AguiEvent> events = adapter.run(effectiveInput);
        
        // Wrap with response hooks
        Flux<AguiEvent> hookedEvents = events.doOnComplete(() -> {
           if (hookRegistry != null) {
                hookRegistry.getResponseHooks().forEach(hook -> 
                    hook.onComplete(null, context));
            }
        }).doOnError(error -> {
           if (hookRegistry != null) {
                hookRegistry.getResponseHooks().forEach(hook -> 
                    hook.onError(error, context));
            }
        });
        
       return new ProcessResult(agent, hookedEvents);
    }
}

Usage Examples

Example 1: Spring Boot Auto-Configuration

@Configuration
public class AguiHookConfiguration {
    
    @Bean
   public AguiHookRegistry aguiHookRegistry() {
       return new AguiHookRegistry();
    }
    
    @Bean
   public AguiRequestHook tenantContextHook() {
       return (input, context) -> {
            // Extract tenant ID from forwardedProps
           String tenantId = (String) input.getForwardedProp("tenantId");
           if (tenantId != null) {
                context.setAttribute("tenantId", tenantId);
                MDC.put("tenantId", tenantId); // For logging
            }
        };
    }
    
    @Bean
   public AguiRequestHook dynamicConfigHook() {
       return (input, context) -> {
            // Allow frontend to override model parameters
            Double temperature = (Double) input.getForwardedProp("temperature");
           if (temperature != null) {
                context.setAttribute("model.temperature", temperature);
            }
            
            Integer maxTokens = (Integer) input.getForwardedProp("maxTokens");
           if (maxTokens != null) {
                context.setAttribute("model.maxTokens", maxTokens);
            }
        };
    }
    
    @Bean
   public AguiResponseHook auditLogHook() {
       return (event, context) -> {
           if (event instanceof AguiEvent.RunFinished) {
               String tenantId = context.getAttribute("tenantId");
               logAuditEvent(tenantId, context.getThreadId(), context.getRunId());
            }
        };
    }
}

Example 2: Custom Tool Injection

@Bean
public AguiRequestHook customToolInjectionHook() {
   return (input, context) -> {
        // Frontend can request specific tools to be enabled
        List<String> requestedTools = (List<String>) input.getForwardedProp("enableTools");
       if (requestedTools != null) {
            context.setAttribute("requestedTools", requestedTools);
        }
    };
}

// In AguiAgentAdapter or a wrapper
private Agent prepareAgent(Agent original, AguiExecutionContext context) {
    List<String> requestedTools = context.getAttribute("requestedTools");
   if (requestedTools != null) {
        // Filter or modify agent's toolkit based on request
       return filterAgentTools(original, requestedTools);
    }
   return original;
}

Example 3: Request Validation

@Bean
public AguiRequestHook validationHook() {
   return (input, context) -> {
        // Validate required props
       if (input.getForwardedProp("requireAuth") != null) {
           String authToken = (String) input.getForwardedProp("authToken");
           if (authToken == null || !isValid(authToken)) {
               throw new AguiException.AuthenticationException("Invalid or missing auth token");
            }
        }
        
        // Rate limiting check
       String userId = (String) input.getForwardedProp("userId");
       if (userId != null && isRateLimitExceeded(userId)) {
           throw new AguiException.RateLimitExceededException("Rate limit exceeded");
        }
    };
}

Describe alternatives you've considered

Alternative 1: Extend RunAgentInput with More Fields

Pros: Type-safe, clear API
Cons: Requires protocol changes, not flexible for custom extensions

Alternative 2: Add Callback Interface to AguiAdapterConfig

AguiAdapterConfig.builder()
    .preExecutionHook((input) -> { ... })
    .postExecutionHook((event) -> { ... })
    .build();

Pros: Simple for basic use cases
Cons: Limited flexibility, doesn't support multiple hooks

Alternative 3: Use ThreadLocal Context

Pros: Easy to implement
Cons: Not reactive-friendly, harder to test, potential memory leaks

Additional context

Current forwardedProps Usage

The only current usage of forwardedProps is in AguiRequestProcessor.resolveAgentId():

// 3. Check forwardedProps for agentId
Object agentIdProp = input.getForwardedProp("agentId");
if (agentIdProp != null) {
   String propsAgentId = agentIdProp.toString();
   logger.debug("Using agent ID from forwardedProps: {}", propsAgentId);
   return propsAgentId;
}

All other properties in forwardedProps are stored but never accessed.

Related AG-UI Protocol Features

The AG-UI protocol already supports:

  • forwardedProps.agentId - for specifying agent ID
  • context - for additional context information
  • state - for state management

This proposal extends the design philosophy by allowing forwardedProps to carry arbitrary metadata that hooks can consume.

Implementation Priority

  1. Core: Define hook interfaces and execution context
  2. Integration: Modify AguiRequestProcessor to invoke hooks
  3. Spring Boot: Provide auto-configuration and convenient registration
  4. Documentation: Add examples and best practices

Benefits

  1. Extensibility: Allows custom logic without modifying core code
  2. Separation of Concerns: Cross-cutting concerns (logging, auth) separated from business logic
  3. Flexibility: Different deployments can have different hook configurations
  4. Backward Compatible: Existing code continues to work without hooks
  5. Reactive Friendly: Designed to work with Reactor Flux

Note: I'm willing to contribute a PR for this feature if the maintainers find it valuable and provide guidance on the preferred implementation approach.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions