Skip to main content

Overview

Agent middleware lets you inject custom logic (logging, tracing, input rewriting, access control, …) at key points in an agent’s execution flow without modifying the agent or model code. In AgentScope Java, you can hook into 5 places — covering everything from the outer reply flow down to the raw model API call: The two types differ:
  • Onion — middleware wraps the next handler; you can insert logic before/after next.apply(input) and observe the intermediate event stream.
  • Transformer — middlewares form a pipeline; the previous output is the next input. There’s no “inner layer” concept.
The diagram below shows how the hooks nest in the agent lifecycle. onSystemPrompt is nested inside onReasoning because it fires when the reasoning step assembles the system prompt:
onActing only wraps tool executions inside the agent runtime. Tools executed outside the agent via external execution are not tracked by onActing.

Equipping middleware

AgentScope packs a set of hooks into a single MiddlewareBase implementation — one middleware class can implement any subset of the 5 hooks (the rest default to next.apply(input)). Pass the instances to the builder’s middlewares(...):
middleware(...) (singular) adds one; middlewares(...) accepts List<? extends MiddlewareBase>. Hooks not implemented by a middleware are skipped at zero cost.

Built-in middlewares

OtelTracingMiddleware

OtelTracingMiddleware (io.agentscope.core.tracing) wires up OpenTelemetry tracing for the agent lifecycle. It instruments onAgent, onModelCall, onActing, producing nested spans:
  • invoke_agent <name> — wraps a full reply
  • chat <model> — wraps each model API call
  • execute_tool <name> — wraps each tool execution
When no OpenTelemetry SDK is configured (only the default no-op provider), every hook short-circuits to next.apply(input) — near-zero overhead. OtelTracingMiddleware reads the process-wide GlobalOpenTelemetry instance. Applications that export spans themselves need the OpenTelemetry SDK and OTLP exporter in addition to AgentScope. Keep their versions aligned through the OpenTelemetry BOM (the version below matches the one currently used by AgentScope):
Build and register the SDK once per process before constructing the agent. The optional environment variable in this example can contain a value such as Basic <base64-credentials> for a backend that requires an Authorization header, including Langfuse:
The SDK must be registered before the middleware is used. If your runtime (for example, Spring Boot OpenTelemetry auto-configuration) already registers GlobalOpenTelemetry, reuse it and only add the middleware. Do not call the deprecated TracerRegistry.register(...) in the new setup. Close the SdkTracerProvider during application shutdown so its batch processor can flush pending spans. Each reply produces a nested span tree with attributes such as agent name, session ID, model name, token counts, tool name, and inputs.

TaskReminderMiddleware

TaskReminderMiddleware (io.agentscope.core.middleware) pairs with the built-in TodoTools: before every reasoning step it renders the current AgentState.tasksContext as a <system-reminder> and injects it into the context, keeping long-running tasks aligned with the plan. Enable it together with TodoTools via enableTaskList(true):

FinalAnswerFilterMiddleware

FinalAnswerFilterMiddleware exposes only the text from the final ReAct reasoning round. Text from rounds that produce tool calls is suppressed, while tool and other non-text events continue to stream normally.
The middleware buffers each round’s text until the model call ends, because it cannot know whether the round is final until no tool call is observed.

Custom middleware

Implement MiddlewareBase (io.agentscope.core.middleware) and override only the hooks you need. Each onion hook receives a next function — calling next.apply(input) enters the next layer. You can insert logic before or after, or use Reactor operators (doOnNext / flatMap / map, …) to observe and rewrite the event stream.
Input record types per hook (under io.agentscope.core.middleware): To replace fields flowing into the next layer, construct a new input record, then call next.apply(...). Runnable examples: agentscope-examples/documentation/.../middleware/CustomizedMiddlewareExample.java, middleware/ModelCallMiddlewareExample.java, middleware/SystemPromptMiddlewareExample.java.

Reading RuntimeContext

Every MiddlewareBase hook receives the RuntimeContext bound for this call / stream as the second argument — you can read session fields and typed/string attributes, and you can write back to it to forward values to downstream hooks and tools.
Things to keep in mind:
  • The same RuntimeContext instance is shared by every hook and tool in the reply; its maps are thread-safe, so put from any hook is safe.
  • Don’t cache per-request state on middleware instance fields — a middleware instance is typically reused across agents / calls. Use RuntimeContext or Reactor’s contextWrite instead.
  • If the builder also has a global toolExecutionContext, the framework merges it after the per-call context when dispatching to tools (per-call wins on key collisions).

Execution order

Onion hooks (onAgent, onReasoning, onActing, onModelCall) are ordered by MiddlewareBase.order()higher values are outermost. The default order is 1; middlewares with the same order retain their builder registration order:
Override order() to move a custom middleware relative to the default order. For example, an order of 0 runs inside middleware that keeps the default order of 1:
For streaming / event-emitting hooks, the inner middleware sees each emitted event first:
Transformer hooks (onSystemPrompt) — left to right pipeline:
Overall hook execution order across one reply:

Practical examples

Timing middleware

The middleware below records the wall-clock time of each model call:

Rate-limit middleware

Enforce a minimum interval between two model calls:

Dynamic system-prompt middleware

Inject runtime context into the system prompt. Or reuse the example middleware/SystemPromptMiddlewareExample.java:

Model-fallback middleware

Swap to a backup model if the primary fails:
For a simple primary→backup fallback, ReActAgent.Builder already exposes fallbackModel(...) and maxRetries(...) directly — no middleware needed.

Stop agent when all tools are denied

When a user denies all tool calls from a reasoning step via HITL, the agent continues to the next reasoning iteration by default (backward compatible). To stop the agent in this scenario, write an onActing middleware that observes AllToolsDeniedEvent and emits a RequestStopEvent:
Once wired up, the agent stops immediately when all tools are denied, returning GenerateReason.ALL_TOOLS_DENIED: