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.
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 singleMiddlewareBase 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 replychat <model>— wraps each model API callexecute_tool <name>— wraps each tool execution
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):
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.
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.
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:
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: