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
nextdelegation; middlewares run in sequence at a fixed lifecycle point.
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 singleMiddlewareBase 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 replychat <model>— wraps each model API callexecute_tool <name>— wraps each tool execution
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):
Basic <base64-credentials> for a backend that requires an Authorization header, including Langfuse:
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.
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.
Custom middleware
ImplementMiddlewareBase (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.
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.
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
EveryMiddlewareBase 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.
- The same
RuntimeContextinstance is shared by every hook and tool in the reply; its maps are thread-safe, soputfrom any hook is safe. - Don’t cache per-request state on middleware instance fields — a middleware instance is typically reused across agents / calls. Use
RuntimeContextor Reactor’scontextWriteinstead. - 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:
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:
onSystemPrompt) — left to right pipeline:
onAgentStateReady) run once per call in order() sequence — higher values first, matching the onion entry order:
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 usualorder()semantics.
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 examplemiddleware/SystemPromptMiddlewareExample.java:
Model-fallback middleware
Swap to a backup model if the primary fails: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 anonActing middleware that observes AllToolsDeniedEvent and emits a RequestStopEvent:
GenerateReason.ALL_TOOLS_DENIED: