Skip to content

[Bug]: WaitAsyncResultsTool waits on the inbox notification chain instead of task state, stalling the main agent when any link breaks #2791

Description

@sailor-smile

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

  1. 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();
  1. 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.

  1. 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:

  1. 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.
  2. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions