Skip to content

[Bug]: ToolOffloadMiddleware keeps the tool running after an interrupt, and parallel calls run twice #3045

Description

@zaher-m

Prerequisites

  • I have searched the existing issues and discussions, and this is not a duplicate.
  • This is a bug, not a usage question. (For questions, please use Discussions instead.)

Background / Description

Stopping a reply in the agent service doesn't stop the tool that is running.

ChatService.run adds ToolOffloadMiddleware to every chat run, in _chat.py:968-980. Its on_acting runs the tool in a separate task, drain_task, and waits up to 10 seconds for its output, in _tool_offload_middleware.py:169-215.

POST /sessions/{id}/interrupt cancels the chat-run task, in _cancel_dispatcher.py:227-246. That cancel lands in the middleware's wait_for, and on_acting re-raises it. Nothing cancels drain_task. So the toolkit's own handler never runs. It would stop the tool and yield an INTERRUPTED result, in _toolkit.py:374-388.

Instead the tool runs to the end after the stop. Whatever it does after that point still happens. Its result is dropped, and the agent records the call as interrupted.
With two or more concurrency-safe calls in one round, the stop is lost. _execute_concurrent_tool_calls cancels its workers and looks for their INTERRUPTED end events, in _agent.py:2286-2307. The middleware raises instead, so no such event arrives and the executor returns normally. The calls are still ALLOWED, so the loop runs each of them again while the first runs are still going. The reply ends completed, and every tool has run twice. Grep, Glob and every FunctionTool are concurrency-safe by default.
Without the middleware the same interrupt works, as tests/agent_interrupt_test.py shows. #2767 reports a similar re-run for a middleware that waits before the tool starts. Its PR #2768 stops the second runs here too, but the first runs still finish after the stop.

Expected: stopping the reply cancels the running tool, and the agent gets the toolkit's INTERRUPTED result, as it does without the middleware. No call runs twice.

Error Messages

1 sequential call(s):
  0.0s start q0
  0.3s interrupt
  0.3s reply ended: interrupted
  1.0s finished q0
  model requests: 1
2 parallel call(s):
  0.0s start q0
  0.0s start q1
  0.3s interrupt
  0.3s start q0
  0.3s start q1
  1.0s finished q0
  1.0s finished q1
  1.3s finished q0
  1.3s finished q1
  1.3s reply ended: completed
  model requests: 2

Steps to Reproduce

The real openai client talks to a fake Chat Completions endpoint behind httpx.MockTransport. The interrupt goes through the service's own CancelDispatcher on an InMemoryMessageBus, as in tests/service_agent_interrupt_test.py.

import asyncio
import json
import time
import httpx
from agentscope.agent import Agent
from agentscope.app._manager import (
    BackgroundTaskManager,
    CancelDispatcher,
    ChatRunRegistry,
)
from agentscope.app.message_bus import InMemoryMessageBus, MessageBusKeys
from agentscope.app.middleware import ToolOffloadMiddleware
from agentscope.credential import OpenAICredential
from agentscope.event import ReplyEndEvent
from agentscope.message import TextBlock, UserMsg
from agentscope.model import OpenAIChatModel
from agentscope.tool import FunctionTool, ToolChunk, Toolkit

T0 = time.monotonic()
log = []

def note(text: str) -> None:
    log.append(f"{time.monotonic() - T0:.1f}s {text}")

async def search(query: str) -> ToolChunk:
    """Search the files for a query."""
    note(f"start {query}")
    try:
        await asyncio.sleep(1)
    except asyncio.CancelledError:
        note(f"cancelled {query}")
        raise
    note(f"finished {query}")
    return ToolChunk(content=[TextBlock(text=f"found {query}")])

def fake_openai(n_calls: int, requests: list):
    # The first reply calls the tool n_calls times, the next one is text
    def handler(request: httpx.Request) -> httpx.Response:
        requests.append(request)
        message = {"role": "assistant", "content": "Done."}
        if len(requests) == 1:
            message = {"role": "assistant", "content": None, "tool_calls": [{
                "id": f"call_{i}", "type": "function",
                "function": {"name": "search",
                             "arguments": json.dumps({"query": f"q{i}"})},
            } for i in range(n_calls)]}
        return httpx.Response(200, json={
            "id": "chatcmpl-1", "object": "chat.completion",
            "created": 0, "model": "gpt-4.1",
            "choices": [{"index": 0, "message": message,
                         "finish_reason": "stop"}],
            "usage": {"prompt_tokens": 10, "completion_tokens": 10,
                      "total_tokens": 20},
        })
    return handler

async def run(n_calls: int, parallel: bool) -> None:
    global T0
    log.clear()
    requests = []
    model = OpenAIChatModel(
        credential=OpenAICredential(api_key="sk-test"),
        model="gpt-4.1",
        stream=False,
        client_kwargs={"http_client": httpx.AsyncClient(
            transport=httpx.MockTransport(fake_openai(n_calls, requests)))},
    )
    bus = InMemoryMessageBus()
    bg_manager = BackgroundTaskManager(message_bus=bus)
    registry = ChatRunRegistry()
    agent = Agent(
        name="Friday",
        system_prompt="You are a helpful assistant.",
        model=model,
        toolkit=Toolkit(tools=[FunctionTool(
            search, is_read_only=True, is_concurrency_safe=parallel)]),
        # ChatService.run installs this middleware on every chat run
        middlewares=[ToolOffloadMiddleware(
            bg_manager=bg_manager, message_bus=bus,
            user_id="u", agent_id="a")],
    )
    async def chat_run() -> None:
        async for event in agent.reply_stream(UserMsg("user", "Search.")):
            if isinstance(event, ReplyEndEvent):
                note(f"reply ended: {event.finished_reason}")

    async with bus:
        async with CancelDispatcher(
            message_bus=bus, registry=registry, bg_manager=bg_manager,
        ):
            T0 = time.monotonic()
            registry.spawn(chat_run(), session_id="s1")
            await asyncio.sleep(0.3)
            # What POST /sessions/s1/interrupt does for a running session
            note("interrupt")
            await bus.publish(
                MessageBusKeys.session_interrupt_channel(),
                {"session_id": "s1"},
            )
            await asyncio.sleep(2)

    kind = "parallel" if parallel else "sequential"
    print(f"{n_calls} {kind} call(s):")
    for line in log:
        print("  " + line)
    print(f"  model requests: {len(requests)}")


async def main() -> None:
    await run(1, parallel=False)
    await run(2, parallel=True)


asyncio.run(main())

Run it with python repro.py on main at 78c4ec47. The model calls search once, and in the second run twice in parallel. The tool takes one second, and the interrupt comes after 0.3 seconds. On main the tool is never cancelled. In the parallel run both calls start again right after the interrupt, and the reply ends completed.

Environment

  • AgentScope Version: 2.0.10dev (main at 78c4ec4), and 2.0.9 behaves the same
  • Python Version: 3.12.3, and 3.11.15 behaves the same
  • OS: Linux (aarch64)
  • openai 3.19.2 and httpx 0.28.1 for the reproduction

Activity

  1. added
    triage/verifyingA bot is verifying whether this bug is real
    triage/confirmedVerified: the reported defect exists
    and removed
    triage/verifyingA bot is verifying whether this bug is real
    on Sep 30, 2026
  2. zaher-m commented on Sep 30, 2026

    @zaher-m
    ContributorAuthor

    /assign

  3. github-actions commented on Sep 30, 2026

    @github-actions
    Contributor

    It's yours, @zaher-m. If there is no pull request and no word from you by 2026-10-14 the claim is released so nobody is blocked — a comment here renews it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

BugSomething doesn't worktriage/confirmedVerified: the reported defect exists

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions