agentscope-ai / agentscope-ai/agentscope-java

[Bug]: onSystemPrompt middleware executes on a Reactor boundedElastic scheduler thread with an uncontrollable TCCL, breaking TCCL-based resource loading (FreeMarker / hutool SPI) in Spring Boot fat-jar deployments

Open
#2,652 2 comments 0 reactions 0 assignees View on GitHub
area/core/agent area/ext/spring-boot bug
Dominant language
Java
Stars
5.6k
Forks
1.3k
Avg merge
4d 12h
Merged PRs (30d)
77

Description

**Describe the bug**
When an Agent (ReActAgent) is invoked asynchronously, the `onSystemPrompt` middleware chain
executes on a Reactor `boundedElastic` scheduler thread whose Thread Context ClassLoader (TCCL)
is not under our control. With the platform `AppClassLoader` as TCCL, any middleware that
resolves classpath resources or SPI services via the TCCL fails in a Spring Boot fat-jar
deployment, even though the resources are present on the classpath.

A telling symptom: the **first agent call succeeds, but subsequent calls fail**. This matches
how Reactor's global `Schedulers.boundedElastic()` pool works — threads are created lazily and
each thread's TCCL is captured once, at creation, from the parent thread that schedules the
first task onto it, then never changes. If the pool first grows on a webapp-classloader thread
the middleware works; once it grows on an `AppClassLoader` thread, later calls get routed to the
"wrong-TCCL" threads and fail.

Observed failures (both TCCL-dependent):
- FreeMarker (`SpringTemplateLoader` / `ClassTemplateLoader` resolve via
`ClassUtils.getDefaultClassLoader()`, which prefers the TCCL) →
`Template not found for name "sysPrompt/xx.md"`.
- Hutool `PinyinUtil` (engine resolved via `ServiceLoader`/`ServiceLoaderUtil`, TCCL-based) →
`No pinyin jar found ! Please add one of it to your project !`.

**To Reproduce**
1. Run a Spring Boot fat jar (`java -jar app.jar`) with AgentScope, Java 21, WebFlux/Reactor.
2. Register a custom `MiddlewareBase` that overrides `onSystemPrompt` and reads a classpath
resource via the TCCL (e.g. a FreeMarker template, or any `ServiceLoader`-loaded SPI).
3. Invoke the agent asynchronously via `ReActAgent.streamEvents(msgs, options)` (the streaming
event API) from a controller, and subscribe to the returned `Flux`.
4. Log the thread + TCCL inside `onSystemPrompt`:

```java
@Override
public Mono onSystemPrompt(Agent agent, RuntimeContext ctx, String currentPrompt) {
System.out.println(Thread.currentThread().getName() + " TCCL="
+ Thread.currentThread().getContextClassLoader());
return Mono.just(currentPrompt);
}
```

5. Make **two or more** calls. The first may run on a `boundedElastic-N` thread with the webapp
class loader and succeed; a later call runs on a `boundedElastic-N` thread with
`jdk.internal.loader.ClassLoaders$AppClassLoader`, and the TCCL-dependent resource load fails.

**Expected behavior**
The `onSystemPrompt` middleware should run on the caller thread (preserving the webapp TCCL), or
the middleware chain should capture/restore the caller's TCCL, so classpath resources and SPI
services remain resolvable regardless of which pooled scheduler thread happens to run it.

**Error messages**
```
freemarker.template.Configuration: Template not found for name "sysPrompt/aiStockIn.md".
The name was interpreted by this TemplateLoader: MultiTemplateLoader(loader1 = SpringTemplateLoader@..., loader2 = ClassTemplateLoader(...)).

cn.hutool.extra.pinyin.PinyinException: No pinyin jar found ! Please add one of it to your project !
```

**Environment (please complete the following information):**
- AgentScope-Java Version: v2.0.1
- Java Version: 21
- OS: linux (SAE) / windows

**Additional context**
The middleware chain itself is fully reactive (`seedSystemMsg` returns `Mono` and
`applySystemPromptMiddlewares` returns `Mono`). When the agent is driven through the
streaming event API, `ReActAgent.streamEvents(...)` builds the event stream via
`AgentBase#createEventStream`, which ends in `.publishOn(Schedulers.boundedElastic())`
(`AgentBase.java:1033`). The whole event stream — including the pre-call system-prompt
middleware — is therefore executed on a Reactor `boundedElastic` scheduler thread whose TCCL is
not controllable. Reactor's global `Schedulers.boundedElastic()` pool threads are created lazily
and each thread's TCCL is fixed at creation from the scheduling parent thread, which is why the
outcome is non-deterministic (first call works, later calls fail) and depends on which thread
happened to grow the pool. Suggested fix: run the middleware chain on the caller's thread, or
capture/restore the caller's TCCL around the chain instead of letting it execute on an arbitrary
scheduler thread.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.