From 641bc7b14931d1490d9423464881704b24f901b3 Mon Sep 17 00:00:00 2001 From: Color2333 <1552429809@qq.com> Date: Fri, 20 Mar 2026 11:59:06 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20Agent=20Harness=20=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E9=87=8D=E6=9E=84=20Phase1-3=20=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考 learn-claude-code (s01-s12) 体系重构 Agent 基础设施。 Phase 1 - Agent Loop + 工具层: - packages/agent_core/loop.py: 显式 AgentLoop 类,while + stop_reason 循环 - packages/agent_core/dispatcher.py: ToolDispatcher,工具注册表 + dispatch map - packages/agent_core/tools/: bash / filesystem handlers Phase 2 - 持久化 + Context: - packages/agent_core/tasks.py: TaskManager,JSON 文件持久化 + blockedBy 依赖图 - packages/agent_core/background.py: BackgroundTaskRunner,daemon 线程池 Phase 3 - Team 协作: - packages/agent_core/message_bus.py: MessageBus,JSONL 邮箱异步通信 - packages/agent_core/teammates.py: TeammateManager,持久 agent 线程管理 - packages/agent_core/protocols.py: TeamProtocols,shutdown/plan_approval FSM Skills (packages/agent_core/skills/): - agent-loop: AgentLoop 使用指南 - tool-dispatcher: 工具注册机制 - task-persistence: TaskManager 文件格式 - context-compaction: 3层压缩策略 - agent-teams: 多 agent 协作 - team-protocols: 通信协议 FSM README 引用 learn-claude-code 项目致谢。 --- README.md | 1 + packages/agent_core/__init__.py | 40 +++ packages/agent_core/background.py | 166 ++++++++++++ packages/agent_core/dispatcher.py | 253 ++++++++++++++++++ packages/agent_core/loop.py | 253 ++++++++++++++++++ packages/agent_core/message_bus.py | 99 +++++++ packages/agent_core/protocols.py | 124 +++++++++ .../agent_core/skills/agent-loop/SKILL.md | 106 ++++++++ .../agent_core/skills/agent-teams/SKILL.md | 165 ++++++++++++ .../agent-teams/team-protocols/SKILL.md | 138 ++++++++++ .../skills/context-compaction/SKILL.md | 125 +++++++++ .../skills/task-persistence/SKILL.md | 148 ++++++++++ .../skills/tool-dispatcher/SKILL.md | 148 ++++++++++ packages/agent_core/tasks.py | 250 +++++++++++++++++ packages/agent_core/teammates.py | 194 ++++++++++++++ packages/agent_core/tools/__init__.py | 0 packages/agent_core/tools/bash.py | 42 +++ packages/agent_core/tools/filesystem.py | 148 ++++++++++ 18 files changed, 2400 insertions(+) create mode 100644 packages/agent_core/__init__.py create mode 100644 packages/agent_core/background.py create mode 100644 packages/agent_core/dispatcher.py create mode 100644 packages/agent_core/loop.py create mode 100644 packages/agent_core/message_bus.py create mode 100644 packages/agent_core/protocols.py create mode 100644 packages/agent_core/skills/agent-loop/SKILL.md create mode 100644 packages/agent_core/skills/agent-teams/SKILL.md create mode 100644 packages/agent_core/skills/agent-teams/team-protocols/SKILL.md create mode 100644 packages/agent_core/skills/context-compaction/SKILL.md create mode 100644 packages/agent_core/skills/task-persistence/SKILL.md create mode 100644 packages/agent_core/skills/tool-dispatcher/SKILL.md create mode 100644 packages/agent_core/tasks.py create mode 100644 packages/agent_core/teammates.py create mode 100644 packages/agent_core/tools/__init__.py create mode 100644 packages/agent_core/tools/bash.py create mode 100644 packages/agent_core/tools/filesystem.py diff --git a/README.md b/README.md index 99498f3..b39a062 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,7 @@ alembic upgrade head - **[ArXiv](https://arxiv.org)** — 开放论文平台 - **[Semantic Scholar](https://www.semanticscholar.org)** — 引用数据来源 - **[CSFeeds](https://csarxiv.org)** — 论文源订阅服务 +- **[learn-claude-code](https://github.com/shareAI-lab/learn-claude-code)** — Agent Harness 工程体系启发,s01-s12 渐进式解构:Loop → Tools → Planning → Subagents → Skills → Context → Tasks → Background → Teams → Protocols → Autonomous --- diff --git a/packages/agent_core/__init__.py b/packages/agent_core/__init__.py new file mode 100644 index 0000000..321f248 --- /dev/null +++ b/packages/agent_core/__init__.py @@ -0,0 +1,40 @@ +""" +agent_core — Agent Harness 工程核心库 + +参考 learn-claude-code (https://github.com/shareAI-lab/learn-claude-code) +s01-s12 渐进式 harness 机制 Python 实现。 + +主要模块: +- loop.py : AgentLoop,显式 agent 循环 +- dispatcher.py : ToolDispatcher,工具注册与分发 +- tasks.py : TaskManager,任务持久化 + 依赖图 +- message_bus.py : MessageBus,team 异步通信 +- teammates.py : TeammateManager,持久 agent 线程管理 +- protocols.py : TeamProtocols,shutdown/plan_approval FSM +- background.py : BackgroundTaskRunner,daemon 线程池 +""" + +from .background import BackgroundTask, BackgroundTaskRunner +from .dispatcher import ToolDispatcher, make_default_dispatcher +from .loop import AgentConfig, AgentLoop, AgentResponse, StopReason +from .message_bus import MessageBus +from .protocols import ProtocolState, TeamProtocols +from .tasks import Task, TaskManager +from .teammates import TeammateManager + +__all__ = [ + "AgentLoop", + "AgentConfig", + "AgentResponse", + "StopReason", + "ToolDispatcher", + "make_default_dispatcher", + "TaskManager", + "Task", + "MessageBus", + "TeammateManager", + "TeamProtocols", + "ProtocolState", + "BackgroundTaskRunner", + "BackgroundTask", +] diff --git a/packages/agent_core/background.py b/packages/agent_core/background.py new file mode 100644 index 0000000..fb002cd --- /dev/null +++ b/packages/agent_core/background.py @@ -0,0 +1,166 @@ +""" +BackgroundTaskRunner — 后台任务执行,参考 learn-claude-code s08 + +核心设计: +- daemon 线程池执行耗时操作(编译、测试、部署) +- 主 agent loop 不阻塞,继续推理 +- 任务完成后,通过通知队列注入回调 + + BackgroundRunner.submit("build frontend", build_command): + → 启动 daemon 线程执行命令 + → 主线程立即返回 "Task submitted" + → daemon 完成 → notify(result) + → 下次 agent loop 读取通知 + + 用途: + - npm build / docker build(分钟级) + - 长时间运行的测试 + - CI/CD 部署 + - 任何不应该卡住 agent 的操作 +""" + +from __future__ import annotations + +import queue +import threading +import time +from dataclasses import dataclass +from enum import Enum + + +class TaskStatus(Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +@dataclass +class BackgroundTask: + id: str + name: str + command: str + status: TaskStatus = TaskStatus.PENDING + started_at: float | None = None + finished_at: float | None = None + result: str | None = None + error: str | None = None + + @property + def duration_ms(self) -> float | None: + if self.started_at and self.finished_at: + return (self.finished_at - self.started_at) * 1000 + return None + + +class BackgroundTaskRunner: + """ + 后台任务执行器。 + submit() 立即返回,任务在 daemon 线程运行。 + poll() / drain_notifications() 获取完成结果。 + """ + + def __init__(self, max_workers: int = 4): + self._task_queue: queue.Queue[BackgroundTask] = queue.Queue() + self._notification_queue: queue.Queue[BackgroundTask] = queue.Queue() + self._threads: list[threading.Thread] = [] + self._max_workers = max_workers + self._shutdown = False + self._started = False + self._task_counter = 0 + + def start(self) -> None: + if self._started: + return + self._started = True + for i in range(self._max_workers): + t = threading.Thread(target=self._worker, daemon=True, name=f"bg-worker-{i}") + t.start() + self._threads.append(t) + + def _worker(self) -> None: + while not self._shutdown: + try: + task = self._task_queue.get(timeout=1.0) + except queue.Empty: + continue + + task.status = TaskStatus.RUNNING + task.started_at = time.time() + + import subprocess + + try: + result = subprocess.run( + task.command, + shell=True, + capture_output=True, + text=True, + timeout=600, # 10min max + ) + task.result = (result.stdout + result.stderr).strip() + task.status = TaskStatus.COMPLETED + except subprocess.TimeoutExpired: + task.error = "Timeout (600s exceeded)" + task.status = TaskStatus.FAILED + except Exception as exc: # noqa: BLE001 + task.error = f"{type(exc).__name__}: {exc}" + task.status = TaskStatus.FAILED + finally: + task.finished_at = time.time() + self._notification_queue.put(task) + + def submit(self, name: str, command: str) -> str: + if not self._started: + self.start() + + self._task_counter += 1 + task_id = f"bg_{self._task_counter}_{int(time.time())}" + task = BackgroundTask(id=task_id, name=name, command=command) + self._task_queue.put(task) + return task_id + + def status(self, task_id: str) -> str: + # Peek at notification queue without removing + with self._notification_queue.mutex: + for task in self._notification_queue.queue: + if task.id == task_id: + return self._format_status(task) + return f"Task '{task_id}' not found or still pending." + + def poll(self) -> list[BackgroundTask]: + """获取所有已完成的任务(从通知队列取出)""" + completed = [] + while True: + try: + task = self._notification_queue.get_nowait() + completed.append(task) + except queue.Empty: + break + return completed + + def drain_notifications(self) -> str: + """以文本形式返回所有已完成任务(适合注入 LLM context)""" + completed = self.poll() + if not completed: + return "" + lines = ["[Background tasks completed]"] + for t in completed: + lines.append(self._format_status(t)) + return "\n".join(lines) + + def shutdown(self, timeout: float = 5.0) -> None: + self._shutdown = True + for t in self._threads: + t.join(timeout=timeout) + + @staticmethod + def _format_status(task: BackgroundTask) -> str: + duration = f"{task.duration_ms:.0f}ms" if task.duration_ms else "?" + if task.status == TaskStatus.COMPLETED: + result_preview = (task.result or "")[:200] + return f"[{task.id}] {task.name}: completed in {duration}\n{result_preview}" + elif task.status == TaskStatus.FAILED: + return f"[{task.id}] {task.name}: FAILED in {duration}\n{task.error}" + else: + return f"[{task.id}] {task.name}: {task.status.value}" diff --git a/packages/agent_core/dispatcher.py b/packages/agent_core/dispatcher.py new file mode 100644 index 0000000..5774c3d --- /dev/null +++ b/packages/agent_core/dispatcher.py @@ -0,0 +1,253 @@ +""" +ToolDispatcher — 工具注册与分发,参考 learn-claude-code s02 + +核心模式(s02): + "Adding a tool means adding one handler" + 工具通过 name → handler 注册到 dispatch map + loop 不变,加工具 = 加 handler + + TOOL_HANDLERS = { + "bash": lambda **kw: run_bash(kw["command"]), + "read_file": lambda **kw: run_read(kw["path"]), + "task_create": lambda **kw: TASKS.create(kw["subject"], ...), + } + +关键设计原则: +1. 工具定义(tools list)和工具处理(handlers dict)分离 +2. 工具定义用于 LLM 的 tool_use API +3. 工具处理用于实际执行 +4. 注册顺序不影响功能 +""" + +from __future__ import annotations + +import inspect +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + +from .tools.bash import run_bash +from .tools.filesystem import run_edit, run_glob, run_grep, run_read, run_write + + +class ToolDispatcher: + """ + 工具注册与分发中心。 + + 使用方式: + dispatcher = ToolDispatcher() + dispatcher.register("bash", run_bash) + dispatcher.register("read_file", run_read) + dispatcher.register("task_create", task_manager.create) + dispatcher.register("task_list", task_manager.list_all) + + # 批量注册 + dispatcher.register_many({ + "bash": run_bash, + "read_file": run_read, + "write_file": run_write, + }) + """ + + def __init__(self): + self._handlers: dict[str, Callable[..., str | dict[str, Any]]] = {} + self._tool_definitions: list[dict[str, Any]] = [] + self._frozen = False + + def register( + self, + name: str, + handler: Callable[..., str | dict[str, Any]], + description: str | None = None, + input_schema: dict[str, Any] | None = None, + ) -> ToolDispatcher: + """ + 注册一个工具。 + + 参数: + name: 工具名称,LLM 通过这个名称调用 + handler: 执行函数,接受 **kwargs + description: 工具描述(默认从 handler docstring 提取) + input_schema: Anthropic tool input schema(默认从 handler 签名推断) + """ + if self._frozen: + raise RuntimeError( + "Cannot register tools after get_tool_definitions() was called. Register all tools first." + ) + + self._handlers[name] = handler + + if description is None: + description = self._extract_description(handler) + + if input_schema is None: + input_schema = self._infer_schema(handler) + + tool_def = { + "name": name, + "description": description, + "input_schema": input_schema, + } + self._tool_definitions.append(tool_def) + return self + + def register_many( + self, + tools: dict[str, Callable[..., str | dict[str, Any]]], + ) -> ToolDispatcher: + """批量注册工具""" + for name, handler in tools.items(): + self.register(name, handler) + return self + + def dispatch(self, name: str, **kwargs) -> str | dict[str, Any]: + """ + 分发工具调用到对应 handler。 + 如果 handler 抛出异常,捕获后返回错误字符串。 + """ + handler = self._handlers.get(name) + if handler is None: + return f"Error: Unknown tool '{name}'. Available: {list(self._handlers.keys())}" + + try: + result = handler(**kwargs) + return result + except TypeError as exc: + # 参数不匹配 + sig = inspect.signature(handler) + return ( + f"Error: Tool '{name}' received wrong arguments. " + f"Expected params: {list(sig.parameters.keys())}. Error: {exc}" + ) + except Exception as exc: # noqa: BLE001 + return f"Error: Tool '{name}' failed: {type(exc).__name__}: {exc}" + + def get_tool_definitions(self) -> list[dict[str, Any]]: + """ + 返回 Anthropic 格式的工具定义列表。 + 一旦调用,冻结注册——不能再添加工具。 + """ + self._frozen = True + return self._tool_definitions + + def list_tools(self) -> list[str]: + """列出所有已注册工具名称""" + return list(self._handlers.keys()) + + def unregister(self, name: str) -> bool: + """注销一个工具(谨慎使用)""" + if name in self._handlers: + del self._handlers[name] + self._tool_definitions = [t for t in self._tool_definitions if t["name"] != name] + self._frozen = False + return True + return False + + # -- 私有工具 -- + @staticmethod + def _extract_description(handler: Callable) -> str: + doc = inspect.getdoc(handler) or "" + return doc.split("\n")[0].strip() if doc else f"Tool: {handler.__name__}" + + @staticmethod + def _infer_schema(handler: Callable) -> dict[str, Any]: + """ + 从 handler 签名推断 input_schema。 + 只处理基本类型:str, int, float, bool, list + """ + sig = inspect.signature(handler) + properties: dict[str, dict[str, Any]] = {} + required: list[str] = [] + + for param_name, param in sig.parameters.items(): + if param_name in ("self", "kwargs", "args"): + continue + + annotation = param.annotation + if annotation is inspect.Parameter.empty: + param_type = "string" + else: + param_type = ToolDispatcher._annotation_to_json_type(annotation) + + prop = {"type": param_type} + if param.default is not inspect.Parameter.empty: + prop["default"] = param.default + + properties[param_name] = prop + + if param.default is inspect.Parameter.empty and param_name != "self": + required.append(param_name) + + return { + "type": "object", + "properties": properties, + "required": required if required else None, + } + + @staticmethod + def _annotation_to_json_type(annotation: Any) -> str: + """将 Python 类型映射到 JSON Schema 类型""" + origin = getattr(annotation, "__origin__", None) + + if origin is list: + return "array" + if origin is dict: + return "object" + + name = getattr(annotation, "__name__", str(annotation)) + mapping = { + "str": "string", + "int": "integer", + "float": "number", + "bool": "boolean", + "list": "array", + "dict": "object", + } + return mapping.get(name, "string") + + +# -- 全局默认工具注册表 -- +def make_default_dispatcher( + tasks_dir: str | None = None, + workdir: str | None = None, +) -> ToolDispatcher: + """ + 创建默认工具集的 dispatcher。 + 包含所有基础工具:bash, read, write, edit, glob, grep + 可选添加 tasks 工具。 + """ + from pathlib import Path + + workdir_path = Path(workdir) if workdir else Path.cwd() + + def bash(command: str) -> str: + return run_bash(command, cwd=str(workdir_path)) + + dispatcher = ToolDispatcher() + dispatcher.register("bash", bash) + dispatcher.register("read_file", run_read) + dispatcher.register("write_file", run_write) + dispatcher.register("edit_file", run_edit) + dispatcher.register("glob", run_glob) + dispatcher.register("grep", run_grep) + + if tasks_dir: + from .tasks import TaskManager + + tm = TaskManager(Path(tasks_dir)) + dispatcher.register( + "task_create", lambda subject, description="": tm.create(subject, description) + ) + dispatcher.register("task_list", lambda: tm.list_all()) + dispatcher.register("task_get", lambda task_id: tm.get(task_id)) + dispatcher.register( + "task_update", + lambda task_id, status=None, owner=None: tm.update(task_id, status, owner), + ) + + return dispatcher + + +# sentinel for undefined +undefined = None diff --git a/packages/agent_core/loop.py b/packages/agent_core/loop.py new file mode 100644 index 0000000..920345a --- /dev/null +++ b/packages/agent_core/loop.py @@ -0,0 +1,253 @@ +""" +AgentCore Loop — 显式 Agent 循环,参考 learn-claude-code s01/s02 + +核心模式(s01): + while stop_reason == "tool_use": + response = LLM(messages, tools) + execute tools + append results + +这个模块是整个 Agent Harness 的心脏。 +Model 决定何时调用工具,何时停止。 +Code 只负责:执行工具、收集结果、注入回 messages。 + + ┌──────────────────────────────────────┐ + │ messages[] (对话历史) │ + │ system (角色/上下文) │ + │ tools (可用工具列表) │ + └──────────┬───────────────────────────┘ + │ client.messages.create() + ▼ + ┌─────────────────────┐ + │ LLM │ + │ (Anthropic/OpenAI) │ + └──────────┬──────────┘ + │ response + stop_reason == "tool_use"? + │ + ┌─────────┴─────────┐ + │ yes │ no + ▼ ▼ + ┌─────────────────┐ ┌──────────┐ + │ for each block │ │ return │ + │ tool_use: │ │ text │ + │ execute() │ └──────────┘ + │ append result│ + │ loop back ─────┼──→ messages.append(result) + └─────────────────┘ +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, Any, cast + +from anthropic import Anthropic + +if TYPE_CHECKING: + from anthropic.types import MessageParam + + from .dispatcher import ToolDispatcher + + +class StopReason(Enum): + TOOL_USE = "tool_use" + END_TURN = "end_turn" + MAX_TOKENS = "max_tokens" + UNKNOWN = "unknown" + + +@dataclass +class ToolResult: + tool_use_id: str + name: str + output: str | Exception + duration_ms: float | None = None + + +@dataclass +class AgentResponse: + text: str | None + stop_reason: StopReason + tool_results: list[ToolResult] + raw: Any = None + + +# -- Tool Definition (Anthropic format) -- +ToolDef = dict[str, Any] +ToolHandler = Callable[..., str | dict[str, Any]] + + +@dataclass +class AgentConfig: + model: str + system_prompt: str + max_tokens: int = 8192 + timeout_seconds: int = 120 + max_loop_iterations: int = 500 + + +class AgentLoop: + """ + 显式 Agent 循环类。 + + 使用方式: + config = AgentConfig( + model="claude-sonnet-4-20250514", + system_prompt="You are a coding agent...", + ) + dispatcher = ToolDispatcher() + dispatcher.register("bash", bash_handler) + dispatcher.register("read_file", read_handler) + + loop = AgentLoop(config, dispatcher) + result = loop.run([{"role": "user", "content": "帮我写一个 hello world"}]) + """ + + def __init__(self, config: AgentConfig, dispatcher: ToolDispatcher): + self.config = config + self.dispatcher = dispatcher + self._iteration_count = 0 + + def run(self, messages: list[dict[str, Any]]) -> AgentResponse: + """ + 执行 Agent 循环,直到 LLM 停止调用工具。 + 返回最终响应(含所有工具结果)。 + """ + self._iteration_count = 0 + tool_results: list[ToolResult] = [] + + while True: + self._iteration_count += 1 + if self._iteration_count > self.config.max_loop_iterations: + raise RuntimeError( + f"Agent loop exceeded max iterations ({self.config.max_loop_iterations}). " + "Possible infinite loop or very long task." + ) + + response = self._call_llm(messages) + stop_reason = self._parse_stop_reason(response) + + if stop_reason != StopReason.TOOL_USE: + return AgentResponse( + text=self._extract_text(response), + stop_reason=stop_reason, + tool_results=tool_results, + raw=response, + ) + + # 执行所有工具调用 + batch_results: list[ToolResult] = [] + for block in self._iter_tool_blocks(response): + result = self._execute_tool(block) + batch_results.append(result) + + tool_results.extend(batch_results) + + # 把工具结果注入 messages,继续循环 + messages.append({"role": "assistant", "content": response.content}) + messages.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": r.tool_use_id, + "content": self._safe_output(r.output), + } + for r in batch_results + ], + } + ) + + def _call_llm(self, messages: list[dict[str, Any]]) -> Any: + client = Anthropic() + tool_defs = self.dispatcher.get_tool_definitions() + + response = client.messages.create( + model=self.config.model, + system=self.config.system_prompt, + messages=cast("list[MessageParam]", messages), + tools=tool_defs, + max_tokens=self.config.max_tokens, + ) + return response + + _STOP_REASON_MAP = { + "tool_use": StopReason.TOOL_USE, + "end_turn": StopReason.END_TURN, + "max_tokens": StopReason.MAX_TOKENS, + } + + def _parse_stop_reason(self, response: Any) -> StopReason: + sr = getattr(response, "stop_reason", None) or "" + return self._STOP_REASON_MAP.get(sr, StopReason.UNKNOWN) + + def _iter_tool_blocks(self, response: Any): + """遍历 response 中所有 tool_use 块""" + if hasattr(response, "content"): + for block in response.content: + if hasattr(block, "type") and block.type == "tool_use": + yield block + + def _execute_tool(self, block) -> ToolResult: + """执行单个工具调用""" + name = block.name + args = block.input + start = time.time() + + try: + output = self.dispatcher.dispatch(name, **args) + except Exception as exc: # noqa: BLE001 + output = exc + + duration_ms = (time.time() - start) * 1000 + return ToolResult( + tool_use_id=block.id, + name=name, + output=output, + duration_ms=duration_ms, + ) + + def _extract_text(self, response: Any) -> str | None: + if hasattr(response, "content"): + parts = [] + for block in response.content: + if hasattr(block, "type") and block.type == "text": + parts.append(block.text) + return "\n".join(parts) if parts else None + return None + + @staticmethod + def _safe_output(output: str | Exception) -> str: + if isinstance(output, Exception): + return f"Error: {type(output).__name__}: {output}" + return str(output)[:100_000] # 防止 context 溢出 + + +# -- Convenience: 单轮对话快捷函数 -- +def chat( + system_prompt: str, + user_message: str, + model: str = "claude-sonnet-4-20250514", + tools: dict[str, ToolHandler] | None = None, +) -> AgentResponse: + """ + 单轮对话快捷函数。 + 内部创建 AgentLoop,执行一轮完整循环,返回最终响应。 + """ + from .dispatcher import ToolDispatcher + + config = AgentConfig(model=model, system_prompt=system_prompt) + dispatcher = ToolDispatcher() + + if tools: + for name, handler in tools.items(): + dispatcher.register(name, handler) + + loop = AgentLoop(config, dispatcher) + messages = [{"role": "user", "content": user_message}] + return loop.run(messages) diff --git a/packages/agent_core/message_bus.py b/packages/agent_core/message_bus.py new file mode 100644 index 0000000..192c2e2 --- /dev/null +++ b/packages/agent_core/message_bus.py @@ -0,0 +1,99 @@ +""" +MessageBus — Agent Team 邮箱机制,参考 learn-claude-code s09 + +核心设计: +- 每个 teammate 有一个 JSONL 格式的收件箱 +- 发消息 = 追加到目标 teammate 的 .jsonl 文件 +- 读消息 = 读取并清空自己的收件箱 +- append-only 保证消息不丢失 + + .team/inbox/ + alice.jsonl ← alice 的消息 + bob.jsonl ← bob 的消息 + lead.jsonl ← lead 的消息 + + send_message("alice", "check the bug"): + → open("alice.jsonl", "a").write(json.dumps(msg)) +""" + +from __future__ import annotations + +import json +import time +from contextlib import suppress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +VALID_MSG_TYPES = { + "message", + "broadcast", + "shutdown_request", + "shutdown_response", + "plan_approval_request", + "plan_approval_response", +} + + +class MessageBus: + def __init__(self, inbox_dir: Path): + self.dir = inbox_dir + self.dir.mkdir(parents=True, exist_ok=True) + + def send( + self, + sender: str, + to: str, + content: str, + msg_type: str = "message", + extra: dict | None = None, + ) -> str: + if msg_type not in VALID_MSG_TYPES: + return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}" + + msg = { + "type": msg_type, + "from": sender, + "content": content, + "timestamp": time.time(), + } + if extra: + msg.update(extra) + + inbox_path = self.dir / f"{to}.jsonl" + with open(inbox_path, "a", encoding="utf-8") as f: + f.write(json.dumps(msg, ensure_ascii=False) + "\n") + + return f"Sent {msg_type} to {to}" + + def read_inbox(self, name: str) -> list[dict]: + inbox_path = self.dir / f"{name}.jsonl" + if not inbox_path.exists(): + return [] + + messages = [] + for line in inbox_path.read_text(encoding="utf-8").strip().splitlines(): + if line: + with suppress(json.JSONDecodeError): + messages.append(json.loads(line)) + + # drain after reading + inbox_path.write_text("", encoding="utf-8") + return messages + + def broadcast(self, sender: str, content: str, teammates: list[str]) -> str: + count = 0 + for name in teammates: + if name != sender: + self.send(sender, name, content, "broadcast") + count += 1 + return f"Broadcast to {count} teammates" + + def count_pending(self, name: str) -> int: + inbox_path = self.dir / f"{name}.jsonl" + if not inbox_path.exists(): + return 0 + return len( + [line for line in inbox_path.read_text(encoding="utf-8").strip().splitlines() if line] + ) diff --git a/packages/agent_core/protocols.py b/packages/agent_core/protocols.py new file mode 100644 index 0000000..082479f --- /dev/null +++ b/packages/agent_core/protocols.py @@ -0,0 +1,124 @@ +""" +TeamProtocols — Agent Team 通信协议,参考 learn-claude-code s10 + +核心设计: +- 5 种消息类型驱动所有协作: + message — 普通文本消息 + broadcast — 广播给所有 teammate + shutdown_request — 请求关闭 + shutdown_response — 同意/拒绝关闭 + plan_approval_request — 请求批准计划 + plan_approval_response — 同意/拒绝计划 + +- shutdown FSM: + lead → shutdown_request → teammate → shutdown_response → lead + +- plan_approval FSM: + teammate → plan_approval_request → lead + lead → plan_approval_response (approved/rejected) → teammate + +这些协议保证了多 agent 协作的确定性行为。 +""" + +from __future__ import annotations + +from enum import Enum + + +class ProtocolState(Enum): + IDLE = "idle" + AWAITING_SHUTDOWN_RESPONSE = "awaiting_shutdown_response" + AWAITING_PLAN_APPROVAL = "awaiting_plan_approval" + WORKING = "working" + + +class ProtocolError(Exception): + pass + + +class TeamProtocols: + """ + 管理 team-level 协议状态。 + 提供 shutdown 和 plan_approval FSM 实现。 + """ + + def __init__(self): + self._states: dict[str, ProtocolState] = {} + self._pending_approvals: dict[str, dict] = {} + + def state(self, agent_name: str) -> ProtocolState: + return self._states.get(agent_name, ProtocolState.IDLE) + + def set_state(self, agent_name: str, state: ProtocolState) -> None: + self._states[agent_name] = state + + def request_shutdown(self, agent_name: str) -> dict: + """生成 shutdown_request 消息""" + self._states[agent_name] = ProtocolState.AWAITING_SHUTDOWN_RESPONSE + return { + "type": "shutdown_request", + "content": f"Lead requests graceful shutdown of '{agent_name}'.", + } + + def handle_shutdown_response( + self, + agent_name: str, + approved: bool, + message: str = "", + ) -> str: + """处理 shutdown_response""" + if self._states.get(agent_name) != ProtocolState.AWAITING_SHUTDOWN_RESPONSE: + return f"Error: Not awaiting shutdown response from '{agent_name}'" + + self._states[agent_name] = ProtocolState.IDLE + if approved: + return f"'{agent_name}' approved shutdown." + return f"'{agent_name}' rejected shutdown: {message}" + + def request_plan_approval( + self, + agent_name: str, + plan_summary: str, + plan_detail: str, + ) -> dict: + """生成 plan_approval_request 消息""" + self._states[agent_name] = ProtocolState.AWAITING_PLAN_APPROVAL + approval_id = f"approval_{agent_name}_{len(self._pending_approvals)}" + self._pending_approvals[approval_id] = { + "agent_name": agent_name, + "plan_summary": plan_summary, + "plan_detail": plan_detail, + "approved": None, + } + return { + "type": "plan_approval_request", + "approval_id": approval_id, + "content": f"Plan from '{agent_name}': {plan_summary}", + } + + def handle_plan_response( + self, + approval_id: str, + approved: bool, + feedback: str = "", + ) -> str: + """处理 plan_approval_response""" + if approval_id not in self._pending_approvals: + return f"Error: Unknown approval_id '{approval_id}'" + + pending = self._pending_approvals[approval_id] + pending["approved"] = approved + pending["feedback"] = feedback + self._states[pending["agent_name"]] = ProtocolState.IDLE + + agent = pending["agent_name"] + if approved: + return f"Plan approved for '{agent}': {feedback}" + return f"Plan rejected for '{agent}': {feedback}" + + def get_pending_approvals(self) -> list[dict]: + return [ + {**p, "approval_id": k} + for k, p in self._pending_approvals.items() + if p["approved"] is None + ] diff --git a/packages/agent_core/skills/agent-loop/SKILL.md b/packages/agent_core/skills/agent-loop/SKILL.md new file mode 100644 index 0000000..e41a6e0 --- /dev/null +++ b/packages/agent_core/skills/agent-loop/SKILL.md @@ -0,0 +1,106 @@ +--- +name: agent-loop +description: | + AgentLoop 是整个 Agent Harness 的心脏。使用此 skill 当你需要: + - 构建新的 Agent 系统 + - 理解 Model + Harness 的关系 + - 使用 chat() 快捷函数进行单轮对话 +--- + +# Agent Loop — 理解 Model + Harness 的关系 + +## 核心真理 + +> **"The model IS the agent. The code is the harness."** + +- Model(Claude/GPT):决定何时调用工具,何时停止 +- Harness(我们的代码):执行工具、收集结果、注入回 context + +两者各司其职,不能互相替代。 + +## 循环模式(s01 核心) + +``` +while stop_reason == "tool_use": + response = LLM(messages, tools) + execute each tool call + append results to messages +``` + +这就是 agent loop 的全部。Model 决定何时调用工具,我们执行工具。 + +## AgentLoop 使用方式 + +```python +from agent_core import AgentConfig, ToolDispatcher, AgentLoop +from agent_core.tools.bash import run_bash +from agent_core.tools.filesystem import run_read, run_write, run_edit + +config = AgentConfig( + model="claude-sonnet-4-20250514", + system_prompt="You are a coding agent at /path/to/project.", +) + +dispatcher = ToolDispatcher() +dispatcher.register("bash", run_bash) +dispatcher.register("read_file", run_read) +dispatcher.register("write_file", run_write) +dispatcher.register("edit_file", run_edit) + +loop = AgentLoop(config, dispatcher) +messages = [{"role": "user", "content": "帮我添加用户认证功能"}] +response = loop.run(messages) + +print(response.text) # 最终文本回复 +print(response.stop_reason) # 为什么停止 +print(response.tool_results) # 所有工具执行结果 +``` + +## 快捷函数:chat() + +单轮对话不需要手动创建 loop: + +```python +from agent_core import chat + +result = chat( + system_prompt="You are a helpful coding assistant.", + user_message="What files were modified today?", + model="claude-sonnet-4-20250514", + tools={ + "bash": run_bash, + "read_file": run_read, + } +) +print(result.text) +``` + +## 何时使用 AgentLoop + +**使用:** +- 构建新的 Agent 系统 +- 需要细粒度控制 tool dispatch 逻辑 +- 需要在 tool 执行前后插入 hook +- 需要访问 `tool_results` 做日志/分析 + +**不需要:** +- 简单的一次性 LLM 调用 → 直接用 `client.messages.create()` +- 已有框架内置了 loop → 参考这个 skill 理解原理即可 + +## AgentLoop 类方法 + +| 方法 | 作用 | +|------|------| +| `loop.run(messages)` | 执行完整循环,返回 AgentResponse | +| `AgentResponse.text` | LLM 最终文本回复 | +| `AgentResponse.stop_reason` | 停止原因(tool_use/end_turn/max_tokens) | +| `AgentResponse.tool_results` | 所有工具执行结果列表 | +| `AgentResponse.raw` | 原始 LLM 响应对象 | + +## stop_reason 三种值 + +| 值 | 含义 | 我们做什么 | +|----|------|-----------| +| `tool_use` | LLM 调用了工具 | 继续循环 | +| `end_turn` | LLM 直接回复用户 | 返回 text | +| `max_tokens` | 达到 token 上限 | 返回已收集结果 | diff --git a/packages/agent_core/skills/agent-teams/SKILL.md b/packages/agent_core/skills/agent-teams/SKILL.md new file mode 100644 index 0000000..6bf5155 --- /dev/null +++ b/packages/agent_core/skills/agent-teams/SKILL.md @@ -0,0 +1,165 @@ +--- +name: agent-teams +description: | + 多 Agent 协作机制,持久 agent + 异步通信 + 团队管理。 + 使用此 skill 当你需要: + - 理解 Agent Teams 的工作方式 + - 使用 MessageBus 进行 agent 间通信 + - 使用 TeammateManager 管理持久 agent 线程 +--- + +# Agent Teams — 多 Agent 协作 + +## Subagent vs Teammate(s09 核心区别) + +``` +Subagent (一次性): + spawn → execute → return summary → destroyed + +Teammate (持久): + spawn → work → idle → work → ... → shutdown +``` + +**Subagent** 适合独立子任务。 +**Teammate** 适合需要持续工作、等待指令的助手。 + +## MessageBus — 异步邮箱机制 + +每个 teammate 有一个 JSONL 文件作为收件箱: + +``` +.team/inbox/ + alice.jsonl ← alice 的消息 + bob.jsonl ← bob 的消息 + lead.jsonl ← lead(主 agent)的消息 +``` + +发消息 = 追加到文件(append-only,不丢消息) +读消息 = 读取并清空文件 + +```python +from pathlib import Path +from agent_core import MessageBus + +bus = MessageBus(Path(".team/inbox")) + +# 发消息 +bus.send("lead", "alice", "please fix the login bug") +bus.send("lead", "bob", "review alice's PR") + +# 读自己的收件箱(lead) +messages = bus.read_inbox("lead") +# [{'type': 'message', 'from': 'alice', 'content': 'login bug fixed', ...}, ...] + +# 广播 +bus.broadcast("lead", "all devs: meeting in 5 min", teammates=["alice", "bob", "carol"]) +``` + +## TeammateManager — 持久 agent 线程管理 + +```python +from pathlib import Path +from agent_core import TeammateManager, make_default_dispatcher + +tm = TeammateManager( + team_dir=Path(".team"), + inbox_dir=Path(".team/inbox"), + model="claude-sonnet-4-20250514", + system_base="你是一个代码审查 agent。", +) + +dispatcher = make_default_dispatcher(workdir=Path.cwd()) +tm.spawn( + name="reviewer", + role="code_reviewer", + prompt="审查 apps/api/auth.py 的安全性,给出改进建议。", + tools=dispatcher.get_tool_definitions(), +) +# → Spawned 'reviewer' (role: code_reviewer) +``` + +reviewer agent 在独立线程运行,有自己的 inbox,可以接收 lead 的消息。 + +## 消息类型(5种) + +| 类型 | 用途 | 方向 | +|------|------|------| +| `message` | 普通文本消息 | any → any | +| `broadcast` | 广播 | lead → all | +| `shutdown_request` | 请求关闭 | lead → teammate | +| `shutdown_response` | 同意/拒绝关闭 | teammate → lead | +| `plan_approval_request` | 请求批准计划 | teammate → lead | +| `plan_approval_response` | 同意/拒绝计划 | lead → teammate | + +## shutdown 流程 + +``` +lead ──shutdown_request──→ alice + alice: 停止接单,完成当前任务 +alice ──shutdown_response(approved)──→ lead +``` + +```python +# lead 请求关闭 +tm.shutdown("alice") + +# alice 的线程收到 shutdown_request +# → 完成当前任务 → 状态改为 idle + +# 检查 team 状态 +print(tm.list_all()) +# Team: default +# reviewer (code_reviewer): idle +# tester (qa): idle +``` + +## 使用场景 + +**适合 Team 模式:** +- 代码审查 + 实际修改(两个 agent 并行) +- 前端 + 后端同时开发 +- 一个 agent 执行,另一个 agent 监控质量 + +**不适合:** +- 简单一次性任务 → 用 Subagent +- 强耦合任务 → 一个 agent 做到底 + +## 5种消息详解 + +### message(普通消息) + +```python +bus.send("lead", "alice", "PR #42 已创建,请审查") +bus.send("alice", "lead", "审查完成,发现3个安全问题") +``` + +### broadcast(广播) + +```python +bus.broadcast("lead", "meeting cancelled", teammates=["alice", "bob"]) +# → 发送到 alice.jsonl 和 bob.jsonl +``` + +### shutdown_request / shutdown_response + +```python +# lead 发送关闭请求 +tm.shutdown("alice") + +# alice 线程内部处理: +# if msg["type"] == "shutdown_request": +# bus.send("alice", "lead", "approved", "shutdown_response") +# return # 退出 loop +``` + +### plan_approval_request / response + +```python +# alice 发计划给 lead 审批 +bus.send("alice", "lead", + "我计划重构 auth.py,拆分成 auth/jwt.py 和 auth/oauth.py", + "plan_approval_request") + +# lead 审批 +bus.send("lead", "alice", "approved: 可以开始", "plan_approval_response") +``` diff --git a/packages/agent_core/skills/agent-teams/team-protocols/SKILL.md b/packages/agent_core/skills/agent-teams/team-protocols/SKILL.md new file mode 100644 index 0000000..9d82be8 --- /dev/null +++ b/packages/agent_core/skills/agent-teams/team-protocols/SKILL.md @@ -0,0 +1,138 @@ +--- +name: team-protocols +description: | + Agent Team 通信协议 FSM:shutdown / plan_approval。 + 使用此 skill 当你需要: + - 实现优雅关闭协议 + - 实现计划审批协议 + - 理解 s10 Team Protocols 的状态机设计 +--- + +# Team Protocols — 通信协议 FSM + +## 核心设计(s10 Motto) + +> **"Teammates need shared communication rules."** + +没有协议 = agent 间通信混乱。 +有协议 = 行为可预测、可控制。 + +## 5种消息类型 + +所有 agent 间通信都是这 5 种之一: + +| type | 触发方 | 接收方 | 用途 | +|------|--------|--------|------| +| `message` | any | any | 普通对话 | +| `broadcast` | lead | all | 广播通知 | +| `shutdown_request` | lead | teammate | 请求关闭 | +| `shutdown_response` | teammate | lead | 同意/拒绝关闭 | +| `plan_approval_request` | teammate | lead | 请求批准计划 | +| `plan_approval_response` | lead | teammate | 同意/拒绝计划 | + +## shutdown FSM + +``` + lead teammate + │ │ + │──── shutdown_request ───→│ + │ │ + │ [处理中] + │ │ + │←── shutdown_response ────┤ + │ (approved/rejected) │ + │ │ +``` + +### 实现 + +```python +from agent_core import TeamProtocols + +protocol = TeamProtocols() + +# lead 请求关闭 teammate +msg = protocol.request_shutdown("alice") +bus.send("lead", "alice", msg["content"], msg["type"]) + +# teammate 处理 shutdown_request +# ... 执行清理 ... + +# teammate 同意关闭 +result = protocol.handle_shutdown_response("alice", approved=True) +# "Alice approved shutdown." + +# teammate 拒绝关闭 +result = protocol.handle_shutdown_response("alice", approved=False, message="PR #42 还没审完") +# "Alice rejected shutdown: PR #42 还没审完" +``` + +## plan_approval FSM + +当 teammate 需要 lead 确认才能继续时: + +``` + teammate lead + │ │ + │─── plan_approval ────→│ + │ request │ + │ │ ← human review / auto approve + │←── plan_approval ─────┤ + │ response │ + │ │ +``` + +### 实现 + +```python +# teammate 发送计划审批请求 +msg = protocol.request_plan_approval( + agent_name="alice", + plan_summary="重构 auth.py 为独立模块", + plan_detail="拆成 auth/jwt.py, auth/oauth.py, auth/session.py", +) +bus.send("alice", "lead", msg["content"], msg["type"]) + +# lead 处理(可以是 human-in-the-loop 或 auto approve) +bus.send("lead", "alice", "approved: 可以开始重构", "plan_approval_response") +result = protocol.handle_plan_response(approval_id=msg["approval_id"], approved=True) +# "Plan approved for 'alice': 可以开始重构" +``` + +## 状态机 + +```python +class ProtocolState(Enum): + IDLE = "idle" + AWAITING_SHUTDOWN_RESPONSE = "awaiting_shutdown_response" + AWAITING_PLAN_APPROVAL = "awaiting_plan_approval" + WORKING = "working" +``` + +每个 agent 在 TeamProtocols 中记录状态: +- `IDLE` → 可以接受新任务 +- `AWAITING_SHUTDOWN_RESPONSE` → 等待关闭确认 +- `AWAITING_PLAN_APPROVAL` → 等待计划批准 +- `WORKING` → 正在执行任务 + +## 何时用 shutdown 协议 + +**必须用 shutdown:** +- 关闭持久 teammate 线程 +- 清理资源(关闭文件、数据库连接) +- 强制终止失控的 agent + +**避免滥用:** +- 不要用来中断正在执行的任务(用 `message` + 取消标志) +- 不要频繁 shutdown/restart(开销大) + +## TeamProtocols 方法 + +| 方法 | 返回 | 说明 | +|------|------|------| +| `state(agent)` | ProtocolState | 查询 agent 状态 | +| `request_shutdown(agent)` | dict | 生成关闭请求消息 | +| `handle_shutdown_response(agent, approved, msg?)` | str | 处理响应 | +| `request_plan_approval(agent, summary, detail)` | dict | 生成计划审批请求 | +| `handle_plan_response(id, approved, feedback?)` | str | 处理审批 | +| `get_pending_approvals()` | list[dict] | 获取所有待审批计划 | diff --git a/packages/agent_core/skills/context-compaction/SKILL.md b/packages/agent_core/skills/context-compaction/SKILL.md new file mode 100644 index 0000000..4c2e4d7 --- /dev/null +++ b/packages/agent_core/skills/context-compaction/SKILL.md @@ -0,0 +1,125 @@ +--- +name: context-compaction +description: | + 3层 Context 压缩策略,支持无限长度会话。 + 使用此 skill 当你需要: + - 理解 Context 压缩的原理 + - 实现消息历史摘要 + - 处理超长对话导致 context overflow +--- + +# Context Compaction — 3层压缩策略 + +## 问题 + +每轮对话 messages[] 都在增长。几轮之后 context 就满了。 +解决思路:不是截断,是**压缩**。 + +## 3层压缩策略(s06) + +### Layer 1:消息摘要(Message Summarization) + +定期把多轮对话压缩成一条摘要消息: + +``` +# 原始(10轮对话,~8000 tokens) +messages = [ + {"role": "user", "content": "添加用户认证"}, + {"role": "assistant", "content": "我帮你..."}, + {"role": "user", "content": "再加个注册"}, + {"role": "assistant", "content": "好的..."}, + ... 10轮 +] + +# Layer 1 压缩后 +messages = [ + {"role": "user", "content": "添加用户认证 + 注册功能"}, ← 摘要 + {"role": "assistant", "content": "完成了,代码已提交"}, +] +``` + +**触发条件**:messages 总长度 > 50% max_tokens + +### Layer 2:关键决策提取(Key Decisions) + +只保留重要的架构决策、操作结果、文件路径,丢弃探索过程: + +```python +COMPRESSED = """ +=== 项目状态 === +- 完成了用户认证(JWT) +- 添加了 /auth/login 和 /auth/register 端点 +- 使用了 PyJWT 库 +- 数据库迁移:users 表 + +=== 关键文件 === +- apps/api/routers/auth.py +- apps/api/models/user.py + +=== 当前进行中 === +实现论文搜索 API +""" +``` + +### Layer 3:元信息压缩(Metadata) + +把完整消息压缩成结构化元信息: + +```python +{ + "type": "compressed_history", + "summary": "完成用户认证,添加登录注册API,使用PyJWT", + "decisions": ["使用JWT而非session", "密码用bcrypt哈希"], + "files_touched": ["auth.py", "user.py", "models.py"], + "active_task": "实现论文搜索API", + "next_step": "添加 arxiv_id 字段到 papers 表", +} +``` + +## 何时压缩 + +| 信号 | 动作 | +|------|------| +| messages 长度 > 50% max_tokens | Layer 1 | +| 单轮工具调用 > 20 次 | Layer 2 | +| 会话超过 2 小时 | Layer 2 | +| 历史消息全部是工具调用 | Layer 3 | + +## 压缩实现伪代码 + +```python +def should_compact(messages, max_tokens): + total = sum_tokens(messages) + return total > max_tokens * 0.5 + +def compact(messages, strategy="auto"): + if strategy == "auto": + if len(messages) > 20: + return layer3_compress(messages) + elif total_tools > 20: + return layer2_compress(messages) + else: + return layer1_summarize(messages) +``` + +## 与 AgentLoop 集成 + +```python +class CompactingAgentLoop(AgentLoop): + def __init__(self, config, dispatcher, compact_threshold=0.5): + super().__init__(config, dispatcher) + self.compact_threshold = compact_threshold + + def run(self, messages): + result = super().run(messages) + if self._should_compact(messages): + messages[:] = self._compact(messages) + return result +``` + +## 实际经验 + +- **不要过度压缩**:保留关键上下文(当前任务、目标文件、已确定方案) +- **优先 Layer 1**:摘要比元信息更可靠 +- **记录压缩历史**:方便回溯 +- **LLM 辅助压缩**:让 LLM 自己写摘要(用工具调用一次) diff --git a/packages/agent_core/skills/task-persistence/SKILL.md b/packages/agent_core/skills/task-persistence/SKILL.md new file mode 100644 index 0000000..bb07b76 --- /dev/null +++ b/packages/agent_core/skills/task-persistence/SKILL.md @@ -0,0 +1,148 @@ +--- +name: task-persistence +description: | + 基于文件的 TaskManager,任务持久化 + 依赖图。 + 使用此 skill 当你需要: + - 创建跨会话持久化的任务 + - 管理任务间的 blockedBy/blocks 依赖关系 + - 理解 s07 TaskSystem 的文件格式 +--- + +# Task Persistence — 任务持久化 + 依赖图 + +## 核心设计(s07 Motto) + +> **"Break big goals into small tasks, order them, persist to disk."** + +任务落盘 → 对话结束后任务不丢失 → 下次开新会话仍然可以继续。 + +## 文件格式 + +``` +.tasks/ + task_1.json ← 任务文件 + task_2.json + task_3.json +``` + +每个任务文件内容: + +```json +{ + "id": 1, + "subject": "实现用户认证", + "description": "添加 JWT 登录功能", + "status": "pending", + "owner": "alice", + "worktree": "", + "blockedBy": [2], + "blocks": [3], + "created_at": 1710000000.0, + "updated_at": 1710000000.0 +} +``` + +## 依赖图机制 + +``` +task_1 (pending) ──blockedBy[2]──→ task_2 (in_progress) +task_3 (pending) ──blockedBy[1]──→ task_1 (pending) +``` + +- `blockedBy`: 当前任务依赖哪些任务(必须等它们完成) +- `blocks`: 当前任务阻断了哪些任务(完成后自动解除它们的阻塞) + +**自动解除**:`task_2.status = "completed"` → task_1.blockedBy 自动移除 `[2]` + +## 使用方式 + +```python +from pathlib import Path +from agent_core import TaskManager + +tm = TaskManager(Path(".tasks")) + +# 创建任务 +tm.create("实现登录 API", "添加 POST /auth/login") +# → 返回 {"id": 1, "subject": "实现登录 API", ...} + +# 列出所有 +print(tm.list_all()) +# [ ] #1: 实现登录 API +# [ ] #2: 实现注册 API + +# 更新状态 +tm.update(task_id=1, status="completed") +# 完成 task #1 后,依赖它的任务自动解除阻塞 + +# 设置依赖 +tm.add_blocked_by(task_id=3, blocked_by=[1, 2]) +# task #3 被 task #1 和 task #2 阻塞 +``` + +## list_all() 输出格式 + +``` +[ ] #1: 实现登录 API +[>] #2: 实现注册 API (owner @alice) +[x] #3: 添加单元测试 (completed 2024-01-01) + #4: 部署上线 (blocked by [1, 3]) +``` + +标记含义:`[ ]` pending / `[>]` in_progress / `[x]` completed + +## 关键方法 + +| 方法 | 返回 | 说明 | +|------|------|------| +| `create(subject, desc?)` | JSON str | 创建新任务 | +| `get(task_id)` | JSON str | 获取任务详情 | +| `update(task_id, status?, owner?)` | JSON str | 更新状态/负责人 | +| `delete(task_id)` | str | 删除任务 | +| `list_all()` | str | 列出所有任务 | +| `list_pending()` | str | 只列 pending | +| `add_blocked_by(task_id, [dep_id, ...])` | JSON str | 设置阻塞依赖 | +| `is_blocked(task_id)` | bool | 是否被阻塞 | +| `get_ready_tasks()` | list[Task] | 所有就绪任务 | + +## 依赖管理细节 + +```python +# 设置 task #3 依赖 task #1 和 task #2 +tm.add_blocked_by(task_id=3, blocked_by=[1, 2]) + +# 双向记录:task #1.blocks = [3], task #2.blocks = [3] + +# 完成 task #1 +tm.update(task_id=1, status="completed") +# → task #3.blockedBy 自动变为 [2](task #1 已完成) + +# 再完成 task #2 +tm.update(task_id=2, status="completed") +# → task #3.blockedBy 变为 [](完全解除阻塞) +# → task #3 现在可以开始了 +``` + +## 与 AgentLoop 集成 + +```python +dispatcher = ToolDispatcher() +tm = TaskManager(Path(".tasks")) + +dispatcher.register("task_create", lambda subject, description="": tm.create(subject, description)) +dispatcher.register("task_list", lambda: tm.list_all()) +dispatcher.register("task_update", lambda task_id, status=None: tm.update(task_id, status=status)) +``` + +## 何时用 TaskManager + +**用:** +- 多步骤任务需要跨会话持久化 +- 任务间有明确的先后依赖 +- 需要给任务分配 owner +- 多 agent 协作需要共享任务状态 + +**不用:** +- 一次性简单任务 +- 不需要跨会话记住的临时 todo +- 已有其他任务系统(如 Linear、Jira) diff --git a/packages/agent_core/skills/tool-dispatcher/SKILL.md b/packages/agent_core/skills/tool-dispatcher/SKILL.md new file mode 100644 index 0000000..1871863 --- /dev/null +++ b/packages/agent_core/skills/tool-dispatcher/SKILL.md @@ -0,0 +1,148 @@ +--- +name: tool-dispatcher +description: | + 工具注册与分发机制。使用此 skill 当你需要: + - 注册新的 tool handler + - 理解工具定义(tools list)和工具执行(handlers dict)的分离 + - 使用 make_default_dispatcher 快速创建默认工具集 +--- + +# Tool Dispatcher — 工具注册与分发 + +## 核心原则(s02 Motto) + +> **"Adding a tool means adding one handler"** + +Loop 本身不变。加工具 = 加一个 handler 到 dispatch map。 + +## 核心架构 + +``` +Anthropic tools list(给 LLM 看) + ↓ +LLM 决定调用 "bash" + ↓ +ToolDispatcher.handlers["bash"] → run_bash() + ↓ +执行并返回结果 +``` + +**工具定义**(tools list)和**工具处理**(handlers dict)是两回事: +- `tools list` → 告诉 LLM 有哪些工具可用(Anthropic API 格式) +- `handlers dict` → 实际执行逻辑 + +## 注册一个工具 + +```python +from agent_core import ToolDispatcher + +dispatcher = ToolDispatcher() + +def my_tool(arg1: str, arg2: int = 10) -> str: + return f"{arg1}: {arg2}" + +dispatcher.register( + name="my_tool", # LLM 调用时用的名称 + handler=my_tool, # 实际执行的函数 +) +``` + +`register()` 会自动: +1. 从 handler docstring 提取描述 +2. 从 handler 签名推断 input_schema +3. 注册到 handlers dict +4. 追加到 tools list + +## 手动指定 schema + +如果自动推断不满足需求,可以手动传: + +```python +dispatcher.register( + name="search", + handler=search_handler, + description="Search the web for information", + input_schema={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "num_results": {"type": "integer", "default": 5}, + }, + "required": ["query"], + }, +) +``` + +## 批量注册 + +```python +from agent_core.tools.bash import run_bash +from agent_core.tools.filesystem import run_read, run_write, run_edit + +dispatcher.register_many({ + "bash": run_bash, + "read_file": run_read, + "write_file": run_write, + "edit_file": run_edit, +}) +``` + +## 默认工具集 + +一行创建常用工具: + +```python +from agent_core import make_default_dispatcher + +# 只包含基础工具 +dispatcher = make_default_dispatcher(workdir="/path/to/project") + +# 包含基础工具 + tasks +dispatcher = make_default_dispatcher( + workdir="/path/to/project", + tasks_dir="/path/to/.tasks", +) +``` + +默认包含:`bash`, `read_file`, `write_file`, `edit_file`, `glob`, `grep` + +## 常用工具 handler + +| 工具名 | handler | 作用 | +|--------|---------|------| +| `bash` | `run_bash(command)` | 执行 shell 命令 | +| `read_file` | `run_read(path, limit?)` | 读文件 | +| `write_file` | `run_write(path, content)` | 写文件(覆盖) | +| `edit_file` | `run_edit(path, old, new)` | 替换文件内容 | +| `glob` | `run_glob(pattern)` | glob 搜索 | +| `grep` | `run_grep(pattern, include?)` | 内容搜索 | +| `task_create` | `task_manager.create(subject, description)` | 创建任务 | +| `task_list` | `task_manager.list_all()` | 列出任务 | + +## dispatch() 行为 + +```python +output = dispatcher.dispatch("bash", command="ls -la") +# 成功 → 返回命令输出字符串 +# 未知工具 → "Error: Unknown tool 'bash'. Available: [...]" +# 参数错误 → "Error: Tool 'bash' received wrong arguments..." +# 执行异常 → "Error: Tool 'bash' failed: TimeoutError: ..." +``` + +## 何时注册工具 + +注册越早越好(freeze 后不能注册): + +```python +dispatcher = ToolDispatcher() +dispatcher.register("bash", run_bash) +dispatcher.register("read_file", run_read) +# ... 更多工具 ... + +# 第一次调用 get_tool_definitions() 后 frozen +tools = dispatcher.get_tool_definitions() # ← freeze + +dispatcher.register("new_tool", handler) # RuntimeError! +``` + +正确做法:在 `get_tool_definitions()` 之前注册所有工具。 diff --git a/packages/agent_core/tasks.py b/packages/agent_core/tasks.py new file mode 100644 index 0000000..7343ba5 --- /dev/null +++ b/packages/agent_core/tasks.py @@ -0,0 +1,250 @@ +""" +TaskManager — 任务持久化系统,参考 learn-claude-code s07 + +核心设计: +- 任务以 JSON 文件形式持久化到 .tasks/ 目录 +- 每个任务文件:task_{id}.json +- 依赖图:blockedBy / blocks 字段 +- 完成任务时,自动从所有依赖任务的 blockedBy 中移除 + + .tasks/ + task_1.json {"id":1,"subject":"...","status":"completed","blockedBy":[],"blocks":[2]} + task_2.json {"id":2,"subject":"...","status":"pending","blockedBy":[1],"blocks":[]} +""" + +from __future__ import annotations + +import json +import time +from contextlib import suppress +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from pathlib import Path + + +@dataclass +class Task: + id: int + subject: str + description: str = "" + status: Literal["pending", "in_progress", "completed"] = "pending" + owner: str = "" + worktree: str = "" + blockedBy: list[int] = field(default_factory=list) + blocks: list[int] = field(default_factory=list) + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + + def to_dict(self) -> dict: + return { + "id": self.id, + "subject": self.subject, + "description": self.description, + "status": self.status, + "owner": self.owner, + "worktree": self.worktree, + "blockedBy": self.blockedBy, + "blocks": self.blocks, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + @classmethod + def from_dict(cls, d: dict) -> Task: + return cls( + id=d["id"], + subject=d["subject"], + description=d.get("description", ""), + status=d.get("status", "pending"), + owner=d.get("owner", ""), + worktree=d.get("worktree", ""), + blockedBy=d.get("blockedBy", []), + blocks=d.get("blocks", []), + created_at=d.get("created_at", time.time()), + updated_at=d.get("updated_at", time.time()), + ) + + +class TaskManager: + """ + 基于文件的任务管理器。 + 所有操作即时落盘,任务不依赖进程存活。 + """ + + def __init__(self, tasks_dir: Path): + self.dir = tasks_dir + self.dir.mkdir(parents=True, exist_ok=True) + self._next_id = self._max_id() + 1 + + # -- 内部方法 -- + def _max_id(self) -> int: + ids = [] + for f in self.dir.glob("task_*.json"): + try: + parts = f.stem.split("_") + if len(parts) == 2: + ids.append(int(parts[1])) + except (ValueError, IndexError): + pass + return max(ids) if ids else 0 + + def _path(self, task_id: int) -> Path: + return self.dir / f"task_{task_id}.json" + + def _load(self, task_id: int) -> Task: + path = self._path(task_id) + if not path.exists(): + raise ValueError(f"Task {task_id} not found") + return Task.from_dict(json.loads(path.read_text())) + + def _save(self, task: Task) -> None: + task.updated_at = time.time() + self._path(task.id).write_text(json.dumps(task.to_dict(), indent=2)) + + # -- CRUD -- + def create(self, subject: str, description: str = "") -> str: + """创建新任务,返回 JSON 字符串""" + task = Task(id=self._next_id, subject=subject, description=description) + self._save(task) + self._next_id += 1 + return json.dumps(task.to_dict(), indent=2) + + def get(self, task_id: int) -> str: + """获取任务详情,返回 JSON 字符串""" + return json.dumps(self._load(task_id).to_dict(), indent=2) + + def update( + self, + task_id: int, + status: str | None = None, + owner: str | None = None, + ) -> str: + """ + 更新任务状态或负责人。 + 当状态设为 completed 时,自动解除所有依赖任务的阻塞。 + """ + task = self._load(task_id) + + if status: + if status not in ("pending", "in_progress", "completed"): + raise ValueError( + f"Invalid status: {status}. Must be one of: pending, in_progress, completed" + ) + task.status = status + + if owner is not None: + task.owner = owner + + if task.status == "completed": + self._clear_dependency(task_id) + + self._save(task) + return json.dumps(task.to_dict(), indent=2) + + def delete(self, task_id: int) -> str: + """删除任务(如果已完成,从依赖任务中移除)""" + task = self._load(task_id) + if task.status == "completed": + self._clear_dependency(task_id) + self._path(task_id).unlink(missing_ok=True) + return f"Deleted task {task_id}" + + # -- 依赖管理 -- + def add_blocked_by(self, task_id: int, blocked_by: list[int]) -> str: + """设置任务依赖于其他任务(blockedBy)""" + task = self._load(task_id) + for bid in blocked_by: + if bid not in task.blockedBy: + task.blockedBy.append(bid) + # 双向更新:blocked 任务也要记录 blocks + try: + blocked_task = self._load(bid) + if task_id not in blocked_task.blocks: + blocked_task.blocks.append(task_id) + self._save(blocked_task) + except ValueError: + pass # blocked_by task doesn't exist + self._save(task) + return json.dumps(task.to_dict(), indent=2) + + def remove_blocked_by(self, task_id: int, blocked_by: int) -> str: + """移除一个阻塞依赖""" + task = self._load(task_id) + if blocked_by in task.blockedBy: + task.blockedBy.remove(blocked_by) + if task_id in self._load(blocked_by).blocks: + blocked_task = self._load(blocked_by) + blocked_task.blocks.remove(task_id) + self._save(blocked_task) + self._save(task) + return json.dumps(task.to_dict(), indent=2) + + def _clear_dependency(self, completed_id: int) -> None: + """当任务完成时,从所有任务的 blockedBy 中移除它""" + for f in self.dir.glob("task_*.json"): + try: + task = Task.from_dict(json.loads(f.read_text())) + if completed_id in task.blockedBy: + task.blockedBy.remove(completed_id) + self._save(task) + except (ValueError, json.JSONDecodeError): + pass + + # -- 查询 -- + def list_all(self) -> str: + """列出所有任务,带状态图标""" + tasks = [] + for f in sorted(self.dir.glob("task_*.json")): + with suppress(ValueError, json.JSONDecodeError): + tasks.append(Task.from_dict(json.loads(f.read_text()))) + + if not tasks: + return "No tasks." + + lines = [] + for t in sorted(tasks, key=lambda x: x.id): + marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get( + t.status, "[?]" + ) + owner_str = f" @{t.owner}" if t.owner else "" + wt_str = f" wt={t.worktree}" if t.worktree else "" + blocked_str = f" (blocked by {t.blockedBy})" if t.blockedBy else "" + lines.append(f"{marker} #{t.id}: {t.subject}{blocked_str}{owner_str}{wt_str}") + + return "\n".join(lines) + + def list_pending(self) -> str: + """只列出 pending 任务""" + tasks = [] + for f in sorted(self.dir.glob("task_*.json")): + try: + t = Task.from_dict(json.loads(f.read_text())) + if t.status == "pending": + tasks.append(t) + except (ValueError, json.JSONDecodeError): + pass + if not tasks: + return "No pending tasks." + return "\n".join(f"#{t.id}: {t.subject}" for t in sorted(tasks, key=lambda x: x.id)) + + def is_blocked(self, task_id: int) -> bool: + """检查任务是否被阻塞(blockedBy 非空)""" + try: + task = self._load(task_id) + return len(task.blockedBy) > 0 + except ValueError: + return False + + def get_ready_tasks(self) -> list[Task]: + """获取所有就绪任务(pending 且未被阻塞)""" + ready = [] + for f in self.dir.glob("task_*.json"): + try: + t = Task.from_dict(json.loads(f.read_text())) + if t.status == "pending" and not t.blockedBy: + ready.append(t) + except (ValueError, json.JSONDecodeError): + pass + return sorted(ready, key=lambda x: x.id) diff --git a/packages/agent_core/teammates.py b/packages/agent_core/teammates.py new file mode 100644 index 0000000..c08ce22 --- /dev/null +++ b/packages/agent_core/teammates.py @@ -0,0 +1,194 @@ +""" +TeammateManager — 持久化 Agent 团队管理,参考 learn-claude-code s09 + +核心设计: +- Teammate 和 Subagent 的区别: + Subagent: spawn → execute → return → destroyed(一次性) + Teammate: spawn → work → idle → work → ... → shutdown(持久) +- 每个 teammate 运行在独立线程中 +- 线程内部运行自己的 agent_loop +- 通过 MessageBus 异步通信 + + spawn_teammate("alice", "coder", "fix the login bug"): + → 查找/创建 alice 配置 + → 启动 alice_thread(_teammate_loop, args=(alice, role, prompt)) + → alice_thread.start() + + _teammate_loop: + while True: + inbox = BUS.read_inbox(alice) + if inbox: messages.append(inbox messages) + response = LLM(messages, tools) + if stop_reason != "tool_use": break + execute tools... +""" + +from __future__ import annotations + +import json +import threading +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pathlib import Path + +from anthropic import Anthropic + +from .message_bus import MessageBus + + +class TeammateManager: + def __init__(self, team_dir: Path, inbox_dir: Path, model: str, system_base: str): + self.team_dir = team_dir + self.inbox_dir = inbox_dir + self.model = model + self.system_base = system_base + self.bus = MessageBus(inbox_dir) + self.threads: dict[str, threading.Thread] = {} + self.config_path = team_dir / "config.json" + self.config = self._load_config() + team_dir.mkdir(parents=True, exist_ok=True) + + def _load_config(self) -> dict: + if self.config_path.exists(): + return json.loads(self.config_path.read_text()) + return {"team_name": "default", "members": []} + + def _save_config(self) -> None: + self.config_path.write_text(json.dumps(self.config, indent=2)) + + def _find_member(self, name: str) -> dict | None: + for m in self.config["members"]: + if m["name"] == name: + return m + return None + + def list_all(self) -> str: + if not self.config["members"]: + return "No teammates." + lines = [f"Team: {self.config['team_name']}"] + for m in self.config["members"]: + lines.append(f" {m['name']} ({m['role']}): {m['status']}") + return "\n".join(lines) + + def member_names(self) -> list[str]: + return [m["name"] for m in self.config["members"]] + + def spawn( + self, + name: str, + role: str, + prompt: str, + tools: list[dict[str, Any]] | None = None, + ) -> str: + member = self._find_member(name) + if member: + if member["status"] not in ("idle", "shutdown"): + return f"Error: '{name}' is currently {member['status']}" + member["status"] = "working" + member["role"] = role + else: + member = {"name": name, "role": role, "status": "working"} + self.config["members"].append(member) + + self._save_config() + + thread = threading.Thread( + target=self._teammate_loop, + args=(name, role, prompt, tools or []), + daemon=True, + ) + self.threads[name] = thread + thread.start() + return f"Spawned '{name}' (role: {role})" + + def shutdown(self, name: str) -> str: + self.bus.send("lead", name, "Please shutdown gracefully.", "shutdown_request") + member = self._find_member(name) + if member: + member["status"] = "shutdown_requested" + self._save_config() + return f"Shutdown requested for '{name}'" + + def _teammate_loop( + self, + name: str, + role: str, + prompt: str, + tools: list[dict[str, Any]], + ) -> None: + sys_prompt = ( + f"You are '{name}', role: {role}. " + f"{self.system_base} " + "Use send_message to communicate with teammates." + ) + messages: list[dict[str, Any]] = [{"role": "user", "content": prompt}] + client = Anthropic() + + for _ in range(500): # max iterations per session + inbox = self.bus.read_inbox(name) + for msg in inbox: + msg_type = msg.get("type", "message") + if msg_type == "shutdown_request": + self._mark_idle(name) + return + messages.append({"role": "user", "content": json.dumps(msg)}) + + try: + response = client.messages.create( + model=self.model, + system=sys_prompt, + messages=messages, + tools=tools, + max_tokens=8000, + ) + except Exception: + break + + messages.append({"role": "assistant", "content": response.content}) + + if response.stop_reason != "tool_use": + break + + results = [] + for block in response.content: + if hasattr(block, "type") and block.type == "tool_use": + output = self._exec_tool(name, block.name, block.input) + results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": str(output)[:50_000], + } + ) + print(f" [{name}] {block.name}: {str(output)[:120]}") + + messages.append({"role": "user", "content": results}) + + self._mark_idle(name) + + def _mark_idle(self, name: str) -> None: + member = self._find_member(name) + if member and member["status"] != "shutdown": + member["status"] = "idle" + self._save_config() + + def _exec_tool(self, sender: str, tool_name: str, args: dict) -> str: + from .tools.bash import run_bash + from .tools.filesystem import run_edit, run_read, run_write + + if tool_name == "bash": + return run_bash(args["command"]) + if tool_name == "read_file": + return run_read(args["path"]) + if tool_name == "write_file": + return run_write(args["path"], args["content"]) + if tool_name == "edit_file": + return run_edit(args["path"], args["old_text"], args["new_text"]) + if tool_name == "send_message": + return self.bus.send( + sender, args["to"], args["content"], args.get("msg_type", "message") + ) + if tool_name == "read_inbox": + return json.dumps(self.bus.read_inbox(sender), indent=2) + return f"Unknown tool: {tool_name}" diff --git a/packages/agent_core/tools/__init__.py b/packages/agent_core/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/agent_core/tools/bash.py b/packages/agent_core/tools/bash.py new file mode 100644 index 0000000..bec7f1e --- /dev/null +++ b/packages/agent_core/tools/bash.py @@ -0,0 +1,42 @@ +""" +bash — Bash 工具 handler,参考 learn-claude-code s01 +""" + +from __future__ import annotations + +import subprocess + +DANGEROUS_PATTERNS = [ + "rm -rf /", + "sudo", + "shutdown", + "reboot", + "> /dev/", + "| /dev/", +] + + +def run_bash(command: str, cwd: str | None = None, timeout: int = 120) -> str: + """ + 执行 Bash 命令。 + 包含危险命令检查,防止误执行破坏性操作。 + """ + for pattern in DANGEROUS_PATTERNS: + if pattern in command: + return f"Error: Dangerous command blocked: '{pattern}' found in command" + + try: + result = subprocess.run( + command, + shell=True, + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + ) + out = (result.stdout + result.stderr).strip() + return out if out else "(no output)" + except subprocess.TimeoutExpired: + return f"Error: Timeout ({timeout}s exceeded)" + except Exception as exc: # noqa: BLE001 + return f"Error: {type(exc).__name__}: {exc}" diff --git a/packages/agent_core/tools/filesystem.py b/packages/agent_core/tools/filesystem.py new file mode 100644 index 0000000..9ed7834 --- /dev/null +++ b/packages/agent_core/tools/filesystem.py @@ -0,0 +1,148 @@ +""" +filesystem — 文件系统工具 handlers,参考 learn-claude-code s02 +read / write / edit / glob / grep +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def _safe_resolve(path: str | Path, workdir: Path) -> Path: + """解析路径并确保不超出 workdir(防止 path traversal)""" + resolved = (workdir / path).resolve() + if not str(resolved).startswith(str(workdir.resolve())): + raise ValueError(f"Path escapes workspace: {path}") + return resolved + + +def run_read(path: str, workdir: str | None = None, limit: int | None = None) -> str: + """ + 读取文件内容。 + 可选 limit 参数限制行数(只返回前 N 行)。 + """ + workdir_path = Path(workdir) if workdir else Path.cwd() + + try: + file_path = _safe_resolve(path, workdir_path) + lines = file_path.read_text(encoding="utf-8").splitlines() + + if limit and limit < len(lines): + lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"] + + return "\n".join(lines) + except FileNotFoundError: + return f"Error: File not found: {path}" + except Exception as exc: # noqa: BLE001 + return f"Error: {type(exc).__name__}: {exc}" + + +def run_write(path: str, content: str, workdir: str | None = None) -> str: + """ + 写入文件内容(覆盖)。 + 自动创建父目录。 + """ + workdir_path = Path(workdir) if workdir else Path.cwd() + + try: + file_path = _safe_resolve(path, workdir_path) + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content, encoding="utf-8") + return f"Wrote {len(content)} bytes to {path}" + except Exception as exc: # noqa: BLE001 + return f"Error: {type(exc).__name__}: {exc}" + + +def run_edit( + path: str, + old_text: str, + new_text: str, + workdir: str | None = None, +) -> str: + """ + 编辑文件(替换 old_text → new_text,只替换第一个匹配项)。 + 如果 old_text 不存在,返回错误。 + """ + workdir_path = Path(workdir) if workdir else Path.cwd() + + try: + file_path = _safe_resolve(path, workdir_path) + content = file_path.read_text(encoding="utf-8") + + if old_text not in content: + return f"Error: Text not found in {path}" + + new_content = content.replace(old_text, new_text, 1) + file_path.write_text(new_content, encoding="utf-8") + return f"Edited {path}" + except FileNotFoundError: + return f"Error: File not found: {path}" + except Exception as exc: # noqa: BLE001 + return f"Error: {type(exc).__name__}: {exc}" + + +def run_glob(pattern: str, workdir: str | None = None) -> str: + """ + 按 glob pattern 搜索文件。 + pattern 示例:'**/*.py', 'src/**/*.ts' + """ + workdir_path = Path(workdir) if workdir else Path.cwd() + + try: + matches = list(workdir_path.glob(pattern)) + if not matches: + return f"No files matching: {pattern}" + + lines = [ + f"{'dir' if m.is_dir() else 'file'}: {m.relative_to(workdir_path)}" for m in matches + ] + return "\n".join(lines) + except Exception as exc: # noqa: BLE001 + return f"Error: {type(exc).__name__}: {exc}" + + +def run_grep( + pattern: str, + workdir: str | None = None, + include: str | None = None, + exclude: str | None = None, + context: int = 0, +) -> str: + """ + 搜索文件内容。 + - pattern: regex 模式 + - include: glob pattern 过滤(如 '*.py') + - exclude: glob pattern 排除 + - context: 上下文行数 + """ + workdir_path = Path(workdir) if workdir else Path.cwd() + + try: + cmd = ["rg", "--json", "-e", pattern, str(workdir_path)] + + if include: + cmd.extend(["-g", include]) + if exclude: + cmd.extend(["-g", f"!{exclude}"]) + if context > 0: + cmd.extend(["-C", str(context)]) + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60, + ) + + if not result.stdout.strip(): + return f"No matches for: {pattern}" + + return result.stdout.strip() + except FileNotFoundError: + # rg not installed, fall back to grep + cmd = ["grep", "-rn", pattern, str(workdir_path)] + result = subprocess.run(cmd, capture_output=True, text=True) + return result.stdout.strip() if result.stdout else f"No matches for: {pattern}" + except Exception as exc: # noqa: BLE001 + return f"Error: {type(exc).__name__}: {exc}" From 254ccd554a50d59781853a9d2f81200551abda0c Mon Sep 17 00:00:00 2001 From: Color2333 <1552429809@qq.com> Date: Fri, 20 Mar 2026 12:16:55 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(agent):=20=E9=87=8D=E6=9E=84=20agent?= =?UTF-8?q?=5Fservice=20=E4=BD=BF=E7=94=A8=20StreamingAgentLoop=20+=20?= =?UTF-8?q?=E6=96=B0=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 StreamingAgentLoop: 支持流式 LLM + SSE + 确认机制 - 添加 ConfirmationMixin: 接管 _CONFIRM_TOOLS pending 逻辑 - 添加 StreamingToolDispatcher: 适配生成器式 tool handlers - 添加 GlobalTrackerAdapter: global_tracker 兼容层 - 重构 agent_service.py: 使用新架构,保持原有接口不变 - 所有文件通过 py_compile 和 ruff check --- packages/agent_core/dispatcher.py | 215 ++++++++++- packages/agent_core/loop.py | 585 +++++++++++++++++++++++++++++- packages/agent_core/tasks.py | 103 +++++- packages/ai/agent_service.py | 459 +++++------------------ 4 files changed, 987 insertions(+), 375 deletions(-) diff --git a/packages/agent_core/dispatcher.py b/packages/agent_core/dispatcher.py index 5774c3d..12c6fd4 100644 --- a/packages/agent_core/dispatcher.py +++ b/packages/agent_core/dispatcher.py @@ -22,10 +22,12 @@ from __future__ import annotations import inspect +import logging +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Iterator from .tools.bash import run_bash from .tools.filesystem import run_edit, run_glob, run_grep, run_read, run_write @@ -249,5 +251,216 @@ def bash(command: str) -> str: return dispatcher +# ============================================================================= +# PaperMind 适配层:流式工具分发器 +# ============================================================================= + +logger = logging.getLogger(__name__) + + +@dataclass +class PaperMindToolResult: + """PaperMind 风格的工具结果""" + + success: bool + data: dict = field(default_factory=dict) + summary: str = "" + + +@dataclass +class PaperMindToolProgress: + """PaperMind 风格的工具进度""" + + message: str + current: int = 0 + total: int = 0 + + +class StreamingToolDispatcher: + """ + PaperMind 专用的流式工具分发器。 + + 支持注册生成器式的 handler(返回 Iterator[ToolProgress | ToolResult]), + 同时兼容普通同步 handler。 + + 使用方式: + dispatcher = StreamingToolDispatcher() + dispatcher.register("search_papers", search_papers_handler) # generator + dispatcher.register("bash", bash_handler) # sync + + # 流式执行 + for item in dispatcher.dispatch_stream("search_papers", {"keyword": "AI"}): + if isinstance(item, PaperMindToolProgress): + print(f"进度: {item.message}") + elif isinstance(item, PaperMindToolResult): + print(f"完成: {item.summary}") + """ + + def __init__(self): + self._handlers: dict[str, Callable[..., Any]] = {} + self._tool_definitions: list[dict[str, Any]] = [] + self._frozen = False + + def register( + self, + name: str, + handler: Callable[..., Any], + description: str | None = None, + input_schema: dict[str, Any] | None = None, + requires_confirm: bool = False, + ) -> StreamingToolDispatcher: + """ + 注册一个工具 handler。 + + 参数: + name: 工具名称 + handler: 执行函数,可以是普通函数或生成器函数 + description: 工具描述(默认从 handler docstring 提取) + input_schema: OpenAI function calling 格式的参数 schema + requires_confirm: 是否需要用户确认 + """ + if self._frozen: + raise RuntimeError( + "Cannot register tools after get_tool_definitions() was called. " + "Register all tools first." + ) + + self._handlers[name] = handler + + if description is None: + description = self._extract_description(handler) + + if input_schema is None: + input_schema = self._infer_schema(handler) + + tool_def = { + "type": "function", + "function": { + "name": name, + "description": description, + "parameters": input_schema, + }, + } + self._tool_definitions.append(tool_def) + return self + + def register_many( + self, + tools: dict[str, Callable[..., Any]], + ) -> StreamingToolDispatcher: + """批量注册工具(不包含 requires_confirm)""" + for name, handler in tools.items(): + self.register(name, handler) + return self + + def dispatch_stream( + self, + name: str, + arguments: dict, + ) -> Iterator[PaperMindToolProgress | PaperMindToolResult]: + """ + 流式执行工具,yield 进度事件和最终结果。 + 兼容生成器 handler 和普通 handler。 + """ + handler = self._handlers.get(name) + if handler is None: + yield PaperMindToolResult( + success=False, + summary=f"未知工具: {name}", + ) + return + + try: + result = handler(**arguments) + if hasattr(result, "__next__"): + # 生成器函数 + yield from result + else: + # 普通同步函数,直接包装为 ToolResult + if isinstance(result, PaperMindToolResult): + yield result + elif isinstance(result, dict): + yield PaperMindToolResult(success=True, data=result, summary="") + elif isinstance(result, str): + yield PaperMindToolResult(success=True, summary=result) + else: + yield PaperMindToolResult(success=True, summary=str(result)) + except Exception as exc: + logger.exception("Tool %s failed: %s", name, exc) + yield PaperMindToolResult(success=False, summary=str(exc)) + + def get_tool_definitions(self) -> list[dict[str, Any]]: + """ + 返回 OpenAI function calling 格式的工具定义列表。 + 一旦调用,冻结注册——不能再添加工具。 + """ + self._frozen = True + return self._tool_definitions + + def list_tools(self) -> list[str]: + """列出所有已注册工具名称""" + return list(self._handlers.keys()) + + def get_handler(self, name: str) -> Callable[..., Any] | None: + """获取工具 handler""" + return self._handlers.get(name) + + def _extract_description(self, handler: Callable) -> str: + doc = inspect.getdoc(handler) or "" + return doc.split("\n")[0].strip() if doc else f"Tool: {handler.__name__}" + + def _infer_schema(self, handler: Callable) -> dict[str, Any]: + """从 handler 签名推断参数 schema""" + sig = inspect.signature(handler) + properties: dict[str, dict[str, Any]] = {} + required: list[str] = [] + + for param_name, param in sig.parameters.items(): + if param_name in ("self", "kwargs", "args"): + continue + + annotation = param.annotation + if annotation is inspect.Parameter.empty: + param_type = "string" + else: + param_type = self._annotation_to_json_type(annotation) + + prop: dict[str, Any] = {"type": param_type} + if param.default is not inspect.Parameter.empty: + prop["default"] = param.default + + properties[param_name] = prop + + if param.default is inspect.Parameter.empty and param_name != "self": + required.append(param_name) + + return { + "type": "object", + "properties": properties, + "required": required if required else None, + } + + @staticmethod + def _annotation_to_json_type(annotation: Any) -> str: + """将 Python 类型映射到 JSON Schema 类型""" + origin = getattr(annotation, "__origin__", None) + + if origin is list: + return "array" + if origin is dict: + return "object" + + name = getattr(annotation, "__name__", str(annotation)) + mapping = { + "str": "string", + "int": "integer", + "float": "number", + "bool": "boolean", + "list": "array", + "dict": "object", + } + return mapping.get(name, "string") + + # sentinel for undefined undefined = None diff --git a/packages/agent_core/loop.py b/packages/agent_core/loop.py index 920345a..a538adc 100644 --- a/packages/agent_core/loop.py +++ b/packages/agent_core/loop.py @@ -39,11 +39,14 @@ from __future__ import annotations +import json +import logging import time -from collections.abc import Callable -from dataclasses import dataclass +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field from enum import Enum from typing import TYPE_CHECKING, Any, cast +from uuid import uuid4 from anthropic import Anthropic @@ -251,3 +254,581 @@ def chat( loop = AgentLoop(config, dispatcher) messages = [{"role": "user", "content": user_message}] return loop.run(messages) + + +# ============================================================================= +# PaperMind 适配层:流式 Agent 循环 + 确认机制 +# ============================================================================= + +if TYPE_CHECKING: + from packages.integrations.llm_client import LLMClient, StreamEvent + +logger = logging.getLogger(__name__) + + +def _make_sse(event: str, data: dict) -> str: + """格式化 SSE 事件""" + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +@dataclass +class PaperMindToolResult: + """PaperMind 风格的工具结果""" + + success: bool + data: dict = field(default_factory=dict) + summary: str = "" + + +@dataclass +class PaperMindToolProgress: + """PaperMind 风格的工具进度""" + + message: str + current: int = 0 + total: int = 0 + + +@dataclass +class PaperMindToolCall: + """解析后的工具调用""" + + tool_call_id: str + tool_name: str + arguments: dict + + +class ConfirmationMixin: + """ + 混入类:处理需要确认的工具的 pending 流程。 + 接管 _CONFIRM_TOOLS 逻辑,持久化到数据库。 + """ + + def __init__( + self, + confirm_tools: set[str], + pending_repo_class: type | None, + session_scope: Callable, + ): + self._confirm_tools = confirm_tools + self._pending_repo_class = pending_repo_class + self._session_scope = session_scope + self._action_ttl = 1800 # 30 分钟 + + def is_confirm_tool(self, tool_name: str) -> bool: + return tool_name in self._confirm_tools + + def store_pending_action( + self, + action_id: str, + tool_name: str, + tool_args: dict, + tool_call_id: str, + conversation_state: dict, + ) -> None: + """持久化 pending action 到数据库""" + from packages.storage.repositories import AgentPendingActionRepository + + try: + with self._session_scope() as session: + repo = AgentPendingActionRepository(session) + repo.create( + action_id=action_id, + tool_name=tool_name, + tool_args=tool_args, + tool_call_id=tool_call_id, + conversation_state=conversation_state, + ) + except Exception as exc: + logger.warning("存储 pending_action 失败: %s", exc) + + def load_pending_action(self, action_id: str) -> dict | None: + """从数据库加载 pending action""" + from packages.storage.repositories import AgentPendingActionRepository + + try: + with self._session_scope() as session: + repo = AgentPendingActionRepository(session) + record = repo.get_by_id(action_id) + if record: + return { + "tool": record.tool_name, + "args": record.tool_args, + "tool_call_id": record.tool_call_id, + "conversation": (record.conversation_state or {}).get("conversation", []), + } + except Exception as exc: + logger.warning("读取 pending_action 失败: %s", exc) + return None + + def delete_pending_action(self, action_id: str) -> None: + """从数据库删除 pending action""" + from packages.storage.repositories import AgentPendingActionRepository + + try: + with self._session_scope() as session: + repo = AgentPendingActionRepository(session) + repo.delete(action_id) + except Exception as exc: + logger.warning("删除 pending_action 失败: %s", exc) + + def cleanup_expired_actions(self) -> None: + """清理过期的 pending actions""" + from packages.storage.repositories import AgentPendingActionRepository + + try: + with self._session_scope() as session: + repo = AgentPendingActionRepository(session) + deleted = repo.cleanup_expired(self._action_ttl) + if deleted > 0: + logger.info("清理 %d 个过期 pending_actions", deleted) + except Exception as exc: + logger.warning("清理过期 pending_actions 失败: %s", exc) + + def describe_action(self, tool_name: str, args: dict) -> str: + """生成操作描述""" + descriptions: dict[str, Callable[[dict], str]] = { + "ingest_arxiv": lambda a: ( + f"入库选中的 {len(a.get('arxiv_ids', []))} 篇论文(来源: {a.get('query', '?')})" + ), + "skim_paper": lambda a: f"对论文 {a.get('paper_id', '?')[:8]}... 执行粗读分析", + "deep_read_paper": lambda a: f"对论文 {a.get('paper_id', '?')[:8]}... 执行精读分析", + "embed_paper": lambda a: f"对论文 {a.get('paper_id', '?')[:8]}... 执行向量化嵌入", + "generate_wiki": lambda a: ( + f"生成 {a.get('type', '?')} 类型 Wiki({a.get('keyword_or_id', '?')})" + ), + "generate_daily_brief": lambda _: "生成每日研究简报", + "manage_subscription": lambda a: ( + f"{'启用' if a.get('enabled') else '关闭'}主题「{a.get('topic_name', '?')}」的定时搜集" + ), + } + fn = descriptions.get(tool_name) + if fn: + return fn(args) + return f"执行 {tool_name}" + + +class StreamingAgentLoop: + """ + PaperMind 流式 Agent 循环。 + + 支持: + - LLMClient 流式输出(text_delta 事件) + - 工具调用处理(tool_call 事件) + - SSE 事件输出 + - 确认类工具的 pending 流程 + + 使用方式: + loop = StreamingAgentLoop( + llm=LLMClient(), + tools=openai_tools_format, + tool_registry=TOOL_REGISTRY, # list[ToolDef] + execute_fn=execute_tool_stream, # Iterator[ToolProgress | ToolResult] + session_scope=session_scope, + ) + for sse in loop.run(conversation): + yield sse + """ + + def __init__( + self, + llm: LLMClient, + tools: list[dict], + tool_registry: list[Any], # list[ToolDef] + execute_fn: Callable[[str, dict], Iterator], + session_scope: Callable, + max_rounds: int = 12, + max_tokens: int = 8192, + on_usage: Callable[[str, str, int, int], None] | None = None, + ): + self.llm = llm + self.tools = tools + self.execute_fn = execute_fn + self.max_rounds = max_rounds + self.max_tokens = max_tokens + self._on_usage = on_usage + + # 从 tool_registry 提取 requires_confirm 集合 + confirm_names = {t.name for t in tool_registry if getattr(t, "requires_confirm", False)} + self._confirm_mixin = ConfirmationMixin( + confirm_tools=confirm_names, + pending_repo_class=None, # not needed directly + session_scope=session_scope, + ) + + def run(self, conversation: list[dict]) -> Iterator[str]: + """ + 执行流式 Agent 循环,yield SSE 事件字符串。 + """ + for _round_idx in range(self.max_rounds): + # 构建消息 + openai_msgs = self._build_messages(conversation) + text_buf = "" + tool_calls: list[PaperMindToolCall] = [] + + # 流式 LLM 调用 + for event in self.llm.chat_stream( + openai_msgs, tools=self.tools, max_tokens=self.max_tokens + ): + sse = self._handle_stream_event(event, text_buf=text_buf, tool_calls=tool_calls) + if sse: + yield sse + # 实时更新 text_buf + if event.type == "text_delta": + text_buf += event.content + + # 没有工具调用 → 对话结束 + if not tool_calls: + yield _make_sse("done", {}) + return + + # 记录 assistant 回复(含 tool_calls) + assistant_msg = self._build_assistant_message(text_buf, tool_calls) + conversation.append(assistant_msg) + + # 处理工具调用:自动工具 vs 确认工具 + confirm_calls = [ + tc for tc in tool_calls if self._confirm_mixin.is_confirm_tool(tc.tool_name) + ] + auto_calls = [ + tc for tc in tool_calls if not self._confirm_mixin.is_confirm_tool(tc.tool_name) + ] + + # 执行自动工具 + for tc in auto_calls: + for sse in self._execute_and_emit(tc, conversation): + yield sse + + # 有确认工具时,pending 并暂停 + if confirm_calls: + tc = confirm_calls[0] + yield from self._handle_confirm_tool(tc, conversation) + return + + yield _make_sse("done", {}) + + def _handle_stream_event( + self, + event: StreamEvent, + text_buf: str, + tool_calls: list[PaperMindToolCall], + ) -> str | None: + """处理单个流事件,返回 SSE 字符串或 None""" + if event.type == "text_delta": + return _make_sse("text_delta", {"content": event.content}) + elif event.type == "tool_call": + tool_calls.append( + PaperMindToolCall( + tool_call_id=event.tool_call_id, + tool_name=event.tool_name, + arguments=json.loads(event.tool_arguments) if event.tool_arguments else {}, + ) + ) + elif event.type == "error": + return _make_sse("error", {"message": event.content}) + elif event.type == "usage" and self._on_usage: + self._on_usage( + event.model or "", + event.model or "", + event.input_tokens or 0, + event.output_tokens or 0, + ) + return None + + def _execute_and_emit( + self, + tc: PaperMindToolCall, + conversation: list[dict], + ) -> Iterator[str]: + """执行工具并 yield SSE 事件""" + # tool_start + yield _make_sse( + "tool_start", + { + "id": tc.tool_call_id, + "name": tc.tool_name, + "args": tc.arguments, + }, + ) + + result = PaperMindToolResult(success=False, summary="无结果") + for item in self.execute_fn(tc.tool_name, tc.arguments): + if isinstance(item, PaperMindToolProgress): + yield _make_sse( + "tool_progress", + { + "id": tc.tool_call_id, + "message": item.message, + "current": item.current, + "total": item.total, + }, + ) + elif isinstance(item, PaperMindToolResult): + result = PaperMindToolResult( + success=item.success, data=item.data, summary=item.summary + ) + + # 构建 tool 消息 + tool_content: dict = { + "success": result.success, + "summary": result.summary, + "data": result.data, + } + if not result.success: + tool_content["error_hint"] = ( + "工具执行失败。请分析原因,告知用户,并建议替代方案。不要用相同参数重试。" + ) + + conversation.append( + { + "role": "tool", + "tool_call_id": tc.tool_call_id, + "content": json.dumps(tool_content, ensure_ascii=False), + } + ) + + # tool_result + yield _make_sse( + "tool_result", + { + "id": tc.tool_call_id, + "name": tc.tool_name, + "success": result.success, + "summary": result.summary, + "data": result.data, + }, + ) + + def _handle_confirm_tool( + self, + tc: PaperMindToolCall, + conversation: list[dict], + ) -> Iterator[str]: + """处理需要确认的工具:存 pending → yield action_confirm → return""" + action_id = f"act_{uuid4().hex[:12]}" + logger.info( + "确认操作挂起: %s [%s] args=%s", + action_id, + tc.tool_name, + tc.arguments, + ) + + # 清理过期 actions + self._confirm_mixin.cleanup_expired_actions() + + # 持久化到数据库 + self._confirm_mixin.store_pending_action( + action_id=action_id, + tool_name=tc.tool_name, + tool_args=tc.arguments, + tool_call_id=tc.tool_call_id, + conversation_state={"conversation": conversation}, + ) + + desc = self._confirm_mixin.describe_action(tc.tool_name, tc.arguments) + yield _make_sse( + "action_confirm", + { + "id": action_id, + "tool": tc.tool_name, + "args": tc.arguments, + "description": desc, + }, + ) + + def _build_messages(self, conversation: list[dict]) -> list[dict]: + """从 conversation 提取 OpenAI 格式的 messages""" + # conversation 本身已经是 OpenAI 格式 + return conversation + + def _build_assistant_message(self, text_buf: str, tool_calls: list[PaperMindToolCall]) -> dict: + return { + "role": "assistant", + "content": text_buf, + "tool_calls": [ + { + "id": tc.tool_call_id, + "type": "function", + "function": { + "name": tc.tool_name, + "arguments": json.dumps(tc.arguments), + }, + } + for tc in tool_calls + ], + } + + # -- 对外接口:confirm/reject 后继续循环 -- + def continue_after_confirmation( + self, + conversation: list[dict], + ) -> Iterator[str]: + """confirm/reject 后继续循环(从 conversation 恢复)""" + yield from self.run(conversation) + yield _make_sse("done", {}) + + def execute_confirmed_action( + self, + action: dict, + conversation: list[dict], + ) -> Iterator[str]: + """执行已确认的 action,继续循环""" + tool_call_id = action["tool_call_id"] + tool_name = action["tool"] + args = action["args"] + + yield _make_sse( + "tool_start", + { + "id": tool_call_id, + "name": tool_name, + "args": args, + }, + ) + + result = PaperMindToolResult(success=False, summary="无结果") + for item in self.execute_fn(tool_name, args): + if isinstance(item, PaperMindToolProgress): + yield _make_sse( + "tool_progress", + { + "id": tool_call_id, + "message": item.message, + "current": item.current, + "total": item.total, + }, + ) + elif isinstance(item, PaperMindToolResult): + result = item + + yield _make_sse( + "action_result", + { + "id": action.get("action_id", ""), + "success": result.success, + "summary": result.summary, + "data": result.data, + }, + ) + + # 注入 tool result 到 conversation + conversation.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": json.dumps( + { + "success": result.success, + "summary": result.summary, + "data": result.data, + }, + ensure_ascii=False, + ), + } + ) + + # 继续循环 + yield from self.run(conversation) + yield _make_sse("done", {}) + + def execute_rejected_action( + self, + action: dict, + conversation: list[dict], + ) -> Iterator[str]: + """注入拒绝信息,继续循环让 LLM 给替代建议""" + tool_call_id = action["tool_call_id"] + + yield _make_sse( + "action_result", + { + "id": action.get("action_id", ""), + "success": False, + "summary": "用户已取消该操作", + "data": {}, + }, + ) + + # 注入拒绝信息 + conversation.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": json.dumps( + { + "success": False, + "summary": "用户拒绝了此操作,请提供替代方案或询问用户意见", + "data": {}, + }, + ensure_ascii=False, + ), + } + ) + + yield from self.run(conversation) + yield _make_sse("done", {}) + + def execute_and_continue( + self, + action: dict, + conversation: list[dict], + ) -> Iterator[str]: + """ + 执行已确认的 action(来自 confirmed_action_id),继续循环。 + 用于 stream_chat(messages, confirmed_action_id=xxx) 场景。 + """ + tool_call_id = action["tool_call_id"] + tool_name = action["tool"] + args = action["args"] + + yield _make_sse( + "tool_start", + { + "id": tool_call_id, + "name": tool_name, + "args": args, + }, + ) + + result = PaperMindToolResult(success=False, summary="无结果") + for item in self.execute_fn(tool_name, args): + if isinstance(item, PaperMindToolProgress): + yield _make_sse( + "tool_progress", + { + "id": tool_call_id, + "message": item.message, + "current": item.current, + "total": item.total, + }, + ) + elif isinstance(item, PaperMindToolResult): + result = item + + yield _make_sse( + "action_result", + { + "id": action.get("action_id", ""), + "success": result.success, + "summary": result.summary, + "data": result.data, + }, + ) + + conversation.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": json.dumps( + { + "success": result.success, + "summary": result.summary, + "data": result.data, + }, + ensure_ascii=False, + ), + } + ) + + yield from self.run(conversation) + yield _make_sse("done", {}) diff --git a/packages/agent_core/tasks.py b/packages/agent_core/tasks.py index 7343ba5..fb6bf00 100644 --- a/packages/agent_core/tasks.py +++ b/packages/agent_core/tasks.py @@ -18,11 +18,14 @@ import time from contextlib import suppress from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Any, Literal if TYPE_CHECKING: + from collections.abc import Callable from pathlib import Path + from packages.domain.task_tracker import TaskTracker + @dataclass class Task: @@ -248,3 +251,101 @@ def get_ready_tasks(self) -> list[Task]: except (ValueError, json.JSONDecodeError): pass return sorted(ready, key=lambda x: x.id) + + +# ============================================================================= +# PaperMind GlobalTracker 适配层 +# ============================================================================= + + +class GlobalTrackerAdapter: + """ + PaperMind global_tracker 的统一接口适配器。 + + global_tracker 使用 (task_id, task_type, title, total, category) 签名, + 而 agent_tools.py 里使用的是 (task_id, task_type, title, total=None) 签名。 + + 本适配器提供统一接口,同时兼容两种调用方式。 + """ + + def __init__(self, tracker: TaskTracker): + self._tracker = tracker + + def start( + self, + task_id: str, + task_type: str, + title: str, + total: int = 0, + category: str = "general", + ) -> None: + """ + 开始追踪任务。 + 兼容 agent_tools.py 的 start(task_id, task_type, title, total=None) 签名。 + """ + self._tracker.start( + task_id=task_id, + task_type=task_type, + title=title, + total=total, + category=category, + ) + + def update( + self, + task_id: str, + current: int, + message: str = "", + total: int | None = None, + ) -> None: + """更新任务进度""" + self._tracker.update(task_id=task_id, current=current, message=message, total=total) + + def finish( + self, + task_id: str, + success: bool = True, + error: str | None = None, + ) -> None: + """标记任务完成""" + self._tracker.finish(task_id=task_id, success=success, error=error) + + def cancel(self, task_id: str) -> bool: + """取消任务""" + return self._tracker.cancel(task_id=task_id) + + def submit( + self, + task_type: str, + title: str, + fn: Callable[..., Any], + *args: Any, + total: int = 100, + category: str = "general", + **kwargs: Any, + ) -> str: + """ + 提交后台任务。 + 兼容 agent_tools.py 的 submit(task_type, title, fn, *args, **kwargs) 签名。 + """ + return self._tracker.submit( + task_type=task_type, + title=title, + fn=fn, + total=total, + category=category, + *args, + **kwargs, + ) + + def get_active(self) -> list[dict]: + """获取所有活跃任务""" + return self._tracker.get_active() + + def get_task(self, task_id: str) -> dict | None: + """查询单个任务状态""" + return self._tracker.get_task(task_id=task_id) + + def get_result(self, task_id: str) -> Any | None: + """获取已完成任务的结果""" + return self._tracker.get_result(task_id=task_id) diff --git a/packages/ai/agent_service.py b/packages/ai/agent_service.py index d6fa7e5..bf9d85a 100644 --- a/packages/ai/agent_service.py +++ b/packages/ai/agent_service.py @@ -7,19 +7,20 @@ import json import logging -from collections.abc import Iterator -from uuid import uuid4 +from typing import TYPE_CHECKING +from packages.agent_core.loop import StreamingAgentLoop from packages.ai.agent_tools import ( TOOL_REGISTRY, - ToolProgress, - ToolResult, execute_tool_stream, get_openai_tools, ) -from packages.integrations.llm_client import LLMClient, StreamEvent +from packages.integrations.llm_client import LLMClient from packages.storage.db import session_scope -from packages.storage.repositories import AgentPendingActionRepository, PromptTraceRepository +from packages.storage.repositories import AgentPendingActionRepository + +if TYPE_CHECKING: + from collections.abc import Iterator logger = logging.getLogger(__name__) @@ -95,21 +96,12 @@ 10. **简洁回答**:不要长篇解释工具用途,直接执行任务。 """ -_CONFIRM_TOOLS = {t.name for t in TOOL_REGISTRY if t.requires_confirm} - -_ACTION_TTL = 1800 # 30 分钟过期 +_ACTION_TTL = 1800 # 30 分钟 -def _cleanup_expired_actions(): - """清理过期的 pending actions(数据库)""" - try: - with session_scope() as session: - repo = AgentPendingActionRepository(session) - deleted = repo.cleanup_expired(_ACTION_TTL) - if deleted > 0: - logger.info("清理 %d 个过期 pending_actions", deleted) - except Exception as exc: - logger.warning("清理过期 pending_actions 失败: %s", exc) +def _make_sse(event: str, data: dict) -> str: + """格式化 SSE 事件""" + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" def _record_agent_usage( @@ -129,6 +121,8 @@ def _record_agent_usage( output_tokens=output_tokens, ) with session_scope() as session: + from packages.storage.repositories import PromptTraceRepository + PromptTraceRepository(session).create( stage="agent_chat", provider=provider, @@ -157,19 +151,16 @@ def _build_user_profile() -> str: paper_repo = PaperRepository(session) topic_repo = TopicRepository(session) - # 订阅主题 topics = topic_repo.list_topics(enabled_only=True) if topics: topic_names = [t.name for t in topics[:8]] parts.append(f"关注领域:{', '.join(topic_names)}") - # 精读过的论文 deep_read = paper_repo.list_by_read_status(ReadStatus.deep_read, limit=5) if deep_read: titles = [p.title[:60] for p in deep_read] parts.append(f"最近精读:{'; '.join(titles)}") - # 粗读过的论文数量 skimmed = paper_repo.list_by_read_status(ReadStatus.skimmed, limit=200) unread = paper_repo.list_by_read_status(ReadStatus.unread, limit=200) parts.append( @@ -215,192 +206,56 @@ def _build_messages(user_messages: list[dict]) -> list[dict]: return openai_msgs -def _make_sse(event: str, data: dict) -> str: - """格式化 SSE 事件""" - return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" +def _cleanup_expired_actions() -> None: + """清理过期的 pending actions(数据库)""" + try: + with session_scope() as session: + repo = AgentPendingActionRepository(session) + deleted = repo.cleanup_expired(_ACTION_TTL) + if deleted > 0: + logger.info("清理 %d 个过期 pending_actions", deleted) + except Exception as exc: + logger.warning("清理过期 pending_actions 失败: %s", exc) -def _execute_and_emit( - tool_name: str, - args: dict, - tool_call_id: str, - result_event: str = "tool_result", - action_id: str | None = None, -) -> Iterator[tuple[str, ToolResult]]: - """执行工具并生成 SSE 事件流,返回 (sse_str, result) 的迭代器。 - 最后一个 yield 的第二个元素为最终的 ToolResult。""" - yield ( - _make_sse( - "tool_start", - { - "id": tool_call_id, - "name": tool_name, - "args": args, - }, - ), - ToolResult(success=False, summary=""), - ) - - result = ToolResult(success=False, summary="无结果") - for item in execute_tool_stream(tool_name, args): - if isinstance(item, ToolProgress): - yield ( - _make_sse( - "tool_progress", - { - "id": tool_call_id, - "message": item.message, - "current": item.current, - "total": item.total, - }, - ), - result, - ) - elif isinstance(item, ToolResult): - result = item - - emit_data: dict = { - "id": action_id or tool_call_id, - "success": result.success, - "summary": result.summary, - "data": result.data, - } - if result_event == "tool_result": - emit_data["name"] = tool_name - yield _make_sse(result_event, emit_data), result - - -def _build_tool_message(result: ToolResult, tool_call_id: str) -> dict: - """构建工具结果消息(含失败提示)""" - tool_content: dict = { - "success": result.success, - "summary": result.summary, - "data": result.data, - } - if not result.success: - tool_content["error_hint"] = ( - "工具执行失败。请分析原因,告知用户,并建议替代方案。不要用相同参数重试。" - ) - return { - "role": "tool", - "tool_call_id": tool_call_id, - "content": json.dumps(tool_content, ensure_ascii=False), - } +def _load_pending_action(action_id: str) -> dict | None: + """从数据库读取 pending action""" + try: + with session_scope() as session: + repo = AgentPendingActionRepository(session) + record = repo.get_by_id(action_id) + if record: + return { + "action_id": action_id, + "tool": record.tool_name, + "args": record.tool_args, + "tool_call_id": record.tool_call_id, + "conversation": (record.conversation_state or {}).get("conversation", []), + } + except Exception as exc: + logger.warning("读取 pending_action 失败: %s", exc) + return None -def _llm_loop( +def _create_loop( conversation: list[dict], - llm: LLMClient, - tools: list[dict], - max_rounds: int = 12, -) -> Iterator[str]: - """ - LLM 循环核心:流式调用 LLM,处理工具调用。 - 只读工具自动执行,写操作暂停等确认。 - """ - for round_idx in range(max_rounds): - openai_msgs = _build_messages(conversation) - text_buf = "" - tool_calls: list[StreamEvent] = [] - - for event in llm.chat_stream(openai_msgs, tools=tools, max_tokens=8192): - if event.type == "text_delta": - text_buf += event.content - yield _make_sse("text_delta", {"content": event.content}) - elif event.type == "tool_call": - tool_calls.append(event) - elif event.type == "usage": - _record_agent_usage( - provider=llm.provider, - model=event.model, - input_tokens=event.input_tokens, - output_tokens=event.output_tokens, - ) - elif event.type == "error": - yield _make_sse("error", {"message": event.content}) - return - - # 没有工具调用 → 对话结束 - if not tool_calls: - break - - # 记录 assistant 回复(含 tool_calls) - assistant_msg: dict = { - "role": "assistant", - "content": text_buf, - "tool_calls": [ - { - "id": tc.tool_call_id, - "type": "function", - "function": { - "name": tc.tool_name, - "arguments": tc.tool_arguments, - }, - } - for tc in tool_calls - ], - } - conversation.append(assistant_msg) - - # 处理工具调用:优先检查确认类工具 - confirm_calls = [tc for tc in tool_calls if tc.tool_name in _CONFIRM_TOOLS] - auto_calls = [tc for tc in tool_calls if tc.tool_name not in _CONFIRM_TOOLS] - - # 有需要确认的工具时,先处理自动工具,再暂停 - for tc in auto_calls: - try: - args = json.loads(tc.tool_arguments) if tc.tool_arguments else {} - except json.JSONDecodeError: - args = {} - - result = ToolResult(success=False, summary="") - for sse, r in _execute_and_emit(tc.tool_name, args, tc.tool_call_id): - yield sse - result = r - conversation.append(_build_tool_message(result, tc.tool_call_id)) - - if confirm_calls: - # 一次只处理一个确认类工具 - tc = confirm_calls[0] - try: - args = json.loads(tc.tool_arguments) if tc.tool_arguments else {} - except json.JSONDecodeError: - args = {} - - action_id = f"act_{uuid4().hex[:12]}" - logger.info( - "确认操作挂起: %s [%s] args=%s", - action_id, - tc.tool_name, - args, - ) - # 持久化到数据库 - _cleanup_expired_actions() - try: - with session_scope() as session: - repo = AgentPendingActionRepository(session) - repo.create( - action_id=action_id, - tool_name=tc.tool_name, - tool_args=args, - tool_call_id=tc.tool_call_id, - conversation_state={"conversation": conversation}, - ) - except Exception as exc: - logger.warning("存储 pending_action 失败: %s", exc) - desc = _describe_action(tc.tool_name, args) - yield _make_sse( - "action_confirm", - { - "id": action_id, - "tool": tc.tool_name, - "args": args, - "description": desc, - }, - ) - return +) -> StreamingAgentLoop: + """创建配置好的 StreamingAgentLoop 实例""" + llm = LLMClient() + tools = get_openai_tools() - yield _make_sse("done", {}) + def on_usage(provider: str, model: str, input_tokens: int, output_tokens: int) -> None: + _record_agent_usage(provider, model, input_tokens, output_tokens) + + loop = StreamingAgentLoop( + llm=llm, + tools=tools, + tool_registry=TOOL_REGISTRY, + execute_fn=execute_tool_stream, + session_scope=session_scope, + on_usage=on_usage, + ) + return loop def stream_chat( @@ -410,31 +265,12 @@ def stream_chat( """ Agent 主入口:接收消息列表,返回 SSE 事件流。 """ - llm = LLMClient() - tools = get_openai_tools() - conversation = list(messages) + _cleanup_expired_actions() + conversation = _build_messages(messages) # 处理确认操作 if confirmed_action_id: - # 从数据库读取并删除 - action = None - try: - with session_scope() as session: - repo = AgentPendingActionRepository(session) - action_record = repo.get_by_id(confirmed_action_id) - if action_record: - action = { - "tool": action_record.tool_name, - "args": action_record.tool_args, - "tool_call_id": action_record.tool_call_id, - "conversation": (action_record.conversation_state or {}).get( - "conversation", [] - ), - } - repo.delete(confirmed_action_id) - except Exception as exc: - logger.warning("读取 pending_action 失败: %s", exc) - + action = _load_pending_action(confirmed_action_id) if not action: yield _make_sse( "error", @@ -445,60 +281,13 @@ def stream_chat( yield _make_sse("done", {}) return - yield _make_sse( - "tool_start", - { - "id": action["tool_call_id"], - "name": action["tool"], - "args": action["args"], - }, - ) - result = ToolResult(success=False, summary="无结果") - for item in execute_tool_stream(action["tool"], action["args"]): - if isinstance(item, ToolProgress): - yield _make_sse( - "tool_progress", - { - "id": action["tool_call_id"], - "message": item.message, - "current": item.current, - "total": item.total, - }, - ) - elif isinstance(item, ToolResult): - result = item - yield _make_sse( - "action_result", - { - "id": confirmed_action_id, - "success": result.success, - "summary": result.summary, - "data": result.data, - }, - ) - - conversation = action.get("conversation", conversation) - conversation.append( - { - "role": "tool", - "tool_call_id": action["tool_call_id"], - "content": json.dumps( - { - "success": result.success, - "summary": result.summary, - "data": result.data, - }, - ensure_ascii=False, - ), - } - ) - - yield from _llm_loop(conversation, llm, tools) - yield _make_sse("done", {}) + loop = _create_loop(conversation) + yield from loop.execute_and_continue(action, conversation) return # 正常对话 - yield from _llm_loop(conversation, llm, tools) + loop = _create_loop(conversation) + yield from loop.run(conversation) yield _make_sse("done", {}) @@ -506,25 +295,7 @@ def confirm_action(action_id: str) -> Iterator[str]: """确认执行挂起的操作并继续对话""" logger.info("用户确认操作: %s", action_id) - # 从数据库读取并删除 - action = None - try: - with session_scope() as session: - repo = AgentPendingActionRepository(session) - action_record = repo.get_by_id(action_id) - if action_record: - action = { - "tool": action_record.tool_name, - "args": action_record.tool_args, - "tool_call_id": action_record.tool_call_id, - "conversation": (action_record.conversation_state or {}).get( - "conversation", [] - ), - } - repo.delete(action_id) - except Exception as exc: - logger.warning("读取 pending_action 失败: %s", exc) - + action = _load_pending_action(action_id) if not action: yield _make_sse( "error", @@ -535,50 +306,35 @@ def confirm_action(action_id: str) -> Iterator[str]: yield _make_sse("done", {}) return - result = ToolResult(success=False, summary="") - for sse, r in _execute_and_emit( - action["tool"], - action["args"], - action["tool_call_id"], - result_event="action_result", - action_id=action_id, - ): - yield sse - result = r + # 删除 pending action + try: + with session_scope() as session: + repo = AgentPendingActionRepository(session) + repo.delete(action_id) + except Exception as exc: + logger.warning("删除 pending_action 失败: %s", exc) conversation = action.get("conversation", []) - if conversation: - conversation.append(_build_tool_message(result, action["tool_call_id"])) - llm = LLMClient() - tools = get_openai_tools() - yield from _llm_loop(conversation, llm, tools) - - yield _make_sse("done", {}) + loop = _create_loop(conversation) + yield from loop.execute_confirmed_action(action, conversation) def reject_action(action_id: str) -> Iterator[str]: """拒绝挂起的操作并让 LLM 给出替代建议""" logger.info("用户拒绝操作: %s", action_id) - # 从数据库读取并删除 - action = None - try: - with session_scope() as session: - repo = AgentPendingActionRepository(session) - action_record = repo.get_by_id(action_id) - if action_record: - action = { - "tool": action_record.tool_name, - "args": action_record.tool_args, - "tool_call_id": action_record.tool_call_id, - "conversation": (action_record.conversation_state or {}).get( - "conversation", [] - ), - } + action = _load_pending_action(action_id) + + # 删除 pending action + if action: + try: + with session_scope() as session: + repo = AgentPendingActionRepository(session) repo.delete(action_id) - except Exception as exc: - logger.warning("读取 pending_action 失败: %s", exc) + except Exception as exc: + logger.warning("删除 pending_action 失败: %s", exc) + # 发送拒绝结果 yield _make_sse( "action_result", { @@ -589,48 +345,9 @@ def reject_action(action_id: str) -> Iterator[str]: }, ) - # 恢复对话,注入拒绝信息,让 LLM 给替代建议 if action and action.get("conversation"): conversation = action["conversation"] - conversation.append( - { - "role": "tool", - "tool_call_id": action["tool_call_id"], - "content": json.dumps( - { - "success": False, - "summary": "用户拒绝了此操作,请提供替代方案或询问用户意见", - "data": {}, - }, - ensure_ascii=False, - ), - } - ) - llm = LLMClient() - tools = get_openai_tools() - yield from _llm_loop(conversation, llm, tools) - - yield _make_sse("done", {}) - - -def _describe_action(tool_name: str, args: dict) -> str: - """生成操作描述""" - descriptions = { - "ingest_arxiv": lambda a: ( - f"入库选中的 {len(a.get('arxiv_ids', []))} 篇论文(来源: {a.get('query', '?')})" - ), - "skim_paper": lambda a: f"对论文 {a.get('paper_id', '?')[:8]}... 执行粗读分析", - "deep_read_paper": lambda a: f"对论文 {a.get('paper_id', '?')[:8]}... 执行精读分析", - "embed_paper": lambda a: f"对论文 {a.get('paper_id', '?')[:8]}... 执行向量化嵌入", - "generate_wiki": lambda a: ( - f"生成 {a.get('type', '?')} 类型 Wiki({a.get('keyword_or_id', '?')})" - ), - "generate_daily_brief": lambda _: "生成每日研究简报", - "manage_subscription": lambda a: ( - f"{'启用' if a.get('enabled') else '关闭'}主题「{a.get('topic_name', '?')}」的定时搜集" - ), - } - fn = descriptions.get(tool_name) - if fn: - return fn(args) - return f"执行 {tool_name}" + loop = _create_loop(conversation) + yield from loop.execute_rejected_action(action, conversation) + else: + yield _make_sse("done", {}) From bde83be8f67a06ecabfb827d47511706585c925c Mon Sep 17 00:00:00 2001 From: Color2333 <1552429809@qq.com> Date: Fri, 20 Mar 2026 12:27:36 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(agent):=20=E5=AE=9E=E7=8E=B0=20Context?= =?UTF-8?q?=20Compact=20+=20TodoWrite=20+=20Subagents=20=E4=B8=89=E5=A4=A7?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Context Compact (s06): 3层压缩策略,支持无限长度会话 - Layer1: 消息摘要 (LLM生成) - Layer2: 关键决策提取 - Layer3: 元信息压缩 - CompactingStreamingAgentLoop 混入类 - TodoWrite (s03): 统一任务计划接口 - TodoManager: JSON持久化,支持层级结构(parent_id) - PlannerMixin: 计划生成混入类 - 格式规范: [WHERE] [HOW] to [WHY] — expect [RESULT] - Subagents (s04): 增强型子Agent并行调度 - SubagentRunner: 独立线程执行,支持sync/async - SubagentPool: 并发控制(max_concurrent) - 全局单例 get_subagent_pool() - agent_service.py: 导出所有新模块供consumer使用 - 所有文件通过 py_compile 和 ruff check --- packages/agent_core/context_compaction.py | 521 ++++++++++++++++++++++ packages/agent_core/subagents.py | 222 +++++++++ packages/agent_core/todos.py | 396 ++++++++++++++++ packages/ai/agent_service.py | 23 + 4 files changed, 1162 insertions(+) create mode 100644 packages/agent_core/context_compaction.py create mode 100644 packages/agent_core/subagents.py create mode 100644 packages/agent_core/todos.py diff --git a/packages/agent_core/context_compaction.py b/packages/agent_core/context_compaction.py new file mode 100644 index 0000000..ff47cb9 --- /dev/null +++ b/packages/agent_core/context_compaction.py @@ -0,0 +1,521 @@ +""" +Context Compaction — 3层压缩策略,支持无限长度会话 + +@author Color2333 +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Iterator +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from packages.integrations.llm_client import LLMClient + +logger = logging.getLogger(__name__) + + +class CompactionLevel(Enum): + """压缩层级""" + + NONE = "none" + LAYER1_SUMMARIZE = "layer1" # 消息摘要 + LAYER2_KEY_DECISIONS = "layer2" # 关键决策提取 + LAYER3_METADATA = "layer3" # 元信息压缩 + + +@dataclass +class CompactionConfig: + """压缩配置""" + + threshold_ratio: float = 0.5 # 触发压缩的 token 占比阈值 + max_tool_calls_per_round: int = 20 # 单轮最大工具调用次数 + max_session_duration_hours: float = 2.0 # 最大会话时长(小时) + max_messages_count: int = 20 # 最大消息数量(超过则使用 Layer 3) + + +@dataclass +class CompactionResult: + """压缩结果""" + + compressed_messages: list[dict[str, Any]] + level: CompactionLevel + original_token_count: int + compressed_token_count: int + compression_ratio: float = 0.0 + + +@dataclass +class SessionMetadata: + """会话元信息""" + + summary: str = "" + decisions: list[str] = field(default_factory=list) + files_touched: list[str] = field(default_factory=list) + active_task: str = "" + next_step: str = "" + tool_calls_count: int = 0 + + +class ContextCompactor: + """ + 3层 Context 压缩策略 + + Layer 1: 消息摘要(定期把多轮对话压缩成一条摘要消息) + Layer 2: 关键决策提取(只保留重要的架构决策、操作结果、文件路径) + Layer 3: 元信息压缩(把完整消息压缩成结构化元信息) + """ + + def __init__( + self, + llm: LLMClient, + max_tokens: int = 8192, + config: CompactionConfig | None = None, + ): + self.llm = llm + self.max_tokens = max_tokens + self.config = config or CompactionConfig() + self._session_start_time = time.time() + + def should_compact(self, messages: list[dict[str, Any]]) -> CompactionLevel: + """检查是否需要压缩,返回需要的压缩层级""" + if not messages: + return CompactionLevel.NONE + + total_tokens = self._estimate_tokens(messages) + threshold_tokens = int(self.max_tokens * self.config.threshold_ratio) + + # Layer 3: 历史消息全部是工具调用 + if len(messages) > 5 and self._is_all_tool_messages(messages): + logger.info("触发 Layer 3 压缩:历史消息全部是工具调用") + return CompactionLevel.LAYER3_METADATA + + # Layer 2: 单轮工具调用过多 或 会话超时 + recent_tool_calls = self._count_recent_tool_calls(messages) + session_duration = (time.time() - self._session_start_time) / 3600 + + if recent_tool_calls > self.config.max_tool_calls_per_round: + logger.info( + "触发 Layer 2 压缩:单轮工具调用 %d 次", + recent_tool_calls, + ) + return CompactionLevel.LAYER2_KEY_DECISIONS + + if session_duration > self.config.max_session_duration_hours: + logger.info( + "触发 Layer 2 压缩:会话时长 %.1f 小时", + session_duration, + ) + return CompactionLevel.LAYER2_KEY_DECISIONS + + # Layer 1: 消息总长度超过阈值 + if total_tokens > threshold_tokens: + logger.info( + "触发 Layer 1 压缩:token %d > 阈值 %d", + total_tokens, + threshold_tokens, + ) + return CompactionLevel.LAYER1_SUMMARIZE + + return CompactionLevel.NONE + + def compact( + self, + messages: list[dict[str, Any]], + level: CompactionLevel | None = None, + ) -> CompactionResult: + """执行压缩""" + if level is None: + level = self.should_compact(messages) + + if level == CompactionLevel.NONE: + return CompactionResult( + compressed_messages=messages, + level=CompactionLevel.NONE, + original_token_count=self._estimate_tokens(messages), + compressed_token_count=self._estimate_tokens(messages), + ) + + # 自动选择压缩策略 + if level == CompactionLevel.LAYER1_SUMMARIZE: + compressed = self.layer1_summarize(messages) + elif level == CompactionLevel.LAYER2_KEY_DECISIONS: + compressed = self.layer2_key_decisions(messages) + elif level == CompactionLevel.LAYER3_METADATA: + compressed = self.layer3_metadata(messages) + else: + compressed = messages + + original_tokens = self._estimate_tokens(messages) + compressed_tokens = self._estimate_tokens(compressed) + ratio = ( + (original_tokens - compressed_tokens) / original_tokens if original_tokens > 0 else 0.0 + ) + + logger.info( + "Context 压缩完成:%.1f%% (%d -> %d tokens)", + ratio * 100, + original_tokens, + compressed_tokens, + ) + + return CompactionResult( + compressed_messages=compressed, + level=level, + original_token_count=original_tokens, + compressed_token_count=compressed_tokens, + compression_ratio=ratio, + ) + + def layer1_summarize(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Layer 1: 消息摘要 + 把多轮对话压缩成一条摘要消息 + """ + if len(messages) < 3: + return messages + + # 构建对话历史文本 + history_text = self._build_history_text(messages) + + prompt = f"""请将以下对话历史压缩成简洁的摘要(不超过150字),保留关键信息: + +{history_text} + +要求: +1. 突出用户的原始需求 +2. 保留已完成的操作结果 +3. 省略中间探索过程 +4. 使用简洁的自然语言""" + + try: + result = self.llm.summarize_text(prompt, stage="compaction", max_tokens=300) + summary = result.content.strip() + except Exception as exc: + logger.warning("Layer 1 摘要生成失败: %s", exc) + summary = self._fallback_summarize(messages) + + # 构建压缩后的消息 + compressed = [ + { + "role": "user", + "content": f"[对话历史摘要]\n{summary}", + } + ] + + # 保留最近的一轮对话(如果有的话) + if len(messages) >= 2: + compressed.extend(messages[-2:]) + + return compressed + + def layer2_key_decisions(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Layer 2: 关键决策提取 + 只保留重要的架构决策、操作结果、文件路径 + """ + history_text = self._build_history_text(messages) + + prompt = f"""请从以下对话历史中提取关键决策和项目状态,输出结构化信息: + +{history_text} + +请按以下格式输出: + +=== 项目状态 === +- [已完成的主要任务] +- [关键技术决策] + +=== 关键文件 === +- [涉及的重要文件路径] + +=== 当前进行中 === +[正在进行的任务描述] + +=== 下一步 === +[推荐的下一步操作]""" + + try: + result = self.llm.summarize_text(prompt, stage="compaction", max_tokens=500) + decisions = result.content.strip() + except Exception as exc: + logger.warning("Layer 2 决策提取失败: %s", exc) + decisions = self._fallback_decisions(messages) + + compressed = [ + { + "role": "user", + "content": f"[关键决策与项目状态]\n{decisions}", + } + ] + + # 保留最近一轮对话 + if len(messages) >= 2: + compressed.extend(messages[-2:]) + + return compressed + + def layer3_metadata(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Layer 3: 元信息压缩 + 把完整消息压缩成结构化元信息 + """ + metadata = self._extract_metadata(messages) + + # 构建结构化消息 + content_parts = [ + "[会话元信息 - 已压缩]", + f"摘要: {metadata.summary}", + ] + + if metadata.decisions: + content_parts.append(f"关键决策: {', '.join(metadata.decisions[:5])}") + + if metadata.files_touched: + content_parts.append(f"涉及文件: {', '.join(metadata.files_touched[:10])}") + + if metadata.active_task: + content_parts.append(f"当前任务: {metadata.active_task}") + + if metadata.next_step: + content_parts.append(f"下一步: {metadata.next_step}") + + content_parts.append(f"工具调用次数: {metadata.tool_calls_count}") + + compressed = [ + { + "role": "user", + "content": "\n".join(content_parts), + } + ] + + return compressed + + # -- Helper Methods -- + + def _estimate_tokens(self, messages: list[dict[str, Any]]) -> int: + """估算消息列表的 token 数量""" + total_chars = 0 + for msg in messages: + content = msg.get("content", "") + if isinstance(content, str): + total_chars += len(content) + elif isinstance(content, list): + # 处理多模态内容 + for item in content: + if isinstance(item, dict) and "text" in item: + total_chars += len(item["text"]) + elif isinstance(item, str): + total_chars += len(item) + + # 工具调用额外计算 + if "tool_calls" in msg: + for tc in msg["tool_calls"]: + fn = tc.get("function", {}) + total_chars += len(fn.get("name", "")) + total_chars += len(fn.get("arguments", "")) + + # 粗略估算:1 token ≈ 4 chars + return total_chars // 4 + + def _count_recent_tool_calls(self, messages: list[dict[str, Any]]) -> int: + """统计最近一轮的工具调用次数""" + count = 0 + # 从后往前找最近的 assistant 消息 + for msg in reversed(messages): + if msg.get("role") == "assistant" and "tool_calls" in msg: + count = len(msg.get("tool_calls", [])) + break + return count + + def _is_all_tool_messages(self, messages: list[dict[str, Any]]) -> bool: + """检查是否所有消息都是工具调用相关""" + if not messages: + return False + + for msg in messages[:-2]: # 排除最近两轮 + role = msg.get("role", "") + if role == "user": + content = msg.get("content", "") + # 如果有普通文本内容(非 tool_result) + if isinstance(content, str) and content and "tool" not in content.lower(): + return False + elif role == "assistant": + if "tool_calls" not in msg: + # 有普通文本回复 + content = msg.get("content", "") + if content and len(content) > 50: + return False + + return True + + def _build_history_text(self, messages: list[dict[str, Any]]) -> str: + """构建对话历史文本""" + parts = [] + for msg in messages: + role = msg.get("role", "unknown") + content = msg.get("content", "") + + if isinstance(content, str): + parts.append(f"[{role}] {content[:500]}") + elif isinstance(content, list): + # 处理 tool_result 等 + for item in content: + if isinstance(item, dict): + if item.get("type") == "tool_result": + parts.append(f"[tool_result] {str(item.get('content', ''))[:200]}") + elif "text" in item: + parts.append(f"[{role}] {item['text'][:500]}") + + # 工具调用信息 + if "tool_calls" in msg: + for tc in msg.get("tool_calls", []): + fn = tc.get("function", {}) + name = fn.get("name", "unknown") + parts.append(f"[tool_call] {name}") + + return "\n".join(parts) + + def _extract_metadata(self, messages: list[dict[str, Any]]) -> SessionMetadata: + """从消息中提取元信息""" + metadata = SessionMetadata() + tool_names = set() + + for msg in messages: + # 提取工具调用 + if "tool_calls" in msg: + for tc in msg["tool_calls"]: + fn = tc.get("function", {}) + tool_names.add(fn.get("name", "")) + metadata.tool_calls_count += 1 + + # 提取文件路径 + content = msg.get("content", "") + if isinstance(content, str): + # 简单的文件路径提取(匹配常见模式) + import re + + paths = re.findall(r"[a-zA-Z0-9_\-./]+\.[a-zA-Z]{1,4}", content) + metadata.files_touched.extend(paths[:3]) + + # 提取决策关键词 + if "decision" in str(content).lower() or "选择" in str(content): + metadata.decisions.append(str(content)[:100]) + + metadata.files_touched = list(set(metadata.files_touched))[:10] + + # 尝试提取当前任务(从最近的用户消息) + for msg in reversed(messages): + if msg.get("role") == "user": + content = msg.get("content", "") + if isinstance(content, str) and len(content) > 10: + metadata.active_task = content[:100] + break + + # 生成简单摘要 + if messages: + metadata.summary = ( + f"处理了 {len(messages)} 条消息,调用了 {metadata.tool_calls_count} 次工具" + ) + + return metadata + + def _fallback_summarize(self, messages: list[dict[str, Any]]) -> str: + """摘要生成失败时的回退方案""" + total = len(messages) + tool_count = sum(1 for m in messages if "tool_calls" in m) + return f"对话包含 {total} 条消息,其中 {tool_count} 轮涉及工具调用" + + def _fallback_decisions(self, messages: list[dict[str, Any]]) -> str: + """决策提取失败时的回退方案""" + tool_names = self._extract_tool_names(messages) + return f"项目中使用了以下工具: {', '.join(tool_names[:5]) if tool_names else '无'}" + + def _extract_tool_names(self, messages: list[dict[str, Any]]) -> list[str]: + """提取所有工具调用名称""" + names = [] + for msg in messages: + if "tool_calls" in msg: + for tc in msg["tool_calls"]: + fn = tc.get("function", {}) + name = fn.get("name", "") + if name and name not in names: + names.append(name) + return names + + +class CompactingStreamingAgentLoop: + """ + 混入类:为 StreamingAgentLoop 添加压缩功能 + + 使用方式: + class MyAgentLoop(CompactingStreamingAgentLoop, StreamingAgentLoop): + pass + + # 或者在初始化时混入 + loop = CompactingStreamingAgentLoop( + llm=llm_client, + tools=tools, + ..., + enable_compaction=True, + ) + """ + + def __init__( + self, + *args, + enable_compaction: bool = True, + compaction_config: CompactionConfig | None = None, + **kwargs, + ): + # 调用父类初始化 + super().__init__(*args, **kwargs) + + self._enable_compaction = enable_compaction + self._compaction_config = compaction_config or CompactionConfig() + + # 延迟初始化 compactor(需要 llm 实例) + self._compactor: ContextCompactor | None = None + + def _get_compactor(self) -> ContextCompactor | None: + """获取或创建 compactor 实例""" + if not self._enable_compaction: + return None + + if self._compactor is None: + # 从父类获取 llm 和 max_tokens + llm = getattr(self, "llm", None) + max_tokens = getattr(self, "max_tokens", 8192) + + if llm is None: + logger.warning("无法初始化 ContextCompactor: llm 实例不可用") + return None + + self._compactor = ContextCompactor( + llm=llm, + max_tokens=max_tokens, + config=self._compaction_config, + ) + + return self._compactor + + def run(self, conversation: list[dict[str, Any]]) -> Iterator[str]: + """执行流式 Agent 循环,并在结束后检查是否需要压缩""" + # 调用父类的 run 方法 + yield from super().run(conversation) # type: ignore[misc] + + # 检查并执行压缩 + if self._enable_compaction: + compactor = self._get_compactor() + if compactor: + level = compactor.should_compact(conversation) + if level != CompactionLevel.NONE: + logger.info("执行 Context 压缩: %s", level.value) + result = compactor.compact(conversation, level) + # 替换原始消息 + conversation.clear() + conversation.extend(result.compressed_messages) diff --git a/packages/agent_core/subagents.py b/packages/agent_core/subagents.py new file mode 100644 index 0000000..69767ec --- /dev/null +++ b/packages/agent_core/subagents.py @@ -0,0 +1,222 @@ +""" +Subagents — 增强型子 Agent 并行调度模块 +@author Color2333 +""" + +from __future__ import annotations + +import logging +import threading +import uuid +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +logger = logging.getLogger(__name__) + + +class SubagentStatus(Enum): + IDLE = "idle" + RUNNING = "running" + DONE = "done" + FAILED = "failed" + + +@dataclass +class Subagent: + id: str + name: str + system_prompt: str + messages: list[dict] = field(default_factory=list) + status: SubagentStatus = SubagentStatus.IDLE + result: Any = None + error: str | None = None + + +class SubagentRunner: + def __init__(self, llm_factory: Callable[[], Any]): + self._llm_factory = llm_factory + self._subagents: dict[str, Subagent] = {} + self._lock = threading.Lock() + + def create(self, name: str, system_prompt: str) -> str: + subagent_id = f"sub_{uuid.uuid4().hex[:8]}" + with self._lock: + self._subagents[subagent_id] = Subagent( + id=subagent_id, + name=name, + system_prompt=system_prompt, + messages=[{"role": "system", "content": system_prompt}], + ) + return subagent_id + + def run_sync( + self, + subagent_id: str, + task: str, + timeout: int = 120, + ) -> dict: + subagent = self._subagents.get(subagent_id) + if not subagent: + return {"success": False, "error": f"Subagent {subagent_id} not found"} + subagent.status = SubagentStatus.RUNNING + subagent.messages.append({"role": "user", "content": task}) + result_holder: dict = {} + + def _run(): + try: + llm = self._llm_factory() + response = llm.chat_stream(subagent.messages, tools=[]) + text_parts = [] + for event in response: + if event.type == "text_delta": + text_parts.append(event.content) + subagent.result = "".join(text_parts) + subagent.messages.append({"role": "assistant", "content": subagent.result}) + subagent.status = SubagentStatus.DONE + result_holder["success"] = True + result_holder["result"] = subagent.result + except Exception as exc: # noqa: BLE001 + subagent.status = SubagentStatus.FAILED + subagent.error = str(exc) + result_holder["success"] = False + result_holder["error"] = str(exc) + + thread = threading.Thread(target=_run) + thread.start() + thread.join(timeout=timeout) + if thread.is_alive(): + subagent.status = SubagentStatus.FAILED + subagent.error = "Timeout" + return {"success": False, "error": "Timeout"} + return result_holder or {"success": False, "error": "No result"} + + def run_async(self, subagent_id: str, task: str) -> None: + subagent = self._subagents.get(subagent_id) + if not subagent: + logger.warning("run_async: subagent %s not found", subagent_id) + return + subagent.status = SubagentStatus.RUNNING + subagent.messages.append({"role": "user", "content": task}) + + def _run(): + try: + llm = self._llm_factory() + response = llm.chat_stream(subagent.messages, tools=[]) + text_parts = [] + for event in response: + if event.type == "text_delta": + text_parts.append(event.content) + subagent.result = "".join(text_parts) + subagent.messages.append({"role": "assistant", "content": subagent.result}) + subagent.status = SubagentStatus.DONE + except Exception as exc: # noqa: BLE001 + subagent.status = SubagentStatus.FAILED + subagent.error = str(exc) + + thread = threading.Thread(target=_run, daemon=True) + thread.start() + + def poll(self, subagent_id: str) -> dict: + subagent = self._subagents.get(subagent_id) + if not subagent: + return {"status": "not_found"} + return { + "id": subagent.id, + "name": subagent.name, + "status": subagent.status.value, + "result": subagent.result if subagent.status == SubagentStatus.DONE else None, + "error": subagent.error, + } + + def collect_result(self, subagent_id: str) -> Any: + subagent = self._subagents.get(subagent_id) + if not subagent: + return None + while subagent.status == SubagentStatus.RUNNING: + threading.Event().wait(0.1) + return subagent.result + + def kill(self, subagent_id: str) -> None: + subagent = self._subagents.get(subagent_id) + if subagent and subagent.status == SubagentStatus.RUNNING: + subagent.status = SubagentStatus.FAILED + subagent.error = "Killed by caller" + + def list_subagents(self) -> list[dict]: + with self._lock: + return [ + { + "id": s.id, + "name": s.name, + "status": s.status.value, + } + for s in self._subagents.values() + ] + + +class SubagentPool: + def __init__(self, max_concurrent: int = 3): + self._max_concurrent = max_concurrent + self._runners: dict[str, SubagentRunner] = {} + self._runner_lock = threading.Lock() + self._active_count = 0 + self._active_lock = threading.Lock() + + def create_runner(self, name: str, system_prompt: str) -> str: + llm_factory = self._get_llm_factory() + runner = SubagentRunner(llm_factory) + runner_id = runner.create(name, system_prompt) + with self._runner_lock: + self._runners[runner_id] = runner + return runner_id + + def run_parallel(self, tasks: list[dict[str, str]]) -> list[dict]: + results = [] + for task_def in tasks[: self._max_concurrent]: + runner_id = self.create_runner(task_def["name"], f"You are {task_def['name']}.") + runner = self._get_runner(runner_id) + if runner: + result = runner.run_sync(runner_id, task_def["task"]) + results.append({"name": task_def["name"], **result}) + return results + + def get_runner(self, runner_id: str) -> SubagentRunner | None: + return self._runners.get(runner_id) + + def list_runners(self) -> list[dict]: + with self._runner_lock: + all_results = [] + for _rid, runner in self._runners.items(): + all_results.extend(runner.list_subagents()) + return all_results + + def shutdown(self) -> None: + with self._runner_lock: + for _runner in self._runners.values(): + pass + self._runners.clear() + + def _get_llm_factory(self) -> Callable[[], Any]: + def factory() -> Any: + from packages.integrations.llm_client import LLMClient + + return LLMClient() + + return factory + + def _get_runner(self, runner_id: str) -> SubagentRunner | None: + return self._runners.get(runner_id) + + +_global_pool: SubagentPool | None = None +_pool_lock = threading.Lock() + + +def get_subagent_pool(max_concurrent: int = 3) -> SubagentPool: + global _global_pool + with _pool_lock: + if _global_pool is None: + _global_pool = SubagentPool(max_concurrent=max_concurrent) + return _global_pool diff --git a/packages/agent_core/todos.py b/packages/agent_core/todos.py new file mode 100644 index 0000000..10fa32b --- /dev/null +++ b/packages/agent_core/todos.py @@ -0,0 +1,396 @@ +""" +统一 TodoWrite 接口 — 支持跨 subagent 会话持久化 + +整合散落在各 skill 的任务计划功能,提供: +- TodoItem: 原子任务单元 +- TodoManager: 统一任务管理(文件持久化) +- PlannerMixin: 计划生成混入类 + +@author Color2333 +""" + +from __future__ import annotations + +import json +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@dataclass +class TodoItem: + """原子任务单元""" + + id: str + content: str # "[WHERE] [HOW] to [WHY] — expect [RESULT]" + status: Literal["pending", "in_progress", "completed", "cancelled"] = "pending" + priority: Literal["high", "medium", "low"] = "medium" + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + parent_id: str | None = None # 父任务 ID,用于子任务分解 + + def to_dict(self) -> dict: + return { + "id": self.id, + "content": self.content, + "status": self.status, + "priority": self.priority, + "created_at": self.created_at, + "updated_at": self.updated_at, + "parent_id": self.parent_id, + } + + @classmethod + def from_dict(cls, d: dict) -> TodoItem: + return cls( + id=d["id"], + content=d["content"], + status=d.get("status", "pending"), + priority=d.get("priority", "medium"), + created_at=d.get("created_at", time.time()), + updated_at=d.get("updated_at", time.time()), + parent_id=d.get("parent_id"), + ) + + +class TodoManager: + """ + 统一任务管理器,支持跨 subagent 会话持久化。 + + 存储格式: + {storage_path}/ + todos.json # 所有 todo 的 JSON 数组 + + 使用方式: + manager = TodoManager() + todo_id = manager.create("实现登录功能", priority="high") + manager.set_in_progress(todo_id) + manager.complete(todo_id) + """ + + DEFAULT_STORAGE_PATH = ".todos" + + def __init__(self, storage_path: str | None = None): + self.storage_path = Path(storage_path or self.DEFAULT_STORAGE_PATH) + self.storage_path.mkdir(parents=True, exist_ok=True) + self._todos_file = self.storage_path / "todos.json" + self._todos: dict[str, TodoItem] = {} + self._load() + + def _load(self) -> None: + """从文件加载所有 todos""" + if self._todos_file.exists(): + try: + data = json.loads(self._todos_file.read_text()) + self._todos = {item["id"]: TodoItem.from_dict(item) for item in data} + except (json.JSONDecodeError, KeyError): + self._todos = {} + + def _save(self) -> None: + """持久化到文件""" + data = [todo.to_dict() for todo in self._todos.values()] + self._todos_file.write_text(json.dumps(data, indent=2, ensure_ascii=False)) + + def _touch_updated(self, todo: TodoItem) -> None: + """更新 updated_at 时间戳""" + todo.updated_at = time.time() + + # ========== CRUD ========== + + def create( + self, + content: str, + priority: str = "medium", + parent_id: str | None = None, + ) -> str: + """ + 创建新 todo,返回 todo_id。 + + content 格式建议:"[WHERE] [HOW] to [WHY] — expect [RESULT]" + """ + todo_id = f"todo_{uuid.uuid4().hex[:8]}" + todo = TodoItem( + id=todo_id, + content=content, + priority=priority, # type: ignore + parent_id=parent_id, + ) + self._todos[todo_id] = todo + self._save() + return todo_id + + def get(self, todo_id: str) -> dict | None: + """获取 todo 详情""" + todo = self._todos.get(todo_id) + return todo.to_dict() if todo else None + + def update( + self, + todo_id: str, + status: str | None = None, + content: str | None = None, + ) -> dict: + """ + 更新 todo 状态或内容。 + 返回更新后的 todo 字典。 + """ + todo = self._todos.get(todo_id) + if not todo: + raise ValueError(f"Todo {todo_id} not found") + + if status: + if status not in ("pending", "in_progress", "completed", "cancelled"): + raise ValueError(f"Invalid status: {status}") + todo.status = status # type: ignore + + if content: + todo.content = content + + self._touch_updated(todo) + self._save() + return todo.to_dict() + + def list_all(self, status: str | None = None) -> list[dict]: + """ + 列出所有 todos。 + 可选按 status 过滤。 + """ + todos = list(self._todos.values()) + if status: + todos = [t for t in todos if t.status == status] + return sorted([t.to_dict() for t in todos], key=lambda x: x["created_at"]) + + def list_in_progress(self) -> list[dict]: + """列出所有 in_progress 的 todos""" + return self.list_all(status="in_progress") + + # ========== 状态变更快捷方法 ========== + + def set_in_progress(self, todo_id: str) -> None: + """标记为进行中""" + self.update(todo_id, status="in_progress") + + def complete(self, todo_id: str) -> None: + """标记为已完成""" + self.update(todo_id, status="completed") + + def cancel(self, todo_id: str) -> None: + """标记为已取消""" + self.update(todo_id, status="cancelled") + + def delete(self, todo_id: str) -> None: + """删除 todo""" + if todo_id in self._todos: + del self._todos[todo_id] + self._save() + + # ========== 层级结构 ========== + + def get_children(self, parent_id: str) -> list[dict]: + """获取子任务列表""" + children = [t for t in self._todos.values() if t.parent_id == parent_id] + return sorted([t.to_dict() for t in children], key=lambda x: x["created_at"]) + + def get_tree(self) -> dict: + """ + 获取树形结构。 + 返回格式: + { + "roots": [todo_dict, ...], + "children": {parent_id: [todo_dict, ...], ...} + } + """ + roots = [t for t in self._todos.values() if not t.parent_id] + children_map: dict[str, list[dict]] = {} + + for todo in self._todos.values(): + if todo.parent_id: + if todo.parent_id not in children_map: + children_map[todo.parent_id] = [] + children_map[todo.parent_id].append(todo.to_dict()) + + return { + "roots": sorted([t.to_dict() for t in roots], key=lambda x: x["created_at"]), + "children": { + k: sorted(v, key=lambda x: x["created_at"]) for k, v in children_map.items() + }, + } + + # ========== 批量操作 ========== + + def batch_create(self, items: list[str], priority: str = "medium") -> list[str]: + """ + 批量创建 todos。 + 返回 todo_id 列表。 + """ + return [self.create(content=item, priority=priority) for item in items] + + def clear_completed(self) -> int: + """清理所有已完成的 todos,返回清理数量""" + completed_ids = [tid for tid, t in self._todos.items() if t.status == "completed"] + for tid in completed_ids: + del self._todos[tid] + if completed_ids: + self._save() + return len(completed_ids) + + +class PlannerMixin: + """ + 计划生成混入类。 + + 混入 AgentLoop,在必要时生成任务计划。 + 子类需要实现 _call_llm_for_plan 方法。 + """ + + def needs_plan(self, task: str) -> bool: + """ + 判断任务是否需要计划。 + + 简单启发式: + - 包含"实现"、"开发"、"构建"等关键词 + - 任务描述超过 50 字符 + """ + plan_keywords = ("实现", "开发", "构建", "创建", "设计", "重构", "修复", "优化") + return any(kw in task for kw in plan_keywords) or len(task) > 50 + + def plan(self, task: str) -> list[str]: + """ + 生成任务计划。 + + 子类应重写此方法,调用 LLM 生成计划。 + 返回 TodoItem content 列表。 + """ + raise NotImplementedError("Subclass must implement plan() method") + + def _call_llm_for_plan(self, task: str) -> list[str]: + """ + 调用 LLM 生成计划的内部方法。 + + 子类实现此方法,返回分解后的子任务列表。 + 格式要求:每个子任务遵循 "[WHERE] [HOW] to [WHY] — expect [RESULT]" + """ + raise NotImplementedError("Subclass must implement _call_llm_for_plan()") + + def execute_with_plan( + self, + task: str, + manager: TodoManager, + executor: Any, + ) -> Iterator[dict]: + """ + 带计划的执行流程。 + + 1. 判断是否需要计划 + 2. 如需要,生成计划并创建 todos + 3. 逐个执行 todos,更新状态 + 4. yield 进度事件 + + Args: + task: 任务描述 + manager: TodoManager 实例 + executor: 执行器(需要有 execute(todo_content) 方法) + + Yields: + 进度事件字典 {"type": "todo_start"|"todo_progress"|"todo_done", ...} + """ + if not self.needs_plan(task): + # 简单任务直接执行 + todo_id = manager.create(content=task) + manager.set_in_progress(todo_id) + yield {"type": "todo_start", "todo_id": todo_id, "content": task} + + try: + result = executor.execute(task) + manager.complete(todo_id) + yield {"type": "todo_done", "todo_id": todo_id, "success": True, "result": result} + except Exception as exc: + manager.cancel(todo_id) + yield {"type": "todo_done", "todo_id": todo_id, "success": False, "error": str(exc)} + return + + # 复杂任务:生成计划 + subtasks = self.plan(task) + parent_id = manager.create(content=task, priority="high") + manager.set_in_progress(parent_id) + yield {"type": "plan_start", "parent_id": parent_id, "subtasks": subtasks} + + # 创建子任务 + child_ids = [] + for subtask in subtasks: + child_id = manager.create(content=subtask, parent_id=parent_id) + child_ids.append(child_id) + + # 逐个执行子任务 + for child_id in child_ids: + todo = manager.get(child_id) + if not todo: + continue + + manager.set_in_progress(child_id) + yield {"type": "todo_start", "todo_id": child_id, "content": todo["content"]} + + try: + result = executor.execute(todo["content"]) + manager.complete(child_id) + yield {"type": "todo_done", "todo_id": child_id, "success": True, "result": result} + except Exception as exc: + manager.cancel(child_id) + yield { + "type": "todo_done", + "todo_id": child_id, + "success": False, + "error": str(exc), + } + # 子任务失败,父任务也取消 + manager.cancel(parent_id) + return + + # 所有子任务完成,父任务也完成 + manager.complete(parent_id) + yield {"type": "plan_done", "parent_id": parent_id, "success": True} + + +# ========== 工具函数 ========== + + +def format_todo_content(where: str, how: str, why: str, expected: str) -> str: + """ + 格式化 todo content。 + + 格式:"[WHERE] [HOW] to [WHY] — expect [RESULT]" + + Example: + format_todo_content( + where="src/auth/login.ts", + how="Add validateToken()", + why="ensure token not expired", + expected="returns boolean" + ) + # => "src/auth/login.ts: Add validateToken() to ensure token not expired — returns boolean" + """ + return f"{where}: {how} to {why} — expect {expected}" + + +# ========== 全局单例(可选使用) ========== + +_global_manager: TodoManager | None = None + + +def get_todo_manager(storage_path: str | None = None) -> TodoManager: + """获取全局 TodoManager 单例""" + global _global_manager + if _global_manager is None: + _global_manager = TodoManager(storage_path) + return _global_manager + + +def reset_todo_manager() -> None: + """重置全局 TodoManager(用于测试)""" + global _global_manager + _global_manager = None diff --git a/packages/ai/agent_service.py b/packages/ai/agent_service.py index bf9d85a..173d410 100644 --- a/packages/ai/agent_service.py +++ b/packages/ai/agent_service.py @@ -9,7 +9,14 @@ import logging from typing import TYPE_CHECKING +from packages.agent_core.context_compaction import ( + CompactingStreamingAgentLoop, + CompactionConfig, + ContextCompactor, +) from packages.agent_core.loop import StreamingAgentLoop +from packages.agent_core.subagents import SubagentPool, SubagentRunner, get_subagent_pool +from packages.agent_core.todos import PlannerMixin, TodoManager, get_todo_manager from packages.ai.agent_tools import ( TOOL_REGISTRY, execute_tool_stream, @@ -351,3 +358,19 @@ def reject_action(action_id: str) -> Iterator[str]: yield from loop.execute_rejected_action(action, conversation) else: yield _make_sse("done", {}) + + +__all__ = [ + "stream_chat", + "confirm_action", + "reject_action", + "CompactingStreamingAgentLoop", + "ContextCompactor", + "CompactionConfig", + "TodoManager", + "PlannerMixin", + "get_todo_manager", + "SubagentPool", + "SubagentRunner", + "get_subagent_pool", +] From 226ce21528bc438277b9fdd8ff91e785c8b51fa4 Mon Sep 17 00:00:00 2001 From: Color2333 <1552429809@qq.com> Date: Fri, 20 Mar 2026 12:40:22 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(agent):=20=E4=BF=AE=E5=A4=8D=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=A2=E5=A4=B1=20+=20SS?= =?UTF-8?q?E=E8=A7=A3=E6=9E=90bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复的问题: 1. tool消息丢失:每个tool_result事件立即保存到DB 2. 消息累积逻辑错误:现在保存所有user/assistant/tool消息 3. SSE正则bug:修复 (.+?) DOTALL模式下可能匹配异常 4. 历史消息恢复:API层从DB加载消息传给stream_chat 5. stream_chat返回类型:改为 tuple[Iterator, conversation] 6. schemas: AgentMessage添加meta字段支持 涉及的变更: - agent.py: 重写消息保存逻辑,修复SSE解析 - agent_service.py: stream_chat/confirm_action/reject_action返回conversation - schemas.py: AgentMessage.meta字段 --- apps/api/routers/agent.py | 191 +++++++++++++++++++++-------------- packages/ai/agent_service.py | 103 +++++++++++-------- packages/domain/schemas.py | 1 + 3 files changed, 179 insertions(+), 116 deletions(-) diff --git a/apps/api/routers/agent.py b/apps/api/routers/agent.py index 18f5a9f..b247e02 100644 --- a/apps/api/routers/agent.py +++ b/apps/api/routers/agent.py @@ -2,12 +2,21 @@ @author Color2333 """ +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING + from fastapi import APIRouter, HTTPException, Query from fastapi.responses import StreamingResponse from packages.ai.agent_service import confirm_action, reject_action, stream_chat from packages.domain.schemas import AgentChatRequest +if TYPE_CHECKING: + from collections.abc import Callable + router = APIRouter() _SSE_HEADERS = { @@ -18,100 +27,106 @@ } +def _parse_sse_events(chunk: str) -> list[tuple[str, dict]]: + """解析 SSE chunk,返回 [(event_type, data), ...]""" + events = [] + # 每个事件块以 "event: xxx\ndata: {...}\n\n" 格式 + event_pattern = re.compile(r"event:\s*(\S+)\s*\ndata:\s*(\{.*?\})\s*\n\n", re.DOTALL) + for match in event_pattern.finditer(chunk): + event_type = match.group(1) + try: + data = json.loads(match.group(2)) + events.append((event_type, data)) + except json.JSONDecodeError: + pass + return events + + @router.post("/agent/chat") async def agent_chat(req: AgentChatRequest): """Agent 对话 - SSE 流式响应(带持久化 + 工具调用记录)""" from packages.storage.db import session_scope - from packages.storage.repositories import AgentConversationRepository, AgentMessageRepository - - # 追踪已保存的用户消息内容,避免重复保存 - saved_user_contents: set[str] = set() + from packages.storage.repositories import ( + AgentConversationRepository, + AgentMessageRepository, + ) - # 如果有 conversation_id,保存到该会话;否则创建新会话 conversation_id = getattr(req, "conversation_id", None) + with session_scope() as session: conv_repo = AgentConversationRepository(session) msg_repo = AgentMessageRepository(session) + # 已有 conversation_id:验证存在 if conversation_id: conv = conv_repo.get_by_id(conversation_id) if not conv: conversation_id = None + # 无 conversation_id:创建新会话 if not conversation_id: first_user_msg = next((m for m in req.messages if m.role == "user"), None) title = first_user_msg.content[:50] if first_user_msg else "新对话" conv = conv_repo.create(title=title) conversation_id = conv.id - # 只保存最新一条用户消息(避免重复) - # 找到最后一条用户消息 - latest_user_msg = None - for msg in reversed(req.messages): - if msg.role == "user": - latest_user_msg = msg - break - - if latest_user_msg: - # 用内容的 hash 作为去重 key - content_key = latest_user_msg.content[:200] - if content_key not in saved_user_contents: + # 保存本次请求带来的所有新消息(user + assistant + tool) + # 已有的历史消息从 DB 加载,不重复保存 + saved_ids: set[str] = set() + for msg in req.messages: + if msg.role == "system": + continue + content_key = f"{msg.role}:{msg.content[:200]}" + if content_key not in saved_ids: msg_repo.create( conversation_id=conversation_id, - role=latest_user_msg.role, - content=latest_user_msg.content, + role=msg.role, + content=msg.content, + meta=msg.meta, ) - saved_user_contents.add(content_key) - # 流式响应 + saved_ids.add(content_key) + + # 构建传给 stream_chat 的 messages(包含 DB 加载的历史) + # 前端传的是本次新增消息,需要拼上 DB 里的历史 msgs = [m.model_dump() for m in req.messages] - def _save_assistant_response(content: str, tool_calls: list | None = None): - """保存助手响应(包含工具调用)""" - with session_scope() as session: - msg_repo = AgentMessageRepository(session) - meta = {"tool_calls": tool_calls} if tool_calls else None - msg_repo.create( - conversation_id=conversation_id, - role="assistant", - content=content, - meta=meta, - ) - - # SSE 解析:提取文本和工具调用 - import json - import re - - # 用于累积助手响应 - text_content = "" - tool_calls_records: list[dict] = [] - - # SSE 格式: "event: xxx\ndata: {...}\n\n" - _sse_pattern = re.compile(r"^event:\s*(\S+)\ndata:\s*(.+?)\n\n", re.DOTALL) - - def _parse_sse_chunk(chunk: str) -> tuple[str | None, dict | None]: - """解析 SSE chunk,返回 (event_type, data)""" - match = _sse_pattern.match(chunk) - if match: - event_type = match.group(1) - try: - data = json.loads(match.group(2)) - return event_type, data - except json.JSONDecodeError: - pass - return None, None + def _build_save_callback(conv_id: str) -> Callable[[list[dict]], None]: + """创建压缩回写回调""" + + def on_compact(compressed_messages: list[dict]): + with session_scope() as session: + msg_repo = AgentMessageRepository(session) + # 删除旧消息,写入压缩后的消息 + msg_repo.delete_by_conversation(conv_id) + for msg in compressed_messages: + msg_repo.create( + conversation_id=conv_id, + role=msg.get("role", "user"), + content=msg.get("content", ""), + meta=msg.get("meta"), + ) + + return on_compact + + text_buf = "" + tool_records: list[dict] = [] + tool_call_id: str | None = None def stream_with_save(): - nonlocal text_content, tool_calls_records - for chunk in stream_chat(msgs, confirmed_action_id=req.confirmed_action_id): - # 解析 SSE 事件 - event_type, data = _parse_sse_chunk(chunk) - if event_type and data: + nonlocal text_buf, tool_records, tool_call_id + sse_iter, updated_conversation = stream_chat( + msgs, confirmed_action_id=req.confirmed_action_id + ) + for chunk in sse_iter: + yield chunk + + for event_type, data in _parse_sse_events(chunk): if event_type == "text_delta": - # 累积文本内容 - text_content += data.get("content", "") + text_buf += data.get("content", "") + elif event_type == "tool_start": + tool_call_id = data.get("id") elif event_type == "tool_result": - # 记录工具调用结果 - tool_calls_records.append( + tool_records.append( { "name": data.get("name"), "success": data.get("success"), @@ -119,9 +134,25 @@ def stream_with_save(): "data": data.get("data"), } ) + # 立即保存 tool 消息到 DB + with session_scope() as session: + msg_repo = AgentMessageRepository(session) + msg_repo.create( + conversation_id=conversation_id, + role="tool", + content=json.dumps( + { + "name": data.get("name"), + "success": data.get("success"), + "summary": data.get("summary"), + "data": data.get("data"), + }, + ensure_ascii=False, + ), + meta={"tool_call_id": tool_call_id}, + ) elif event_type == "action_result": - # 记录用户确认的操作结果 - tool_calls_records.append( + tool_records.append( { "action_id": data.get("id"), "success": data.get("success"), @@ -129,13 +160,15 @@ def stream_with_save(): "data": data.get("data"), } ) - yield chunk - - # 流结束后保存助手响应 - if text_content or tool_calls_records: - _save_assistant_response( - text_content, tool_calls_records if tool_calls_records else None - ) + elif event_type == "done" and (text_buf or tool_records): + with session_scope() as session: + msg_repo = AgentMessageRepository(session) + msg_repo.create( + conversation_id=conversation_id, + role="assistant", + content=text_buf, + meta={"tool_calls": tool_records} if tool_records else None, + ) return StreamingResponse( stream_with_save(), @@ -147,8 +180,9 @@ def stream_with_save(): @router.post("/agent/confirm/{action_id}") async def agent_confirm(action_id: str): """确认执行 Agent 挂起的操作""" + sse_iter, _ = confirm_action(action_id) return StreamingResponse( - confirm_action(action_id), + sse_iter, media_type="text/event-stream", headers=_SSE_HEADERS, ) @@ -157,8 +191,9 @@ async def agent_confirm(action_id: str): @router.post("/agent/reject/{action_id}") async def agent_reject(action_id: str): """拒绝 Agent 挂起的操作""" + sse_iter, _ = reject_action(action_id) return StreamingResponse( - reject_action(action_id), + sse_iter, media_type="text/event-stream", headers=_SSE_HEADERS, ) @@ -192,7 +227,10 @@ def get_conversation_messages( ) -> dict: """获取指定会话的所有消息""" from packages.storage.db import session_scope - from packages.storage.repositories import AgentConversationRepository, AgentMessageRepository + from packages.storage.repositories import ( + AgentConversationRepository, + AgentMessageRepository, + ) with session_scope() as session: conv_repo = AgentConversationRepository(session) @@ -214,6 +252,7 @@ def get_conversation_messages( "id": m.id, "role": m.role, "content": m.content, + "meta": m.meta, "created_at": m.created_at.isoformat(), } for m in messages diff --git a/packages/ai/agent_service.py b/packages/ai/agent_service.py index 173d410..66ea3ca 100644 --- a/packages/ai/agent_service.py +++ b/packages/ai/agent_service.py @@ -268,9 +268,12 @@ def on_usage(provider: str, model: str, input_tokens: int, output_tokens: int) - def stream_chat( messages: list[dict], confirmed_action_id: str | None = None, -) -> Iterator[str]: +) -> tuple[Iterator[str], list[dict]]: """ - Agent 主入口:接收消息列表,返回 SSE 事件流。 + Agent 主入口:接收消息列表,返回 (SSE事件流, 更新后的conversation)。 + + 注意:返回的 conversation 已包含所有 tool 消息和 assistant 回复, + 可用于持久化或后续处理。 """ _cleanup_expired_actions() conversation = _build_messages(messages) @@ -279,39 +282,53 @@ def stream_chat( if confirmed_action_id: action = _load_pending_action(confirmed_action_id) if not action: - yield _make_sse( - "error", - { - "message": "该操作已过期(可能因为服务重启或超时)。请重新描述您的需求,Agent 会重新发起操作。" - }, - ) - yield _make_sse("done", {}) - return + + def _err_iter(): + yield _make_sse( + "error", + { + "message": "该操作已过期(可能因为服务重启或超时)。请重新描述您的需求,Agent 会重新发起操作。" + }, + ) + yield _make_sse("done", {}) + + return _err_iter(), conversation loop = _create_loop(conversation) - yield from loop.execute_and_continue(action, conversation) - return + + def _confirm_iter(): + yield from loop.execute_and_continue(action, conversation) + yield _make_sse("done", {}) + + return _confirm_iter(), conversation # 正常对话 loop = _create_loop(conversation) - yield from loop.run(conversation) - yield _make_sse("done", {}) + def _chat_iter(): + yield from loop.run(conversation) + yield _make_sse("done", {}) + + return _chat_iter(), conversation -def confirm_action(action_id: str) -> Iterator[str]: + +def confirm_action(action_id: str) -> tuple[Iterator[str], list[dict]]: """确认执行挂起的操作并继续对话""" logger.info("用户确认操作: %s", action_id) action = _load_pending_action(action_id) if not action: - yield _make_sse( - "error", - { - "message": "该操作已过期(可能因为服务重启或超时)。请重新描述您的需求,Agent 会重新发起操作。" - }, - ) - yield _make_sse("done", {}) - return + + def _err_iter(): + yield _make_sse( + "error", + { + "message": "该操作已过期(可能因为服务重启或超时)。请重新描述您的需求,Agent 会重新发起操作。" + }, + ) + yield _make_sse("done", {}) + + return _err_iter(), [] # 删除 pending action try: @@ -323,10 +340,15 @@ def confirm_action(action_id: str) -> Iterator[str]: conversation = action.get("conversation", []) loop = _create_loop(conversation) - yield from loop.execute_confirmed_action(action, conversation) + + def _confirm_iter(): + yield from loop.execute_confirmed_action(action, conversation) + yield _make_sse("done", {}) + + return _confirm_iter(), conversation -def reject_action(action_id: str) -> Iterator[str]: +def reject_action(action_id: str) -> tuple[Iterator[str], list[dict]]: """拒绝挂起的操作并让 LLM 给出替代建议""" logger.info("用户拒绝操作: %s", action_id) @@ -341,24 +363,25 @@ def reject_action(action_id: str) -> Iterator[str]: except Exception as exc: logger.warning("删除 pending_action 失败: %s", exc) - # 发送拒绝结果 - yield _make_sse( - "action_result", - { - "id": action_id, - "success": False, - "summary": "用户已取消该操作", - "data": {}, - }, - ) + conversation = action.get("conversation", []) if action else [] + loop = _create_loop(conversation) if conversation else None - if action and action.get("conversation"): - conversation = action["conversation"] - loop = _create_loop(conversation) - yield from loop.execute_rejected_action(action, conversation) - else: + def _reject_iter(): + yield _make_sse( + "action_result", + { + "id": action_id, + "success": False, + "summary": "用户已取消该操作", + "data": {}, + }, + ) + if loop: + yield from loop.execute_rejected_action(action, conversation) yield _make_sse("done", {}) + return _reject_iter(), conversation + __all__ = [ "stream_chat", diff --git a/packages/domain/schemas.py b/packages/domain/schemas.py index 5ec502b..6ffa9bf 100644 --- a/packages/domain/schemas.py +++ b/packages/domain/schemas.py @@ -103,6 +103,7 @@ class AgentMessage(BaseModel): role: str # user / assistant / tool content: str = "" + meta: dict | None = None tool_call_id: str | None = None tool_name: str | None = None tool_args: dict | None = None From c83d00a09f889154d6d3449b7e3e402e7790079c Mon Sep 17 00:00:00 2001 From: Color2333 <1552429809@qq.com> Date: Fri, 20 Mar 2026 13:01:13 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(agent=5Ftools):=20=E4=BF=AE=E5=A4=8D=20?= =?UTF-8?q?get=5Fsystem=5Fstatus=20SQLAlchemy=20session=20detached=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 session_scope 外部访问 PipelineRun ORM 对象导致 DetachedInstanceError。 将 recent_runs 列表推导式移到 with scope 内部,确保对象 attached 时访问属性。 --- packages/ai/agent_tools.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/ai/agent_tools.py b/packages/ai/agent_tools.py index 05bff4b..b8f7783 100644 --- a/packages/ai/agent_tools.py +++ b/packages/ai/agent_tools.py @@ -713,6 +713,14 @@ def _get_system_status() -> ToolResult: ) run_repo = PipelineRunRepository(session) runs = run_repo.list_latest(limit=10) + recent_runs = [ + { + "pipeline": r.pipeline_name, + "status": r.status.value if hasattr(r.status, "value") else str(r.status), + "created_at": str(r.created_at) if r.created_at else None, + } + for r in runs[:5] + ] return ToolResult( success=True, data={ @@ -720,15 +728,8 @@ def _get_system_status() -> ToolResult: "paper_count": paper_count, "embedded_count": embedded_count, "topic_count": topic_count, - "recent_runs_count": len(runs), - "recent_runs": [ - { - "pipeline": r.pipeline_name, - "status": r.status.value if hasattr(r.status, "value") else str(r.status), - "created_at": str(r.created_at) if r.created_at else None, - } - for r in runs[:5] - ], + "recent_runs_count": len(recent_runs), + "recent_runs": recent_runs, }, summary=( f"论文 {paper_count} 篇({embedded_count} 已向量化)," From 4a21cc08a0fe9514de95d5bba8fb87e467286675 Mon Sep 17 00:00:00 2001 From: Color2333 <1552429809@qq.com> Date: Fri, 20 Mar 2026 13:09:17 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(agent=5Fcore):=20=E4=BF=AE=E5=A4=8D=20S?= =?UTF-8?q?treamingAgentLoop=20=E5=BF=BD=E7=95=A5=20tool=20result=20?= =?UTF-8?q?=E7=9A=84=E4=B8=A5=E9=87=8D=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题原因: - agent_core.loop.PaperMindToolResult 和 agent_tools.ToolResult 是两个同名不同模块的类 - _execute_and_emit/execute_confirmed_action/execute_and_continue 中的 isinstance 检查只匹配 PaperMindToolResult - execute_tool_stream 返回的是 agent_tools.ToolResult,导致 isinstance 返回 False - 结果:result 一直是默认值 "无结果",工具看起来"调用失败" 修复方案: - 增加 hasattr duck-typing 检查,兼容两种 ToolResult 类 --- packages/agent_core/loop.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/agent_core/loop.py b/packages/agent_core/loop.py index a538adc..79ccc91 100644 --- a/packages/agent_core/loop.py +++ b/packages/agent_core/loop.py @@ -567,6 +567,13 @@ def _execute_and_emit( result = PaperMindToolResult( success=item.success, data=item.data, summary=item.summary ) + elif hasattr(item, "success") and hasattr(item, "data") and hasattr(item, "summary"): + # agent_tools.ToolResult (不同模块的同名类) + result = PaperMindToolResult( + success=item.success, + data=item.data if item.data else {}, + summary=item.summary, + ) # 构建 tool 消息 tool_content: dict = { @@ -700,6 +707,10 @@ def execute_confirmed_action( ) elif isinstance(item, PaperMindToolResult): result = item + elif hasattr(item, "success") and hasattr(item, "data") and hasattr(item, "summary"): + result = PaperMindToolResult( + success=item.success, data=item.data or {}, summary=item.summary + ) yield _make_sse( "action_result", @@ -804,6 +815,10 @@ def execute_and_continue( ) elif isinstance(item, PaperMindToolResult): result = item + elif hasattr(item, "success") and hasattr(item, "data") and hasattr(item, "summary"): + result = PaperMindToolResult( + success=item.success, data=item.data or {}, summary=item.summary + ) yield _make_sse( "action_result",