mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-06 08:16:00 +00:00
### 1. Agent Wrapper(统一 Agent 后端抽象) - **`base_agent_wrapper.py`**:`reply()` 返回值从 `tuple[str, Any]` 改为 `dict`(含 `session_id` / `last_message` / `result` / 可选 `structured_output`);`reply_stream()` 改为产出统一的 `StreamChunk`。废弃 `add_tools()`,改为 `add_job_tools(names: list[str])`(按名解析 BaseJob)与 `add_skills()`;新增 `_resolve_job_tools()`、`_merged_kwargs()`、`_chunk()` 辅助方法及 `project_path` / `project_skills_root` 属性。 - **`as_agent_wrapper.py`(AgentScope 后端)**: - 会话持久化重写:`session_path` 落地到 `<vault>/<session_dir>/agentscope/`,`_load_state` 支持 `resume` / `session_id` / `fork_session`,并做 UUID 校验(`_validate_session_id`);`_cleanup_expired_sessions` 按天数清理过期会话。 - 新增内置工具集(`BypassAnalysisBash` + Edit/Glob/Grep/Read/Write),`BypassAnalysisBash` 绕过 AgentScope 自带 Bash 静态分析以让 permission_mode 生效;`_resolve_skills()` 把配置的 skill 暴露给后端,`_load_tool_env()` 注入项目 `.env`。 - `_event_to_chunk()` 把 20+ 种 AgentScope 事件(Reply/Text/Thinking/Data/ToolCall/ToolResult/ModelCall/ExceedMaxIters)归一化为 `StreamChunk`。 - **`cc_agent_wrapper.py`(Claude Code SDK 后端,+551 行)**: - 新增 `_CcFileSessionStore`:基于 vault 的文件型会话存储,实现 append(按 uuid 去重)/ load / list / delete / list_subkeys,并对路径做 `_safe_parts` + `resolve()` 防越界校验。 - `_build_options()`:统一构建 `ClaudeAgentOptions`,处理 skills、disallowed_tools(默认禁 `WebSearch`)、`.env` 注入、Claude Code 的 API 凭据解析(`_claude_code_api_env`,多级 base_url/api_key 回退)、`CLAUDE_CONFIG_DIR` 设置、skill 目录软链接(`_ensure_claude_skill_dir`)。 - `_raw_event_to_chunk()` / `_message_content_to_chunks()`:把 Anthropic 流式事件(message_start/delta/stop、content_block_*)与 SDK 消息块(AssistantMessage/UserMessage/ResultMessage/RateLimitEvent)转换为统一 `StreamChunk`;跟踪 block_id/block_type/tool_call_name 做关联;处理尾部 `"success"` 误报异常的吞掉逻辑。 ### 2. 统一流式协议(StreamChunk / ChunkEnum) - **`stream_chunk.py`**:`StreamChunk` 扩展为承载 AS + CC 双后端完整信息的统一结构,新增 `session_id` / `block_id` / `tool_call_id` / `tool_call_name` / `media_type` / `input_tokens` / `output_tokens` 等字段,纯文本流仍保持轻量。 - **`chunk_enum.py`**:补全生命周期标记 `REPLY_START` / `REPLY_END`,并文档化两套后端事件 → ChunkEnum 的映射。 ### 3. Index 模块重构(变化批次化 + dispatch) - 新增 `_change_batch.py`:`coalesce_changes()` 把同路径多次事件折叠为最终状态(结合 path 存在性判定),`bucket_changes()` 按 watchfiles.Change 分桶。 - 新增 `init_changes.py`(`InitChangesStep`):一次性扫描,对比 file_store / file_catalog 已索引节点计算 added/modified/deleted,写入 `context["changes"]` 后 dispatch。 - 新增 `update_changes.py`:抽象基类 `ChangeApplyStep` 统一 added/modified/deleted 处理与错误收集;`UpdateCatalogStep`(写 file_catalog)、`UpdateIndexStep`(写 file_store,含按后缀解析 chunker)。 - **`watch_changes.py`**:改用 `dispatch_step_specs`(基类提供的 `dispatch_steps()`),每批先 `coalesce_changes` 再 dispatch;默认参数调整(debounce 5000ms / step 1000ms / poll 5000ms)并暴露常量。 - 删除旧步骤:`clear_and_scan` / `foreach_dispatch` / `scan_changes` / `update_catalog`(旧) / `update_index`(旧);`clear_store.py` 取代 clear_and_scan。 ### 4. Evolve / Dream 模块(拆分为多步 pipeline) - 删除旧的单体 `auto_dream.py` / `dream.py` / `dream.yaml`,新增 `dream/` 子包,按 5 个步骤组织: - **`extract.py`**:扫描当日 day-index + daily 笔记,对比 file_catalog 找出 changed/deleted,调用 LLM 全局抽取 `units`(procedure/personal/wiki 三桶)与 `topics`,路径与桶做清洗/路由。 - **`integrate.py`**:逐个 unit 调用 LLM 写入 digest,结构化输出 `IntegrateOutcome`(CREATE/CORROBORATE/REFINE/CORRECT),失败 unit/路径收集回写。 - **`topics.py`**:写 `daily/<date>/interests.yaml`,结合当天已有 + 近 N 天做去重(`normalize_topic`),可走 LLM 或纯规则去重两条路径。 - **`proactive.py`**:读取当日 `interests.yaml`,作为主动推荐话题的入口。 - **`finish.py`**:把变更路径落盘到 dream file_catalog(checkpoint),渲染最终汇总摘要。 - 新增 `schema.py`(`DreamState` 等跨步骤共享状态与结构化输出模型)与 `utils.py`(状态存取、扫描打包、YAML 读写、结构化回复解析等公共函数)。 - `evolve/__init__.py` 导出全部新 step。 ### 5. auto_memory / auto_resource(适配新 Agent API) - **`auto_memory.py`**:会话路径迁移到 `<session_dir>/dialog/<session_id>.jsonl`;改用 `job_tools`;新增 `source_conversation` frontmatter 反向链接(`_session_link`);执行后刷新 day 索引(`refresh_day_index`),并对 session_id 做合法性校验。 - **`auto_resource.py`**:资源改用「同名 daily note」方案(`_compute_note_stem` 取文件 stem);批量处理 `changes: list[dict]`(`_handle_change` 逐项处理,返回逐项结果摘要);agent 会话 id 用稳定的 `uuid5`;同样刷新 day 索引。 ### 6. BaseStep 基类增强 - 新增 `dispatch_steps` / `dispatch_step_specs` 机制:`_resolve_dispatch_step()` 支持字符串或 dict 形式的 step spec,`dispatch_steps()` 复用当前 context 调用下游 step。 - 新增 `config_value()`:按 key 取 app config,缺失时回退 `ApplicationConfig` 默认值。 - 小幅清理:`language` 初始化、`copy()`、`Ref.__init__` 签名精简。 ### 7. Components 改动 - **`file_store/local_file_store.py`**:持久化改用 zstd 压缩(`.jsonl.zst`,通过新 `utils/jsonl_zst.py`);upsert 时先删除旧 chunk 的 keyword 文档;embedding 复用改为 `(text, embedding)` 键控,要求文本一致才复用;新增 `_matches_search_filter()` 对 vector/keyword 搜索做 path/path_prefix/metadata 的统一后过滤。 - **`keyword_index/bm25_index.py`**:索引文件名加入组件名 + tokenizer 指纹(sha256 前 12 位),快照/恢复时校验指纹防配置漂移;空索引 dump 时删除文件,加载失败抛错而非静默。 - **`file_chunker/markdown_file_chunker.py`**:弃用 `python-frontmatter`,改用内置 YAML 解析(非法 YAML 不阻断正文索引),并修正因 frontmatter 占用行号导致的 AST 行号偏移(`line_offset`)。 - **`cron_job.py`**:大幅简化(-187 行),由原来「dispatch 外部 job/step + 多种调度模式」改为「在自身 steps 上跑 cron 表达式」;`Application` 启动顺序随之调整为 base > stream > background > cron。 - 其余小调整:service(base/http/mcp)、file_graph、file_catalog、as_llm、as_embedding、tokenizer、prompt_handler、base_component 的签名/接口微调。 ### 8. Application 生命周期 - `_start()` 启动顺序明确为 components → base → stream → background → cron,启动失败会触发 `_close()` 回滚并 re-raise(不再吞异常)。 - 启动时创建 `session_dir` 目录;新增 `update_component()`(按类型/名就地更新已存在组件,不存在则报错)。 ### 9. File IO / 路径安全 - **`_path.py`**:`resolve_path` 增加 vault 越界防护(`is_relative_to` 校验),禁止 `.` / `..` 路径分量,支持 `allow_empty`。 - **`read.py`**:大文件(超过 `MAX_FILE_READ_BYTES`)走按行读取 `read_file_lines_safe`,避免一次性载入内存。 - **`_file_io.py` / `_daily_index.py` / `_path.py`** 等支持函数补齐(如 `refresh_day_index`、`read_file_lines_safe`)。 - **`env_utils.py`**:新增 `parse_env_file()`,`load_env()` 返回加载到的键值、支持 `override`、对无路径调用做幂等缓存。 ### 10. Config - `ApplicationConfig` 新增 `session_dir`(默认 `reme_session`)。 - `config_parser.py`:环境变量展开后做类型转换(`_convert_value`)、dot-notation 与 key=value 参数校验更严格、配置文件路径支持相对 `_CONFIG_DIR` 查找、根非 dict 报错。 - `default.yaml`:作业编排改用 `init_changes_step` + `dispatch_steps`(index/resource/digest 三个 watch loop 与 reindex);新增 `auto_dream`(4 步)、`proactive` 作业,移除旧 `dream`;file_catalog 增配 `resource` / `digest` / `dream` 实例;LLM 默认值与 Claude Code 凭据配置调整(tool_result_limit 50000、thinking_enable=false 等)。 ### 11. 其它 - 新增 `steps/common/add.py`(`AddStep` 算术 demo)、`channel/__init__.py` 与 common `__init__` 导出整理。 - 新增 4 篇文档:`docs4/auto_dream_logic_and_step_refactor.md`、`docs4/watch_loop_step_refactor_plan.md`、`docs4/todo.md`,以及 `reme_design.md` 更新。 **
370 lines
15 KiB
Python
370 lines
15 KiB
Python
"""AgentScope backend for the unified agent wrapper."""
|
|
|
|
import json
|
|
import re
|
|
import time
|
|
from collections.abc import AsyncGenerator
|
|
from pathlib import Path
|
|
from typing import Any, TYPE_CHECKING
|
|
from uuid import uuid4
|
|
|
|
from agentscope.agent import Agent, ContextConfig, ReActConfig
|
|
from agentscope.agent._config import ModelConfig
|
|
from agentscope.event import (
|
|
DataBlockDeltaEvent,
|
|
DataBlockEndEvent,
|
|
DataBlockStartEvent,
|
|
ExceedMaxItersEvent,
|
|
ModelCallEndEvent,
|
|
ModelCallStartEvent,
|
|
ReplyEndEvent,
|
|
ReplyStartEvent,
|
|
ThinkingBlockDeltaEvent,
|
|
ThinkingBlockEndEvent,
|
|
ThinkingBlockStartEvent,
|
|
TextBlockDeltaEvent,
|
|
TextBlockEndEvent,
|
|
TextBlockStartEvent,
|
|
ToolCallDeltaEvent,
|
|
ToolCallEndEvent,
|
|
ToolCallStartEvent,
|
|
ToolResultDataDeltaEvent,
|
|
ToolResultEndEvent,
|
|
ToolResultStartEvent,
|
|
ToolResultTextDeltaEvent,
|
|
)
|
|
from agentscope.message import TextBlock, ToolResultState, UserMsg
|
|
from agentscope.permission import PermissionBehavior, PermissionContext, PermissionDecision, PermissionMode
|
|
from agentscope.state import AgentState
|
|
from agentscope.tool import (
|
|
Bash,
|
|
Edit,
|
|
FunctionTool,
|
|
Glob,
|
|
Grep,
|
|
Read,
|
|
ToolBase,
|
|
ToolChunk,
|
|
Toolkit,
|
|
Write,
|
|
)
|
|
|
|
from .base_agent_wrapper import BaseAgentWrapper
|
|
from ..as_llm import BaseAsLLM
|
|
from ..component_registry import R
|
|
from ...enumeration import ChunkEnum
|
|
from ...schema import StreamChunk
|
|
from ...utils import AsStateHandler
|
|
from ...utils.env_utils import load_env
|
|
|
|
if TYPE_CHECKING:
|
|
from ..job.base_job import BaseJob
|
|
|
|
_UUID_RE = re.compile(
|
|
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
class BypassAnalysisBash(Bash):
|
|
"""Bash variant that delegates permission decisions to PermissionEngine.
|
|
|
|
AgentScope's built-in Bash performs bypass-immune static analysis before
|
|
the engine can apply `permission_mode: bypass`. For this app we want the
|
|
configured permission mode to be authoritative.
|
|
"""
|
|
|
|
async def check_permissions(
|
|
self,
|
|
_tool_input: dict[str, Any],
|
|
_context: PermissionContext,
|
|
) -> PermissionDecision:
|
|
"""Bypass Bash static analysis and let the permission engine decide."""
|
|
return PermissionDecision(
|
|
behavior=PermissionBehavior.PASSTHROUGH,
|
|
message="Bash static analysis skipped; delegating to permission engine.",
|
|
)
|
|
|
|
|
|
@R.register("agentscope")
|
|
class AsAgentWrapper(BaseAgentWrapper):
|
|
"""Agent wrapper backed by AgentScope framework."""
|
|
|
|
def __init__(self, as_llm: str = "default", session_retention_days: int = 10, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.as_llm = self.bind(as_llm, BaseAsLLM, optional=False)
|
|
self.session_retention_days = int(session_retention_days)
|
|
self._session_cleanup_done = False
|
|
|
|
@staticmethod
|
|
def _make_tool(job: "BaseJob") -> FunctionTool:
|
|
async def run_job(**kwargs) -> ToolChunk:
|
|
response = await job(**kwargs)
|
|
state = ToolResultState.SUCCESS if response.success else ToolResultState.ERROR
|
|
return ToolChunk(content=[TextBlock(text=str(response.answer))], state=state)
|
|
|
|
tool = FunctionTool(func=run_job, name=job.name, description=job.description)
|
|
if job.parameters:
|
|
tool.input_schema = job.parameters
|
|
return tool
|
|
|
|
@classmethod
|
|
def _builtin_tools(cls) -> list[ToolBase]:
|
|
"""Return built-in tools expected by local skills."""
|
|
return [BypassAnalysisBash(), Edit(), Glob(), Grep(), Read(), Write()]
|
|
|
|
@property
|
|
def session_path(self) -> Path:
|
|
"""Directory used for persisted AgentScope sessions."""
|
|
if self.app_context is None:
|
|
return self.vault_path / "session" / "agentscope"
|
|
return self.vault_path / self.app_context.app_config.session_dir / "agentscope"
|
|
|
|
@staticmethod
|
|
def _validate_session_id(session_id: str, field: str = "session_id") -> str:
|
|
if not _UUID_RE.match(session_id):
|
|
raise ValueError(f"{field} must be a valid UUID: {session_id!r}")
|
|
return session_id.lower()
|
|
|
|
def _cleanup_expired_sessions(self) -> None:
|
|
"""Delete persisted session files older than ``session_retention_days``."""
|
|
if self._session_cleanup_done or self.session_retention_days <= 0:
|
|
self._session_cleanup_done = True
|
|
return
|
|
|
|
session_path = self.session_path
|
|
if not session_path.is_dir():
|
|
self._session_cleanup_done = True
|
|
return
|
|
|
|
cutoff = time.time() - self.session_retention_days * 24 * 60 * 60
|
|
removed = 0
|
|
for path in session_path.glob("*.jsonl"):
|
|
try:
|
|
if path.is_file() and path.stat().st_mtime < cutoff:
|
|
path.unlink()
|
|
removed += 1
|
|
except OSError as exc:
|
|
self.logger.warning(f"Failed to clean expired AgentScope session {path}: {exc}")
|
|
|
|
if removed:
|
|
self.logger.info(
|
|
f"Cleaned {removed} AgentScope session(s) older than {self.session_retention_days} day(s)",
|
|
)
|
|
self._session_cleanup_done = True
|
|
|
|
async def _load_state(self, kwargs: dict[str, Any], perm_mode: PermissionMode) -> AgentState:
|
|
resume = kwargs.get("resume") or ""
|
|
session_id = kwargs.get("session_id") or ""
|
|
fork_session = bool(kwargs.get("fork_session", False))
|
|
if resume:
|
|
resume = self._validate_session_id(resume, "resume")
|
|
if session_id:
|
|
session_id = self._validate_session_id(session_id)
|
|
|
|
if session_id and resume and not fork_session:
|
|
raise ValueError("session_id cannot be used with resume unless fork_session=True")
|
|
|
|
if resume:
|
|
handler = AsStateHandler.for_session(self.session_path, resume)
|
|
state = await handler.load_or_none()
|
|
if state is None:
|
|
raise FileNotFoundError(f"AgentScope session not found: {resume}")
|
|
state.permission_context = PermissionContext(mode=perm_mode)
|
|
state.session_id = resume
|
|
if fork_session:
|
|
forked = AgentState(
|
|
session_id=session_id or str(uuid4()),
|
|
summary=state.summary,
|
|
context=list(state.context),
|
|
permission_context=PermissionContext(mode=perm_mode),
|
|
)
|
|
return forked
|
|
return state
|
|
|
|
return AgentState(session_id=session_id or str(uuid4()), permission_context=PermissionContext(mode=perm_mode))
|
|
|
|
async def _dump_state(self, state: AgentState) -> None:
|
|
await AsStateHandler.for_session(self.session_path, state.session_id).dump(state)
|
|
|
|
def _resolve_skills(self, skills: list[str] | str | None) -> list[str]:
|
|
"""Resolve configured skill names to AgentScope local skill directories."""
|
|
if skills is None:
|
|
return []
|
|
if skills == "all":
|
|
return [str(self.project_skills_root)]
|
|
if isinstance(skills, str):
|
|
skills = [skills]
|
|
return [str(self.project_skills_root / skill) for skill in skills]
|
|
|
|
def _load_tool_env(self) -> dict[str, str]:
|
|
"""Load project environment variables for tools spawned by AgentScope."""
|
|
project_env = self.project_path / ".env"
|
|
return load_env(project_env) if project_env.exists() else load_env()
|
|
|
|
async def _build_agent(self, inputs: Any, **kwargs) -> tuple[Agent, Any]:
|
|
"""Build an Agent instance from kwargs. Returns (agent, processed_inputs)."""
|
|
model = self.as_llm.model if self.as_llm else None
|
|
if model is None:
|
|
raise ValueError("AsAgentWrapper requires a bound as_llm component with a valid model.")
|
|
|
|
kwargs = self._merged_kwargs(kwargs)
|
|
self._cleanup_expired_sessions()
|
|
self._load_tool_env()
|
|
|
|
system_prompt = kwargs.get("system_prompt", "You are a helpful assistant.")
|
|
job_tools: list[str] = kwargs.get("job_tools", [])
|
|
resolved_jobs = self._resolve_job_tools(job_tools)
|
|
skills = self._resolve_skills(kwargs.get("skills"))
|
|
toolkit = kwargs.get("toolkit") or Toolkit(
|
|
tools=[*self._builtin_tools(), *(self._make_tool(job) for job in resolved_jobs)],
|
|
skills_or_loaders=skills,
|
|
)
|
|
|
|
perm_mode = PermissionMode(kwargs.get("permission_mode", "bypass"))
|
|
state = await self._load_state(kwargs, perm_mode)
|
|
|
|
agent = Agent(
|
|
name=self.name,
|
|
system_prompt=system_prompt,
|
|
model=model,
|
|
toolkit=toolkit,
|
|
state=state,
|
|
model_config=ModelConfig(**(kwargs.get("model_config") or {})),
|
|
context_config=ContextConfig(**(kwargs.get("context_config") or {})),
|
|
react_config=ReActConfig(**(kwargs.get("react_config") or {})),
|
|
)
|
|
|
|
if isinstance(inputs, str):
|
|
inputs = UserMsg(name="user", content=inputs)
|
|
|
|
return agent, inputs
|
|
|
|
async def reply(self, inputs: Any, **kwargs) -> dict:
|
|
kwargs = self._merged_kwargs(kwargs)
|
|
agent, inputs = await self._build_agent(inputs, **kwargs)
|
|
|
|
await agent.observe(inputs)
|
|
await agent.reply()
|
|
await self._dump_state(agent.state)
|
|
last_msg = agent.state.context[-1]
|
|
|
|
result = {
|
|
"session_id": agent.state.session_id,
|
|
"last_message": last_msg.model_dump(),
|
|
"result": last_msg.get_text_content(),
|
|
}
|
|
|
|
output_schema: dict | None = kwargs.get("output_schema")
|
|
if output_schema is not None:
|
|
assert self.as_llm is not None, "AsAgentWrapper requires a bound as_llm component with a valid model."
|
|
model = self.as_llm.model
|
|
assert model is not None, "AsAgentWrapper requires a bound as_llm component with a valid model."
|
|
res = await model.generate_structured_output(
|
|
messages=agent.state.context,
|
|
structured_model=output_schema,
|
|
)
|
|
result["structured_output"] = res.content
|
|
|
|
return result
|
|
|
|
# ----- StreamChunk conversion -------------------------------------------
|
|
|
|
@classmethod
|
|
# pylint: disable=too-many-return-statements
|
|
def _event_to_chunk(cls, event: Any) -> StreamChunk | None:
|
|
"""Convert an AgentScope event to a unified StreamChunk.
|
|
|
|
Returns ``None`` for events that should be silently skipped
|
|
(e.g. ``RequireUserConfirmEvent``).
|
|
"""
|
|
if isinstance(event, ReplyStartEvent):
|
|
meta = {"reply_id": event.reply_id, "name": event.name, "role": event.role}
|
|
return cls._chunk(ChunkEnum.REPLY_START, session_id=event.session_id, chunk="", metadata=meta)
|
|
if isinstance(event, ReplyEndEvent):
|
|
return cls._chunk(
|
|
ChunkEnum.REPLY_END,
|
|
session_id=event.session_id,
|
|
chunk="",
|
|
metadata={"reply_id": event.reply_id},
|
|
)
|
|
|
|
for event_cls, chunk_type, attr in (
|
|
(TextBlockStartEvent, ChunkEnum.CONTENT, None),
|
|
(TextBlockDeltaEvent, ChunkEnum.CONTENT, "delta"),
|
|
(TextBlockEndEvent, ChunkEnum.CONTENT, None),
|
|
(ThinkingBlockStartEvent, ChunkEnum.THINK, None),
|
|
(ThinkingBlockDeltaEvent, ChunkEnum.THINK, "delta"),
|
|
(ThinkingBlockEndEvent, ChunkEnum.THINK, None),
|
|
(DataBlockStartEvent, ChunkEnum.DATA, None),
|
|
(DataBlockDeltaEvent, ChunkEnum.DATA, "data"),
|
|
(DataBlockEndEvent, ChunkEnum.DATA, None),
|
|
):
|
|
if isinstance(event, event_cls):
|
|
kwargs = {"block_id": event.block_id, "chunk": getattr(event, attr) if attr else ""}
|
|
if isinstance(event, (DataBlockStartEvent, DataBlockDeltaEvent)):
|
|
kwargs["media_type"] = event.media_type
|
|
return cls._chunk(chunk_type, **kwargs)
|
|
|
|
if isinstance(event, ToolCallStartEvent):
|
|
payload = {"name": event.tool_call_name, "id": event.tool_call_id}
|
|
return cls._chunk(
|
|
ChunkEnum.TOOL_CALL,
|
|
tool_call_id=event.tool_call_id,
|
|
tool_call_name=event.tool_call_name,
|
|
chunk=json.dumps(payload),
|
|
)
|
|
if isinstance(event, ToolCallDeltaEvent):
|
|
return cls._chunk(ChunkEnum.TOOL_CALL, tool_call_id=event.tool_call_id, chunk=event.delta)
|
|
if isinstance(event, ToolCallEndEvent):
|
|
return cls._chunk(ChunkEnum.TOOL_CALL, tool_call_id=event.tool_call_id, chunk="")
|
|
if isinstance(event, ToolResultStartEvent):
|
|
return cls._chunk(
|
|
ChunkEnum.TOOL_RESULT,
|
|
tool_call_id=event.tool_call_id,
|
|
tool_call_name=event.tool_call_name,
|
|
chunk="",
|
|
)
|
|
if isinstance(event, ToolResultTextDeltaEvent):
|
|
return cls._chunk(ChunkEnum.TOOL_RESULT, tool_call_id=event.tool_call_id, chunk=event.delta)
|
|
if isinstance(event, ToolResultDataDeltaEvent):
|
|
return cls._chunk(
|
|
ChunkEnum.TOOL_RESULT,
|
|
tool_call_id=event.tool_call_id,
|
|
chunk=event.data,
|
|
media_type=event.media_type,
|
|
metadata={"url": event.url} if event.url else {},
|
|
)
|
|
if isinstance(event, ToolResultEndEvent):
|
|
return cls._chunk(
|
|
ChunkEnum.TOOL_RESULT,
|
|
tool_call_id=event.tool_call_id,
|
|
chunk="",
|
|
metadata={"state": str(event.state)},
|
|
)
|
|
if isinstance(event, ModelCallStartEvent):
|
|
return cls._chunk(ChunkEnum.USAGE, chunk="", metadata={"model_name": getattr(event, "model_name", None)})
|
|
if isinstance(event, ModelCallEndEvent):
|
|
usage = {"input_tokens": event.input_tokens, "output_tokens": event.output_tokens}
|
|
return cls._chunk(
|
|
ChunkEnum.USAGE,
|
|
chunk=json.dumps(usage),
|
|
input_tokens=event.input_tokens,
|
|
output_tokens=event.output_tokens,
|
|
metadata={"model_name": getattr(event, "model_name", None)},
|
|
)
|
|
if isinstance(event, ExceedMaxItersEvent):
|
|
return cls._chunk(ChunkEnum.ERROR, chunk="Exceeded max iterations")
|
|
return None
|
|
|
|
async def reply_stream(self, inputs: Any, **kwargs) -> AsyncGenerator[StreamChunk, None]:
|
|
"""Stream agent events as unified StreamChunk objects."""
|
|
agent, inputs = await self._build_agent(inputs, **kwargs)
|
|
|
|
async for event in agent.reply_stream(inputs):
|
|
chunk = self._event_to_chunk(event)
|
|
if chunk is not None:
|
|
chunk.session_id = chunk.session_id or agent.state.session_id
|
|
yield chunk
|
|
|
|
await self._dump_state(agent.state)
|