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 6 places — covering everything from the outer reply flow down to the raw model API call: The three 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.
  • Notification — one-way synchronous notification without next delegation; middlewares run in sequence at a fixed lifecycle point.
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; onAgentStateReady fires right after the call’s AgentState is bound to the RuntimeContext, before the input enters the pipeline:
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 6 hooks (onion/transformer hooks not implemented default to next.apply(input); notification hooks default to a no-op). 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. By default, OtelTracingMiddleware reads the process-wide GlobalOpenTelemetry instance. The no-argument constructor looks that instance up lazily, when a hook runs, so the middleware can be constructed before the global SDK is registered. 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. To export through an application-owned SDK instead of the process-wide one, build that SDK with build() (not buildAndRegisterGlobal()), point an OTLP HTTP exporter at the application’s endpoint, and attach the middleware explicitly. This is an opt-in middleware path: the caller owns the SDK lifecycle and shuts the provider down. ReActAgent.builder().middleware(...) is already the production wiring; no other builder change is required.
Passing appSdk does not replace GlobalOpenTelemetry and does not change StudioManager. StudioManager still installs deprecated TracerRegistry tracing on its own during initialize(). Vendor instrumentation that rewrites tracer lookup, and a missing Studio call-tree expansion control, need separate verification against the deployment that produces them. This middleware selects which OpenTelemetry SDK records the onAgent, onModelCall, and onActing spans. OpenTelemetry.noop() is also a valid argument: the downstream chain still runs, and no spans are recorded. Constructing OtelTracingMiddleware — either new OtelTracingMiddleware() or new OtelTracingMiddleware(appSdk) — also registers a JVM-wide Reactor hook, ContextPropagationOperator.registerOnEachOperator(), the first time any instance is created. From then on, the hook wraps each Flux and Mono operator as it is assembled, in any code in the process, so parent spans survive publishOn / subscribeOn hops. It cannot reach chains that were assembled before it was registered: a publisher built earlier and reused later does not gain propagation, so construct the middleware before assembling pipelines that need it. It is independent of which SDK records spans: an application-owned SDK isolates tracer lookup from GlobalOpenTelemetry, not this instrumentation side effect. The hook is installed at most once per JVM and is not removed when the middleware or the SDK is closed. 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.

State-ready notification

onAgentStateReady is a notification hook (no next): it fires synchronously once per call, right after the call-scoped AgentState is bound to the RuntimeContext and before the input enters the pipeline. state is the same instance as ctx.getAgentState() and is never null — a brand-new session receives a freshly-created state with an empty context. inputMessages is this call’s input as a private mutable copy: in-place modification applies to the whole call, while the caller’s original list stays untouched.
Keep implementations non-blocking: the notification runs on the call’s subscription thread (possibly a Reactor event-loop thread in streaming scenarios). Firing is per lifecycle execution (subscription) — re-subscribing a cold stream triggers it again. A thrown exception fails the call unchanged and the remaining middlewares are not invoked. Middlewares that don’t override this hook simply run their no-op default; the only fixed cost is one shallow copy of the input list per call, which keeps the caller’s list isolated unconditionally. Participation follows activePoints() like every extension point: a middleware that omits ExtensionPoint.ON_AGENT_STATE_READY is never notified, even if it overrides the hook. Lifecycle window: the notification runs after the call has been admitted and registered for interruption, but before the tracing envelope and the error-event chain are in place — an interrupt issued during the notification is honored, while a thrown exception reaches the caller directly, without ErrorEvent hooks or tracing spans.

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:
Notification hooks (onAgentStateReady) run once per call in order() sequence — higher values first, matching the onion entry order:
Overall hook execution order across one reply:

Extension point participation (activePoints())

MiddlewareBase.activePoints() declares at which extension points the middleware is active. It is a participation switch, not a statement about which methods are overridden: Key semantics:
  • Default is active everywhere. Middlewares that do not override activePoints() keep today’s behavior unchanged, including at extension points added in future releases.
  • Overriding means taking over. Once overridden, the active set is exactly the returned set. A point you forgot to declare is silently inactive even though its method is overridden.
  • Empty set is legal. EnumSet.noneOf(ExtensionPoint.class) disables the middleware at every point while keeping its registration — useful as a runtime switch.
  • Frozen at construction. The agent reads declarations once when it is built; later changes to the returned set have no effect on an already-built agent.
  • Orthogonal to order(). The declaration only answers whether a middleware participates at a point, never in what order — participants keep the usual order() semantics.
Recommended practice: define activePoints() accurately for every middleware you write. Precise declarations let the framework skip non-participants entirely — an extension point with no participants builds no wrapper at all (zero wrapper layers, no pipeline assembly), which keeps chains short and the effective execution plan visible at build time. Not declaring keeps the fully compatible default (active everywhere), so adding the declaration later never breaks behavior — it only narrows participation. The same applies when subclassing a built-in middleware: shipped classes declare exactly the points they hook, so a subclass that overrides an additional hook (e.g. adding onModelCall to TaskReminderMiddleware) must extend the inherited activePoints() — otherwise the new hook is silently skipped.

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. Observing the switch is the same story: it happens below the onModelCall seam, so use ReActAgent.Builder.failoverListener(...) rather than a middleware.

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: