Akshay66777's picture
AgentScope Gradio chat app — ZeroGPU-ready
9792ea7 verified
Raw
History Blame Contribute Delete
108 kB
# -*- coding: utf-8 -*-
"""The unified agent class in AgentScope library."""
import asyncio
import inspect
from asyncio import Queue
from copy import deepcopy
from typing import (
Any,
AsyncGenerator,
Sequence,
Literal,
List,
TYPE_CHECKING,
)
import jsonschema
from ._config import ContextConfig, ReActConfig, ModelConfig
from ..state import AgentState
from ._utils import _ToolCallBatch
from .._logging import logger
from .._utils._common import _generate_id, _json_loads_with_repair
from ..event import (
AgentEvent,
ModelCallEndEvent,
ModelCallStartEvent,
ReplyEndEvent,
ReplyStartEvent,
TextBlockDeltaEvent,
TextBlockEndEvent,
TextBlockStartEvent,
ThinkingBlockDeltaEvent,
ThinkingBlockEndEvent,
ThinkingBlockStartEvent,
ToolCallDeltaEvent,
ToolCallEndEvent,
ToolCallStartEvent,
ToolResultDataDeltaEvent,
ToolResultEndEvent,
ToolResultStartEvent,
ToolResultTextDeltaEvent,
RequireUserConfirmEvent,
RequireExternalExecutionEvent,
ExternalExecutionResultEvent,
UserConfirmResultEvent,
DataBlockStartEvent,
DataBlockDeltaEvent,
DataBlockEndEvent,
ExceedMaxItersEvent,
ReplyEndReason,
UserInterruptEvent,
)
from ..exception import AgentOrientedException
from ..model import (
ChatResponse,
ChatUsage,
ChatModelBase,
FinishedReason,
)
from ..message import (
Msg,
AssistantMsg,
SystemMsg,
UserMsg,
TextBlock,
ThinkingBlock,
ToolCallBlock,
ToolResultBlock,
DataBlock,
Base64Source,
URLSource,
ToolCallState,
ToolResultState,
Usage,
HintBlock,
)
from ..tool import (
Toolkit,
ToolChunk,
ToolChoice,
ToolResponse,
)
from ..permission import (
PermissionBehavior,
PermissionEngine,
PermissionDecision,
)
from ..workspace import Offloader, WorkspaceBase
if TYPE_CHECKING:
from ..middleware import MiddlewareBase
else:
MiddlewareBase = Any
class Agent:
"""The agent class."""
def __init__(
self,
name: str,
system_prompt: str,
model: ChatModelBase,
toolkit: Toolkit | None = None,
middlewares: list[MiddlewareBase] | None = None,
state: AgentState | None = None,
offloader: Offloader | None = None,
# The agent configurations
model_config: ModelConfig | None = None,
context_config: ContextConfig | None = None,
react_config: ReActConfig | None = None,
) -> None:
"""Initialize the agent class in AgentScope.
Args:
name (`str`):
The agent identifier.
system_prompt (`str`):
The agent's system prompt. Additional instructions may be
appended to it dynamically during operation.
model (`ChatModelBase`):
The chat model/llm used for this agent.
toolkit (`Toolkit | None`, optional):
The toolkit used for registering tools, MCPs and skills as the
sole source.
middlewares (`list[MiddlewareBase] | None`, optional):
Middlewares applied to the agent to modify its behavior
without altering its source code. Supported hook points
include: reply, reasoning, acting, model call, and system
prompt retrieval.
state (`AgentState | None`, optional):
The agent state. A new state will be created if not provided.
offloader (`Offloader | None`, optional):
The context offloader. If provided, the compressed context and
tool result will be offloaded.
model_config (`ModelConfig | None`, optional):
The additional chat model configuration including fallback
model and retries.
context_config (`ContextConfig | None`, optional):
The context config for context compression and tool result
compression.
react_config (`ReActConfig | None`, optional):
The config for the reasoning-acting loop.
"""
self.name = name
self._system_prompt = system_prompt
self.model = model
self.state = state or AgentState()
self.model_config = model_config or ModelConfig()
self.context_config = context_config or ContextConfig()
self.react_config = react_config or ReActConfig()
# The permission engine
self._engine = PermissionEngine(self.state.permission_context)
# The offloader/workspace
self.offloader = offloader
# ====================================================================
# The Tool-related logics
# ====================================================================
self.toolkit = toolkit or Toolkit()
# ====================================================================
# The Middleware-related attributes
# ====================================================================
# Filter middlewares by implemented hooks (only once)
middlewares = middlewares or []
self._reply_middlewares = [
_ for _ in middlewares if _.is_implemented("on_reply")
]
self._reasoning_middlewares = [
_ for _ in middlewares if _.is_implemented("on_reasoning")
]
self._acting_middlewares = [
_ for _ in middlewares if _.is_implemented("on_acting")
]
self._model_call_middlewares = [
_ for _ in middlewares if _.is_implemented("on_model_call")
]
self._system_prompt_middlewares = [
_ for _ in middlewares if _.is_implemented("on_system_prompt")
]
self._compress_context_middlewares = [
_ for _ in middlewares if _.is_implemented("on_compress_context")
]
# =======================================================================
# Agent public methods
# =======================================================================
async def reply_stream(
self,
inputs: Msg
| list[Msg]
| UserConfirmResultEvent
| UserInterruptEvent
| ExternalExecutionResultEvent
| None = None,
) -> AsyncGenerator[AgentEvent, None]:
"""Reply to the given inputs and stream agent events.
Args:
inputs (`Msg | list[Msg] | UserConfirmResultEvent | \
UserInterruptEvent | ExternalExecutionResultEvent | None`, \
optional):
The inputs that trigger this reply. See :meth:`reply` for
the full list of accepted variants.
Yields:
`AgentEvent`:
Streamed events produced during the reply.
.. note:: If requiring outside interaction for multiple tool calls
and only receive partial confirmation or execution results, the
agent won't re-send the requiring events for the unconfirmed
or unexecuted tool calls.
"""
async for chunk in self._reply(inputs=inputs):
if not isinstance(chunk, Msg):
yield chunk
async def reply(
self,
inputs: Msg
| list[Msg]
| UserConfirmResultEvent
| UserInterruptEvent
| ExternalExecutionResultEvent
| None = None,
) -> Msg:
"""Reply to the given inputs, consuming all streamed events.
Args:
inputs (`Msg | list[Msg] | UserConfirmResultEvent | \
UserInterruptEvent | ExternalExecutionResultEvent | None`, \
optional):
The inputs that trigger this reply. It can be:
- a single `Msg` or a list of `Msg` objects to start a new
reply,
- a `UserConfirmResultEvent` or
`ExternalExecutionResultEvent` to continue from the
outside interaction required by the previous reply,
- a `UserInterruptEvent` to abort a parked reply — the
agent closes all pending tool calls with an interrupted
tool result and ends the reply without entering the
reasoning-acting loop,
- `None` if there is nothing new to feed in (e.g. just
continue from the current state).
Returns:
`Msg`:
A final reply message.
"""
final_msg: Msg | None = None
async for evt_or_msg in self._reply(inputs=inputs):
if isinstance(evt_or_msg, Msg):
final_msg = evt_or_msg
if final_msg is None:
raise RuntimeError("Agent did not produce a final message.")
return final_msg
async def observe(self, msgs: Msg | list[Msg] | None = None) -> None:
"""Receive external observation message(s) and save them into
context."""
await self._handle_incoming_messages(msgs)
async def compress_context(
self,
context_config: ContextConfig | None = None,
instructions: HintBlock | None = None,
) -> None:
"""Compress the agent's context if the token count exceeds the
threshold.
Args:
context_config (`ContextConfig | None`, optional):
If provided, compress the context with the given context
config. Otherwise, use the default context config in the
agent.
instructions (`HintBlock | None`, optional):
Optional hints or instructions injected into the compression
context to guide the summarization behavior.
"""
if not self._compress_context_middlewares:
await self._compress_context_impl(
context_config=context_config,
instructions=instructions,
)
else:
async def execute_chain(
index: int = 0,
context_config: ContextConfig | None = context_config,
instructions: HintBlock | None = instructions,
) -> None:
"""Execute the compress_context middleware chain."""
if index >= len(self._compress_context_middlewares):
await self._compress_context_impl(
context_config=context_config,
instructions=instructions,
)
else:
mw = self._compress_context_middlewares[index]
input_kwargs = {
"context_config": context_config,
"instructions": instructions,
}
async def next_handler(**kwargs: Any) -> None:
await execute_chain(
index + 1,
**{**input_kwargs, **kwargs},
)
await mw.on_compress_context(
agent=self,
input_kwargs=input_kwargs,
next_handler=next_handler,
)
await execute_chain()
async def _compress_context_impl(
self,
context_config: ContextConfig | None = None,
instructions: HintBlock | None = None,
) -> None:
"""Compress the agent's context if the token count exceeds the
threshold.
Args:
context_config (`ContextConfig | None`, optional):
If provided, compress the context with the given context
config. Otherwise, use the default context config in the
agent.
instructions (`HintBlock | None`, optional):
Optional hints or instructions injected into the compression
context to guide the summarization behavior.
"""
cfg: ContextConfig = context_config or self.context_config
# Count the current tokens
kwargs = await self._prepare_model_input()
estimated_tokens = await self.model.count_tokens(**kwargs)
# Skip if no compression is needed
threshold = cfg.trigger_ratio * self.model.context_size
if estimated_tokens < threshold:
return
logger.info(
"[AGENT %s]: Current token count %d exceeds the threshold %d, "
"activating compression.",
self.name,
int(estimated_tokens),
int(threshold),
)
if len(self.state.context) == 0:
# The system prompt and the summary (if exists) exceeds the
# threshold, which cannot be compressed, raise the error to the
# developer!
suffix = ""
if self.state.summary:
suffix = "and the compression summary "
raise RuntimeError(
f"The system prompt {suffix}exceed(s) the compression "
f"threshold ({threshold} tokens), cannot be compressed.",
)
# Split the context into the ones to be compressed, and the others to
# be reserved
tools = kwargs.get("tools", [])
(
msgs_to_compress,
msgs_to_reserve,
) = await self._split_context_for_compression(
cfg.reserve_ratio * self.model.context_size,
tools,
)
if len(msgs_to_compress) == 0:
# The reserve ratio is too large so that although it exceeds the
# trigger threshold, the context to be compressed is empty
# Fallback by lowering the reserve ratio to compress more context.
logger.warning(
"The reserve ratio %.2f is too large to compress any context."
"Lower the reserve ratio to 0 as a fallback.",
cfg.reserve_ratio,
)
(
msgs_to_compress,
msgs_to_reserve,
) = await self._split_context_for_compression(
0 * self.model.context_size,
tools,
)
# The msgs to be compressed cannot be empty here, unless the
# system prompt and summary (if any) already exceed the context
# length, which we have handled before.
# Prepare the messages to compress
msgs_system = [
SystemMsg(
name="system",
content=await self._get_system_prompt(),
),
]
if self.state.summary:
msgs_system.append(UserMsg("user", self.state.summary))
instruction_msgs: list[Msg] = []
if instructions is not None:
instruction_msgs.append(
AssistantMsg(
name=self.name,
content=[instructions],
),
)
messages = (
msgs_system
+ msgs_to_compress
+ instruction_msgs
+ [
UserMsg(name="user", content=cfg.compression_prompt),
]
)
# The compression prompt may exceed the context length, here we mark
# the overflow by a bool flag
compression_tool_schema = [
{
"type": "function",
"function": {
"name": "generate_structured_output",
"description": "Call this function to generate "
"structured output required by "
"the user.",
"parameters": cfg.summary_schema,
},
},
]
context_overflow = False
estimated_compression_tokens = await self.model.count_tokens(
messages,
compression_tool_schema,
)
if estimated_compression_tokens > self.model.context_size:
logger.warning(
"The current context length exceeds the model's context "
"length (%d tokens), the compression maybe failed due to "
"insufficient reserved context for compression.",
self.model.context_size,
)
context_overflow = True
# Compress the messages
try:
res = await self.model.generate_structured_output(
messages=messages,
structured_model=cfg.summary_schema,
)
except Exception as e:
if context_overflow:
logger.warning(
"Failed to compress context, which may be caused by "
"insufficient reserved context for compression. "
"Trying to compress by removing the oldest context.",
)
for i in range(1, len(msgs_to_compress) + 1):
messages = (
msgs_system
+ msgs_to_compress[i:]
+ instruction_msgs
+ [
UserMsg(
name="user",
content=cfg.compression_prompt,
),
]
)
estimated_compression_tokens = (
await self.model.count_tokens(
messages,
compression_tool_schema,
)
)
# Considering trigger_ratio <= 0.9, at least reserve 10%
# tokens for compression response
if (
estimated_compression_tokens
< self.model.context_size * cfg.trigger_ratio
):
break
res = await self.model.generate_structured_output(
messages=messages,
structured_model=cfg.summary_schema,
)
else:
raise e from None
if res.finished_reason == FinishedReason.INTERRUPTED:
logger.warning(
"The context compression was interrupted and skipped. ",
)
raise asyncio.CancelledError()
# Update the summary
async def _apply_change() -> None:
"""Apply the context change with interruption protection."""
new_summary = cfg.summary_template.format(**res.content)
if self.offloader:
path = await self.offloader.offload_context(
self.state.session_id,
msgs=msgs_to_compress,
)
new_summary += (
f"\n<system-reminder>The compressed context is offloaded "
f"to '{path}', you can refer to it when needed."
f"</system-reminder>"
)
# Protected from interruption
await self._clear_unreserved_read_cache(msgs_to_reserve)
# Update the context
self.state.summary = new_summary
self.state.context = msgs_to_reserve
logger.info(
"[AGENT %s]: The context compression finished.",
self.name,
)
apply_task = asyncio.create_task(_apply_change())
try:
await asyncio.shield(apply_task)
except asyncio.CancelledError:
await apply_task
raise
# ======================================================================
# Agent core methods, including _reply, _reasoning, _acting, etc.
# ======================================================================
async def _reply(
self,
inputs: Msg
| list[Msg]
| UserConfirmResultEvent
| UserInterruptEvent
| ExternalExecutionResultEvent
| None = None,
) -> AsyncGenerator[AgentEvent | Msg, None]:
"""Reply entry point (maybe wrapped by middleware)."""
if not self._reply_middlewares:
async for item in self._reply_impl(inputs=inputs):
yield item
else:
async def execute_chain(
index: int = 0,
inputs: Msg
| list[Msg]
| UserConfirmResultEvent
| UserInterruptEvent
| ExternalExecutionResultEvent
| None = inputs,
) -> AsyncGenerator[AgentEvent | Msg, None]:
if index >= len(self._reply_middlewares):
async for item in self._reply_impl(inputs=inputs):
yield item
else:
mw = self._reply_middlewares[index]
input_kwargs = {"inputs": inputs}
async def next_handler(
**kwargs: Any,
) -> AsyncGenerator[AgentEvent | Msg, None]:
async for item in execute_chain(
index + 1,
**{**input_kwargs, **kwargs},
):
yield item
async for item in mw.on_reply(
agent=self,
input_kwargs=input_kwargs,
next_handler=next_handler,
):
yield item
async for item in execute_chain():
yield item
async def _close_unfinished_tool_calls(
self,
) -> AsyncGenerator[
ToolResultStartEvent | ToolResultTextDeltaEvent | ToolResultEndEvent,
None,
]:
"""Close the unfinished tool calls on interruption, so the next
input will be handled normally."""
if not self.state.context:
return
last_msg = self.state.context[-1]
if last_msg.role != "assistant" or last_msg.name != self.name:
return
# Searching for tool calls that requires user confirmation or external
# execution without tool results
awaiting_tool_calls: dict = {}
for index, block in enumerate(last_msg.content):
if isinstance(block, ToolCallBlock):
awaiting_tool_calls[block.id] = index
elif isinstance(block, ToolResultBlock):
awaiting_tool_calls.pop(block.id, None)
interruption_message = (
"<system-reminder>The tool call has been interrupted by "
"the user.</system-reminder>"
)
for index in awaiting_tool_calls.values():
# First update the status
assert isinstance(last_msg.content[index], ToolCallBlock)
last_msg.content[index].state = ToolCallState.FINISHED
# Emit the full tool_result lifecycle (START → DELTA → END)
yield ToolResultStartEvent(
reply_id=self.state.reply_id,
tool_call_id=last_msg.content[index].id,
tool_call_name=last_msg.content[index].name,
)
yield ToolResultTextDeltaEvent(
reply_id=self.state.reply_id,
tool_call_id=last_msg.content[index].id,
delta=interruption_message,
)
yield ToolResultEndEvent(
reply_id=self.state.reply_id,
tool_call_id=last_msg.content[index].id,
state=ToolResultState.INTERRUPTED,
)
last_msg.content.append(
ToolResultBlock(
id=last_msg.content[index].id,
name=last_msg.content[index].name,
output=interruption_message,
state=ToolResultState.INTERRUPTED,
),
)
async def _reply_impl(
self,
inputs: Msg
| list[Msg]
| UserConfirmResultEvent
| UserInterruptEvent
| ExternalExecutionResultEvent
| None = None,
) -> AsyncGenerator[AgentEvent | Msg, None]:
"""Core reply logic."""
end_event: ReplyEndEvent | None = None
try:
# Dispatch the unified inputs by type into the legacy local
# variables
event: (
UserConfirmResultEvent
| UserInterruptEvent
| ExternalExecutionResultEvent
| None
)
msgs: Msg | list[Msg] | None
if isinstance(
inputs,
(
UserConfirmResultEvent,
UserInterruptEvent,
ExternalExecutionResultEvent,
),
):
event = inputs
msgs = None
else:
event = None
msgs = inputs
# Parked-interrupt short-circuit: only signal an INTERRUPTED
# end when there is actual HITL work to close; otherwise the
# session is effectively idle and the call is a silent no-op.
# ``finally`` reuses the CancelledError cleanup path when
# ``end_event`` is set — no reasoning-acting loop either way.
if isinstance(inputs, UserInterruptEvent):
if self.state.has_awaiting_tool_calls(self.name):
end_event = ReplyEndEvent(
session_id=self.state.session_id,
reply_id=self.state.reply_id,
finished_reason=ReplyEndReason.INTERRUPTED,
)
return
# ===================================================================
# Step 1: Checking agent input:
# - if incoming event and agent is waiting for an event
# - if event is None and agent is not waiting for an event
# ===================================================================
is_awaiting = await self._check_incoming_event(event)
# ===================================================================
# Step 2: Handling agent event if applicable
# - yield tool result events for the denied tool calls, or
# - update the reply state as a new reply process
# ===================================================================
if is_awaiting:
async for evt in self._handle_incoming_event(event):
yield evt
else:
await self._handle_incoming_messages(msgs)
# Update the context with the incoming message and state
self.state.reply_id = _generate_id()
self.state.cur_iter = 0
yield ReplyStartEvent(
session_id=self.state.session_id,
reply_id=self.state.reply_id,
name=self.name,
)
# =================================================================
# Step 3: Enter the reasoning-acting loop until reaching max_iters
# or no more tool calls to execute
# =================================================================
while self.state.cur_iter < self.react_config.max_iters:
# =============================================================
# Step 3.1:
# =============================================================
action, data = self._check_next_action()
if action == "exit" and isinstance(data, Msg):
yield data
return
# =============================================================
# Step 3.2: Execute reasoning if no more tools to be executed
# =============================================================
if action == "reasoning":
# Compressed the memory if needed before reasoning
await self.compress_context()
# Perform reasoning
interrupted = False
async for evt in self._reasoning():
# Exit the loop when no tool calls generated and the
# reply message is generated
if isinstance(evt, Msg):
end_event = ReplyEndEvent(
session_id=self.state.session_id,
reply_id=self.state.reply_id,
finished_reason=ReplyEndReason.COMPLETED,
)
yield evt
return
elif isinstance(evt, ModelCallEndEvent):
interrupted = (
evt.finished_reason
== FinishedReason.INTERRUPTED
)
yield evt
if interrupted:
end_event = ReplyEndEvent(
session_id=self.state.session_id,
reply_id=self.state.reply_id,
finished_reason=ReplyEndReason.INTERRUPTED,
)
return
# =============================================================
# Step 3.3: Getting batches of tool calls to be executed
# - If not, finish loop by yielding RunFinishedEvent and exit
# - Otherwise, execute by batch and continue the loop
# =============================================================
for batch in await self._batch_tool_calls():
if batch.type == "sequential":
evt_generator = self._execute_sequential_tool_calls(
batch.tool_calls,
)
elif batch.type == "concurrent":
evt_generator = self._execute_concurrent_tool_calls(
batch.tool_calls,
)
else:
raise ValueError(
f"Invalid batch type: {batch.type}",
)
break_execution_for_hitl = False
break_execution_for_interruption = False
break_message = ""
async for evt in evt_generator:
yield evt
if isinstance(
evt,
(
RequireUserConfirmEvent,
RequireExternalExecutionEvent,
),
):
break_execution_for_hitl = True
break_message = (
"Waiting for tool calls to be confirmed or "
"executed from outside ..."
)
elif (
isinstance(evt, ToolResultEndEvent)
and evt.state == ToolResultState.INTERRUPTED
):
# Handle the interruption event
break_execution_for_interruption = True
if break_execution_for_interruption:
end_event = ReplyEndEvent(
session_id=self.state.session_id,
reply_id=self.state.reply_id,
finished_reason=ReplyEndReason.INTERRUPTED,
)
return
# If it requires outside interaction stop executing the
# next batch and wait for outside trigger events
if break_execution_for_hitl:
yield AssistantMsg(
id=self.state.reply_id,
name=self.name,
content=break_message,
)
return
# Update iteration count after each round of reasoning-acting
self.state.cur_iter += 1
# =================================================================
# Step 4: Handling the max iteration executed
# =================================================================
yield ExceedMaxItersEvent(
reply_id=self.state.reply_id,
name=self.name,
)
logger.warning(
"Agent %s exceeds the max iteration numbers %d. "
"Stop the react loop.",
self.name,
self.react_config.max_iters,
)
# Mirror the normal-exit path so subscribers (e.g. SSE clients
# waiting on a terminal event) don't hang when the loop bails
# out on max_iters.
end_event = ReplyEndEvent(
session_id=self.state.session_id,
reply_id=self.state.reply_id,
finished_reason=ReplyEndReason.EXCEED_MAX_ITERS,
)
yield AssistantMsg(
id=self.state.reply_id,
name=self.name,
content="Executed maximum iterations of reasoning-acting loop "
"without finishing the task.",
)
except asyncio.CancelledError:
# Handle the CancelledError within the _reply_impl for the
# agent middlewares
end_event = ReplyEndEvent(
session_id=self.state.session_id,
reply_id=self.state.reply_id,
finished_reason=ReplyEndReason.INTERRUPTED,
)
if self.react_config.interruption_raise_cancelled_error:
raise
finally:
if end_event is not None:
if end_event.finished_reason == ReplyEndReason.INTERRUPTED:
# Handle the context when interruption
async for _ in self._close_unfinished_tool_calls():
yield _
# A fallback msg object
yield AssistantMsg(
id=self.state.reply_id,
name=self.name,
content=self.react_config.interruption_message,
)
yield end_event
async def _reasoning(
self,
tool_choice: ToolChoice | None = None,
) -> AsyncGenerator[
ModelCallStartEvent
| TextBlockStartEvent
| TextBlockDeltaEvent
| TextBlockEndEvent
| ToolCallBlock
| ToolCallDeltaEvent
| ToolCallEndEvent
| ThinkingBlockStartEvent
| ThinkingBlockDeltaEvent
| ThinkingBlockEndEvent
| DataBlockStartEvent
| DataBlockDeltaEvent
| DataBlockEndEvent
| ModelCallEndEvent
| Msg,
None,
]:
"""Reasoning entry point (maybe wrapped by middleware)."""
if not self._reasoning_middlewares:
async for item in self._reasoning_impl(tool_choice=tool_choice):
yield item
else:
async def execute_chain(
index: int = 0,
tool_choice: ToolChoice = tool_choice,
) -> AsyncGenerator:
if index >= len(self._reasoning_middlewares):
async for item in self._reasoning_impl(
tool_choice=tool_choice,
):
yield item
else:
mw = self._reasoning_middlewares[index]
input_kwargs = {"tool_choice": tool_choice}
async def next_handler(**kwargs: Any) -> AsyncGenerator:
async for item in execute_chain(
index + 1,
**{**input_kwargs, **kwargs},
):
yield item
async for item in mw.on_reasoning(
agent=self,
input_kwargs=input_kwargs,
next_handler=next_handler,
):
yield item
async for item in execute_chain():
yield item
async def _reasoning_impl(
self,
tool_choice: ToolChoice | None = None,
) -> AsyncGenerator[
ModelCallStartEvent
| TextBlockStartEvent
| TextBlockDeltaEvent
| TextBlockEndEvent
| ToolCallBlock
| ToolCallDeltaEvent
| ToolCallEndEvent
| ThinkingBlockStartEvent
| ThinkingBlockDeltaEvent
| ThinkingBlockEndEvent
| DataBlockStartEvent
| DataBlockDeltaEvent
| DataBlockEndEvent
| ModelCallEndEvent
| Msg,
None,
]:
"""Core reasoning logic. Yields chunks with is_last flag."""
yield ModelCallStartEvent(
reply_id=self.state.reply_id,
model_name=self.model.model,
)
# Get the input arguments for the chat model, including messages and
# tools
kwargs = await self._prepare_model_input()
# Call the chat model
res = await self._call_model(
tool_choice=tool_choice,
**kwargs,
)
block_ids: dict = {
"text": None,
"thinking": None,
"tools": [],
"data": [],
}
completed_response: ChatResponse | None = None
# Check if res is an async generator (streaming response)
if inspect.isasyncgen(res):
async for chunk in res:
# Save the last chunk with completed response
if chunk.is_last:
completed_response = chunk
else:
# Convert the chunk into events
async for evt in self._convert_chat_response_to_event(
block_ids,
chunk,
):
yield evt
elif isinstance(res, ChatResponse):
completed_response = res
async for evt in self._convert_chat_response_to_event(
block_ids,
res,
):
yield evt
# Send the ended events for the remaining active blocks
if block_ids["text"] is not None:
yield TextBlockEndEvent(
reply_id=self.state.reply_id,
block_id=block_ids["text"],
)
if block_ids["thinking"] is not None:
yield ThinkingBlockEndEvent(
reply_id=self.state.reply_id,
block_id=block_ids["thinking"],
)
for tool_call_id in block_ids["tools"]:
yield ToolCallEndEvent(
reply_id=self.state.reply_id,
tool_call_id=tool_call_id,
)
for data_block_id in block_ids["data"]:
yield DataBlockEndEvent(
reply_id=self.state.reply_id,
block_id=data_block_id,
)
# Guard against empty or interrupted streaming responses.
if completed_response is None:
raise RuntimeError(
"Model returned an empty streaming response: no is_last=True"
" chunk was received. The model call may have been "
"interrupted mid-stream (network dropout, timeout, or model "
"bug).",
)
# Send the model call ended event with usage if available
yield ModelCallEndEvent(
reply_id=self.state.reply_id,
input_tokens=completed_response.usage.input_tokens
if completed_response.usage
else 0,
output_tokens=completed_response.usage.output_tokens
if completed_response.usage
else 0,
finished_reason=completed_response.finished_reason,
)
self._save_to_context(
list(completed_response.content),
completed_response.usage,
)
# If no tool call is generated, return the final message directly
if (
completed_response.finished_reason != FinishedReason.INTERRUPTED
and not any(
isinstance(_, ToolCallBlock)
for _ in completed_response.content
)
):
last_ctx = self._get_last_msg()
final_usage = (
Usage(
input_tokens=last_ctx.usage.input_tokens,
output_tokens=last_ctx.usage.output_tokens,
)
if last_ctx is not None and last_ctx.usage is not None
else None
)
yield AssistantMsg(
id=self.state.reply_id,
name=self.name,
# Text only response message
content=list(completed_response.content),
usage=final_usage,
)
async def _check_incoming_event(
self,
event: UserConfirmResultEvent | ExternalExecutionResultEvent | None,
) -> bool:
"""Check if the agent is waiting for the incoming event, if no, raise
error.
Args:
event (`UserConfirmResultEvent | ExternalExecutionResultEvent \
| None`):
The incoming event to be checked.
Raises:
`ValueError`:
If the agent is not waiting for the incoming event, or the
event is not valid.
Returns:
`bool`:
If the agent is waiting for the incoming event, that means
this reply calling continues from the previous one. If not,
the reply id and iteration count should be updated for the new
reply.
"""
awaiting_confirmations = []
awaiting_external_executions = []
last_msg = self._get_last_msg()
if last_msg:
# The completed tool call ids
tool_result_ids = [
_.id for _ in last_msg.get_content_blocks("tool_result")
]
for tool_call in last_msg.get_content_blocks("tool_call"):
if tool_call.state == ToolCallState.ASKING:
awaiting_confirmations.append(tool_call.id)
elif (
tool_call.state == ToolCallState.SUBMITTED
and tool_call.id not in tool_result_ids
):
# submitted but no result yet, i.e. external execution
awaiting_external_executions.append(tool_call.id)
# No incoming event but needed
if event is None and (
awaiting_confirmations or awaiting_external_executions
):
raise ValueError(
f"Agent is waiting for {len(awaiting_confirmations)} tool "
f"calls and external execution results for "
f"{len(awaiting_external_executions)} tool calls, "
f"but received no event.",
)
if isinstance(event, UserConfirmResultEvent):
if not awaiting_confirmations:
raise ValueError(
f"Agent is not waiting for user confirmation, "
f"but received UserConfirmResultEvent: {event}",
)
# Given event, required but not match
extra_ids = set(
_.tool_call.id for _ in event.confirm_results
) - set(awaiting_confirmations)
if extra_ids:
raise ValueError(
f"Received UserConfirmResultEvent with tool call ids "
f"{extra_ids} that are not waiting for confirmation.",
)
if isinstance(event, ExternalExecutionResultEvent):
if not awaiting_external_executions:
raise ValueError(
f"Agent is not waiting for external execution result, "
f"but received ExternalExecutionResultEvent: {event}",
)
extra_ids = set(_.id for _ in event.execution_results) - set(
awaiting_external_executions,
)
if extra_ids:
raise ValueError(
f"Received ExternalExecutionResultEvent with tool call "
f"ids {extra_ids} that are not waiting for external "
f"execution results.",
)
return event is not None
async def _handle_incoming_event(
self,
event: UserConfirmResultEvent | ExternalExecutionResultEvent | None,
) -> AsyncGenerator[
ToolResultStartEvent
| ToolResultTextDeltaEvent
| ToolResultDataDeltaEvent
| ToolResultEndEvent,
None,
]:
"""Handle the incoming event and update the context accordingly.
Args:
event (`UserConfirmResultEvent | ExternalExecutionResultEvent \
| None`):
The incoming event to be handled.
Yields:
`ToolResultStartEvent \
| ToolResultTextDeltaEvent \
| ToolResultDataDeltaEvent \
| ToolResultEndEvent`:
The events generated during the handling of the incoming event.
"""
# Return directly if no event
if event is None or len(self.state.context) == 0:
return
if isinstance(event, UserConfirmResultEvent):
# The confirmed tool calls
confirmed_tool_calls = {
_.tool_call.id: _ for _ in event.confirm_results
}
# Update the state with the confirmed tool calls
last_msg = self.state.context[-1]
for tool_call in last_msg.get_content_blocks("tool_call"):
if len(confirmed_tool_calls) == 0:
break
if tool_call.id in confirmed_tool_calls:
confirmation = confirmed_tool_calls[tool_call.id]
if confirmation.confirmed:
# Update state and wait for execution in the next step
self._update_tool_call_state(
tool_call.id,
ToolCallState.ALLOWED,
)
# Update name and input in case user modification is
# allowed
tool_call.name = confirmation.tool_call.name
tool_call.input = confirmation.tool_call.input
# Update the permission rule if accepted
if confirmation.rules:
for rule in confirmation.rules:
self._engine.add_rule(rule)
else:
# Update the state to deny and handling
async for evt in self._handle_error_tool_call(
tool_call,
message=(
"<system-reminder>The execution of tool "
f'"{tool_call.name}" is denied by user!'
"</system-reminder>"
),
state=ToolResultState.DENIED,
):
yield evt
# Delete for quick lookup and later processing
confirmed_tool_calls.pop(tool_call.id)
elif isinstance(event, ExternalExecutionResultEvent):
# Directly append the execution results into context
for tool_result in event.execution_results:
async for evt in self._convert_tool_chunk_to_event(
tool_result.id,
tool_result.output,
):
yield evt
yield ToolResultEndEvent(
reply_id=self.state.reply_id,
tool_call_id=tool_result.id,
state=tool_result.state,
metadata=tool_result.metadata,
)
self._save_to_context([tool_result])
# Update the state according to the execution result state
self._update_tool_call_state(
tool_result.id,
ToolCallState.FINISHED,
)
else:
raise ValueError(f"Invalid event type: {event}")
async def _handle_incoming_messages(
self,
msgs: Msg | list[Msg] | None,
) -> None:
"""Check and handle the incoming messages before the reasoning-acting
loop."""
if msgs:
copied_msgs: list = deepcopy(msgs)
if isinstance(copied_msgs, Msg):
copied_msgs = [copied_msgs]
for msg in copied_msgs:
if (
not isinstance(msg, Msg)
or msg.role == "system"
or msg.has_content_blocks(
["tool_call", "tool_result", "thinking"],
)
):
raise ValueError(
f"Invalid message in the input: {msg}. "
f"The message should be a Msg object with "
f"role 'user' or 'assistant', "
f"and should not contain tool calls, "
f"tool results or thinking blocks.",
)
self.state.context.append(msg)
async def _batch_tool_calls(self) -> list[_ToolCallBatch]:
"""Batch the tool calls into a sequence of batches that should be
executed **sequentially** or **concurrently** according to the tool
properties `is_concurrency_safe` and `is_read_only`.
"""
# All tool calls that haven't the corresponding results in the context
tool_calls = self._get_executable_tool_calls()
# Batch the tool calls according to whether they can be executed
# concurrently or not
batches: list[_ToolCallBatch] = []
for tool_call in tool_calls:
tool = await self.toolkit.get_tool(tool_call.name)
# Treat unregistered or unavailable tools as concurrent tools since
# it will not generate side effects and be blocked with acting
if tool is None or tool.is_concurrency_safe:
if len(batches) == 0 or batches[-1].type != "concurrent":
batches.append(
_ToolCallBatch(
type="concurrent",
tool_calls=[tool_call],
),
)
else:
batches[-1].tool_calls.append(tool_call)
else:
if len(batches) == 0 or batches[-1].type != "sequential":
batches.append(
_ToolCallBatch(
type="sequential",
tool_calls=[tool_call],
),
)
else:
batches[-1].tool_calls.append(tool_call)
return batches
async def _execute_sequential_tool_calls(
self,
tool_calls: list[ToolCallBlock],
) -> AsyncGenerator[
RequireUserConfirmEvent
| RequireExternalExecutionEvent
| ToolResultStartEvent
| ToolResultTextDeltaEvent
| ToolResultDataDeltaEvent
| ToolResultEndEvent,
None,
]:
"""Execute the given tool calls sequentially and yield the events.
If "RequireUserConfirmEvent" or "RequireExternalExecutionEvent" is
yielded during the execution, the execution will be paused in the
sequential mode and wait for the outside trigger events.
Args:
tool_calls (`list[ToolCallBlock]`):
The tool calls to be executed sequentially.
Yields:
`RequireUserConfirmEvent \
| RequireExternalExecutionEvent \
| ToolResultStartEvent \
| ToolResultTextDeltaEvent \
| ToolResultDataDeltaEvent \
| ToolResultEndEvent`:
The events generated during the execution of the tool calls.
"""
break_execution = False
for tool_call in tool_calls:
async for evt in self._execute_tool_call(tool_call):
yield evt
if isinstance(
evt,
(
RequireUserConfirmEvent,
RequireExternalExecutionEvent,
),
) or (
isinstance(evt, ToolResultEndEvent)
and evt.state == ToolResultState.INTERRUPTED
):
break_execution = True
break
if break_execution:
break
async def _execute_concurrent_tool_calls(
self,
tool_calls: list[ToolCallBlock],
) -> AsyncGenerator[
RequireUserConfirmEvent
| RequireExternalExecutionEvent
| ToolResultStartEvent
| ToolResultTextDeltaEvent
| ToolResultDataDeltaEvent
| ToolResultEndEvent,
None,
]:
"""Execute the given tool calls concurrently and yield the events.
All tool calls are executed concurrently. If one or more tool calls
fail, the remaining ones are **not** cancelled and will run to
completion. After all tool calls finish, every exception is collected
and re-raised together as an :py:exc:`ExceptionGroup` so the caller
can inspect each failure individually.
The event stream is guaranteed to be complete: the loop exits only
after a sentinel value placed by the gather task is received, which
means every ``queue.put`` from every worker has already finished
before the generator returns.
If the caller task is cancelled from outside, the concurrent worker
tasks are cancelled explicitly (to avoid orphan tasks), any events
already queued by the workers (including interruption chunks emitted
by ``toolkit.call_tool`` when it catches ``CancelledError``) are
flushed to the caller, and the generator returns normally. The
caller is expected to detect the interruption via the flushed
``ToolResultEndEvent(state=INTERRUPTED)`` events, mirroring the
event-based propagation used by
:meth:`_execute_sequential_tool_calls`.
Args:
tool_calls (`list[ToolCallBlock]`):
The tool calls to be executed concurrently.
Yields:
`RequireUserConfirmEvent \
| RequireExternalExecutionEvent \
| ToolResultStartEvent \
| ToolResultTextDeltaEvent \
| ToolResultDataDeltaEvent \
| ToolResultEndEvent`:
The events generated during the execution of the tool calls.
Raises:
`ExceptionGroup`:
Raised after all tool calls finish when one or more of them
raised an exception. Each individual exception is included in
the group.
"""
# A sentinel object that signals all worker tasks have finished and
# all events have already been put into the queue.
sentinel = object()
# Create a queue to collect events from all concurrent workers.
queue: Queue = Queue()
async def _run_all() -> list[BaseException | None]:
"""Run all tool calls concurrently and push the sentinel when done.
Returns:
`list[BaseException | None]`:
One entry per tool call. Each entry is either ``None``
(success) or the exception raised by that tool call.
"""
# return_exceptions=True keeps all tasks running even when some
# fail, and returns exceptions as values instead of re-raising.
results = await asyncio.gather(
*[self._into_queue(tc, queue) for tc in tool_calls],
return_exceptions=True,
)
# The sentinel is placed AFTER gather returns, which guarantees
# that every queue.put inside _into_queue has already completed.
await queue.put(sentinel)
return results # type: ignore[return-value]
gather_task = asyncio.create_task(_run_all())
try:
# Drain the queue until the sentinel is encountered.
while True:
event = await queue.get()
if event is sentinel:
break
yield event
except asyncio.CancelledError:
# Cancel the gather tasks, which will be handled within the toolkit
gather_task.cancel()
try:
await gather_task
except asyncio.CancelledError:
pass
while True:
try:
event = queue.get_nowait()
except asyncio.QueueEmpty:
break
if event is sentinel:
continue
yield event
# Consume the cancel so this generator returns normally. The
# caller relies on the flushed ``ToolResultEndEvent(state=
# INTERRUPTED)`` events, not on the exception, to detect the
# interruption — mirroring the event-based propagation used by
# :meth:`_execute_sequential_tool_calls`.
asyncio.current_task().uncancel()
return
# All tasks are done at this point; collect and re-raise exceptions.
results = await gather_task
exceptions = [r for r in results if isinstance(r, Exception)]
if exceptions:
raise ExceptionGroup(
"One or more tool calls raised an exception",
exceptions,
)
async def _into_queue(
self,
tool_call: ToolCallBlock,
queue: Queue,
) -> None:
"""Execute a single tool call and forward every event into *queue*.
Args:
tool_call (`ToolCallBlock`):
The tool call to execute.
queue (`Queue`):
The shared async queue that collects events from all
concurrent workers.
"""
async for evt in self._execute_tool_call(tool_call):
await queue.put(evt)
async def _execute_tool_call(
self,
tool_call: ToolCallBlock,
) -> AsyncGenerator[
RequireUserConfirmEvent
| RequireExternalExecutionEvent
| ToolResultStartEvent
| ToolResultTextDeltaEvent
| ToolResultDataDeltaEvent
| ToolResultEndEvent,
None,
]:
"""Execute a single tool call with permission checking and context
management.
This method handles the full tool call lifecycle: input validation,
permission checking, event emission, and context writes. The raw
tool execution (``toolkit.call_tool``) is delegated to
:meth:`_acting`, which is the hook point for ``on_acting``
middleware.
Args:
tool_call (`ToolCallBlock`):
The tool call block to be executed.
Yields:
`RequireUserConfirmEvent \
| RequireExternalExecutionEvent \
| ToolResultStartEvent \
| ToolResultTextDeltaEvent \
| ToolResultDataDeltaEvent \
| ToolResultEndEvent`:
The events generated during the tool call execution.
"""
# ===================================================================
# Step 1: Check and parse the tool call input:
# - if failed, directly return the error message to the agent
# - if success, continue to permission checking and tool execution
# ===================================================================
try:
# Check if the tool is available
tool = await self.toolkit.check_tool_available(
tool_call.name,
self.state.tool_context.activated_groups,
)
# Try to parse the input with the tool schema
parsed_input = _json_loads_with_repair(
tool_call.input,
tool.input_schema,
)
# Validate the parsed input with the tool schema
# TODO: Maybe some logic to mix the validation error in runtime
try:
jsonschema.validate(parsed_input, tool.input_schema)
except jsonschema.ValidationError as e:
raise AgentOrientedException(
f"Input validation failed for tool '{tool_call.name}': "
f"{e.message}",
) from e
# The exceptions that
# - cannot found tool
# - tool not available
# - input parsing failure
except AgentOrientedException as e:
async for evt in self._handle_error_tool_call(
tool_call,
e.message,
state=ToolResultState.ERROR,
):
yield evt
return
# ===================================================================
# Step 2: Check permission by toolkit and permission engine
# ===================================================================
if tool_call.state == ToolCallState.ALLOWED:
# Already allowed by user confirmation, skip permission checking
decision = PermissionDecision(
behavior=PermissionBehavior.ALLOW,
message="Already allowed by user confirmation.",
)
else:
decision = await self._engine.check_permission(
tool,
parsed_input,
)
# ===================================================================
# Step 3: Handle the permission and execute the tool call if allowed
# ===================================================================
# Case 1: Ask for user confirmation if needed
if decision.behavior in [
PermissionBehavior.ASK,
PermissionBehavior.PASSTHROUGH,
]:
# Set the state of the tool call to "ask"
# **Note** the update must be done before yielding the event
self._update_tool_call_state(
tool_call.id,
ToolCallState.ASKING,
)
tool_call.suggested_rules = decision.suggested_rules or []
yield RequireUserConfirmEvent(
reply_id=self.state.reply_id,
tool_calls=[tool_call],
)
return
# Case 2: Denied by the permission system
if decision.behavior == PermissionBehavior.DENY:
async for evt in self._handle_error_tool_call(
tool_call,
decision.message,
state=ToolResultState.DENIED,
):
yield evt
return
# Case 3: Allowed by the permission system, execute the tool call and
# yield the events
if decision.behavior == PermissionBehavior.ALLOW:
self._update_tool_call_state(
tool_call.id,
ToolCallState.ALLOWED,
)
# Send start event
yield ToolResultStartEvent(
reply_id=self.state.reply_id,
tool_call_id=tool_call.id,
tool_call_name=tool_call.name,
)
# Send requiring external execution event if it's an external tool
if tool.is_external_tool:
# Update the state to "submitted" BEFORE yielding
# because the outer loop will break immediately after
# receiving this event, preventing any code after yield
# from executing
self._update_tool_call_state(
tool_call.id,
ToolCallState.SUBMITTED,
)
yield RequireExternalExecutionEvent(
reply_id=self.state.reply_id,
tool_calls=[tool_call],
)
return
# ================================================================
# Step 4: Delegate raw execution to _acting (middleware hook point)
# ================================================================
async for chunk in self._acting(tool_call):
# The ToolResponse is the last and completed tool result here
if isinstance(chunk, ToolResponse):
tool_result_block = ToolResultBlock(
id=tool_call.id,
name=tool_call.name,
output=[TextBlock(text=chunk.content)]
if isinstance(chunk.content, str)
else chunk.content,
state=chunk.state,
metadata=chunk.metadata,
)
# ========================================================
# Step 5: Truncate the tool result if exceed
# ========================================================
(
reserved_tool_result_block,
offload_tool_result_block,
) = await self._split_tool_result_for_compression(
tool_result_block,
)
# If offload result is not empty, attach reminder to the
# reserved context
if offload_tool_result_block is not None:
reminder = (
"\n<<<TRUNCATED>>>\n<system-reminder>The "
"remaining content has been omitted for "
"limited context.{offload_reminder}"
"</system-reminder>"
)
offload_reminder = ""
if self.offloader:
path = await self.offloader.offload_tool_result(
self.state.session_id,
offload_tool_result_block,
)
offload_reminder = (
f" You can refer to the file in '{path}' "
f"for the truncated content if needed."
)
reminder = reminder.format(
offload_reminder=offload_reminder,
)
# Insert the reminder to the tool result output
if isinstance(reserved_tool_result_block.output, str):
reserved_tool_result_block.output += reminder
elif len(
reserved_tool_result_block.output,
) > 0 and isinstance(
reserved_tool_result_block.output[-1],
TextBlock,
):
reserved_tool_result_block.output[
-1
].text += reminder
else:
reserved_tool_result_block.output += [
TextBlock(text=reminder),
]
self._save_to_context([reserved_tool_result_block])
# Ends the tool call lifecycle.
self._update_tool_call_state(
tool_call.id,
ToolCallState.FINISHED,
)
# The ended event for the tool result
yield ToolResultEndEvent(
reply_id=self.state.reply_id,
tool_call_id=tool_call.id,
state=chunk.state,
metadata=chunk.metadata,
)
else:
# Intermediate ToolChunk — convert to streaming events
async for evt in self._convert_tool_chunk_to_event(
tool_call.id,
chunk.content,
):
yield evt
return
raise ValueError(
f"Invalid permission decision behavior: {decision.behavior}",
)
async def _acting(
self,
tool_call: ToolCallBlock,
) -> AsyncGenerator["ToolChunk | ToolResponse", None]:
"""Raw tool execution entry point (maybe wrapped by middleware).
This method is the hook point for ``on_acting`` middleware. It
delegates to :meth:`_acting_impl` which wraps
``toolkit.call_tool`` directly. Permission checking and context
writes are **not** part of this method — they are handled by
:meth:`_execute_tool_call` before and after this call.
Args:
tool_call (`ToolCallBlock`):
The tool call block to execute.
Yields:
`ToolChunk | ToolResponse`:
Intermediate :class:`~agentscope.tool.ToolChunk` objects
followed by a final :class:`~agentscope.tool.ToolResponse`.
"""
if not self._acting_middlewares:
async for item in self._acting_impl(tool_call):
yield item
else:
async def execute_chain(
index: int = 0,
tool_call: ToolCallBlock = tool_call,
) -> AsyncGenerator:
if index >= len(self._acting_middlewares):
async for item in self._acting_impl(tool_call):
yield item
else:
mw = self._acting_middlewares[index]
input_kwargs = {"tool_call": tool_call}
async def next_handler(**kwargs: Any) -> AsyncGenerator:
async for item in execute_chain(
index + 1,
**{**input_kwargs, **kwargs},
):
yield item
async for item in mw.on_acting(
agent=self,
input_kwargs=input_kwargs,
next_handler=next_handler,
):
yield item
async for item in execute_chain():
yield item
async def _acting_impl(
self,
tool_call: ToolCallBlock,
) -> AsyncGenerator["ToolChunk | ToolResponse", None]:
"""Core tool execution logic.
Wraps :meth:`~agentscope.tool.Toolkit.call_tool` and yields its
output unchanged. Does **not** perform permission checking or
write to the agent context — those responsibilities belong to
:meth:`_execute_tool_call`.
.. note::
Tools with ``is_state_injected=True`` receive the live
``agent.state`` object. Offloading such tools to a background
task (via ``on_acting`` middleware) may cause concurrent state
mutations. TODO: block background offloading for
state-injected tools.
Args:
tool_call (`ToolCallBlock`):
The tool call block to execute.
Yields:
`ToolChunk | ToolResponse`:
Intermediate :class:`~agentscope.tool.ToolChunk` objects
followed by a final :class:`~agentscope.tool.ToolResponse`.
"""
async for chunk in self.toolkit.call_tool(tool_call, self.state):
yield chunk
async def _handle_error_tool_call(
self,
tool_call: ToolCallBlock,
message: str,
state: ToolResultState,
) -> AsyncGenerator[
ToolResultStartEvent
| ToolResultTextDeltaEvent
| ToolResultDataDeltaEvent
| ToolResultEndEvent,
None,
]:
"""A quick handling for the non-streaming tool results, and ends the
lifecycle of the tool call by updating its state to "finished".
Args:
tool_call (`ToolCallBlock`):
The tool call block that has errors.
message (`str`):
The error message to be returned for the tool call.
state (`ToolResultState`):
The state of the tool result, such as "error" or "denied".
Yields:
`ToolResultStartEvent \
| ToolResultTextDeltaEvent \
| ToolResultDataDeltaEvent \
| ToolResultEndEvent`:
The events generated for the error tool call.
"""
yield ToolResultStartEvent(
reply_id=self.state.reply_id,
tool_call_id=tool_call.id,
tool_call_name=tool_call.name,
)
result = ToolChunk(
content=[TextBlock(text=message)],
state=state,
)
# Return the result directly to the agent
self._save_to_context(
[
ToolResultBlock(
id=tool_call.id,
name=tool_call.name,
output=message,
state=state,
),
],
)
async for evt in self._convert_tool_chunk_to_event(
tool_call.id,
result.content,
):
yield evt
yield ToolResultEndEvent(
reply_id=self.state.reply_id,
tool_call_id=tool_call.id,
state=state,
)
self._update_tool_call_state(
tool_call.id,
ToolCallState.FINISHED,
)
# =======================================================================
# Context management related methods
# =======================================================================
async def _split_context_for_compression(
self,
to_reserved_tokens: float,
tools: list[dict],
) -> tuple[list[Msg], list[Msg]]:
"""Split context into parts to compress and parts to keep recent.
Args:
to_reserved_tokens (`float`):
The tokens to be reserved.
tools (`list[dict]`):
The tools JSON schemas used for token counting.
Returns:
`tuple[list[Msg], list[Msg]]`:
The message objects to be compressed and reserved during
context compression.
"""
# The system prompt
system_msg = [
SystemMsg(name="system", content=await self._get_system_prompt()),
]
# Append the current summary if exists
if self.state.summary:
system_msg.append(
UserMsg("user", self.state.summary),
)
msg_index = len(self.state.context) - 1
while msg_index >= 0:
# Count the tokens when msgs after msg_index are reserved
reserved_tokens = await self.model.count_tokens(
system_msg + self.state.context[msg_index:],
tools,
)
# If reserved tokens exceed the limit
if reserved_tokens >= to_reserved_tokens:
break
msg_index -= 1
if msg_index < 0:
return [], deepcopy(self.state.context)
# The msgs that won't exceed the reserved token limit
msgs_to_compress = self.state.context[:msg_index]
msgs_to_reserve = self.state.context[msg_index + 1 :]
boundary_msg = self.state.context[msg_index]
# Handle the boundary Msg
boundary_msg_to_compress = deepcopy(boundary_msg)
boundary_msg_to_reserve = deepcopy(boundary_msg)
attempt_msg = deepcopy(boundary_msg)
boundary_msg_content = boundary_msg.get_content_blocks()
block_index = len(boundary_msg_content) - 1
while block_index >= 0:
attempt_msg.content = boundary_msg_content[block_index:]
try_reserved = system_msg + [attempt_msg] + msgs_to_reserve
reserved_tokens = await self.model.count_tokens(
try_reserved,
tools,
)
if reserved_tokens > to_reserved_tokens:
break
block_index -= 1
# Adjust the block_index to avoid splitting tool call and result pairs
# Check if the reserved part has tool results that don't have the
# corresponding tool calls
remain_result_ids = {}
for i in range(len(boundary_msg_content) - 1, block_index, -1):
block = boundary_msg_content[i]
if isinstance(block, ToolResultBlock):
remain_result_ids[block.id] = i
elif isinstance(block, ToolCallBlock):
remain_result_ids.pop(block.id, None)
# Find the largest index of the remaining tool results, which doesn't
# have the corresponding tool calls in the reserved parts
if remain_result_ids:
block_index = max(remain_result_ids.values())
# Split the boundary msg content
boundary_msg_to_compress.content = boundary_msg_content[
: block_index + 1
]
boundary_msg_to_reserve.content = boundary_msg_content[
block_index + 1 :
]
if len(boundary_msg_to_compress.content) > 0:
msgs_to_compress += [boundary_msg_to_compress]
if len(boundary_msg_to_reserve.content) > 0:
msgs_to_reserve = [boundary_msg_to_reserve] + msgs_to_reserve
return msgs_to_compress, msgs_to_reserve
async def _clear_unreserved_read_cache(
self,
msgs_to_reserve: list[Msg],
) -> None:
"""Clean Read caches not referenced by reserved Read tool calls."""
reserved_paths: set[str] = set()
for msg in msgs_to_reserve:
for block in msg.get_content_blocks("tool_call"):
if not (
isinstance(block, ToolCallBlock) and block.name == "Read"
):
continue
try:
tool_input = _json_loads_with_repair(block.input)
except Exception: # pylint: disable=broad-exception-caught
continue
file_path = tool_input.get("file_path")
if isinstance(file_path, str):
reserved_paths.add(file_path)
await self.state.tool_context.clean_file_cache(
reserved_file_paths=reserved_paths,
)
async def _split_tool_result_for_compression(
self,
tool_result: ToolResultBlock,
) -> tuple[ToolResultBlock, ToolResultBlock | None]:
"""Split the tool result for compression.
Args:
tool_result (`ToolResultBlock`):
The tool result block.
Returns:
`tuple[ToolResultBlock, ToolResultBlock | None]`:
A tuple of the tool result blocks to reserved in context and
to offload (if any).
"""
n_tokens = await self.model.count_tokens(
[AssistantMsg(self.name, content=tool_result.output)],
None,
)
# Return the tool result without truncation
if n_tokens <= self.context_config.tool_result_limit:
return tool_result, None
# Use a copied block for token counting
copied_tool_result = deepcopy(tool_result)
# Normalized into content blocks
if isinstance(copied_tool_result.output, str):
copied_tool_result.output = [
TextBlock(text=copied_tool_result.output),
]
# Find the index of the block that will exceed the limit
boundary_index = 0
for i in range(len(copied_tool_result.output) - 1, 0, -1):
copied_tool_result.output = tool_result.output[:i]
cur_tokens = await self.model.count_tokens(
[
AssistantMsg(
self.name,
content=copied_tool_result.output,
),
],
None,
)
if cur_tokens < self.context_config.tool_result_limit:
boundary_index = i
break
# The blocks to reserve and offload (deep copy to avoid
# modifying original)
reserved_blocks: list = [
deepcopy(b) for b in tool_result.output[:boundary_index]
]
offload_blocks: list = [
deepcopy(b) for b in tool_result.output[boundary_index + 1 :]
]
# Get the boundary block, if text block, we can truncate it
boundary_block = tool_result.output[boundary_index]
if isinstance(boundary_block, TextBlock):
# Truncate it
truncated_text = boundary_block.text
cur_tokens = await self.model.count_tokens(
[AssistantMsg(self.name, content=reserved_blocks)],
None,
)
cur_tokens_plus = await self.model.count_tokens(
[
AssistantMsg(
self.name,
content=reserved_blocks + [boundary_block],
),
],
None,
)
# Truncate the text by proportion of tokens
token_delta = cur_tokens_plus - cur_tokens
remaining_token_budget = (
self.context_config.tool_result_limit - cur_tokens
)
if token_delta <= 0:
reserved_tokens = (
len(truncated_text) if remaining_token_budget > 0 else 0
)
else:
reserved_tokens = int(
remaining_token_budget / token_delta * len(truncated_text),
)
reserved_tokens = max(
0,
min(len(truncated_text), reserved_tokens),
)
reserved_text = truncated_text[:reserved_tokens]
offload_text = truncated_text[reserved_tokens:]
if reserved_text:
if (
len(reserved_blocks) > 0
and reserved_blocks[-1].type == "text"
):
reserved_blocks[-1].text += reserved_text
else:
reserved_blocks.append(
TextBlock(text=reserved_text, id=boundary_block.id),
)
if offload_text:
if (
len(offload_blocks) > 0
and offload_blocks[0].type == "text"
):
offload_blocks[0].text = (
offload_text + offload_blocks[0].text
)
else:
offload_blocks.insert(
0,
TextBlock(text=offload_text, id=boundary_block.id),
)
else:
# Drop the boundary block if inseparable
offload_blocks.insert(0, boundary_block)
if len(offload_blocks) == 0:
return tool_result, None
# Create new ToolResultBlock instances for reserved and offload
reserved_tool_result = ToolResultBlock(
id=tool_result.id,
name=tool_result.name,
output=reserved_blocks,
state=tool_result.state,
)
offload_tool_result = ToolResultBlock(
id=tool_result.id,
name=tool_result.name,
output=offload_blocks,
state=tool_result.state,
)
return reserved_tool_result, offload_tool_result
# ======================================================================
# Agent internal utility methods
# ======================================================================
async def _get_system_prompt(self) -> str:
"""Get the system prompt of the agent."""
prompt = [self._system_prompt]
# Skill related instructions
skill_instructions = await self.toolkit.get_skill_instructions(
self.state.tool_context.activated_groups,
)
if skill_instructions:
prompt.append(skill_instructions)
# Workspace & offloader instructions
if isinstance(self.offloader, WorkspaceBase):
offload_instructions = await self.offloader.get_instructions()
if offload_instructions:
prompt.append(offload_instructions)
result = "\n".join(prompt)
# Apply system_prompt middlewares sequentially (transformer pattern)
for mw in self._system_prompt_middlewares:
result = await mw.on_system_prompt(self, result)
return result
async def _prepare_model_input(self) -> dict[str, Any]:
"""A unified method to prepare the chat model input according to
the current context.
Returns:
`dict[str, Any]`
The keyword arguments passed to the model.
"""
# The system prompt
messages = [
SystemMsg(name="system", content=await self._get_system_prompt()),
]
# The compressed summary
if self.state.summary:
messages.append(
UserMsg(name="user", content=self.state.summary),
)
# The conversation context
messages.extend(self.state.context)
# Get the tools schemas
tools = await self.toolkit.get_tool_schemas(
self.state.tool_context.activated_groups,
)
return {
"messages": messages,
"tools": tools,
}
async def _call_model(
self,
messages: list[Msg],
tools: list[dict],
tool_choice: ToolChoice | None = None,
) -> ChatResponse | AsyncGenerator[ChatResponse, None]:
"""Perform model inference with retry logic and middleware support.
Args:
messages (`list[Msg]`):
The input messages to the model.
tools (`list[dict]`):
The function schemas of the tools.
tool_choice (`ToolChoice | None`, optional):
The tool choice strategy for the model call.
Returns:
`ChatResponse | AsyncGenerator[ChatResponse, None]`:
The model response, which can be a `ChatResponse` for
non-streaming models, or an async generator yielding
`ChatResponse` chunks for streaming models.
"""
models = [self.model]
# Fallback to the secondary model if the primary model fails after
# retries
if self.model_config.fallback_model:
models.append(self.model_config.fallback_model)
last_exception = None
# ``max_retries`` is the number of retries on top of the initial
# call (mirrors ``ChatModelBase.max_retries``), so total attempts
# per model is ``max_retries + 1``.
for index, model in enumerate(models):
if index > 0:
logger.info(
"Fallback to model '%s'",
model.model,
)
for attempt in range(self.model_config.max_retries + 1):
try:
# Apply middleware to wrap the actual model() call
if not self._model_call_middlewares:
return await model(
messages=messages,
tools=tools,
tool_choice=tool_choice,
)
else:
# pylint: disable=cell-var-from-loop
async def execute_chain(
index: int = 0,
current_model: ChatModelBase = model,
messages: list[Msg] = messages,
tools: list[dict] = tools,
tool_choice: ToolChoice = tool_choice,
) -> ChatResponse | AsyncGenerator[ChatResponse, None]:
"""Execute the model chain."""
if index >= len(self._model_call_middlewares):
return await current_model(
messages=messages,
tools=tools,
tool_choice=tool_choice,
)
else:
mw = self._model_call_middlewares[index]
input_kwargs = {
"current_model": current_model,
"messages": messages,
"tools": tools,
"tool_choice": tool_choice,
}
async def next_handler(
**kwargs: Any,
) -> (
ChatResponse
| AsyncGenerator[ChatResponse, None]
):
# pylint: disable=cell-var-from-loop
return await execute_chain(
index + 1,
**{**input_kwargs, **kwargs},
)
return await mw.on_model_call(
agent=self,
input_kwargs=input_kwargs,
next_handler=next_handler,
)
return await execute_chain()
except Exception as e:
last_exception = e
# Only log a "Retrying" message when there's actually a
# next attempt left for this model. When ``max_retries=0``
# or the last retry has been used, the outer loop either
# falls over to the fallback or raises.
if attempt < self.model_config.max_retries:
logger.warning(
"Model %s call failed for agent %s. "
"Retrying (%d/%d)...",
model.model,
self.name,
attempt + 1,
self.model_config.max_retries,
)
else:
logger.warning(
"Model %s exhausted all %d attempt(s) "
"for agent %s.",
model.model,
self.model_config.max_retries + 1,
self.name,
)
if last_exception:
raise last_exception from None
raise RuntimeError(
"Model call failed after retries, but no exception was raised.",
)
def _update_tool_call_state(
self,
tool_call_id: str,
state: ToolCallState,
) -> None:
"""Update the tool call state. This function is to avoid the update
not reflected in the context due to the shallow copy of the content
blocks somewhere in the code.
Args:
tool_call_id (`str`):
The tool call id to be updated.
state (`ToolCallState`):
The new state of the tool call.
"""
if len(self.state.context) == 0:
return
last_msg = self.state.context[-1]
if last_msg.role != "assistant" or last_msg.name != self.name:
return
for block in last_msg.get_content_blocks():
if isinstance(block, ToolCallBlock) and block.id == tool_call_id:
block.state = state
break
def _save_to_context(
self,
blocks: Sequence[
TextBlock
| ThinkingBlock
| ToolCallBlock
| ToolResultBlock
| DataBlock
],
usage: ChatUsage | None = None,
) -> None:
"""Save content blocks into the context.
Newly created :class:`AssistantMsg` uses ``self.state.reply_id`` as
its id so that one reply corresponds to one message and the message
id matches the ``reply_id`` carried by streaming events.
"""
msg_usage = (
Usage(
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
)
if usage is not None
else None
)
# Assistant-produced audio (e.g. qwen-omni speaking aloud) is delivered
# to the user via streaming events; the raw bytes don't belong in
# conversation memory. Filtering here keeps every downstream walker
# (formatter, count_tokens, persistence) honest without each having
# to remember.
persisted_blocks = [
b
for b in blocks
if not (
isinstance(b, DataBlock)
and isinstance(b.source, (Base64Source, URLSource))
and b.source.media_type.startswith("audio/")
)
]
if not persisted_blocks and msg_usage is None:
return
if len(self.state.context) == 0:
self.state.context.append(
AssistantMsg(
id=self.state.reply_id,
name=self.name,
content=persisted_blocks,
usage=msg_usage,
),
)
else:
last_msg = self.state.context[-1]
if last_msg.role == "assistant" and last_msg.name == self.name:
if isinstance(last_msg.content, str):
last_msg.content = [TextBlock(text=last_msg.content)]
last_msg.content.extend(persisted_blocks)
if msg_usage is not None:
if last_msg.usage is None:
last_msg.usage = msg_usage
else:
last_msg.usage.input_tokens += msg_usage.input_tokens
last_msg.usage.output_tokens += msg_usage.output_tokens
else:
self.state.context.append(
AssistantMsg(
id=self.state.reply_id,
name=self.name,
content=persisted_blocks,
usage=msg_usage,
),
)
def _get_last_msg(self) -> Msg | None:
"""Get the last message in the context that belongs to this agent."""
if len(self.state.context) == 0:
return None
last_msg = self.state.context[-1]
if last_msg.role == "assistant" and last_msg.name == self.name:
return last_msg
return None
def _check_next_action(
self,
) -> (
tuple[Literal["exit"], Msg]
| tuple[Literal["reasoning"], None]
| tuple[Literal["acting"], None]
):
"""Check the next action for the agent
Awaiting tool calls:
The tool calls waiting for the outside events (confirmation or
external execution results, state = "asking" or "submitted")
Executable tool calls:
The tool calls allowed by the incoming confirmation events and
haven't been executed yet (state = "allowed")
The next action:
| | Awaiting tool calls | No awaiting tool call |
| ------------------------ | ---------------------------- | ---------------------------- |
| Executable tool calls | Acting executable tool calls | Acting executable tool calls |
| No executable tool calls | Exit the _reply | Reasoning |
Returns:
`tuple[Literal["exit"], Msg]`:
If there is no executable tool call and there are awaiting tool
calls, which means the agent is waiting for the outside events
and should not do anything before that, the next action is to
exit the _reply and wait for the outside events.
`tuple[Literal["reasoning"], None]`:
If there is no executable tool call and no awaiting tool call,
which means the agent has nothing to do in this iteration and
can continue reasoning for the next step.
`tuple[Literal["acting"], None]`:
If there are executable tool calls, which means the agent can
act by executing the tool calls.
""" # noqa: E501
last_msg = self._get_last_msg()
if last_msg is None:
return "reasoning", None
# In case wrong tool call state, first filter with the results
finished_ids = {
_.id for _ in last_msg.get_content_blocks("tool_result")
}
unfinished_tool_calls = [
_
for _ in last_msg.get_content_blocks("tool_call")
if _.id not in finished_ids
]
# Find if there are executable or awaiting tool calls
awaiting_tool_calls: list[ToolCallBlock] = []
executable_tool_calls: list[ToolCallBlock] = []
confirming_names, asking_names = [], []
for _ in unfinished_tool_calls:
if _.state in [ToolCallState.PENDING, ToolCallState.ALLOWED]:
executable_tool_calls.append(_)
elif _.state == ToolCallState.ASKING:
asking_names.append(_.name)
awaiting_tool_calls.append(_)
elif _.state == ToolCallState.SUBMITTED:
confirming_names.append(_.name)
awaiting_tool_calls.append(_)
if executable_tool_calls:
return "acting", None
if awaiting_tool_calls:
# Prepare the message
evt = ["I'm waiting for "]
if asking_names:
evt += [
f"user confirmation for {len(asking_names)} tool calls",
]
if confirming_names:
if evt:
evt += [", and "]
evt += [
f"external execution results for {len(confirming_names)} "
f"tool calls",
]
text = "".join(evt) + "."
return "exit", AssistantMsg(
name=self.name,
content=[TextBlock(text=text)],
)
return "reasoning", None
def _get_executable_tool_calls(self) -> list[ToolCallBlock]:
"""Get tool calls from the last message that to be executed, which
means we should reserve the tool calls that:
1. doesn't have results yet, **and**
2. haven't been submitted for external execution (state != "submitted")
"""
last_msg = self._get_last_msg()
if last_msg is None:
return []
# The tool results
result_ids = {_.id for _ in last_msg.get_content_blocks("tool_result")}
# The tool calls that doesn't have results yet
tool_calls_wo_results = [
_
for _ in last_msg.get_content_blocks("tool_call")
if _.id not in result_ids
]
# Filter the ones that are "submitted", which already report the
# external execution requirement
pending_tool_calls = [
_
for _ in tool_calls_wo_results
if _.state
in [
ToolCallState.PENDING,
ToolCallState.ALLOWED,
]
]
return pending_tool_calls
async def _convert_chat_response_to_event(
self,
block_ids: dict,
chunk: ChatResponse,
) -> AsyncGenerator:
"""Convert a ChatResponse chunk into a sequence of agent events. To
keep the identifiers of the content blocks reasonable, the input
blocks_ids is used to track the block ids.
Args:
block_ids (`dict`):
The block ids used to track the block generation.
chunk (`ChatResponse`):
The chat response chunk to be converted.
"""
# Classify the content blocks into different types
text_blocks, thinking_blocks, tool_call_blocks = [], [], []
data_blocks: list = []
for block in chunk.content:
if isinstance(block, TextBlock):
text_blocks.append(block)
elif isinstance(block, ThinkingBlock):
thinking_blocks.append(block)
elif isinstance(block, ToolCallBlock):
tool_call_blocks.append(block)
elif isinstance(block, DataBlock):
data_blocks.append(block)
# Handle the thinking stream: continue/open or close.
# We only auto-close when the chunk also carries no data blocks;
# a data-only chunk (e.g. an omni-style audio PCM delta) must keep
# both text and thinking streams alive so the frontend doesn't
# fragment one logical stream into many separate bubbles.
if thinking_blocks:
# Generate a new thinking block id and start event
if not block_ids.get("thinking"):
block_ids["thinking"] = _generate_id()
yield ThinkingBlockStartEvent(
reply_id=self.state.reply_id,
block_id=block_ids["thinking"],
)
# Generate the thinking delta event with the existing id
yield ThinkingBlockDeltaEvent(
reply_id=self.state.reply_id,
block_id=block_ids["thinking"],
delta="".join([_.thinking for _ in thinking_blocks]),
)
elif block_ids.get("thinking") and not data_blocks:
yield ThinkingBlockEndEvent(
reply_id=self.state.reply_id,
block_id=block_ids["thinking"],
)
block_ids["thinking"] = None
# Handle the text stream: continue/open or close. Placed after
# thinking so that a chunk carrying both ThinkingBlock and TextBlock
# emits thinking events first.
if text_blocks:
if not block_ids.get("text"):
block_ids["text"] = _generate_id()
yield TextBlockStartEvent(
reply_id=self.state.reply_id,
block_id=block_ids["text"],
)
yield TextBlockDeltaEvent(
reply_id=self.state.reply_id,
block_id=block_ids["text"],
delta="".join([_.text for _ in text_blocks]),
)
elif block_ids.get("text") and not data_blocks:
yield TextBlockEndEvent(
reply_id=self.state.reply_id,
block_id=block_ids["text"],
)
block_ids["text"] = None
# Handle the tool calls that exist in the current chunk
for tool_call in tool_call_blocks:
# Not in previous chunk, start with a start event
if tool_call.id not in block_ids["tools"]:
block_ids["tools"].append(tool_call.id)
yield ToolCallStartEvent(
reply_id=self.state.reply_id,
tool_call_id=tool_call.id,
tool_call_name=tool_call.name,
)
yield ToolCallDeltaEvent(
reply_id=self.state.reply_id,
tool_call_id=tool_call.id,
delta=tool_call.input,
)
# Handle the tool calls that exist in the previous chunk but not in the
# current chunk
finished_ids = set(block_ids["tools"]) - set(
_.id for _ in tool_call_blocks
)
for finished_id in finished_ids:
yield ToolCallEndEvent(
reply_id=self.state.reply_id,
tool_call_id=finished_id,
)
block_ids["tools"].remove(finished_id)
# Handle the data blocks (streaming binary content, e.g. omni audio).
# Each DataBlock chunk from the model carries a delta payload with a
# stable block id; we open a stream the first time we see an id and
# emit delta events for subsequent chunks with the same id.
for data_block in data_blocks:
if not isinstance(data_block.source, Base64Source):
# Only Base64Source carries inline delta bytes; URLSource is
# one-shot and not part of the streaming protocol.
continue
if data_block.id not in block_ids["data"]:
block_ids["data"].append(data_block.id)
yield DataBlockStartEvent(
reply_id=self.state.reply_id,
block_id=data_block.id,
media_type=data_block.source.media_type,
)
yield DataBlockDeltaEvent(
reply_id=self.state.reply_id,
block_id=data_block.id,
data=data_block.source.data,
media_type=data_block.source.media_type,
)
async def _convert_tool_chunk_to_event(
self,
tool_call_id: str,
output_blocks: str | List[TextBlock | DataBlock],
) -> AsyncGenerator:
"""Convert a ToolChunk into a sequence of agent events."""
if isinstance(output_blocks, str):
yield ToolResultTextDeltaEvent(
reply_id=self.state.reply_id,
tool_call_id=tool_call_id,
delta=output_blocks,
)
return
for block in output_blocks:
if isinstance(block, TextBlock):
yield ToolResultTextDeltaEvent(
reply_id=self.state.reply_id,
tool_call_id=tool_call_id,
delta=block.text,
)
elif isinstance(block, DataBlock):
if isinstance(block.source, Base64Source):
yield ToolResultDataDeltaEvent(
reply_id=self.state.reply_id,
tool_call_id=tool_call_id,
media_type=block.source.media_type,
data=block.source.data,
)
elif isinstance(block.source, URLSource):
yield ToolResultDataDeltaEvent(
reply_id=self.state.reply_id,
tool_call_id=tool_call_id,
media_type=block.source.media_type,
url=str(block.source.url),
)