Describe the bug
WaitAsyncResultsTool builds its waiting semantics on an indirect, multi-hop chain rather than
observing task state directly:
subtask completes -> TaskRepository completionCallback -> MessageBus inbox push
-> InboxMiddleware injects before next LLM call -> LLM sees the result
The tool itself only polls the inbox every 3s. If any link in that chain fails to deliver, the
tool never observes the fact that the task has actually finished — and it never cross-checks the
real task status in TaskRepository.
Combined with the MAX_CONSECUTIVE_EMPTY_WAITS rejection, the consequence escalates from "waits
too long" to "hard stall": after two consecutive empty waits the tool refuses to wait any longer,
while the main agent has neither received results nor been given any other way to make progress.
To Reproduce
- You code
HarnessAgent supervisor = HarnessAgent.builder()
.model(model)
.messageBus(messageBus)
.subagentFactory("worker", name -> workerAgent)
.build();
// Spawn one or more async subagents (background task mode), then let the supervisor
// call wait_async_results to collect their results.
supervisor.streamEvents(new UserMessage(promptThatSpawnsThenWaits), ctx).subscribe();
- How to execute
Run the flow, and make any one link of the injection chain fail. Any of these is enough:
- another
WorkspaceTaskRepository.setCompletionCallback call replaces the callback, so the
inbox push no longer happens (later registration overwrites the earlier one);
- the task reaches a terminal state between
putTask and callback registration (race), so the
completion event is lost;
- the inbox message is consumed but injection lands after the round that needed it (injector race).
Then compare the subagent's real state with what the tool reports.
- See error
TaskRepository shows the task as COMPLETED, the inbox is empty, and the tool keeps waiting
until timeout, then refuses to wait again. The LLM never receives the results.
Expected behavior
Once a task has actually reached a terminal state, wait_async_results should observe it and hand
the result to the LLM. Even if the inbox notification side-channel fails, the tool must not treat
"notification missing" as "task not finished".
Error messages
No exception is raised. The failure is silent, so the observable symptoms are:
- the tool blocks for the full 60s timeout although the task is already terminal;
- after two consecutive empty waits, waiting is refused via
MAX_CONSECUTIVE_EMPTY_WAITS and the
conversation cannot proceed;
- when the inbox is empty but all tasks are terminal, the tool returns a promise such as
results will be injected automatically, which is never fulfilled in this scenario and actively
misleads the LLM into waiting further.
Environment (please complete the following information):
- AgentScope-Java Version: 2.0.0 (agentscope-harness 2.0.0)
- Java Version: 17
- OS: macos
Additional context
Root cause. The tool's wait condition is not the same thing as the semantics it means to express:
|
Intended semantics |
What is actually observed |
| Goal |
whether tasks reached a terminal state |
task status in TaskRepository |
| Actual |
whether the inbox has a message |
a product of the notification chain |
Task status is the authoritative fact and is guaranteed to happen (unless the task itself
hangs). The inbox message is only a derived notification with several failure points along the
way. Observing the derived signal without checking the authoritative one makes "lost notification"
indistinguishable from "task not finished".
MAX_CONSECUTIVE_EMPTY_WAITS amplifies the defect. It assumes repeated empty waits mean the LLM
is abusing the wait, but when the notification chain is broken, repeated empty waits are a symptom
of the tool's own faulty observation — refusing to wait at that moment only pushes the LLM into a
dead end.
Suggested fix. Change the wait condition to poll TaskRepository for terminal task state
directly. TaskRepository.listTasks(ctx, sessionId, null) already returns all tasks with their
TaskStatus, and TaskStatus.isTerminal() already exists, so no new API is required. Once tasks
are terminal, return the result text as the tool's return value so result collection closes within
a single call() and no longer depends on cross-round injection. Inbox injection can stay as a
fast path, but should not be the only correctness dependency.
Secondary points:
- Remove or reposition
MAX_CONSECUTIVE_EMPTY_WAITS. If the wait observes real task state, a
terminal state is guaranteed to arrive and the rejection is meaningless; if the intent is to
guard against hung tasks, the timeout itself is the better mechanism.
- When the inbox is empty and all tasks are terminal, do not return
will be injected automatically. Return the actual results, or state the current status
explicitly, instead of making a promise that will not be kept.
Affected classes:
io.agentscope.harness.agent.tool.WaitAsyncResultsTool
io.agentscope.harness.agent.subagent.task.WorkspaceTaskRepository (completion callback registration)
Describe the bug
WaitAsyncResultsToolbuilds its waiting semantics on an indirect, multi-hop chain rather thanobserving task state directly:
The tool itself only polls the inbox every 3s. If any link in that chain fails to deliver, the
tool never observes the fact that the task has actually finished — and it never cross-checks the
real task status in
TaskRepository.Combined with the
MAX_CONSECUTIVE_EMPTY_WAITSrejection, the consequence escalates from "waitstoo long" to "hard stall": after two consecutive empty waits the tool refuses to wait any longer,
while the main agent has neither received results nor been given any other way to make progress.
To Reproduce
Run the flow, and make any one link of the injection chain fail. Any of these is enough:
WorkspaceTaskRepository.setCompletionCallbackcall replaces the callback, so theinbox push no longer happens (later registration overwrites the earlier one);
putTaskand callback registration (race), so thecompletion event is lost;
Then compare the subagent's real state with what the tool reports.
TaskRepositoryshows the task asCOMPLETED, the inbox is empty, and the tool keeps waitinguntil timeout, then refuses to wait again. The LLM never receives the results.
Expected behavior
Once a task has actually reached a terminal state,
wait_async_resultsshould observe it and handthe result to the LLM. Even if the inbox notification side-channel fails, the tool must not treat
"notification missing" as "task not finished".
Error messages
No exception is raised. The failure is silent, so the observable symptoms are:
MAX_CONSECUTIVE_EMPTY_WAITSand theconversation cannot proceed;
results will be injected automatically, which is never fulfilled in this scenario and activelymisleads the LLM into waiting further.
Environment (please complete the following information):
Additional context
Root cause. The tool's wait condition is not the same thing as the semantics it means to express:
TaskRepositoryTask status is the authoritative fact and is guaranteed to happen (unless the task itself
hangs). The inbox message is only a derived notification with several failure points along the
way. Observing the derived signal without checking the authoritative one makes "lost notification"
indistinguishable from "task not finished".
MAX_CONSECUTIVE_EMPTY_WAITSamplifies the defect. It assumes repeated empty waits mean the LLMis abusing the wait, but when the notification chain is broken, repeated empty waits are a symptom
of the tool's own faulty observation — refusing to wait at that moment only pushes the LLM into a
dead end.
Suggested fix. Change the wait condition to poll
TaskRepositoryfor terminal task statedirectly.
TaskRepository.listTasks(ctx, sessionId, null)already returns all tasks with theirTaskStatus, andTaskStatus.isTerminal()already exists, so no new API is required. Once tasksare terminal, return the result text as the tool's return value so result collection closes within
a single
call()and no longer depends on cross-round injection. Inbox injection can stay as afast path, but should not be the only correctness dependency.
Secondary points:
MAX_CONSECUTIVE_EMPTY_WAITS. If the wait observes real task state, aterminal state is guaranteed to arrive and the rejection is meaningless; if the intent is to
guard against hung tasks, the timeout itself is the better mechanism.
will be injected automatically. Return the actual results, or state the current statusexplicitly, instead of making a promise that will not be kept.
Affected classes:
io.agentscope.harness.agent.tool.WaitAsyncResultsToolio.agentscope.harness.agent.subagent.task.WorkspaceTaskRepository(completion callback registration)