Prerequisites
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
Prerequisites
Background / Description
Stopping a reply in the agent service doesn't stop the tool that is running.
ChatService.runaddsToolOffloadMiddlewareto every chat run, in_chat.py:968-980. Itson_actingruns 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}/interruptcancels the chat-run task, in_cancel_dispatcher.py:227-246. That cancel lands in the middleware'swait_for, andon_actingre-raises it. Nothing cancelsdrain_task. So the toolkit's own handler never runs. It would stop the tool and yield anINTERRUPTEDresult, 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_callscancels its workers and looks for theirINTERRUPTEDend events, in_agent.py:2286-2307. The middleware raises instead, so no such event arrives and the executor returns normally. The calls are stillALLOWED, so the loop runs each of them again while the first runs are still going. The reply endscompleted, and every tool has run twice.Grep,Globand everyFunctionToolare concurrency-safe by default.Without the middleware the same interrupt works, as
tests/agent_interrupt_test.pyshows. #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
INTERRUPTEDresult, as it does without the middleware. No call runs twice.Error Messages
Steps to Reproduce
The real
openaiclient talks to a fake Chat Completions endpoint behindhttpx.MockTransport. The interrupt goes through the service's ownCancelDispatcheron anInMemoryMessageBus, as intests/service_agent_interrupt_test.py.Run it with
python repro.pyon main at78c4ec47. The model callssearchonce, 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 endscompleted.Environment