mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-05 08:06:15 +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` 更新。 **
601 lines
25 KiB
Python
601 lines
25 KiB
Python
"""Claude Code SDK backend for the unified agent wrapper."""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
from collections.abc import AsyncGenerator
|
|
from dataclasses import asdict
|
|
from pathlib import Path
|
|
from typing import Any, TYPE_CHECKING
|
|
|
|
from .base_agent_wrapper import BaseAgentWrapper
|
|
from ..component_registry import R
|
|
from ...enumeration import ChunkEnum
|
|
from ...schema import StreamChunk
|
|
from ...utils.env_utils import load_env
|
|
|
|
if TYPE_CHECKING:
|
|
from ..job.base_job import BaseJob
|
|
from claude_agent_sdk.types import SessionKey, SessionStoreEntry, SessionStoreListEntry
|
|
|
|
|
|
class _CcFileSessionStore:
|
|
"""File-backed Claude Code SessionStore rooted under the ReMe vault."""
|
|
|
|
def __init__(self, root: Path) -> None:
|
|
self.root = root
|
|
|
|
@staticmethod
|
|
def _safe_parts(value: str) -> list[str]:
|
|
parts = [part for part in value.split("/") if part]
|
|
if not parts or any(part in {".", ".."} for part in parts):
|
|
raise ValueError(f"Invalid session store path component: {value!r}")
|
|
return parts
|
|
|
|
def _path_for_key(self, key: "SessionKey") -> Path:
|
|
session_id = key["session_id"]
|
|
subpath = key.get("subpath")
|
|
|
|
path = self.root.joinpath(*self._safe_parts(session_id))
|
|
if subpath:
|
|
path = path.joinpath(*self._safe_parts(subpath))
|
|
else:
|
|
path = path.with_suffix(".jsonl")
|
|
if subpath:
|
|
path = path.with_suffix(".jsonl")
|
|
|
|
resolved_root = self.root.resolve()
|
|
resolved_path = path.resolve()
|
|
if resolved_root != resolved_path and resolved_root not in resolved_path.parents:
|
|
raise ValueError(f"Session store path escapes root: {resolved_path}")
|
|
return path
|
|
|
|
@staticmethod
|
|
def _read_entries(path: Path) -> list["SessionStoreEntry"]:
|
|
"""Read JSONL session-store entries from disk."""
|
|
if not path.exists():
|
|
return []
|
|
entries = []
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
if line.strip():
|
|
entries.append(json.loads(line))
|
|
return entries
|
|
|
|
async def append(self, key: "SessionKey", entries: list["SessionStoreEntry"]) -> None:
|
|
"""Append new session-store entries, deduplicating by UUID."""
|
|
path = self._path_for_key(key)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
existing_uuids = {
|
|
entry.get("uuid") for entry in self._read_entries(path) if isinstance(entry, dict) and entry.get("uuid")
|
|
}
|
|
new_entries = [
|
|
entry for entry in entries if not (isinstance(entry, dict) and entry.get("uuid") in existing_uuids)
|
|
]
|
|
if not new_entries:
|
|
return
|
|
|
|
with path.open("a", encoding="utf-8") as f:
|
|
for entry in new_entries:
|
|
f.write(json.dumps(entry, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
|
|
async def load(self, key: "SessionKey") -> list["SessionStoreEntry"] | None:
|
|
"""Load session-store entries for a key."""
|
|
path = self._path_for_key(key)
|
|
if not path.exists():
|
|
return None
|
|
return self._read_entries(path)
|
|
|
|
async def list_sessions(self, _project_key: str) -> list["SessionStoreListEntry"]:
|
|
"""List root-level Claude Code sessions."""
|
|
if not self.root.exists():
|
|
return []
|
|
return [
|
|
{"session_id": path.stem, "mtime": int(path.stat().st_mtime * 1000)}
|
|
for path in self.root.glob("*.jsonl")
|
|
if path.is_file()
|
|
]
|
|
|
|
async def delete(self, key: "SessionKey") -> None:
|
|
"""Delete a session-store entry and any subkey directory."""
|
|
path = self._path_for_key(key)
|
|
if path.exists():
|
|
path.unlink()
|
|
|
|
if not key.get("subpath"):
|
|
session_dir = self.root.joinpath(*self._safe_parts(key["session_id"]))
|
|
if session_dir.exists():
|
|
for child in sorted(session_dir.rglob("*"), reverse=True):
|
|
if child.is_file():
|
|
child.unlink()
|
|
elif child.is_dir():
|
|
child.rmdir()
|
|
session_dir.rmdir()
|
|
|
|
async def list_subkeys(self, key: dict[str, str]) -> list[str]:
|
|
"""List subkeys below a root session key."""
|
|
session_dir = self.root.joinpath(*self._safe_parts(key["session_id"]))
|
|
if not session_dir.exists():
|
|
return []
|
|
subkeys = []
|
|
for path in session_dir.rglob("*.jsonl"):
|
|
if path.is_file():
|
|
subkeys.append(str(path.relative_to(session_dir).with_suffix("")))
|
|
return subkeys
|
|
|
|
|
|
@R.register("claude_code")
|
|
class CcAgentWrapper(BaseAgentWrapper):
|
|
"""Agent wrapper backed by Claude Code SDK."""
|
|
|
|
DEFAULT_DISALLOWED_TOOLS = ["WebSearch"]
|
|
|
|
@staticmethod
|
|
def _first_non_empty(*values: Any) -> str:
|
|
for value in values:
|
|
if isinstance(value, str) and value:
|
|
return value
|
|
return ""
|
|
|
|
def _default_llm_credential(self) -> dict[str, Any]:
|
|
"""Return the default as_llm credential config, if available."""
|
|
if self.app_context is None:
|
|
return {}
|
|
components = self.app_context.app_config.components
|
|
llm_configs = components.get("as_llm") or components.get("AS_LLM") or components.get("as_llm".upper())
|
|
if llm_configs is None:
|
|
from ...enumeration import ComponentEnum
|
|
|
|
llm_configs = components.get(ComponentEnum.AS_LLM)
|
|
if not isinstance(llm_configs, dict):
|
|
return {}
|
|
|
|
default_llm = llm_configs.get("default")
|
|
credential = getattr(default_llm, "credential", None)
|
|
return credential if isinstance(credential, dict) else {}
|
|
|
|
def _claude_code_api_env(self, kwargs: dict[str, Any]) -> dict[str, str]:
|
|
"""Resolve Anthropic-compatible API environment for Claude Code."""
|
|
credential = kwargs.get("credential") if isinstance(kwargs.get("credential"), dict) else {}
|
|
default_credential = self._default_llm_credential()
|
|
|
|
base_url = self._first_non_empty(
|
|
kwargs.get("base_url"),
|
|
credential.get("base_url"),
|
|
os.getenv("ANTHROPIC_BASE_URL"),
|
|
os.getenv("CLAUDE_CODE_BASE_URL"),
|
|
os.getenv("LLM_BASE_URL"),
|
|
default_credential.get("base_url"),
|
|
)
|
|
api_key = self._first_non_empty(
|
|
kwargs.get("api_key"),
|
|
credential.get("api_key"),
|
|
os.getenv("ANTHROPIC_AUTH_TOKEN"),
|
|
os.getenv("CLAUDE_CODE_API_KEY"),
|
|
os.getenv("LLM_API_KEY"),
|
|
default_credential.get("api_key"),
|
|
)
|
|
|
|
env: dict[str, str] = {}
|
|
if base_url:
|
|
env["ANTHROPIC_BASE_URL"] = base_url
|
|
if api_key:
|
|
env["ANTHROPIC_AUTH_TOKEN"] = api_key
|
|
return env
|
|
|
|
@property
|
|
def session_path(self) -> Path:
|
|
"""Directory used for persisted Claude Code sessions."""
|
|
if self.app_context is None:
|
|
return self.vault_path / "session"
|
|
return self.vault_path / self.app_context.app_config.session_dir
|
|
|
|
def _ensure_claude_skill_dir(self, config_dir: Path) -> None:
|
|
"""Expose project skills through Claude Code skill discovery locations."""
|
|
project_skills = self.project_skills_root
|
|
if not project_skills.exists():
|
|
return
|
|
|
|
for target in (self.project_path / ".claude" / "skills", config_dir / "skills"):
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
if target.exists() or target.is_symlink():
|
|
if target.resolve() == project_skills.resolve():
|
|
continue
|
|
if target.is_dir() and not target.is_symlink():
|
|
shutil.rmtree(target)
|
|
else:
|
|
target.unlink()
|
|
|
|
target.symlink_to(project_skills, target_is_directory=True)
|
|
except OSError as exc:
|
|
self.logger.warning(f"Failed to link Claude Code skills directory {target}: {exc}")
|
|
|
|
@staticmethod
|
|
def _make_tool(job: "BaseJob"):
|
|
from claude_agent_sdk import SdkMcpTool
|
|
|
|
async def run_job(args):
|
|
response = await job(**args)
|
|
return {"content": [{"type": "text", "text": str(response.answer)}], "is_error": not response.success}
|
|
|
|
return SdkMcpTool(name=job.name, description=job.description, input_schema=job.parameters, handler=run_job)
|
|
|
|
def _build_options(self, inputs: Any, stream: bool = False, **kwargs) -> Any:
|
|
"""Build ClaudeAgentOptions from kwargs.
|
|
|
|
``stream=True`` enables ``include_partial_messages`` so that
|
|
``StreamEvent`` messages are emitted alongside the final
|
|
``ResultMessage``.
|
|
"""
|
|
from claude_agent_sdk import create_sdk_mcp_server
|
|
from claude_agent_sdk.types import ClaudeAgentOptions
|
|
|
|
kwargs = self._merged_kwargs(kwargs)
|
|
|
|
skills = kwargs.get("skills")
|
|
if isinstance(skills, str) and skills != "all":
|
|
kwargs["skills"] = [skills]
|
|
|
|
if "setting_sources" not in kwargs and kwargs.get("skills") is None:
|
|
kwargs["setting_sources"] = []
|
|
disallowed_tools = list(kwargs.get("disallowed_tools") or [])
|
|
for tool_name in self.DEFAULT_DISALLOWED_TOOLS:
|
|
if tool_name not in disallowed_tools:
|
|
disallowed_tools.append(tool_name)
|
|
kwargs["disallowed_tools"] = disallowed_tools
|
|
|
|
opts = ClaudeAgentOptions()
|
|
if stream:
|
|
opts.include_partial_messages = True
|
|
|
|
skip_keys = {"job_tools", "output_schema", "api_key", "base_url", "credential"}
|
|
for k, v in kwargs.items():
|
|
if k not in skip_keys and hasattr(opts, k):
|
|
setattr(opts, k, v)
|
|
|
|
model = getattr(opts, "model", None) or kwargs.get("model")
|
|
project_env = self.project_path / ".env"
|
|
opts.env.update(load_env(project_env) if project_env.exists() else load_env())
|
|
extra_env_dict: dict = self._claude_code_api_env(kwargs)
|
|
if model:
|
|
extra_env_dict.update(
|
|
{
|
|
"ANTHROPIC_MODEL": model,
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": model,
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL": model,
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": model,
|
|
},
|
|
)
|
|
opts.env.update(extra_env_dict)
|
|
self.session_path.mkdir(parents=True, exist_ok=True)
|
|
opts.cwd = opts.cwd or self.project_path
|
|
claude_config_dir = self.session_path / "claude_config"
|
|
opts.env.setdefault("CLAUDE_CONFIG_DIR", str(claude_config_dir))
|
|
if opts.skills is not None:
|
|
self._ensure_claude_skill_dir(claude_config_dir)
|
|
opts.session_store = opts.session_store or _CcFileSessionStore(self.session_path / "claude_code")
|
|
|
|
job_tools: list[str] = kwargs.get("job_tools", [])
|
|
resolved_jobs = self._resolve_job_tools(job_tools)
|
|
if resolved_jobs:
|
|
sdk_tools = [self._make_tool(job) for job in resolved_jobs]
|
|
server = create_sdk_mcp_server(name="mcp_server", tools=sdk_tools)
|
|
opts.mcp_servers = opts.mcp_servers if isinstance(opts.mcp_servers, dict) else {}
|
|
opts.mcp_servers["mcp_server"] = server
|
|
opts.allowed_tools.extend(job.name for job in resolved_jobs)
|
|
|
|
if output_schema := kwargs.get("output_schema"):
|
|
opts.output_format = {"type": "json_schema", "schema": output_schema}
|
|
|
|
if not isinstance(inputs, str):
|
|
raise NotImplementedError("Only string input is supported for Claude Code.")
|
|
|
|
return opts
|
|
|
|
# ----- StreamChunk conversion -------------------------------------------
|
|
|
|
@classmethod
|
|
# pylint: disable=too-many-return-statements
|
|
def _raw_event_to_chunk(
|
|
cls,
|
|
raw: dict,
|
|
session_id: str | None = None,
|
|
block_ids: dict[int, str] | None = None,
|
|
block_types: dict[int, str] | None = None,
|
|
tool_call_names: dict[int, str] | None = None,
|
|
) -> StreamChunk | None:
|
|
"""Convert a raw Anthropic streaming event dict to a StreamChunk.
|
|
|
|
``block_ids`` / ``block_types`` / ``tool_call_names`` map
|
|
content-block ``index`` to metadata tracked from the
|
|
``content_block_start`` event, so that later delta / stop
|
|
events can reference the correct ``block_id`` and
|
|
``chunk_type``.
|
|
|
|
Returns ``None`` for events that should be silently skipped.
|
|
"""
|
|
event_type = raw.get("type")
|
|
|
|
# --- Message-level lifecycle ----------------------------------------
|
|
|
|
if event_type == "message_start":
|
|
message = raw.get("message", {})
|
|
meta = {"message_id": message.get("id"), "model": message.get("model"), "role": message.get("role")}
|
|
return cls._chunk(ChunkEnum.REPLY_START, session_id=session_id, chunk="", metadata=meta)
|
|
|
|
if event_type == "message_delta":
|
|
delta = raw.get("delta", {})
|
|
usage = raw.get("usage", {})
|
|
return cls._chunk(
|
|
ChunkEnum.REPLY_END,
|
|
session_id=session_id,
|
|
chunk="",
|
|
output_tokens=usage.get("output_tokens"),
|
|
metadata={"stop_reason": delta.get("stop_reason")},
|
|
)
|
|
|
|
if event_type == "message_stop":
|
|
return cls._chunk(ChunkEnum.REPLY_END, session_id=session_id, chunk="")
|
|
|
|
# --- Content-block lifecycle ----------------------------------------
|
|
|
|
if event_type == "content_block_start":
|
|
idx, content_block = raw.get("index", 0), raw.get("content_block", {})
|
|
block_type, bid = content_block.get("type", ""), content_block.get("id", "")
|
|
|
|
# Track for later delta / stop correlation
|
|
if block_ids is not None and bid:
|
|
block_ids[idx] = bid
|
|
if block_types is not None and block_type:
|
|
block_types[idx] = block_type
|
|
if tool_call_names is not None and content_block.get("name"):
|
|
tool_call_names[idx] = content_block["name"]
|
|
|
|
if block_type == "text":
|
|
return cls._chunk(ChunkEnum.CONTENT, block_id=bid, chunk=content_block.get("text", ""))
|
|
if block_type == "thinking":
|
|
return cls._chunk(ChunkEnum.THINK, block_id=bid, chunk=content_block.get("thinking", ""))
|
|
if block_type == "tool_use":
|
|
payload = {"name": content_block.get("name"), "id": content_block.get("id")}
|
|
return cls._chunk(
|
|
ChunkEnum.TOOL_CALL,
|
|
block_id=bid,
|
|
tool_call_id=content_block.get("id"),
|
|
tool_call_name=content_block.get("name"),
|
|
chunk=json.dumps(payload),
|
|
)
|
|
return None
|
|
|
|
if event_type == "content_block_delta":
|
|
delta = raw.get("delta", {})
|
|
delta_type = delta.get("type", "")
|
|
idx = raw.get("index", 0)
|
|
bid = block_ids.get(idx) if block_ids else None
|
|
tc_name = tool_call_names.get(idx) if tool_call_names else None
|
|
|
|
if delta_type == "text_delta":
|
|
return cls._chunk(ChunkEnum.CONTENT, block_id=bid, chunk=delta.get("text", ""))
|
|
if delta_type == "thinking_delta":
|
|
return cls._chunk(ChunkEnum.THINK, block_id=bid, chunk=delta.get("thinking", ""))
|
|
if delta_type == "input_json_delta":
|
|
return cls._chunk(
|
|
ChunkEnum.TOOL_CALL,
|
|
block_id=bid,
|
|
tool_call_id=bid,
|
|
tool_call_name=tc_name,
|
|
chunk=delta.get("partial_json", ""),
|
|
)
|
|
return None
|
|
|
|
if event_type == "content_block_stop":
|
|
idx = raw.get("index", 0)
|
|
bid = block_ids.get(idx) if block_ids else None
|
|
btype = block_types.get(idx) if block_types else None
|
|
tc_name = tool_call_names.get(idx) if tool_call_names else None
|
|
|
|
if btype == "tool_use":
|
|
return cls._chunk(ChunkEnum.TOOL_CALL, block_id=bid, tool_call_id=bid, tool_call_name=tc_name, chunk="")
|
|
if btype == "thinking":
|
|
return cls._chunk(ChunkEnum.THINK, block_id=bid, chunk="")
|
|
# text or unknown -> CONTENT
|
|
return cls._chunk(ChunkEnum.CONTENT, block_id=bid, chunk="")
|
|
|
|
# Ping / other unknown types -> skip
|
|
return None
|
|
|
|
@classmethod
|
|
def _message_content_to_chunks(
|
|
cls,
|
|
msg: Any,
|
|
session_id: str | None = None,
|
|
visible_tool_call_ids: set[str] | None = None,
|
|
include_text: bool = False,
|
|
) -> list[StreamChunk]:
|
|
"""Convert non-partial SDK message content blocks into stream chunks.
|
|
|
|
Claude Code streams assistant text/tool-use deltas as ``StreamEvent``
|
|
objects, but tool results can arrive later as regular message content
|
|
blocks. Surface those blocks so the UI can show what each tool
|
|
returned. Some SDK/CLI combinations also put assistant text only in
|
|
regular message blocks, so callers can opt into text conversion.
|
|
"""
|
|
chunks: list[StreamChunk] = []
|
|
content = getattr(msg, "content", None)
|
|
if not isinstance(content, list):
|
|
return chunks
|
|
|
|
for block in content:
|
|
block_name = block.__class__.__name__
|
|
if include_text and block_name == "TextBlock":
|
|
text = getattr(block, "text", "")
|
|
if text:
|
|
chunks.append(cls._chunk(ChunkEnum.CONTENT, session_id=session_id, chunk=text))
|
|
elif include_text and isinstance(block, dict) and block.get("type") == "text":
|
|
text = block.get("text", "")
|
|
if text:
|
|
chunks.append(cls._chunk(ChunkEnum.CONTENT, session_id=session_id, chunk=text))
|
|
elif include_text and isinstance(block, str):
|
|
chunks.append(cls._chunk(ChunkEnum.CONTENT, session_id=session_id, chunk=block))
|
|
elif block_name in {"ToolResultBlock", "ServerToolResultBlock"}:
|
|
tool_use_id = getattr(block, "tool_use_id", None)
|
|
if visible_tool_call_ids is not None and tool_use_id not in visible_tool_call_ids:
|
|
continue
|
|
payload: dict[str, Any] = {
|
|
"tool_use_id": tool_use_id,
|
|
"content": getattr(block, "content", None),
|
|
}
|
|
if hasattr(block, "is_error"):
|
|
payload["is_error"] = getattr(block, "is_error")
|
|
chunks.append(
|
|
cls._chunk(
|
|
ChunkEnum.TOOL_RESULT,
|
|
session_id=session_id,
|
|
block_id=tool_use_id,
|
|
tool_call_id=tool_use_id,
|
|
chunk=payload,
|
|
),
|
|
)
|
|
|
|
return chunks
|
|
|
|
@staticmethod
|
|
def _result_message_is_error(msg: Any) -> bool:
|
|
"""Return whether an SDK ResultMessage represents a failed result."""
|
|
subtype = getattr(msg, "subtype", None)
|
|
if isinstance(subtype, str) and subtype.lower() == "success":
|
|
return False
|
|
|
|
is_error = getattr(msg, "is_error", False)
|
|
if isinstance(is_error, bool):
|
|
return is_error
|
|
if isinstance(is_error, str):
|
|
return is_error.lower() in {"true", "error", "errored", "failed", "failure"}
|
|
|
|
return isinstance(subtype, str) and subtype.lower() in {"error", "failed", "failure"}
|
|
|
|
@staticmethod
|
|
def _is_trailing_success_error(exc: Exception) -> bool:
|
|
"""Return whether an SDK iterator error is the known success-exit artifact."""
|
|
return "Claude Code returned an error result: success" in str(exc)
|
|
|
|
# ----- reply / reply_stream --------------------------------------------
|
|
|
|
async def reply(self, inputs: Any, **kwargs) -> dict:
|
|
from claude_agent_sdk import query, ResultMessage
|
|
|
|
opts = self._build_options(inputs, stream=False, **kwargs)
|
|
|
|
last_msg = None
|
|
async for msg in query(prompt=inputs, options=opts):
|
|
if isinstance(msg, ResultMessage):
|
|
last_msg = msg
|
|
|
|
if last_msg is None:
|
|
raise ValueError("No message received from Claude Code.")
|
|
|
|
result = {
|
|
"session_id": last_msg.session_id or "",
|
|
"last_message": asdict(last_msg),
|
|
"result": last_msg.result,
|
|
}
|
|
output_schema = kwargs.get("output_schema") or self.kwargs.get("output_schema")
|
|
if output_schema and last_msg.structured_output:
|
|
result["structured_output"] = last_msg.structured_output
|
|
return result
|
|
|
|
async def reply_stream(self, inputs: Any, **kwargs) -> AsyncGenerator[StreamChunk, None]:
|
|
"""Stream Claude Code events as unified StreamChunk objects."""
|
|
from claude_agent_sdk import query, ResultMessage, AssistantMessage, StreamEvent, UserMessage
|
|
from claude_agent_sdk.types import RateLimitEvent
|
|
|
|
opts = self._build_options(inputs, stream=True, **kwargs)
|
|
|
|
block_ids: dict[int, str] = {}
|
|
block_types: dict[int, str] = {}
|
|
tool_call_names: dict[int, str] = {}
|
|
visible_tool_call_ids: set[str] = set()
|
|
current_session_id: str | None = None
|
|
emitted_content = False
|
|
received_result_message = False
|
|
|
|
stream = query(prompt=inputs, options=opts)
|
|
try:
|
|
async for msg in stream:
|
|
if isinstance(msg, StreamEvent):
|
|
current_session_id = msg.session_id or current_session_id
|
|
chunk = self._raw_event_to_chunk(
|
|
msg.event,
|
|
session_id=msg.session_id,
|
|
block_ids=block_ids,
|
|
block_types=block_types,
|
|
tool_call_names=tool_call_names,
|
|
)
|
|
if chunk is not None:
|
|
chunk.session_id = chunk.session_id or msg.session_id
|
|
if chunk.chunk_type == ChunkEnum.TOOL_CALL and chunk.tool_call_id:
|
|
visible_tool_call_ids.add(chunk.tool_call_id)
|
|
if chunk.chunk_type == ChunkEnum.CONTENT and chunk.chunk:
|
|
emitted_content = True
|
|
yield chunk
|
|
|
|
elif isinstance(msg, UserMessage):
|
|
for chunk in self._message_content_to_chunks(msg, current_session_id, visible_tool_call_ids):
|
|
yield chunk
|
|
|
|
elif isinstance(msg, ResultMessage):
|
|
received_result_message = True
|
|
current_session_id = msg.session_id or current_session_id
|
|
if not emitted_content and getattr(msg, "result", None):
|
|
emitted_content = True
|
|
yield self._chunk(ChunkEnum.CONTENT, session_id=msg.session_id or "", chunk=msg.result)
|
|
# Final result: emit USAGE + REPLY_END
|
|
meta = {
|
|
"duration_ms": msg.duration_ms,
|
|
"duration_api_ms": msg.duration_api_ms,
|
|
"stop_reason": msg.stop_reason,
|
|
"num_turns": msg.num_turns,
|
|
}
|
|
yield self._chunk(
|
|
ChunkEnum.USAGE,
|
|
session_id=msg.session_id or "",
|
|
chunk=json.dumps(msg.usage or {}),
|
|
metadata=meta,
|
|
)
|
|
if self._result_message_is_error(msg):
|
|
yield self._chunk(
|
|
ChunkEnum.ERROR,
|
|
session_id=msg.session_id or "",
|
|
chunk=str(msg.errors) if msg.errors else "Unknown error",
|
|
)
|
|
yield self._chunk(ChunkEnum.REPLY_END, session_id=msg.session_id or "", chunk="")
|
|
|
|
elif isinstance(msg, AssistantMessage):
|
|
current_session_id = msg.session_id or current_session_id
|
|
# Intermediate assistant text/tool-use is already streamed
|
|
# via StreamEvents. Still surface tool-result blocks if the
|
|
# SDK includes any in a regular assistant message.
|
|
for chunk in self._message_content_to_chunks(
|
|
msg,
|
|
current_session_id,
|
|
visible_tool_call_ids,
|
|
include_text=not emitted_content,
|
|
):
|
|
if chunk.chunk_type == ChunkEnum.CONTENT and chunk.chunk:
|
|
emitted_content = True
|
|
yield chunk
|
|
|
|
elif isinstance(msg, RateLimitEvent):
|
|
yield self._chunk(ChunkEnum.ERROR, session_id=msg.session_id, chunk="Rate limit exceeded")
|
|
except Exception as exc:
|
|
if received_result_message and self._is_trailing_success_error(exc):
|
|
self.logger.debug(f"Ignoring Claude Code trailing success error after final result: {exc}")
|
|
else:
|
|
raise
|
|
finally:
|
|
try:
|
|
await stream.aclose()
|
|
except Exception as exc:
|
|
if not (received_result_message and self._is_trailing_success_error(exc)):
|
|
raise
|
|
self.logger.debug(f"Ignoring Claude Code stream close error after final result: {exc}")
|