agentscope-chat / src /agentscope /tool /_response.py
Akshay66777's picture
AgentScope Gradio chat app — ZeroGPU-ready
9792ea7 verified
Raw
History Blame Contribute Delete
6.52 kB
# -*- coding: utf-8 -*-
"""The tool response class."""
import base64
import binascii
from typing import List, Literal, Self
from pydantic import BaseModel, Field
from .._utils._common import _generate_id
from ..message import DataBlock, TextBlock, Base64Source, ToolResultState
def _merge_base64_chunks(existing: str, incoming: str) -> str:
"""Merge independently encoded base64 chunks without corrupting padding."""
try:
merged = base64.b64decode(
existing,
validate=True,
) + base64.b64decode(incoming, validate=True)
except (binascii.Error, ValueError):
# Keep compatibility with callers/tests that used placeholder strings
# instead of valid base64 payloads.
return existing + incoming
return base64.b64encode(merged).decode("ascii")
class ToolChunk(BaseModel):
"""The tool result chunk from a tool execution."""
content: List[TextBlock | DataBlock]
"""The chunk data blocks, note for one multimodal data, the DataBlock
instance should have the same block id, so that the agent can group them
together."""
state: ToolResultState = ToolResultState.RUNNING
"""The execution state of the tool chunk."""
is_last: bool = True
"""Whether this is the last response in a stream tool execution."""
metadata: dict = Field(default_factory=dict)
"""The metadata to be accessed within the agent, so that we don't need to
parse the tool result block."""
id: str = Field(default_factory=_generate_id)
"""The identity of the tool chunk."""
class ToolResponse(BaseModel):
"""The tool response from a tool execution, which contains the completed
tool result (compared to ToolChunk)."""
content: List[TextBlock | DataBlock] = Field(default_factory=list)
"""The completed tool result data blocks."""
state: Literal[
ToolResultState.ERROR,
ToolResultState.DENIED,
ToolResultState.INTERRUPTED,
ToolResultState.SUCCESS,
] = ToolResultState.SUCCESS
"""The execution state of the tool response."""
metadata: dict = Field(default_factory=dict)
"""The metadata to be accessed within the agent, so that we don't need to
parse the tool result block."""
id: str = Field(default_factory=_generate_id)
"""The identity of the tool response."""
def append_chunk(self, chunk: ToolChunk) -> Self:
"""Append a tool chunk to the current tool response, accumulate the
data blocks and update the state and metadata."""
# Update content blocks
current_ids_to_index = {
_.id: index for index, _ in enumerate(self.content)
}
for chunk_block in chunk.content:
if chunk_block.id in current_ids_to_index:
# Append to the existing block
target_block = self.content[
current_ids_to_index[chunk_block.id]
]
if isinstance(target_block, TextBlock) and isinstance(
chunk_block,
TextBlock,
):
target_block.text += chunk_block.text
elif isinstance(target_block, DataBlock) and isinstance(
chunk_block,
DataBlock,
):
if isinstance(
target_block.source,
Base64Source,
) and isinstance(chunk_block.source, Base64Source):
# Accumulate independently encoded base64 chunks.
target_block.source.data = _merge_base64_chunks(
target_block.source.data,
chunk_block.source.data,
)
# Update the newest media type and name if provided
target_block.name = (
chunk_block.name or target_block.name
)
target_block.source.media_type = (
chunk_block.source.media_type
or target_block.source.media_type
)
else:
raise ValueError(
"Cannot append DataBlock with URL source or "
f"different source types: {target_block.source} "
f"vs {chunk_block.source}",
)
else:
# For different block types with the same ID, we just
# append the new block with a new ID to avoid the conflict
new_chunk_block = chunk_block.model_copy(deep=True)
new_chunk_block.id = _generate_id()
self.content.append(new_chunk_block)
else:
# Append a copy to avoid modifying the original chunk
self.content.append(chunk_block.model_copy(deep=True))
# Update the index mapping for the new block
current_ids_to_index[chunk_block.id] = len(self.content) - 1
# Update id, state and metadata
# Only reserve the failure state and keep the previous state if not
# worse.
if chunk.state == ToolResultState.ERROR:
self.state = ToolResultState.ERROR
elif chunk.state == "interrupted":
self.state = ToolResultState.INTERRUPTED
elif chunk.state == ToolResultState.DENIED:
self.state = ToolResultState.DENIED
self.metadata.update(chunk.metadata)
# Post-processing: merge consecutive TextBlocks
# DataBlocks are kept separate and only merged by explicit id matching
merged_content: List[TextBlock | DataBlock] = []
for block in self.content:
if isinstance(block, TextBlock) and merged_content:
# Check if the last block is also a TextBlock
last_block = merged_content[-1]
if isinstance(last_block, TextBlock):
# Merge consecutive TextBlocks
last_block.text += block.text
else:
# Last block is DataBlock, append current TextBlock
merged_content.append(block)
else:
# First block or current block is DataBlock
merged_content.append(block)
self.content = merged_content
return self