版本:v0.2(Phase 1-5 重构后) 日期:2026-05 作用对象:想参与 ARH 开发、定位 bug、或扩展通道 / 工具 / LLM 的工程师
这份文档描述的是 代码实际的样子,不是理想的样子。已知缺陷在相应章节明确标出。
┌────────── 通道 ──────────┐
user → │ CLI | Telegram | Feishu | WeChat | MCP Server │
└─────────────┬─────────────┘
│ UnifiedMessage
▼
┌──────── Gateway ────────┐
│ SessionStore │ 中心控制面
│ Router + /command │ 命令短路 / 安全 / FastAPI
│ SecurityGuard │
└─────────────┬───────────┘
│ AgentRequest
▼
┌──────────── AgentRuntime ────────────┐
│ dispatch_stream (唯一主引擎, ~850行) │
│ ├─ PreDispatchHook (plugin+NLU 短路)│
│ ├─ PermissionMode (default/plan/acceptEdits/yolo)│
│ ├─ Memory Recall │
│ ├─ Active Task 注入 │
│ ├─ ContextEngine.compact │
│ ├─ AgentLoop.run_stream │─ LLM↔tool loop
│ ├─ SubAgent (spawn_subagent) │
│ ├─ L2 SessionReflector │
│ └─ L3 SkillExtractor │
│ │
│ dispatch() = dispatch_stream + 聚合 │
└─────────────┬───────────┬─────────────┘
│ LLM call │ tool call
▼ ▼
┌──────── LLM Layer ─────┐ ┌──── ToolRegistry ────┐
│ ProviderRegistry │ │ 31 个工具 │
│ ProviderCache ⬅ Phase2 │ │ BaseTool + 自动发现 │
│ OpenAI / Anthropic / │ │ ApprovalGate (exec) │
│ Ollama / Fallback │ │ PermissionMode 拦截 │
│ RuntimeOverride │ └──────────────────────┘
│ → effective config │
└────────────────────────┘
┌────────── 数据/科研层(ARH 特色)──────────┐
│ Crawler (6 平台) Research Clients (7 个) │
│ SQLAlchemy + _session_scope (Phase 2) │
└─────────────────────────────────────────────┘
┌──────────── 存储 ────────────┐
│ ~/.arh/config.yaml / .env │
│ data/ (tasks.db / memory/...)│
│ arh/skills/ (builtin+learned)│
└───────────────────────────────┘
核心指标(代码实测):
| 指标 | 数值 |
|---|---|
| 核心代码量 | ~4,848 行(9 个核心文件) |
| 工具数 | 31 个 |
| Skill 数 | 11 builtin + 4 learned = 15 |
| CLI 命令 | 50 个内置 + 15 个 skill 命令 |
| 通道数 | 4(CLI/Telegram/Feishu/WeChat)+ MCP server |
| LLM Provider | 4 类(OpenAI 兼容覆盖 20+ 实际 provider) |
| 测试 | 160+ 用例,含 Phase 0-5 重构守卫 |
AgentRuntime.dispatch_stream(arh/runtime/agent_runtime.py:287-890, ~850 行)是唯一主引擎。Phase 1 合并了原先分裂的 dispatch / dispatch_stream 双路径。
async def dispatch_stream(self, request) -> AsyncIterator[StreamEvent]:
# 1. 构建 RuntimeContext + 注入 contextvars
# (current_runtime, current_session, file_ops, permission_mode,
# artifact_tracker, abort_scope)
# 2. PreDispatchHook 短路链(插件 / NLU 意图)
# 命中 → yield TextComplete + StreamEnd,不调 LLM 直接返回
# 3. L2 SessionReflector: on_session_start
try:
# 4. 获取 effective_llm_config(应用 RuntimeOverride)
# + ProviderCache.get_or_build → 复用 httpx Client
# 5. 构建 system prompt
# - persona_prompt
# - tool schemas
# - memory recall (BM25 召回前 3 条)
# - active tasks 注入(session 未完成任务)
# - 上次 session handoff 摘要(L2 反思结果)
# - env_snapshot
# 6. ContextEngine.compact(token 超阈值时 LLM 摘要压缩)
# 7. AgentLoop.run_stream → 事件流
# ├─ TurnStart → LLMCallStart → 调 provider.chat
# ├─ /steer 注入(用户在 turn 间注入指导)
# ├─ tool_calls → PermissionMode 拦截 / ApprovalGate
# ├─ 工具执行(并发分级: parallel / exclusive)
# └─ 直到 TextComplete(is_final) 或 max_turns
# 8. 幻觉检测(0 工具 + 含"已完成"关键词)
# 9. L2: on_dispatch_end 累加 session 统计
# 10. L3 SkillExtractor(>=5 工具 + 成功 + 非 override)
# 后台 asyncio.create_task 不阻塞用户
# 11. 保存对话历史
except GeneratorExit:
raise # Ctrl+C / 切会话:正常传播
except Exception as e:
# Phase 3 顶层兜底:确保异常流出去
yield ErrorEvent(message=f"内部错误: {e}")
yield StreamEnd(success=False)
finally:
# 重置所有 contextvarsasync def execute(self, request) -> AgentResponse:
"""纯聚合包装 —— dispatch_stream 的事件 → AgentResponse"""
final_text, last_stream_end, amendments = "", None, []
async for ev in self.dispatch_stream(request):
if isinstance(ev, TextComplete) and ev.is_final:
final_text = ev.text
elif isinstance(ev, StreamEnd):
last_stream_end = ev
# ...
return AgentResponse(reply=final_text + amendments, ...)不变量(Phase 0 守卫测试锁定):dispatch(req).reply == drain(dispatch_stream(req)).final_text
arh/core/agent_loop.py ~750 行。职责:LLM 调用 → tool_call 检测 → 工具执行 → 结果回传 → 循环,直到 TextComplete(is_final) 或 max_turns。
- 工具并发:三级分类(read_only / parallel_safe / exclusive)
- 重试:
error_classifier.classify_error判 retryable → 指数退避 / 切 fallback model - 压缩:
compact_callback在每轮前检查是否需要 - Checkpoint:当前 session 有 in_progress task 时,每 2 轮保存快照到
task_store
tools/base.py ← BaseTool + ToolResult + ConcurrencyClass
↑
tools/registry.py ← ToolRegistry,auto_discover() 用 pkgutil 扫 arh/tools/
↑
tools/*.py ← 每个文件一个工具,import 时自动注册
↑
registry.to_api_schemas() → function-call schema 发给 LLM
| 分组 | 工具 | 写类 |
|---|---|---|
| 文件 | read_file / write_file / edit_file / glob / grep |
后 3 是写类 |
| 执行 | exec / process |
都是写类,exec 过 ApprovalGate |
| 网络 | web_fetch(SSRF 防护)/ web_search |
否 |
| 论文检索 | search_paper / find_citations / find_references / find_similar / find_code / get_oa_pdf / export_citation / literature_review |
否(智能 ID 解析) |
| 研究分析 | analyze_paper / recommend / check_deadlines |
否 |
| 爬虫 | crawl |
写类 |
| Agent 控制 | task_manage / enter_plan_mode / exit_plan_mode / resume_task / spawn_subagent / run_skill |
控制类 |
tool_call arrives
│
├─ 1. PermissionMode 检查(plan 模式 + 写类工具 → auth_error)
│
├─ 2. 并发分类 & 调度
│ ├─ read_only + parallel_safe → asyncio.gather
│ └─ exclusive → 串行
│
├─ 3. ApprovalGate(仅 exec 命令)
│ ├─ y: 本次允许
│ ├─ a: 永久允许(写 allow_rules.json 指纹)
│ └─ n: 拒绝
│
├─ 4. 超时保护(每个 tool 声明 get_timeout())
│ asyncio.wait_for + ToolResult.network_error 兜底
│
└─ 5. 结果大小截断(get_max_result_chars())
避免返回给 LLM 的 tool_result 过长炸 context
arh/skills/
├── base.py # Skill dataclass (frontmatter + body)
├── loader.py # YAML frontmatter 解析 + 自动加载
├── builtin/ # 11 个内置 skill
│ ├── arxiv.md 搜索/解析 arXiv → 结构化元数据 + BibTeX
│ ├── paper_deep_dive.md 论文深度解读
│ ├── topic_research.md 完整选题调研
│ ├── trend_analysis.md 领域趋势分析
│ ├── daily_digest.md 今日科研精选摘要
│ ├── writing_plans.md 多步任务规划生成器
│ ├── systematic_debugging.md 4 阶段根因分析
│ ├── test_driven_development.md RED/GREEN/REFACTOR
│ ├── github_code_review.md
│ ├── codebase_inspection.md
│ └── ocr_documents.md
└── learned/ # 4 个自动提取的 skill(L3 反思产物)
├── python-logging-integration/SKILL.md
├── flask-api-cicd-scaffold/SKILL.md
├── ml-model-comparison/SKILL.md
└── check-and-generate-report/SKILL.md
1. 显式 CLI 命令:每个 skill 自动注册为 /<skill-name>(kebab-case),由 commands.py:_register_skill_commands 完成:
"/arxiv GRPO reinforcement learning"
→ 把 prompt_template + 用户输入合并后走 dispatch_stream2. LLM 主动调用 run_skill 工具:加载 SKILL.md 正文作为新轮的 system prompt。
3. SkillLoader 把描述塞进可选列表:LLM 在 "Skill: arxiv — 搜索/解析 arXiv..." 这一行后自主决定是否调。
runtime/skill_extractor.py:
- 触发条件:depth=0 + StreamEnd.success + ≥5 tool_call + 非 system_prompt_override
- 流程:后台
asyncio.create_task→ LLM 生成 SKILL.md → CLI 弹审批 → 写入learned/<slug>/SKILL.md→ 热加载(不需重启 ARH)
三层架构(arh/interaction/):
memory.py 持久笔记(MEMORY.md 索引 + 主题文件)
跨会话保留,存到 ~/.arh/data/memory/
memory_index.py BM25 索引,对所有主题文件建索引
→ memory_recall.recall_relevant(query, top_k=3)
auto_memory.py 自动提取:每 N 轮(默认 10)让 LLM
从用户消息提取偏好 / 研究方向 / 教训
runtime/session_reflector.py:
- 触发:
/quit/ 长静默 / compact 前钩子 - 动作:让 LLM 生成 4 节摘要(Active / Resolved / Remaining / Insights)
- 存储:
~/.arh/data/memory/sessions/{session_id}.md - 注入:下次同 user 新会话开始时,dispatch_stream 自动找最近摘要注入 system prompt
AgentRuntime._memory_scope 字段是 no-op。SubAgent 设置 memory_scope="isolated" 在代码里完全没被读取 → 子 Agent 仍然召回全局 memory。
tests/test_phase5_subagent_profile.py::test_memory_scope_field_is_actually_consumed_by_dispatch 用 @pytest.mark.xfail(strict=True) 守卫此缺陷,修复后测试自动变绿,强制维护者更新文档。
原 Pipeline 有 3 个 Stage(NLUStage / PluginDispatchStage / IntentHandleStage)做"命中短路"。Phase 1 删除 Pipeline 后,改为 arh/runtime/predispatch.py:
PreDispatchHook = Callable[[UnifiedMessage, Any], Awaitable[Optional[str]]]
# 默认链
_default_hooks = [
plugin_dispatch_hook, # plugin_loader.dispatch_message 命中 → 返回 reply
intent_handle_hook, # NLU 置信度 ≥ 0.7 的 search/topic/analyze/trend 意图
# 直接调业务模块
]
# SubAgent 显式传 predispatch_hooks=[] 禁用短路
# (task 是结构化指令,不能被 NLU 误识别为 search)dispatch_stream 在 LLM 调用前跑 hook 链。命中即 yield TextComplete + StreamEnd 直接返回,不调 LLM,不计 turns。
arh/runtime/permission_mode.py(Phase 1 统一为唯一权限机制,删除了旧 PlanModeGate):
| 模式 | 行为 |
|---|---|
default |
每次问 write 工具,用户 y/a/n |
plan |
阻止一切 write;LLM 必须 exit_plan_mode 申请批准才能干 |
accept_edits |
文件写入自动通过;exec 仍问 |
yolo |
全部免询问(用户对工作区完全信任) |
切换路径:
- 用户命令:
/mode [default|plan|accept_edits|yolo] - LLM 主动:调
enter_plan_mode()/exit_plan_mode()工具 - Session 级 20 分钟 TTL:plan 模式超时自动退出
ApprovalGate 指纹记忆(allow_rules.py):用户按 a 永久允许后,相同命令指纹直接放行。指纹基于"命令链里真命令"(pip install && python -m pytest 会抽 pytest 做指纹)。
arh/gateway/gateway.py 是 Hub-Spoke 中心控制面:
class Gateway:
async def _make_stream_handler(self, channel):
async def handler(message):
# 1. SecurityGuard 检查(auth_mode / rate_limit / bind_mode)
# 2. SessionStore 解析 / 创建 session
# 3. /command 短路:走 router._command_handlers 不进 runtime
# 4. 派发到 runtime.dispatch_stream
try:
async for ev in self._default_stream_dispatch(request):
yield ev
except Exception as e:
# Phase 3 二级兜底
yield ErrorEvent(...)
yield StreamEnd(success=False)| 通道 | 依赖 |
|---|---|
| CLI | prompt_toolkit + rich |
| Telegram | python-telegram-bot |
| Feishu | lark-oapi |
| 公众号被动回复 + 客服消息 |
FastMCP 对外暴露所有工具给 Claude Desktop / Cursor / Cline 等 MCP 客户端。SSE(HTTP)+ stdio 双模式。
arh/llm/providers/
├── openai_provider.py OpenAI 兼容(DeepSeek/通义/OpenRouter/Kimi/GLM/...)
├── anthropic_provider.py Anthropic Messages API
├── ollama_provider.py 本地 Ollama
└── fallback_provider.py 包装主 provider,失败按 cooldown 切备用
背景:旧代码每次 dispatch 都 provider_cls(config.llm.model_dump()) 重建 provider,意味着:
- 每次新建
httpx.AsyncClient(连接池失效) - Anthropic ephemeral prompt cache 跨轮无法命中
- Provider 健康探测每轮重跑
arh/llm/provider_cache.py:
_cache: Dict[CacheKey, BaseLLMProvider] = {}
# CacheKey = (provider_name, model, base_url, api_key)
def get_or_build(provider_name, llm_config, provider_cls) -> BaseLLMProvider:
key = _make_key(provider_name, llm_config)
if key in _cache:
return _cache[key] # 复用
instance = provider_cls(llm_config)
_cache[key] = instance
return instance
def invalidate(provider_name=None) -> int:
# /llm set / switch_model / app.stop 时清缓存dispatch_stream / resume / reflect 所有 provider 实例化点都用 cached_provider(...)。
旧 bug:/llm set model=xxx 存到 runtime_override.json,但 dispatch_stream 用 config.llm.model_dump()(未合并 override)→ 新 model 根本没被使用。
修复:arh/core/config.py:
def get_effective_llm_config() -> Dict:
"""合并 RuntimeOverride 后的 LLM 配置"""
base = get_config().llm.model_dump()
return get_runtime_override().apply_to(base)
def get_effective_provider_name() -> str: ...dispatch_stream 8 处原 config.llm.model_dump() 改为 get_effective_llm_config()。配合 Phase 2 的 provider_cache.invalidate(),形成完整"改配置立即生效"闭环:
/llm set model=gpt-4o
→ override 写入 runtime.json
→ provider_cache.invalidate()
→ 下次 dispatch: get_effective_llm_config() 拿新 model
→ get_or_build() cache miss → 新建 provider with gpt-4o
arh/llm/error_classifier.py → ErrorKind 枚举(auth / rate_limit / overloaded / server_error / context_overflow / network / invalid_response / other)
AgentLoop 内:
is_retryable(kind)→ 指数退避 + jitter 重试- 重试用光 →
_try_fallback_provider(kind)从config.llm.fallback.fallback_models挑下一个可用模型
ConversationManager 和 PersonaManager 共 14 处 _get_session() 从不 close:
session = self._get_session()
if session:
try:
session.add(conv)
session.commit()
except Exception as e:
session.rollback() # ← 没 close()后果:SQLAlchemy session 累积、SQLite WAL writer lock 风险。
_session_scope() context manager:
@contextmanager
def _session_scope(self):
if not self._db:
yield None; return
session = self._db.get_session()
try:
yield session
except Exception as e:
session.rollback()
logger.error(...)
raise
finally:
session.close() # 必定执行14 处调用点全部改成 with self._session_scope() as session:。
| 模型 | 用途 |
|---|---|
Paper |
论文(含 doi / s2_id / openalex_id / citation_count / oa_status / venue / tldr / fields_of_study) |
ConferenceDeadline |
会议截止日期 |
Conversation |
多对话管理(history JSON + persona_id) |
InteractionRecord |
交互日志 |
UserConfig |
用户配置 |
Persona |
AI 人格 |
启动时对比 Base.metadata 和 PRAGMA table_info,ALTER TABLE ADD COLUMN 补齐缺失列(仅 SQLite)。
旧 .gitignore 规则 data/ 把整个 arh/data/ Python 包都忽略了 → 远程 main 完全没有 arh/data/ 目录的 7 个 Python 文件。任何人 clone 仓库都无法启动 ARH。Phase 2 commit 把 .gitignore 改为 /data/ 锚定根目录 + 把 arh/data/ 补进版本控制。
目的:15 个全局单例(task_store / tool_registry / memory_manager / ...)分布在 30+ 文件、316+ 调用点。这带来测试隔离困难、SubAgent 真正隔离做不到、多 Profile 不可能。
arh/core/services.py:
class ServiceContainer:
def register(name, factory): ... # lazy 注册
def register_instance(name, obj): ... # 直接登记实例
def get(name): ... # 缓存 hit → factory → KeyError
def replace(name, obj): ... # 测试时换实现
def reset(name=None): ... # 清缓存
def reset_all(): ... # 清 factory
_default_container = ServiceContainer() # 进程级默认
def register_default_singletons(container):
"""包装现有 15 个全局单例到 container(兼容 shim)"""ARHApp.__init__ 持有 self.services = get_default_container(),init() 最后注册 19 个服务(包括 db / cache / runtime / gateway)。
- ✅ 容器本身就位:API 完整,守卫测试锁定
- ✅ 包装层就位:从 container 取和
from x import singleton返回同一实例 - ⏸ 调用点迁移未做:316+ 处调用仍用
from x import singleton路径 - ⚠ 多 Profile 不可用:所有 ARHApp 共享同一个
default_container(Phase 5 xfail 守卫)
- 功能层面零收益(两条路径返回同一实例)
- 测试层面当前用 monkeypatch 也能活
- 真实用户场景没有"同进程跑多 Profile"需求
- 迁移涉及 316+ 文件改动,风险远大于收益
未来若真撞上痛点(例如:"我想在测试里替换整个 tool_registry"),再按子系统逐步迁移。
arxiv / huggingface / github / openreview / ccf_deadlines / dblp
每个 crawler 继承 BaseCrawler,支持增量 / 限速 / 关键词过滤。crawler/scheduler.py 用 APScheduler 做定时抓取。
| 模块 | 职责 |
|---|---|
api_client.py |
BaseAPIClient:令牌桶限速 / 指数退避 / follow_redirects |
s2_client.py |
Semantic Scholar(论文搜索 / 引用 / 推荐;无 key 时 3s 限速) |
openalex_client.py |
OpenAlex Works/Authors/Venues |
crossref_client.py |
Crossref DOI + Content Negotiation |
unpaywall_client.py |
OA 状态 + PDF 链接 |
pwc_client.py |
Papers With Code |
dblp_client.py |
DBLP |
recommender.py多策略推荐advisor.pytopic_advisoranalyzer.pypaper_analyzertracker.pytrend_trackerenricher.py论文富化(DOI / citation / OA / venue / tldr)citation_graph.py引用图谱search_orchestrator.py多源搜索去重keyword_translator.py中文 → 英文学术关键词(内置词典)
arh/core/app.py 1919 行,其中 1500 行是 51 个 async def cmd_xxx 塞在 ARHApp._register_gateway_commands 一个类方法里。修命令要在巨文件里翻。
arh/core/app.py → 401 行(只剩启停 + 配置协调)
arh/gateway/cli/commands.py → 1553 行(承接全部命令代码,按注释分 6 段)
arh/gateway/cli/adapter.py → CLI 通道 adapter
arh/gateway/cli/completer.py → 自动补全
app._register_gateway_commands 简化为一行委派:
def _register_gateway_commands(self):
if self.gateway is None or self.runtime is None:
return
from arh.gateway.cli.commands import register_all_commands
register_all_commands(self, self.gateway.router, self.runtime)| 类别 | 命令 |
|---|---|
| Session | /new /reset /clear /history /save /retry /undo /title /branch /fork /compress /snap /snapshot /stop /background /bg /btw /agents /tasks /queue /q /steer /status /resume |
| Info | /profile /help /usage /insights /platforms /gateway /copy /debug |
| Config | /config /model /provider /personality /verbose /yolo /mode /allow-rules /llm |
| Tools | /tools /toolsets /skills /cron /reload /plugins /checkpoints /env |
| Skill | 每个 skill 自动注册 /<name>(kebab-case):/arxiv /topic-research 等 15 个 |
| Exit | /exit |
1. AgentLoop 内 LLM 调用 → error_classifier 分类 → retry / fallback
2. dispatch_stream 顶层 except → yield ErrorEvent + StreamEnd(success=False)
3. Gateway._make_stream_handler 二级兜底 → 防止 async generator 打穿到 adapter
不变量:用户永远不会看到空白回复或未渲染的 traceback;所有错误都走事件流被 UI 渲染。
/llm set model=xxx
→ RuntimeOverride.set("model", xxx) + save()
→ provider_cache.invalidate()
│
下次 dispatch_stream
→ get_effective_llm_config() 合并 override
→ effective_cfg["model"] == xxx
→ cached_provider(name, effective_cfg, cls) # cache miss
→ new OpenAIProvider with new model
→ httpx.AsyncClient 新建
Phase 2 做了 cache invalidate,Phase 3 补上了 effective config 应用。两者协同。
| 模块 | 职责 |
|---|---|
runtime/agent_runtime.py |
核心,dispatch_stream 主引擎 |
runtime/predispatch.py |
PreDispatchHook 链(Phase 1) |
runtime/subagent.py |
SubAgentManager,隔离度: session_id ✓ / conversation_id ✓ / memory_scope ✗(已知缺陷) |
runtime/permission_mode.py |
4 档权限模式(Phase 1 统一) |
runtime/approval.py |
ApprovalGate,exec 命令人机审批 |
runtime/allow_rules.py |
审批指纹持久化 |
runtime/fs_policy.py |
文件系统黑白名单 |
runtime/checkpoint.py |
任务级快照(每 2 轮自动) |
runtime/task_store.py |
Todo / task_manage SQLite 存储 |
runtime/session_reflector.py |
L2:会话结束写 4 节摘要 |
runtime/skill_extractor.py |
L3:复杂任务成功后自动生成 SKILL.md |
runtime/process_manager.py |
后台子进程管理(/background) |
runtime/abort.py |
中止协议(/stop) |
runtime/artifact_tracker.py |
工具产物追踪 |
runtime/env_snapshot.py |
环境探测(python / pkgs / git) |
runtime/interaction_buffer.py |
/queue /steer 消息缓冲 |
以 CLI 输入 > 帮我把这篇 arXiv 论文总结一下 https://arxiv.org/abs/2410.xxxxx 为例:
1. CLI adapter 封 UnifiedMessage(channel="cli", user_id, content)
2. Gateway._make_stream_handler:
├─ SecurityGuard.check
├─ SessionStore.resolve → SessionInfo
├─ 不是 /command → 派发 runtime.dispatch_stream
3. AgentRuntime.dispatch_stream:
├─ _build_context → RuntimeContext
├─ 注入 contextvars (runtime/session/file_ops/...)
├─ PreDispatchHook: NLU 识别为 chat 意图(非 SEARCH_PAPER),不短路
├─ L2 on_session_start
│
├─ get_effective_llm_config() + cached_provider(...)
│
├─ 构建 system_prompt:
│ - tools schema
│ - persona_prompt(conversation.persona_id)
│ - memory_recall 召回 "论文阅读偏好"
│ - active_tasks(如果有未完成)
│ - 上次 session handoff 摘要
│ - env_snapshot
│
├─ ContextEngine.compact 检查(未触发)
│
├─ AgentLoop.run_stream:
│ ├─ Turn 1: LLM → tool_call: web_fetch(arXiv URL)
│ │ → read_only 直接执行
│ ├─ Turn 2: LLM → tool_call: analyze_paper(...)
│ │ → 调 S2 + Crossref + Unpaywall 富化
│ ├─ Turn 3: LLM → tool_call: write_file(report.md)
│ │ → PermissionMode != plan → 走 ApprovalGate
│ │ → 用户按 `a`:allow_rules 记下指纹
│ │ → 执行成功,ArtifactTracker 记下产物
│ └─ Turn 4: LLM → final TextComplete
│
├─ 幻觉检测:4 个 tool_call,不触发
│
├─ L2 on_dispatch_end (记 turns=4, tool_calls=3, tokens=...)
│
├─ L3 SkillExtractor 评估:>=5 tool_calls? 否,跳过
│
└─ 对话历史保存(user + assistant)
4. gateway yield 的事件流:
StreamStart → LLMCallStart → LLMCallEnd → ToolCallStart(web_fetch) →
ToolCallComplete → TurnEnd → ... → TextComplete(is_final=True) → StreamEnd
5. CLI adapter 流式渲染(rich + renderer.py)
| Phase | 核心改动 | 新测试 |
|---|---|---|
| Phase 0 | Characterization tests(dispatch_stream 12 用例) | +13 |
| Phase 1 | 双引擎合并 / 删 Pipeline 目录 / 删 PlanModeGate / 新增 PreDispatchHook | +0 |
| Phase 2 | _session_scope / ProviderCache / ServiceContainer 骨架 + 修复 .gitignore 存量灾难(arh/data/ 被误删 7 文件) |
+16 |
| Phase 3 | dispatch_stream 顶层异常兜底 / 修复 /llm set 不生效 bug / get_effective_llm_config |
+9 |
| Phase 4 | CLI 命令 1500 行从 app.py 拆到 commands.py(app.py 1919→401) | +8 |
| Phase 5 | SubAgent 隔离性 + 多 Profile 守卫,文档化 2 个已知缺陷(memory_scope no-op / 多 ARHApp 共享 container) | +12(10 pass + 2 xfail) |
测试轨迹:
| 时点 | Passed | xfailed |
|---|---|---|
| main 历史基线 | 192 | 0 |
| Phase 1 | 205 | 0 |
| Phase 2 | 221 | 0 |
| Phase 3 | 230 | 0 |
| Phase 4 | 238 | 0 |
| Phase 5 | 248 | 2 |
全程 0 skip。2 个 xfail 都是 strict=True 模式——未来修好后强制要求维护者更新文档。
1. AgentRuntime._memory_scope 字段是 no-op
- SubAgent 设置
memory_scope="isolated"在agent_runtime.py/memory_recall.py里没有任何条件判定使用。 - 实际影响:SubAgent 仍然召回全局 memory,不是真正隔离。
- 守卫:
test_phase5_subagent_profile.py::test_memory_scope_field_is_actually_consumed_by_dispatch(xfail strict=True) - 修复方案:让
recall_relevant接受 scope 参数,或 dispatch_stream 判定self._memory_scope == 'isolated'时跳过全局 recall。
2. 多 ARHApp 共享 default_container
get_default_container()是进程级单例 → 两个 ARHApp 实例不可能真正独立。- 守卫:
test_phase5_subagent_profile.py::test_two_arhapps_have_independent_services - 修复方案:
ARHApp.__init__创建自己的 container,不用全局。但这要求把 316+ 处from x import singleton调用点都改为从app.services.get('x')取。业务痛点未到,暂不动。
3. 15 个全局单例 316+ 调用点未迁移
- ServiceContainer 骨架已就位,但调用方仍
from arh.runtime.task_store import task_store。 - 实际影响:功能层面零影响;测试用 monkeypatch 也能凑合。
- 不修的成本:未来某天真想做"同进程多 Profile"时,得 316 次全改。
4. 函数内延迟 import 约 347 处
- 全仓
^ from arh\.有 347 处。 - 实测:所有核心模块都能顶层 import 成功,零循环依赖。
- 这 347 处全是合法懒加载(减少启动时间),不是循环依赖伪装。
- 美观问题,无功能影响。
5. arh/gateway/cli/commands.py 1553 行单文件
- Phase 4 从 1919 行拆成 389 + 1553 = 两个文件。按注释已分 6 段(Session / Info / Configuration / Tools / Skill / Exit)。
- 进一步按类别拆 5 个文件的工作:风险收益比不高(命令之间互相引用
router._command_handlers做 alias,拆分增加而不是减少复杂度)。
arh/tools/my_tool.py 继承 BaseTool
↓
registry.auto_discover() 启动时自动扫到
↓
LLM 看到 tool schema 可直接调用
写 YAML frontmatter + Markdown 正文:
arh/skills/builtin/my_skill.md
或用 L3 SkillExtractor 自动提取:
arh/skills/learned/<slug>/SKILL.md
arh/gateway/my_channel/
├─ adapter.py 实现 ChannelAdapter
└─ __init__.py 注册到 channel_registry
arh/llm/providers/my_provider.py 继承 BaseLLMProvider
↓
llm_registry.auto_discover()
↓
dispatch_stream 走 ProviderCache 缓存路径自动支持
arh/crawler/<platform>/ 继承 BaseCrawler
↓
scheduler.register 挂到 APScheduler
arh/research/my_client.py 继承 BaseAPIClient
(令牌桶限速 / 指数退避 / 超时 / follow_redirects 自动继承)
↓
对应工具 arh/tools/xxx.py 里 get_my_client() 单例调用
ARH 是什么:面向科研场景的 Agent 框架。由 dispatch_stream 单引擎(Phase 1 合并双引擎后)驱动 LLM↔工具循环,配 PreDispatchHook 做 NLU/插件短路,带 4 档 PermissionMode + ApprovalGate 三重安全闸门,并有 L2 session reflect + L3 skill 自动提取的自我进化机制。
区别于其它 Agent 框架:
- 聚焦科研:6 平台爬虫 + 7 个学术 API 客户端 + 中文学术关键词翻译 + 论文 ID 智能解析 + Crossref Content Negotiation
- 克制规模:31 工具 / 15 skill / 4 通道(不追求 hermes 那样的 28+ 工具 / 91 skill / 20+ 通道)
- Plan Mode 硬绑定:写类工具必须先
task_manage+exit_plan_mode过审批(不是靠 system prompt 提示)
一句话概括:dispatch_stream 是唯一引擎,其它都是装饰。
| 文件 | 行数 | 职责 |
|---|---|---|
arh/core/app.py |
401 | 启停协调(Phase 4 瘦身) |
arh/core/agent_loop.py |
751 | LLM↔tool 循环核心 |
arh/runtime/agent_runtime.py |
1138 | dispatch_stream 主引擎 |
arh/runtime/predispatch.py |
193 | Phase 1 新增:短路 hook |
arh/runtime/subagent.py |
159 | SubAgent 管理 |
arh/runtime/permission_mode.py |
167 | 4 档权限(Phase 1 统一) |
arh/core/services.py |
230 | Phase 2 新增:IoC 骨架 |
arh/llm/provider_cache.py |
112 | Phase 2 新增:Provider 缓存 |
arh/gateway/gateway.py |
311 | Gateway 中心 |
arh/gateway/cli/commands.py |
1553 | Phase 4 新增:CLI 命令 |
tests/test_dispatch_characterization.py |
704 | Phase 0 守卫 |
tests/test_phase2_services.py |
301 | Phase 2 守卫 |
tests/test_phase3_error_config.py |
230 | Phase 3 守卫 |
tests/test_phase4_cli_split.py |
158 | Phase 4 守卫 |
tests/test_phase5_subagent_profile.py |
461 | Phase 5 守卫 |
| Phase | 分支 | 状态 |
|---|---|---|
| Phase 1 | refactor/phase-1-unify-dispatch |
✅ 已合并 main |
| Phase 2 | refactor/phase-2-services |
✅ 已合并 main |
| Phase 3 | refactor/phase-3-error-config |
✅ 已合并 main |
| Phase 4 | refactor/phase-4-cli-split |
✅ 已合并 main |
| Phase 5 | refactor/phase-5-subagent-profile-tests |
⏳ PR 待 review |