ReMe/reme4/components/agent_wrapper/cc_agent_wrapper.py
jinliyl 83831ec90c
feat(core): enhance reme4 (#281)
### 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` 更新。
**
2026-06-19 01:35:31 +08:00

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}")