feat(engine): 添加MCP连接管理和工具集成功能

- 集成MCP连接管理器,支持MCP服务器连接
- 添加多种内置工具:ClarifyTool、CronTool、DelegateTool、ExecuteCodeTool、
  PatchFileTool、ProcessTool、SendMessageTool、SpawnTool、TerminalTool、
  TodoTool、WebFetchTool、WebSearchTool、WriteFileTool等
- 实现工具注册和装配功能
- 添加技能选择上下文参数
- 支持思考模式控制参数thinking_enabled

feat(coordinator): 重构任务执行计划器参数命名

- 将learning_candidate_enabled重命名为allow_candidate_generation
- 更新TeamGraphScheduler中的参数传递
- 修改LocalAgentRunner中的相关参数处理
- 更新README文档中的相应描述

refactor(context): 标准化工具调用参数格式

- 添加_json导入用于参数序列化
- 实现_provider_tool_calls方法标准化OpenAI兼容的工具调用载荷
- 修复工具调用中参数非字符串类型的序列化问题

refactor(session): 优化消息历史记录过滤逻辑

- 修改get_messages_as_conversation为基于运行状态过滤消息
- 排除未完成、失败或错误结束的运行记录
- 改进对话历史的可见性控制机制

fix(store): 修复FTS索引重建逻辑

- 添加异常处理防止FTS索引创建失败
- 实现_rebuild_fts_index方法重新构建全文搜索索引
- 优化索引触发器和表的维护流程
This commit is contained in:
2026-05-14 09:43:48 +08:00
parent 8a12c30141
commit 30ab74ffb2
149 changed files with 12293 additions and 2812 deletions

145
agents/registry.json Normal file
View File

@ -0,0 +1,145 @@
{
"agents": [
{
"agent_id": "researcher",
"capabilities": [
"research",
"analysis",
"source review",
"requirements"
],
"created_at": "2026-05-11T03:13:06.912240+00:00",
"description": "Finds facts, references, constraints, and implementation options.",
"display_name": "Researcher",
"metadata": {},
"model": null,
"name": "researcher",
"priority": 50,
"provider_name": null,
"role": "research",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
"tags": [
"planning",
"research"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.912247+00:00"
},
{
"agent_id": "implementer",
"capabilities": [
"implementation",
"coding",
"refactor",
"integration"
],
"created_at": "2026-05-11T03:13:06.912250+00:00",
"description": "Builds scoped implementation slices and proposes concrete changes.",
"display_name": "Implementer",
"metadata": {},
"model": null,
"name": "implementer",
"priority": 45,
"provider_name": null,
"role": "implementation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.",
"tags": [
"coding",
"build"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.912251+00:00"
},
{
"agent_id": "reviewer",
"capabilities": [
"review",
"quality",
"risk",
"verification"
],
"created_at": "2026-05-11T03:13:06.912252+00:00",
"description": "Reviews plans, code, outputs, and risks before final synthesis.",
"display_name": "Reviewer",
"metadata": {},
"model": null,
"name": "reviewer",
"priority": 45,
"provider_name": null,
"role": "review",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.",
"tags": [
"review",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.912253+00:00"
},
{
"agent_id": "tester",
"capabilities": [
"testing",
"verification",
"regression",
"qa"
],
"created_at": "2026-05-11T03:13:06.912255+00:00",
"description": "Designs and executes verification checks for task outputs.",
"display_name": "Tester",
"metadata": {},
"model": null,
"name": "tester",
"priority": 40,
"provider_name": null,
"role": "testing",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.",
"tags": [
"test",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.912256+00:00"
},
{
"agent_id": "documenter",
"capabilities": [
"documentation",
"explanation",
"migration notes",
"release notes"
],
"created_at": "2026-05-11T03:13:06.912257+00:00",
"description": "Writes and reconciles user-facing and internal documentation updates.",
"display_name": "Documenter",
"metadata": {},
"model": null,
"name": "documenter",
"priority": 35,
"provider_name": null,
"role": "documentation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.",
"tags": [
"docs",
"communication"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.912258+00:00"
}
],
"version": 1
}

View File

@ -0,0 +1,145 @@
{
"agents": [
{
"agent_id": "researcher",
"capabilities": [
"research",
"analysis",
"source review",
"requirements"
],
"created_at": "2026-05-11T03:13:06.921512+00:00",
"description": "Finds facts, references, constraints, and implementation options.",
"display_name": "Researcher",
"metadata": {},
"model": null,
"name": "researcher",
"priority": 50,
"provider_name": null,
"role": "research",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
"tags": [
"planning",
"research"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.921520+00:00"
},
{
"agent_id": "implementer",
"capabilities": [
"implementation",
"coding",
"refactor",
"integration"
],
"created_at": "2026-05-11T03:13:06.921522+00:00",
"description": "Builds scoped implementation slices and proposes concrete changes.",
"display_name": "Implementer",
"metadata": {},
"model": null,
"name": "implementer",
"priority": 45,
"provider_name": null,
"role": "implementation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.",
"tags": [
"coding",
"build"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.921523+00:00"
},
{
"agent_id": "reviewer",
"capabilities": [
"review",
"quality",
"risk",
"verification"
],
"created_at": "2026-05-11T03:13:06.921527+00:00",
"description": "Reviews plans, code, outputs, and risks before final synthesis.",
"display_name": "Reviewer",
"metadata": {},
"model": null,
"name": "reviewer",
"priority": 45,
"provider_name": null,
"role": "review",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.",
"tags": [
"review",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.921528+00:00"
},
{
"agent_id": "tester",
"capabilities": [
"testing",
"verification",
"regression",
"qa"
],
"created_at": "2026-05-11T03:13:06.921529+00:00",
"description": "Designs and executes verification checks for task outputs.",
"display_name": "Tester",
"metadata": {},
"model": null,
"name": "tester",
"priority": 40,
"provider_name": null,
"role": "testing",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.",
"tags": [
"test",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.921530+00:00"
},
{
"agent_id": "documenter",
"capabilities": [
"documentation",
"explanation",
"migration notes",
"release notes"
],
"created_at": "2026-05-11T03:13:06.921533+00:00",
"description": "Writes and reconciles user-facing and internal documentation updates.",
"display_name": "Documenter",
"metadata": {},
"model": null,
"name": "documenter",
"priority": 35,
"provider_name": null,
"role": "documentation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.",
"tags": [
"docs",
"communication"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.921534+00:00"
}
],
"version": 1
}

View File

@ -10,7 +10,7 @@
2. 聊天入口支持 Main Agent 自动 Task 化、验证、反馈门控。 2. 聊天入口支持 Main Agent 自动 Task 化、验证、反馈门控。
3. skills 已有版本化、receipt/effect 记录、学习候选门控,以及后台 assisted learning pipeline。 3. skills 已有版本化、receipt/effect 记录、学习候选门控,以及后台 assisted learning pipeline。
4. Agent Team v1 已支持内部 `sequence / parallel / dag` coordinator。 4. Agent Team v1 已支持内部 `sequence / parallel / dag` coordinator。
5. Task mode 已能通过 `TaskExecutionPlanner` 按需调用 sub-agent/teamteam node 由 `TaskSkillResolver` 绑定 published skill缺失时生成 draft-only ephemeral skill,最终仍由主 Agent synthesis 生成用户回答。 5. Task mode 已能通过 `TaskExecutionPlanner` 按需调用 sub-agent/teamteam node 由 `TaskSkillResolver` 绑定 published skill缺失时生成 ephemeral guidance,最终仍由主 Agent synthesis 生成用户回答。
6. Skill Learning 已支持后台 run-once/worker 自动生成 draft、safety report、eval report、人工审核发布和前端审核工作台worker 不会自动 approve/publish。 6. Skill Learning 已支持后台 run-once/worker 自动生成 draft、safety report、eval report、人工审核发布和前端审核工作台worker 不会自动 approve/publish。
## 当前结构 ## 当前结构

View File

@ -32,7 +32,7 @@ class TeamGraphScheduler:
provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None = None, provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None = None,
inherited_pinned_skills: list[str] | None = None, inherited_pinned_skills: list[str] | None = None,
inherited_pinned_skill_contexts: list["SkillContext"] | None = None, inherited_pinned_skill_contexts: list["SkillContext"] | None = None,
learning_candidate_enabled: bool = False, allow_candidate_generation: bool = False,
) -> TeamRunResult: ) -> TeamRunResult:
graph.validate() graph.validate()
if provider_bundle is not None and len(graph.nodes) > 1: if provider_bundle is not None and len(graph.nodes) > 1:
@ -49,7 +49,7 @@ class TeamGraphScheduler:
provider_bundle_factory=provider_bundle_factory, provider_bundle_factory=provider_bundle_factory,
inherited_pinned_skills=inherited, inherited_pinned_skills=inherited,
inherited_pinned_skill_contexts=inherited_contexts, inherited_pinned_skill_contexts=inherited_contexts,
learning_candidate_enabled=learning_candidate_enabled, allow_candidate_generation=allow_candidate_generation,
) )
elif graph.strategy == "parallel": elif graph.strategy == "parallel":
results = await self._run_parallel( results = await self._run_parallel(
@ -61,7 +61,7 @@ class TeamGraphScheduler:
provider_bundle_factory=provider_bundle_factory, provider_bundle_factory=provider_bundle_factory,
inherited_pinned_skills=inherited, inherited_pinned_skills=inherited,
inherited_pinned_skill_contexts=inherited_contexts, inherited_pinned_skill_contexts=inherited_contexts,
learning_candidate_enabled=learning_candidate_enabled, allow_candidate_generation=allow_candidate_generation,
) )
else: else:
results = await self._run_dag( results = await self._run_dag(
@ -73,7 +73,7 @@ class TeamGraphScheduler:
provider_bundle_factory=provider_bundle_factory, provider_bundle_factory=provider_bundle_factory,
inherited_pinned_skills=inherited, inherited_pinned_skills=inherited,
inherited_pinned_skill_contexts=inherited_contexts, inherited_pinned_skill_contexts=inherited_contexts,
learning_candidate_enabled=learning_candidate_enabled, allow_candidate_generation=allow_candidate_generation,
) )
return self._summarize(results, task_id=parent_task_id) return self._summarize(results, task_id=parent_task_id)
@ -162,7 +162,7 @@ class TeamGraphScheduler:
provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None, provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None,
inherited_pinned_skills: list[str], inherited_pinned_skills: list[str],
inherited_pinned_skill_contexts: list["SkillContext"], inherited_pinned_skill_contexts: list["SkillContext"],
learning_candidate_enabled: bool, allow_candidate_generation: bool,
dependency_outputs: dict[str, str], dependency_outputs: dict[str, str],
) -> NodeRunResult: ) -> NodeRunResult:
try: try:
@ -188,7 +188,7 @@ class TeamGraphScheduler:
return await self.runner.run( return await self.runner.run(
envelope, envelope,
provider_bundle=node_provider_bundle, provider_bundle=node_provider_bundle,
learning_candidate_enabled=learning_candidate_enabled, allow_candidate_generation=allow_candidate_generation,
) )
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise

View File

@ -21,7 +21,7 @@ class LocalAgentRunner:
envelope: DelegationEnvelope, envelope: DelegationEnvelope,
*, *,
provider_bundle: ProviderBundle | None = None, provider_bundle: ProviderBundle | None = None,
learning_candidate_enabled: bool = False, allow_candidate_generation: bool = False,
) -> NodeRunResult: ) -> NodeRunResult:
if provider_bundle is not None and (envelope.agent.model or envelope.agent.provider_name): if provider_bundle is not None and (envelope.agent.model or envelope.agent.provider_name):
raise ValueError( raise ValueError(
@ -37,6 +37,7 @@ class LocalAgentRunner:
source=f"team:{envelope.agent.name}", source=f"team:{envelope.agent.name}",
title=envelope.agent.role or envelope.agent.name, title=envelope.agent.role or envelope.agent.name,
execution_context=self._execution_context(envelope), execution_context=self._execution_context(envelope),
skill_selection_context=self._skill_selection_context(envelope),
model=envelope.agent.model, model=envelope.agent.model,
provider_name=envelope.agent.provider_name, provider_name=envelope.agent.provider_name,
provider_bundle=provider_bundle, provider_bundle=provider_bundle,
@ -44,7 +45,7 @@ class LocalAgentRunner:
task_mode=bool(envelope.parent_task_id), task_mode=bool(envelope.parent_task_id),
pinned_skill_names=envelope.inherited_pinned_skills, pinned_skill_names=envelope.inherited_pinned_skills,
pinned_skill_contexts=envelope.inherited_pinned_skill_contexts, pinned_skill_contexts=envelope.inherited_pinned_skill_contexts,
learning_candidate_enabled=learning_candidate_enabled, allow_candidate_generation=allow_candidate_generation,
) )
success = result.finish_reason == "stop" success = result.finish_reason == "stop"
return NodeRunResult( return NodeRunResult(
@ -86,7 +87,48 @@ class LocalAgentRunner:
sections.append("Pinned inherited skills:\n" + "\n".join(f"- {item}" for item in envelope.inherited_pinned_skills)) sections.append("Pinned inherited skills:\n" + "\n".join(f"- {item}" for item in envelope.inherited_pinned_skills))
if envelope.inherited_pinned_skill_contexts: if envelope.inherited_pinned_skill_contexts:
sections.append( sections.append(
"Ephemeral pinned skill drafts:\n" "Ephemeral pinned guidance:\n"
+ "\n".join(f"- {item.name} ({item.version})" for item in envelope.inherited_pinned_skill_contexts) + "\n".join(f"- {item.name} ({item.version})" for item in envelope.inherited_pinned_skill_contexts)
) )
return "\n\n".join(sections) return "\n\n".join(sections)
@staticmethod
def _skill_selection_context(envelope: DelegationEnvelope) -> str:
sections: list[str] = []
if envelope.parent_task_id:
sections.append(f"Parent task ID:\n{envelope.parent_task_id}")
sections.append(f"Node task:\n{envelope.task}")
sections.append("Execution phase:\nteam_node")
if envelope.agent.role:
sections.append(f"Agent role:\n{envelope.agent.role}")
skill_query = envelope.agent.metadata.get("skill_query")
if skill_query:
sections.append(f"Skill query:\n{skill_query}")
required_capabilities = envelope.agent.metadata.get("required_capabilities")
if required_capabilities:
if isinstance(required_capabilities, list):
rendered = "\n".join(f"- {item}" for item in required_capabilities)
else:
rendered = str(required_capabilities)
sections.append(f"Required capabilities:\n{rendered}")
if envelope.constraints:
sections.append("Constraints:\n" + "\n".join(f"- {item}" for item in envelope.constraints))
if envelope.expected_output:
sections.append(f"Expected output:\n{envelope.expected_output}")
if envelope.inherited_pinned_skills:
sections.append(
"Pinned inherited skills (must be injected separately; use as strong context):\n"
+ "\n".join(f"- {item}" for item in envelope.inherited_pinned_skills)
)
if envelope.dependency_outputs:
rendered = "\n\n".join(
f"Dependency {node_id} output:\n{output[:800]}"
for node_id, output in envelope.dependency_outputs.items()
)
sections.append("Dependency outputs:\n" + rendered)
sections.append(
"Skill selection instruction:\n"
"Select published skills for this delegated node. "
"If no published skill matches, return [] and let the node continue without skills."
)
return "\n\n".join(sections)

View File

@ -22,6 +22,7 @@
from __future__ import annotations from __future__ import annotations
import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@ -224,8 +225,29 @@ class ContextBuilder:
clean = {key: value for key, value in message.items() if key in allowed} clean = {key: value for key, value in message.items() if key in allowed}
if "name" not in clean and message.get("tool_name"): if "name" not in clean and message.get("tool_name"):
clean["name"] = message.get("tool_name") clean["name"] = message.get("tool_name")
if isinstance(clean.get("tool_calls"), list):
clean["tool_calls"] = ContextBuilder._provider_tool_calls(clean["tool_calls"])
return clean return clean
@staticmethod
def _provider_tool_calls(tool_calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Normalize persisted tool calls to OpenAI-compatible provider payloads."""
normalized: list[dict[str, Any]] = []
for tool_call in tool_calls:
if not isinstance(tool_call, dict):
continue
clean = dict(tool_call)
function = clean.get("function")
if isinstance(function, dict):
clean_function = dict(function)
arguments = clean_function.get("arguments")
if not isinstance(arguments, str):
clean_function["arguments"] = json.dumps(arguments or {}, ensure_ascii=False, default=str)
clean["function"] = clean_function
normalized.append(clean)
return normalized
def add_tool_result( def add_tool_result(
self, self,
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
@ -278,7 +300,7 @@ class ContextBuilder:
"content": content, "content": content,
} }
if tool_calls: if tool_calls:
message["tool_calls"] = tool_calls message["tool_calls"] = self._provider_tool_calls(tool_calls)
if reasoning_content is not None: if reasoning_content is not None:
message["reasoning_content"] = reasoning_content message["reasoning_content"] = reasoning_content
messages.append(message) messages.append(message)

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@ -11,6 +12,7 @@ from beaver.coordinator.registry import AgentRegistry
from beaver.engine.context import ContextBuilder from beaver.engine.context import ContextBuilder
from beaver.engine.session import SessionManager from beaver.engine.session import SessionManager
from beaver.foundation.config import BeaverConfig, load_config from beaver.foundation.config import BeaverConfig, load_config
from beaver.integrations.mcp import MCPConnectionManager
from beaver.memory.curated.store import MemoryStore from beaver.memory.curated.store import MemoryStore
from beaver.memory.runs import RunMemoryStore from beaver.memory.runs import RunMemoryStore
from beaver.memory.skills import SkillLearningStore from beaver.memory.skills import SkillLearningStore
@ -27,13 +29,27 @@ from beaver.tasks.skill_resolver import TaskSkillResolver
from beaver.skills import SkillAssembler, SkillsLoader from beaver.skills import SkillAssembler, SkillsLoader
from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry
from beaver.tools.builtins import ( from beaver.tools.builtins import (
ClarifyTool,
CronTool,
DelegateTool,
EchoTool, EchoTool,
ExecuteCodeTool,
ListDirectoryTool, ListDirectoryTool,
MemoryTool, MemoryTool,
PatchFileTool,
ProcessTool,
ReadFileTool, ReadFileTool,
SearchFilesTool, SearchFilesTool,
SendMessageTool,
SpawnTool,
SessionSearchTool, SessionSearchTool,
SkillViewTool, SkillManageTool,
SkillsListTool,
TerminalTool,
TodoTool,
WebFetchTool,
WebSearchTool,
WriteFileTool,
) )
@ -76,6 +92,8 @@ class EngineLoadResult:
task_service: TaskService | None = None task_service: TaskService | None = None
task_execution_planner: TaskExecutionPlanner | None = None task_execution_planner: TaskExecutionPlanner | None = None
validation_service: ValidationService | None = None validation_service: ValidationService | None = None
mcp_manager: MCPConnectionManager | None = None
mcp_report: dict[str, dict] = field(default_factory=dict)
closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False) closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False)
closed: bool = False closed: bool = False
@ -198,11 +216,25 @@ class EngineLoader:
[ [
ObjectBackedTool(EchoTool()), ObjectBackedTool(EchoTool()),
ObjectBackedTool(MemoryTool(store=memory_service.get_store())), ObjectBackedTool(MemoryTool(store=memory_service.get_store())),
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
ObjectBackedTool(SessionSearchTool(db=session_manager)), ObjectBackedTool(SessionSearchTool(db=session_manager)),
ObjectBackedTool(ListDirectoryTool()), ObjectBackedTool(ListDirectoryTool()),
ObjectBackedTool(ReadFileTool()), ObjectBackedTool(ReadFileTool()),
ObjectBackedTool(SearchFilesTool()), ObjectBackedTool(SearchFilesTool()),
ObjectBackedTool(WriteFileTool()),
ObjectBackedTool(PatchFileTool()),
ObjectBackedTool(WebFetchTool()),
ObjectBackedTool(WebSearchTool()),
ObjectBackedTool(TerminalTool()),
ObjectBackedTool(ProcessTool()),
ObjectBackedTool(ExecuteCodeTool()),
ObjectBackedTool(TodoTool()),
ObjectBackedTool(ClarifyTool()),
ObjectBackedTool(SendMessageTool()),
ObjectBackedTool(DelegateTool()),
ObjectBackedTool(SpawnTool()),
SkillsListTool(),
SkillManageTool(),
CronTool(),
] ]
) )
@ -240,6 +272,11 @@ class EngineLoader:
task_service = self._task_service or TaskService(workspace / "tasks") task_service = self._task_service or TaskService(workspace / "tasks")
task_execution_planner = self._task_execution_planner or TaskExecutionPlanner(task_skill_resolver=task_skill_resolver) task_execution_planner = self._task_execution_planner or TaskExecutionPlanner(task_skill_resolver=task_skill_resolver)
validation_service = self._validation_service or ValidationService() validation_service = self._validation_service or ValidationService()
mcp_manager = MCPConnectionManager(
self.config.tools.mcp_servers,
authz_config=self.config.authz,
backend_identity=self.config.backend_identity,
)
result = EngineLoadResult( result = EngineLoadResult(
workspace=workspace, workspace=workspace,
@ -270,7 +307,18 @@ class EngineLoader:
task_service=task_service, task_service=task_service,
task_execution_planner=task_execution_planner, task_execution_planner=task_execution_planner,
validation_service=validation_service, validation_service=validation_service,
mcp_manager=mcp_manager,
) )
if self._session_manager is None: if self._session_manager is None:
result.register_closeable("session_manager", session_manager.close) result.register_closeable("session_manager", session_manager.close)
result.register_closeable("mcp_manager", lambda: _close_mcp_manager(mcp_manager))
return result return result
def _close_mcp_manager(manager: MCPConnectionManager) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
asyncio.run(manager.close())
return
loop.create_task(manager.close())

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
@ -64,6 +65,7 @@ class AgentLoop:
self.profile = profile or AgentProfile() self.profile = profile or AgentProfile()
self.loader = loader or EngineLoader() self.loader = loader or EngineLoader()
self.loaded: EngineLoadResult | None = None self.loaded: EngineLoadResult | None = None
self.runtime_services: dict[str, Any] = {}
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
self._running = False self._running = False
self._stop_requested = False self._stop_requested = False
@ -190,6 +192,7 @@ class AgentLoop:
user_id: str | None = None, user_id: str | None = None,
title: str | None = None, title: str | None = None,
execution_context: str | None = None, execution_context: str | None = None,
skill_selection_context: str | None = None,
model: str | None = None, model: str | None = None,
provider_name: str | None = None, provider_name: str | None = None,
api_key: str | None = None, api_key: str | None = None,
@ -202,6 +205,9 @@ class AgentLoop:
embedding_model: str | None = None, embedding_model: str | None = None,
max_tokens: int | None = None, max_tokens: int | None = None,
temperature: float | None = None, temperature: float | None = None,
thinking_enabled: bool | None = None,
include_skill_assembly: bool = True,
include_tools: bool = True,
max_tool_iterations: int | None = None, max_tool_iterations: int | None = None,
provider_bundle: ProviderBundle | None = None, provider_bundle: ProviderBundle | None = None,
parent_session_id: str | None = None, parent_session_id: str | None = None,
@ -210,7 +216,7 @@ class AgentLoop:
attempt_index: int | None = None, attempt_index: int | None = None,
pinned_skill_names: list[str] | None = None, pinned_skill_names: list[str] | None = None,
pinned_skill_contexts: list[SkillContext] | None = None, pinned_skill_contexts: list[SkillContext] | None = None,
learning_candidate_enabled: bool = False, allow_candidate_generation: bool = False,
) -> AgentRunResult: ) -> AgentRunResult:
"""跑通最小 direct run 主链。 """跑通最小 direct run 主链。
@ -234,6 +240,7 @@ class AgentLoop:
user_id=user_id, user_id=user_id,
title=title, title=title,
execution_context=execution_context, execution_context=execution_context,
skill_selection_context=skill_selection_context,
model=model, model=model,
provider_name=provider_name, provider_name=provider_name,
api_key=api_key, api_key=api_key,
@ -246,6 +253,9 @@ class AgentLoop:
embedding_model=embedding_model, embedding_model=embedding_model,
max_tokens=max_tokens, max_tokens=max_tokens,
temperature=temperature, temperature=temperature,
thinking_enabled=thinking_enabled,
include_skill_assembly=include_skill_assembly,
include_tools=include_tools,
max_tool_iterations=max_tool_iterations, max_tool_iterations=max_tool_iterations,
provider_bundle=provider_bundle, provider_bundle=provider_bundle,
parent_session_id=parent_session_id, parent_session_id=parent_session_id,
@ -254,7 +264,7 @@ class AgentLoop:
attempt_index=attempt_index, attempt_index=attempt_index,
pinned_skill_names=pinned_skill_names, pinned_skill_names=pinned_skill_names,
pinned_skill_contexts=pinned_skill_contexts, pinned_skill_contexts=pinned_skill_contexts,
learning_candidate_enabled=learning_candidate_enabled, allow_candidate_generation=allow_candidate_generation,
) )
async def _process_direct_impl( async def _process_direct_impl(
@ -266,6 +276,7 @@ class AgentLoop:
user_id: str | None = None, user_id: str | None = None,
title: str | None = None, title: str | None = None,
execution_context: str | None = None, execution_context: str | None = None,
skill_selection_context: str | None = None,
model: str | None = None, model: str | None = None,
provider_name: str | None = None, provider_name: str | None = None,
api_key: str | None = None, api_key: str | None = None,
@ -278,6 +289,9 @@ class AgentLoop:
embedding_model: str | None = None, embedding_model: str | None = None,
max_tokens: int | None = None, max_tokens: int | None = None,
temperature: float | None = None, temperature: float | None = None,
thinking_enabled: bool | None = None,
include_skill_assembly: bool = True,
include_tools: bool = True,
max_tool_iterations: int | None = None, max_tool_iterations: int | None = None,
provider_bundle: ProviderBundle | None = None, provider_bundle: ProviderBundle | None = None,
parent_session_id: str | None = None, parent_session_id: str | None = None,
@ -286,7 +300,7 @@ class AgentLoop:
attempt_index: int | None = None, attempt_index: int | None = None,
pinned_skill_names: list[str] | None = None, pinned_skill_names: list[str] | None = None,
pinned_skill_contexts: list[SkillContext] | None = None, pinned_skill_contexts: list[SkillContext] | None = None,
learning_candidate_enabled: bool = False, allow_candidate_generation: bool = False,
) -> AgentRunResult: ) -> AgentRunResult:
"""真正执行一轮 direct run 的内部实现。 """真正执行一轮 direct run 的内部实现。
@ -306,6 +320,10 @@ class AgentLoop:
skills_loader = self._require_loaded("skills_loader") skills_loader = self._require_loaded("skills_loader")
skill_assembler = self._require_loaded("skill_assembler") skill_assembler = self._require_loaded("skill_assembler")
skill_learning_service = self._require_loaded("skill_learning_service") skill_learning_service = self._require_loaded("skill_learning_service")
mcp_manager = getattr(loaded, "mcp_manager", None)
if mcp_manager is not None:
loaded.mcp_report = await mcp_manager.connect_all(tool_registry)
loaded.tools = [spec.name for spec in tool_registry.list_specs()]
config = loaded.config config = loaded.config
configured_provider = config.resolve_provider_target(model=model, provider_name=provider_name) configured_provider = config.resolve_provider_target(model=model, provider_name=provider_name)
@ -357,6 +375,9 @@ class AgentLoop:
"task_id": task_id, "task_id": task_id,
"task_mode": task_mode, "task_mode": task_mode,
"attempt_index": attempt_index, "attempt_index": attempt_index,
"thinking_enabled": thinking_enabled,
"include_skill_assembly": include_skill_assembly,
"skill_selection_context_present": bool(skill_selection_context),
"parent_session_id": parent_session_id, "parent_session_id": parent_session_id,
"pinned_skill_names": list(pinned_skill_names or []), "pinned_skill_names": list(pinned_skill_names or []),
"pinned_skill_context_names": [skill.name for skill in pinned_skill_contexts or []], "pinned_skill_context_names": [skill.name for skill in pinned_skill_contexts or []],
@ -396,19 +417,39 @@ class AgentLoop:
if bundle.auxiliary_runtime is not None if bundle.auxiliary_runtime is not None
else bundle.main_runtime.model else bundle.main_runtime.model
) )
assembled_skills = await skill_assembler.assemble( pinned_skills = [
task_description=task, *(pinned_skill_contexts or []),
provider=skill_selector_provider, *self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []),
model=skill_selector_model, ]
embedding_runtime=bundle.embedding_runtime, if not include_skill_assembly or thinking_enabled is False:
) activated_skills = self._merge_skill_contexts(pinned_skills, [])
activated_skills = self._merge_skill_contexts( else:
[ skill_query = skill_selection_context or task
*(pinned_skill_contexts or []), assembled_skills = await skill_assembler.assemble(
*self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []), task_description=skill_query,
], provider=skill_selector_provider,
assembled_skills.activated_skills, model=skill_selector_model,
) embedding_runtime=bundle.embedding_runtime,
thinking_enabled=thinking_enabled,
)
for interaction in getattr(assembled_skills, "llm_interactions", []) or []:
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="skill_assembler_llm_interaction_snapshotted",
event_payload=interaction,
content=json.dumps(interaction, ensure_ascii=False, default=str),
context_visible=False,
source=source,
title=title,
model=skill_selector_model,
user_id=user_id,
)
activated_skills = self._merge_skill_contexts(
pinned_skills,
assembled_skills.activated_skills,
)
skill_activation_messages = context_builder.build_skill_activation_messages( skill_activation_messages = context_builder.build_skill_activation_messages(
activated_skills activated_skills
) )
@ -444,14 +485,19 @@ class AgentLoop:
user_id=user_id, user_id=user_id,
) )
selected_tool_specs = await tool_assembler.assemble( if not include_tools:
task_description=task, selected_tool_specs = []
registry=tool_registry, elif thinking_enabled is False:
skills_loader=skills_loader, selected_tool_specs = tool_registry.list_specs()
activated_skills=activated_skills, else:
embedding_runtime=bundle.embedding_runtime, selected_tool_specs = await tool_assembler.assemble(
top_k=10, task_description=task,
) registry=tool_registry,
skills_loader=skills_loader,
activated_skills=activated_skills,
embedding_runtime=bundle.embedding_runtime,
top_k=10,
)
tool_schemas = tool_registry.export_selected_provider_schemas(selected_tool_specs) tool_schemas = tool_registry.export_selected_provider_schemas(selected_tool_specs)
session_manager.append_message( session_manager.append_message(
resolved_session_id, resolved_session_id,
@ -486,6 +532,25 @@ class AgentLoop:
execution_context=execution_context, execution_context=execution_context,
) )
context_result = context_builder.build_messages(build_input) context_result = context_builder.build_messages(build_input)
if skill_selection_context:
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="skill_selection_context_snapshotted",
event_payload={
"skill_selection_context": skill_selection_context,
"task_id": task_id,
"task_mode": task_mode,
"attempt_index": attempt_index,
},
content=skill_selection_context,
context_visible=False,
source=source,
title=title,
model=resolved_model,
user_id=user_id,
)
session_manager.update_system_prompt(resolved_session_id, context_result.system_prompt) session_manager.update_system_prompt(resolved_session_id, context_result.system_prompt)
session_manager.append_message( session_manager.append_message(
resolved_session_id, resolved_session_id,
@ -528,6 +593,9 @@ class AgentLoop:
"memory_service": memory_service, "memory_service": memory_service,
"memory_store": memory_service.get_store(), "memory_store": memory_service.get_store(),
"tool_registry": tool_registry, "tool_registry": tool_registry,
"skills_loader": skills_loader,
"draft_service": getattr(loaded, "draft_service", None),
**self.runtime_services,
}, },
metadata={ metadata={
"source": source, "source": source,
@ -541,13 +609,45 @@ class AgentLoop:
final_model = bundle.main_runtime.model final_model = bundle.main_runtime.model
while True: while True:
response = await provider.chat( chat_kwargs: dict[str, Any] = {
messages=messages, "messages": messages,
tools=tool_schemas, "tools": tool_schemas,
"model": final_model,
"max_tokens": resolved_max_tokens,
"temperature": resolved_temperature,
}
if thinking_enabled is not None:
chat_kwargs["thinking_enabled"] = thinking_enabled
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="llm_request_snapshotted",
event_payload={
"iteration": iterations,
"provider_name": final_provider_name,
"model": final_model,
"messages": messages,
"tools": tool_schemas,
"max_tokens": resolved_max_tokens,
"temperature": resolved_temperature,
"thinking_enabled": thinking_enabled,
},
content=json.dumps(
{
"messages": messages,
"tools": tool_schemas,
},
ensure_ascii=False,
default=str,
),
context_visible=False,
source=source,
title=title,
model=final_model, model=final_model,
max_tokens=resolved_max_tokens, user_id=user_id,
temperature=resolved_temperature,
) )
response = await provider.chat(**chat_kwargs)
final_provider_name = response.provider_name or final_provider_name final_provider_name = response.provider_name or final_provider_name
final_model = response.model or final_model final_model = response.model or final_model
final_usage = self._merge_usage(final_usage, response.usage or {}) final_usage = self._merge_usage(final_usage, response.usage or {})
@ -650,7 +750,7 @@ class AgentLoop:
model=final_model, model=final_model,
user_id=user_id, user_id=user_id,
) )
self._record_skill_learning( self._record_run_receipts(
skill_learning_service=skill_learning_service, skill_learning_service=skill_learning_service,
session_manager=session_manager, session_manager=session_manager,
session_id=resolved_session_id, session_id=resolved_session_id,
@ -663,7 +763,7 @@ class AgentLoop:
success=(final_finish_reason == "stop"), success=(final_finish_reason == "stop"),
task_id=task_id, task_id=task_id,
attempt_index=attempt_index, attempt_index=attempt_index,
generate_candidates=learning_candidate_enabled, allow_candidate_generation=False,
) )
return AgentRunResult( return AgentRunResult(
session_id=resolved_session_id, session_id=resolved_session_id,
@ -703,7 +803,7 @@ class AgentLoop:
usage=final_usage, usage=final_usage,
task_id=task_id, task_id=task_id,
) )
self._record_skill_learning( self._record_run_receipts(
skill_learning_service=skill_learning_service, skill_learning_service=skill_learning_service,
session_manager=session_manager, session_manager=session_manager,
session_id=resolved_session_id, session_id=resolved_session_id,
@ -716,7 +816,7 @@ class AgentLoop:
success=False, success=False,
task_id=task_id, task_id=task_id,
attempt_index=attempt_index, attempt_index=attempt_index,
generate_candidates=learning_candidate_enabled, allow_candidate_generation=False,
) )
return result return result
@ -771,13 +871,16 @@ class AgentLoop:
def _serialize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]: def _serialize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]:
payload: list[dict[str, Any]] = [] payload: list[dict[str, Any]] = []
for tool_call in tool_calls: for tool_call in tool_calls:
arguments = tool_call.arguments
if not isinstance(arguments, str):
arguments = json.dumps(arguments or {}, ensure_ascii=False, default=str)
payload.append( payload.append(
{ {
"id": tool_call.id, "id": tool_call.id,
"type": "function", "type": "function",
"function": { "function": {
"name": tool_call.name, "name": tool_call.name,
"arguments": tool_call.arguments, "arguments": arguments,
}, },
} }
) )
@ -877,7 +980,7 @@ class AgentLoop:
) )
@staticmethod @staticmethod
def _record_skill_learning( def _record_run_receipts(
*, *,
skill_learning_service: Any, skill_learning_service: Any,
session_manager: Any, session_manager: Any,
@ -891,7 +994,7 @@ class AgentLoop:
success: bool, success: bool,
task_id: str | None = None, task_id: str | None = None,
attempt_index: int | None = None, attempt_index: int | None = None,
generate_candidates: bool = False, allow_candidate_generation: bool = False,
) -> None: ) -> None:
run_record = RunRecord( run_record = RunRecord(
run_id=run_id, run_id=run_id,
@ -921,7 +1024,7 @@ class AgentLoop:
try: try:
candidates = skill_learning_service.collect_run_receipts( candidates = skill_learning_service.collect_run_receipts(
RunReceiptContext(run_record=run_record, effect_records=effect_records), RunReceiptContext(run_record=run_record, effect_records=effect_records),
generate_candidates=generate_candidates, generate_candidates=allow_candidate_generation,
) )
except Exception as exc: # pragma: no cover - defensive hot-path guard except Exception as exc: # pragma: no cover - defensive hot-path guard
session_manager.append_message( session_manager.append_message(
@ -948,7 +1051,7 @@ class AgentLoop:
"run_record": run_record.to_dict(), "run_record": run_record.to_dict(),
"skill_effects": [item.to_dict() for item in effect_records], "skill_effects": [item.to_dict() for item in effect_records],
"learning_candidates": [candidate.to_dict() for candidate in candidates], "learning_candidates": [candidate.to_dict() for candidate in candidates],
"learning_candidate_enabled": generate_candidates, "candidate_generation_allowed": allow_candidate_generation,
}, },
content=f"Recorded {len(effect_records)} skill effect record(s).", content=f"Recorded {len(effect_records)} skill effect record(s).",
context_visible=False, context_visible=False,

View File

@ -45,6 +45,7 @@ class AnthropicProvider(LLMProvider):
model: str | None = None, model: str | None = None,
max_tokens: int = 4096, max_tokens: int = 4096,
temperature: float = 0.7, temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse: ) -> LLMResponse:
try: try:
client = self._client_or_raise() client = self._client_or_raise()

View File

@ -90,6 +90,7 @@ class LLMProvider(ABC):
model: str | None = None, model: str | None = None,
max_tokens: int = 4096, max_tokens: int = 4096,
temperature: float = 0.7, temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse: ) -> LLMResponse:
"""统一聊天接口。""" """统一聊天接口。"""

View File

@ -58,6 +58,7 @@ class FallbackProviderChain(LLMProvider):
model: str | None = None, model: str | None = None,
max_tokens: int = 4096, max_tokens: int = 4096,
temperature: float = 0.7, temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse: ) -> LLMResponse:
self._last_provider = self.primary_provider self._last_provider = self.primary_provider
self._last_runtime = self.primary_runtime self._last_runtime = self.primary_runtime
@ -71,6 +72,7 @@ class FallbackProviderChain(LLMProvider):
model=model or self.primary_runtime.model, model=model or self.primary_runtime.model,
max_tokens=max_tokens, max_tokens=max_tokens,
temperature=temperature, temperature=temperature,
thinking_enabled=thinking_enabled,
) )
response = self._decorate_response(response, self.primary_runtime) response = self._decorate_response(response, self.primary_runtime)
if not self._should_activate_fallback(response): if not self._should_activate_fallback(response):
@ -91,6 +93,7 @@ class FallbackProviderChain(LLMProvider):
model=self.fallback_runtime.model, model=self.fallback_runtime.model,
max_tokens=max_tokens, max_tokens=max_tokens,
temperature=temperature, temperature=temperature,
thinking_enabled=thinking_enabled,
) )
return self._decorate_response(response, self.fallback_runtime) return self._decorate_response(response, self.fallback_runtime)
@ -114,6 +117,7 @@ class FallbackProviderChain(LLMProvider):
model: str, model: str,
max_tokens: int, max_tokens: int,
temperature: float, temperature: float,
thinking_enabled: bool | None,
) -> LLMResponse: ) -> LLMResponse:
"""把 provider 抛出的异常也收敛成统一 error response。 """把 provider 抛出的异常也收敛成统一 error response。
@ -121,13 +125,16 @@ class FallbackProviderChain(LLMProvider):
""" """
try: try:
return await provider.chat( kwargs = {
messages=messages, "messages": messages,
tools=tools, "tools": tools,
model=model, "model": model,
max_tokens=max_tokens, "max_tokens": max_tokens,
temperature=temperature, "temperature": temperature,
) }
if thinking_enabled is not None:
kwargs["thinking_enabled"] = thinking_enabled
return await provider.chat(**kwargs)
except Exception as exc: except Exception as exc:
return LLMResponse( return LLMResponse(
content=f"Error: {exc}", content=f"Error: {exc}",

View File

@ -41,6 +41,7 @@ class OpenAICodexProvider(LLMProvider):
model: str | None = None, model: str | None = None,
max_tokens: int = 4096, max_tokens: int = 4096,
temperature: float = 0.7, temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse: ) -> LLMResponse:
if httpx is None or get_codex_token is None: if httpx is None or get_codex_token is None:
return LLMResponse(content="Error: codex dependencies are not installed", finish_reason="error", provider_name="openai_codex") return LLMResponse(content="Error: codex dependencies are not installed", finish_reason="error", provider_name="openai_codex")

View File

@ -49,6 +49,7 @@ class CustomProvider(LLMProvider):
model: str | None = None, model: str | None = None,
max_tokens: int = 4096, max_tokens: int = 4096,
temperature: float = 0.7, temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse: ) -> LLMResponse:
client = self._client_or_raise() client = self._client_or_raise()
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {

View File

@ -123,6 +123,25 @@ class LiteLLMProvider(LLMProvider):
clean = {key: value for key, value in message.items() if key in _ALLOWED_MSG_KEYS} clean = {key: value for key, value in message.items() if key in _ALLOWED_MSG_KEYS}
if clean.get("role") == "assistant" and "content" not in clean: if clean.get("role") == "assistant" and "content" not in clean:
clean["content"] = None clean["content"] = None
if isinstance(clean.get("tool_calls"), list):
clean["tool_calls"] = LiteLLMProvider._sanitize_tool_calls(clean["tool_calls"])
sanitized.append(clean)
return sanitized
@staticmethod
def _sanitize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]:
sanitized: list[dict[str, Any]] = []
for tool_call in tool_calls:
if not isinstance(tool_call, dict):
continue
clean = dict(tool_call)
function = clean.get("function")
if isinstance(function, dict):
clean_function = dict(function)
arguments = clean_function.get("arguments")
if not isinstance(arguments, str):
clean_function["arguments"] = json.dumps(arguments or {}, ensure_ascii=False, default=str)
clean["function"] = clean_function
sanitized.append(clean) sanitized.append(clean)
return sanitized return sanitized
@ -155,6 +174,18 @@ class LiteLLMProvider(LLMProvider):
if provider_payload: if provider_payload:
kwargs["provider"] = provider_payload kwargs["provider"] = provider_payload
def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None:
if enabled is None:
return
model_key = f"{original_model} {resolved_model}".lower()
if "qwen" not in model_key:
return
extra_body = dict(kwargs.get("extra_body") or {})
chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {})
chat_template_kwargs["enable_thinking"] = bool(enabled)
extra_body["chat_template_kwargs"] = chat_template_kwargs
kwargs["extra_body"] = extra_body
async def chat( async def chat(
self, self,
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
@ -162,6 +193,7 @@ class LiteLLMProvider(LLMProvider):
model: str | None = None, model: str | None = None,
max_tokens: int = 4096, max_tokens: int = 4096,
temperature: float = 0.7, temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse: ) -> LLMResponse:
if acompletion is None: if acompletion is None:
return LLMResponse(content="Error: litellm is not installed", finish_reason="error", provider_name=self.provider_name) return LLMResponse(content="Error: litellm is not installed", finish_reason="error", provider_name=self.provider_name)
@ -174,6 +206,7 @@ class LiteLLMProvider(LLMProvider):
"messages": sanitized_messages, "messages": sanitized_messages,
"max_tokens": max(1, max_tokens), "max_tokens": max(1, max_tokens),
"temperature": temperature, "temperature": temperature,
"timeout": self.request_timeout_seconds or 45.0,
} }
if self.api_key: if self.api_key:
kwargs["api_key"] = self.api_key kwargs["api_key"] = self.api_key
@ -186,6 +219,7 @@ class LiteLLMProvider(LLMProvider):
kwargs["tool_choice"] = "auto" kwargs["tool_choice"] = "auto"
self._apply_model_overrides(original_model, kwargs) self._apply_model_overrides(original_model, kwargs)
self._apply_openrouter_routing(kwargs) self._apply_openrouter_routing(kwargs)
self._apply_thinking_mode(original_model, resolved_model, kwargs, thinking_enabled)
env_overrides = self._build_env_overrides(self.api_key, self.api_base, original_model) env_overrides = self._build_env_overrides(self.api_key, self.api_base, original_model)
try: try:

View File

@ -121,7 +121,37 @@ class SessionManager:
3. 让 `ContextBuilder` 明确消费的是“上游裁剪后的可见片段” 3. 让 `ContextBuilder` 明确消费的是“上游裁剪后的可见片段”
""" """
history = self.get_messages_as_conversation(session_id) records = self.get_event_records(session_id)
completed_run_ids = {
record.run_id
for record in records
if record.run_id and record.event_type == "run_completed"
}
failed_run_ids = {
record.run_id
for record in records
if record.run_id
and record.event_type == "run_completed"
and (
record.finish_reason == "error"
or (record.event_payload or {}).get("finish_reason") == "error"
)
}
history = []
for record in records:
if not record.context_visible or record.role == "system":
continue
if record.role == "tool":
continue
if record.role == "assistant" and record.tool_calls:
continue
if record.run_id and record.run_id not in completed_run_ids:
continue
if record.run_id and record.run_id in failed_run_ids:
continue
if record.role == "assistant" and record.finish_reason == "error":
continue
history.append(record.to_conversation_message())
sliced = history[-max_messages:] sliced = history[-max_messages:]
for index, message in enumerate(sliced): for index, message in enumerate(sliced):
if message.get("role") == "user": if message.get("role") == "user":

View File

@ -88,6 +88,15 @@ class MessageRecord:
payload["feedback_state"] = self.event_payload.get("feedback_state") payload["feedback_state"] = self.event_payload.get("feedback_state")
if self.event_payload.get("feedback_error"): if self.event_payload.get("feedback_error"):
payload["feedback_error"] = self.event_payload.get("feedback_error") payload["feedback_error"] = self.event_payload.get("feedback_error")
for key in (
"message_type",
"scheduled_job_id",
"scheduled_run_id",
"cron_job_name",
"mode",
):
if self.event_payload.get(key):
payload[key] = self.event_payload.get(key)
if self.tool_name: if self.tool_name:
payload["tool_name"] = self.tool_name payload["tool_name"] = self.tool_name
if self.tool_calls: if self.tool_calls:

View File

@ -70,6 +70,7 @@ class SessionSearchService:
include_children: bool = False, include_children: bool = False,
source: str | None = None, source: str | None = None,
exclude_sources: list[str] | None = None, exclude_sources: list[str] | None = None,
exclude_end_reasons: list[str] | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""列出最近活跃的 session 及其摘要元数据。""" """列出最近活跃的 session 及其摘要元数据。"""
@ -85,6 +86,10 @@ class SessionSearchService:
placeholders = ",".join("?" for _ in exclude_sources) placeholders = ",".join("?" for _ in exclude_sources)
clauses.append(f"source NOT IN ({placeholders})") clauses.append(f"source NOT IN ({placeholders})")
params.extend(exclude_sources) params.extend(exclude_sources)
if exclude_end_reasons:
placeholders = ",".join("?" for _ in exclude_end_reasons)
clauses.append(f"(end_reason IS NULL OR end_reason NOT IN ({placeholders}))")
params.extend(exclude_end_reasons)
where = f"WHERE {' AND '.join(clauses)}" if clauses else "" where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.extend([limit, offset]) params.extend([limit, offset])

View File

@ -128,19 +128,46 @@ class SessionStore:
self._conn.executescript(SCHEMA_SQL) self._conn.executescript(SCHEMA_SQL)
try: try:
self._conn.execute("SELECT * FROM messages_fts LIMIT 0") self._conn.execute("SELECT * FROM messages_fts LIMIT 0")
except sqlite3.OperationalError: self._conn.executescript(FTS_TRIGGER_SQL)
self._conn.executescript(FTS_TABLE_SQL) except sqlite3.Error:
self._conn.executescript(FTS_TRIGGER_SQL) self._rebuild_fts_index()
return
# 旧版本可能把 hidden 事件也写进了 FTS初始化时顺手清掉这些噪声项。 # 旧版本可能把 hidden 事件也写进了 FTS初始化时顺手清掉这些噪声项。
self._conn.execute( try:
""" self._conn.execute(
INSERT INTO messages_fts(messages_fts, rowid, content) """
SELECT 'delete', id, content INSERT INTO messages_fts(messages_fts, rowid, content)
FROM messages SELECT 'delete', id, content
WHERE context_visible = 0 AND content IS NOT NULL FROM messages
""" WHERE context_visible = 0 AND content IS NOT NULL
) """
self._conn.commit() )
self._conn.commit()
except sqlite3.Error:
self._rebuild_fts_index()
def _rebuild_fts_index(self) -> None:
"""Recreate the derived FTS index without touching canonical session rows."""
self._conn.executescript(
"""
DROP TRIGGER IF EXISTS messages_fts_insert;
DROP TRIGGER IF EXISTS messages_fts_delete;
DROP TRIGGER IF EXISTS messages_fts_update;
DROP TABLE IF EXISTS messages_fts;
"""
)
self._conn.executescript(FTS_TABLE_SQL)
self._conn.executescript(FTS_TRIGGER_SQL)
self._conn.execute(
"""
INSERT INTO messages_fts(rowid, content)
SELECT id, content
FROM messages
WHERE context_visible = 1 AND content IS NOT NULL
"""
)
self._conn.commit()
def close(self) -> None: def close(self) -> None:
with self._lock: with self._lock:

View File

@ -1,13 +1,26 @@
"""Configuration models and loaders.""" """Configuration models and loaders."""
from .loader import default_config_path, load_config from .loader import default_config_path, load_config
from .schema import AgentDefaultsConfig, BeaverConfig, EmbeddingConfig, ProviderConfig from .schema import (
AgentDefaultsConfig,
AuthzConfig,
BackendIdentityConfig,
BeaverConfig,
EmbeddingConfig,
MCPServerConfig,
ProviderConfig,
ToolsConfig,
)
__all__ = [ __all__ = [
"AgentDefaultsConfig", "AgentDefaultsConfig",
"AuthzConfig",
"BackendIdentityConfig",
"BeaverConfig", "BeaverConfig",
"EmbeddingConfig", "EmbeddingConfig",
"MCPServerConfig",
"ProviderConfig", "ProviderConfig",
"ToolsConfig",
"default_config_path", "default_config_path",
"load_config", "load_config",
] ]

View File

@ -4,10 +4,30 @@ from __future__ import annotations
import json import json
import os import os
import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from .schema import AgentDefaultsConfig, BeaverConfig, EmbeddingConfig, ProviderConfig from .schema import (
AgentDefaultsConfig,
AuthzConfig,
BackendIdentityConfig,
BeaverConfig,
EmbeddingConfig,
MCPServerConfig,
ProviderConfig,
ToolsConfig,
)
LOCAL_MCP_CATEGORIES: dict[str, dict[str, str]] = {
"local_filesystem_mcp": {"category": "filesystem", "display_name": "本地文件工具"},
"local_runtime_mcp": {"category": "runtime", "display_name": "本地运行工具"},
"local_memory_mcp": {"category": "memory", "display_name": "本地记忆工具"},
"local_skills_mcp": {"category": "skills", "display_name": "本地技能工具"},
"local_coordination_mcp": {"category": "coordination", "display_name": "本地协作工具"},
"local_scheduler_mcp": {"category": "scheduler", "display_name": "本地定时工具"},
"local_web_mcp": {"category": "web", "display_name": "本地联网工具"},
}
def default_config_path(*, workspace: str | Path | None = None) -> Path: def default_config_path(*, workspace: str | Path | None = None) -> Path:
@ -57,6 +77,9 @@ def load_config(
agents_defaults=_parse_agent_defaults(data), agents_defaults=_parse_agent_defaults(data),
providers=_parse_providers(data.get("providers")), providers=_parse_providers(data.get("providers")),
embedding=_parse_embedding(data), embedding=_parse_embedding(data),
tools=_parse_tools(data.get("tools")),
authz=_parse_authz(data.get("authz")),
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
config_path=path, config_path=path,
) )
@ -104,6 +127,73 @@ def _parse_embedding(data: dict[str, Any]) -> EmbeddingConfig:
) )
def _parse_tools(raw: Any) -> ToolsConfig:
data = _as_dict(raw)
mcp_servers: dict[str, MCPServerConfig] = {}
for server_id, payload in _as_dict(data.get("mcpServers") or data.get("mcp_servers")).items():
if not isinstance(payload, dict):
continue
mcp_servers[str(server_id)] = MCPServerConfig(
command=_string(payload.get("command")) or "",
args=_string_list(payload.get("args")),
env=_string_dict(payload.get("env")),
url=_string(payload.get("url")) or "",
headers=_string_dict(payload.get("headers")),
auth_mode=(_string(payload.get("authMode") or payload.get("auth_mode")) or "none").lower(),
auth_audience=_string(payload.get("authAudience") or payload.get("auth_audience")) or "",
auth_scopes=_string_list(payload.get("authScopes") or payload.get("auth_scopes")),
tool_timeout=int(_float(payload.get("toolTimeout") or payload.get("tool_timeout")) or 30),
sensitive=_bool(payload.get("sensitive"), default=False),
kind=(_string(payload.get("kind")) or ("local" if payload.get("command") else "online")).lower(),
category=_string(payload.get("category")) or ("local" if payload.get("command") else "online"),
managed=_bool(payload.get("managed"), default=False),
display_name=_string(payload.get("displayName") or payload.get("display_name")) or "",
source=_string(payload.get("source")) or "config",
)
for server_id, meta in LOCAL_MCP_CATEGORIES.items():
if server_id in mcp_servers:
continue
mcp_servers[server_id] = MCPServerConfig(
command=sys.executable or "python",
args=["-m", "beaver.interfaces.mcp.tools_server", "--category", meta["category"]],
env={},
kind="local",
category=meta["category"],
managed=True,
display_name=meta["display_name"],
source="beaver-default",
tool_timeout=60,
)
return ToolsConfig(
restrict_to_workspace=_bool(
data.get("restrictToWorkspace") if "restrictToWorkspace" in data else data.get("restrict_to_workspace"),
default=True,
),
mcp_servers=mcp_servers,
)
def _parse_authz(raw: Any) -> AuthzConfig:
data = _as_dict(raw)
return AuthzConfig(
enabled=_bool(data.get("enabled"), default=False),
base_url=_string(data.get("baseUrl") or data.get("base_url")) or "",
request_timeout_seconds=int(_float(data.get("requestTimeoutSeconds") or data.get("request_timeout_seconds")) or 10),
outlook_mcp_url=_string(data.get("outlookMcpUrl") or data.get("outlook_mcp_url")) or "",
)
def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
data = _as_dict(raw)
return BackendIdentityConfig(
backend_id=_string(data.get("backendId") or data.get("backend_id")) or "",
client_id=_string(data.get("clientId") or data.get("client_id")) or "",
client_secret=_string(data.get("clientSecret") or data.get("client_secret")) or "",
name=_string(data.get("name")) or "",
public_base_url=_string(data.get("publicBaseUrl") or data.get("public_base_url")) or "",
)
def _as_dict(value: Any) -> dict[str, Any]: def _as_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {} return value if isinstance(value, dict) else {}
@ -121,7 +211,23 @@ def _string_dict(value: Any) -> dict[str, str]:
return {str(key): str(item) for key, item in value.items() if item is not None} return {str(key): str(item) for key, item in value.items() if item is not None}
def _string_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return [str(item) for item in value if str(item).strip()]
def _float(value: Any) -> float | None: def _float(value: Any) -> float | None:
if value in (None, ""): if value in (None, ""):
return None return None
return float(value) return float(value)
def _bool(value: Any, *, default: bool) -> bool:
if isinstance(value, bool):
return value
if value in (None, ""):
return default
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)

View File

@ -39,6 +39,65 @@ class EmbeddingConfig:
request_timeout_seconds: float | None = None request_timeout_seconds: float | None = None
@dataclass(slots=True)
class MCPServerConfig:
"""One configured MCP server.
Transport is inferred from fields:
- command => local stdio MCP server
- url => remote streamable HTTP MCP server
"""
command: str = ""
args: list[str] = field(default_factory=list)
env: dict[str, str] = field(default_factory=dict)
url: str = ""
headers: dict[str, str] = field(default_factory=dict)
auth_mode: str = "none"
auth_audience: str = ""
auth_scopes: list[str] = field(default_factory=list)
tool_timeout: int = 30
sensitive: bool = False
kind: str = "online"
category: str = "online"
managed: bool = False
display_name: str = ""
source: str = "config"
@property
def transport(self) -> str:
return "stdio" if _clean(self.command) else "http"
@dataclass(slots=True)
class ToolsConfig:
"""Runtime tool configuration."""
restrict_to_workspace: bool = True
mcp_servers: dict[str, MCPServerConfig] = field(default_factory=dict)
@dataclass(slots=True)
class AuthzConfig:
"""External AuthZ service configuration."""
enabled: bool = False
base_url: str = ""
request_timeout_seconds: int = 10
outlook_mcp_url: str = ""
@dataclass(slots=True)
class BackendIdentityConfig:
"""This backend's AuthZ client identity."""
backend_id: str = ""
client_id: str = ""
client_secret: str = ""
name: str = ""
public_base_url: str = ""
@dataclass(slots=True) @dataclass(slots=True)
class BeaverConfig: class BeaverConfig:
"""Config loaded once per backend sandbox instance.""" """Config loaded once per backend sandbox instance."""
@ -46,6 +105,9 @@ class BeaverConfig:
agents_defaults: AgentDefaultsConfig = field(default_factory=AgentDefaultsConfig) agents_defaults: AgentDefaultsConfig = field(default_factory=AgentDefaultsConfig)
providers: dict[str, ProviderConfig] = field(default_factory=dict) providers: dict[str, ProviderConfig] = field(default_factory=dict)
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig) embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
tools: ToolsConfig = field(default_factory=ToolsConfig)
authz: AuthzConfig = field(default_factory=AuthzConfig)
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
config_path: Path | None = None config_path: Path | None = None
@property @property
@ -69,7 +131,13 @@ class BeaverConfig:
""" """
resolved_model = _clean(model) or self.default_model resolved_model = _clean(model) or self.default_model
resolved_provider = _clean(provider_name) or self._infer_provider(resolved_model) requested_provider = _clean(provider_name)
enabled_providers = self._enabled_provider_names()
resolved_provider = (
requested_provider
if requested_provider and requested_provider in enabled_providers
else self._infer_provider(resolved_model)
)
provider_cfg = self.providers.get(resolved_provider or "") if resolved_provider else None provider_cfg = self.providers.get(resolved_provider or "") if resolved_provider else None
payload: dict[str, Any] = { payload: dict[str, Any] = {
"model": resolved_model, "model": resolved_model,
@ -115,22 +183,36 @@ class BeaverConfig:
def _infer_provider(self, model: str | None) -> str | None: def _infer_provider(self, model: str | None) -> str | None:
configured_provider = _clean(self.agents_defaults.provider) configured_provider = _clean(self.agents_defaults.provider)
if configured_provider: if configured_provider and configured_provider != "custom":
return configured_provider return configured_provider
if model and "/" in model: if model and "/" in model:
prefix = model.split("/", 1)[0] prefix = model.split("/", 1)[0]
if prefix in self.providers: if prefix in self._enabled_provider_names():
return prefix return prefix
if len(self.providers) == 1: enabled_providers = self._enabled_provider_names()
return next(iter(self.providers)) if len(enabled_providers) == 1:
return enabled_providers[0]
return None return None
def _enabled_provider_names(self) -> list[str]:
return [
name
for name, provider in self.providers.items()
if name != "custom"
and any(
[
_clean(provider.api_key),
_clean(provider.api_base),
provider.extra_headers,
]
)
]
def _clean(value: str | None) -> str | None: def _clean(value: str | None) -> str | None:
if value is None: if value is None:
return None return None
value = str(value).strip() value = str(value).strip()
return value or None return value or None

View File

@ -19,7 +19,7 @@ class EmbeddingRetriever:
api_key_env: str = "OPENAI_API_KEY", api_key_env: str = "OPENAI_API_KEY",
api_base_env: str = "OPENAI_API_BASE", api_base_env: str = "OPENAI_API_BASE",
model: str = "text-embedding-v4", model: str = "text-embedding-v4",
timeout_seconds: float = 20.0, timeout_seconds: float = 3.0,
) -> None: ) -> None:
self.api_key_env = api_key_env self.api_key_env = api_key_env
self.api_base_env = api_base_env self.api_base_env = api_base_env

View File

@ -1,2 +1,11 @@
"""Shared data models.""" """Shared Beaver data models."""
from .cron import CronExecutionResult, CronJob, CronPayload, CronRunRecord, CronSchedule
__all__ = [
"CronExecutionResult",
"CronJob",
"CronPayload",
"CronRunRecord",
"CronSchedule",
]

View File

@ -0,0 +1,266 @@
"""Scheduled task models for Beaver cron.
The scheduler borrows Hermes' durable JSON + explicit schedule parsing shape,
but the execution target is Beaver Task mode: every trigger creates a normal
Task run instead of a detached agent turn.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
from uuid import uuid4
CronScheduleKind = Literal["at", "every", "cron"]
CronPayloadKind = Literal["agent_turn", "system_event"]
CronPayloadMode = Literal["notification", "task"]
@dataclass(slots=True)
class CronSchedule:
kind: CronScheduleKind
at_ms: int | None = None
every_ms: int | None = None
expr: str | None = None
tz: str | None = None
display: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"at_ms": self.at_ms,
"every_ms": self.every_ms,
"expr": self.expr,
"tz": self.tz,
"display": self.display,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "CronSchedule":
return cls(
kind=str(payload.get("kind") or "every"), # type: ignore[arg-type]
at_ms=_optional_int(payload.get("at_ms") or payload.get("atMs")),
every_ms=_optional_int(payload.get("every_ms") or payload.get("everyMs")),
expr=_optional_str(payload.get("expr")),
tz=_optional_str(payload.get("tz")),
display=_optional_str(payload.get("display")),
)
@dataclass(slots=True)
class CronPayload:
kind: CronPayloadKind = "agent_turn"
mode: CronPayloadMode = "notification"
message: str = ""
session_key: str | None = None
requires_followup: bool = False
deliver: bool = False
channel: str | None = None
to: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"mode": self.mode,
"message": self.message,
"session_key": self.session_key,
"requires_followup": self.requires_followup,
"deliver": self.deliver,
"channel": self.channel,
"to": self.to,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "CronPayload":
return cls(
kind=str(payload.get("kind") or "agent_turn"), # type: ignore[arg-type]
mode=_payload_mode(payload.get("mode"), default="task"),
message=str(payload.get("message") or ""),
session_key=_optional_str(payload.get("session_key") or payload.get("sessionKey")),
requires_followup=bool(payload.get("requires_followup") or payload.get("requiresFollowup") or False),
deliver=bool(payload.get("deliver", False)),
channel=_optional_str(payload.get("channel")),
to=_optional_str(payload.get("to")),
)
@dataclass(slots=True)
class CronRunRecord:
started_at_ms: int
scheduled_run_id: str = field(default_factory=lambda: uuid4().hex)
finished_at_ms: int | None = None
status: Literal["running", "ok", "error", "skipped"] = "running"
mode: CronPayloadMode = "notification"
notification_session_id: str | None = None
output: str | None = None
task_id: str | None = None
run_id: str | None = None
error: str | None = None
engaged: bool = False
engaged_at_ms: int | None = None
engage_intent: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"scheduled_run_id": self.scheduled_run_id,
"started_at_ms": self.started_at_ms,
"finished_at_ms": self.finished_at_ms,
"status": self.status,
"mode": self.mode,
"notification_session_id": self.notification_session_id,
"output": self.output,
"task_id": self.task_id,
"run_id": self.run_id,
"error": self.error,
"engaged": self.engaged,
"engaged_at_ms": self.engaged_at_ms,
"engage_intent": self.engage_intent,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "CronRunRecord":
return cls(
scheduled_run_id=str(payload.get("scheduled_run_id") or payload.get("scheduledRunId") or uuid4().hex),
started_at_ms=int(payload.get("started_at_ms") or payload.get("startedAtMs") or 0),
finished_at_ms=_optional_int(payload.get("finished_at_ms") or payload.get("finishedAtMs")),
status=str(payload.get("status") or "running"), # type: ignore[arg-type]
mode=_payload_mode(payload.get("mode"), default="notification"),
notification_session_id=_optional_str(payload.get("notification_session_id") or payload.get("notificationSessionId")),
output=_optional_str(payload.get("output")),
task_id=_optional_str(payload.get("task_id") or payload.get("taskId")),
run_id=_optional_str(payload.get("run_id") or payload.get("runId")),
error=_optional_str(payload.get("error")),
engaged=bool(payload.get("engaged", False)),
engaged_at_ms=_optional_int(payload.get("engaged_at_ms") or payload.get("engagedAtMs")),
engage_intent=_optional_str(payload.get("engage_intent") or payload.get("engageIntent")),
)
@dataclass(slots=True)
class CronJob:
id: str
name: str
enabled: bool
schedule: CronSchedule
payload: CronPayload
created_at_ms: int
updated_at_ms: int
next_run_at_ms: int | None = None
last_run_at_ms: int | None = None
last_status: Literal["ok", "error", "skipped"] | None = None
last_error: str | None = None
delete_after_run: bool = False
history: list[CronRunRecord] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"enabled": self.enabled,
"schedule": self.schedule.to_dict(),
"payload": self.payload.to_dict(),
"created_at_ms": self.created_at_ms,
"updated_at_ms": self.updated_at_ms,
"next_run_at_ms": self.next_run_at_ms,
"last_run_at_ms": self.last_run_at_ms,
"last_status": self.last_status,
"last_error": self.last_error,
"delete_after_run": self.delete_after_run,
"history": [item.to_dict() for item in self.history],
}
def to_api_dict(self) -> dict[str, Any]:
latest = self.history[-1] if self.history else None
return {
"id": self.id,
"name": self.name,
"enabled": self.enabled,
"schedule_kind": self.schedule.kind,
"schedule_display": self.schedule.display or _schedule_display(self.schedule),
"schedule_expr": self.schedule.expr,
"schedule_every_ms": self.schedule.every_ms,
"message": self.payload.message,
"mode": self.payload.mode,
"requires_followup": self.payload.requires_followup,
"deliver": self.payload.deliver,
"channel": self.payload.channel,
"to": self.payload.to,
"session_key": self.payload.session_key,
"next_run_at_ms": self.next_run_at_ms,
"last_run_at_ms": self.last_run_at_ms,
"last_status": self.last_status,
"last_error": self.last_error,
"last_scheduled_run_id": latest.scheduled_run_id if latest else None,
"last_task_id": latest.task_id if latest else None,
"last_run_id": latest.run_id if latest else None,
"history": [item.to_dict() for item in self.history],
"created_at_ms": self.created_at_ms,
"updated_at_ms": self.updated_at_ms,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "CronJob":
schedule_payload = payload.get("schedule") if isinstance(payload.get("schedule"), dict) else {}
payload_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else {}
return cls(
id=str(payload["id"]),
name=str(payload.get("name") or payload["id"]),
enabled=bool(payload.get("enabled", True)),
schedule=CronSchedule.from_dict(schedule_payload),
payload=CronPayload.from_dict(payload_payload),
created_at_ms=int(payload.get("created_at_ms") or payload.get("createdAtMs") or 0),
updated_at_ms=int(payload.get("updated_at_ms") or payload.get("updatedAtMs") or 0),
next_run_at_ms=_optional_int(payload.get("next_run_at_ms") or payload.get("nextRunAtMs")),
last_run_at_ms=_optional_int(payload.get("last_run_at_ms") or payload.get("lastRunAtMs")),
last_status=_optional_str(payload.get("last_status") or payload.get("lastStatus")), # type: ignore[arg-type]
last_error=_optional_str(payload.get("last_error") or payload.get("lastError")),
delete_after_run=bool(payload.get("delete_after_run") or payload.get("deleteAfterRun") or False),
history=[
CronRunRecord.from_dict(item)
for item in payload.get("history") or []
if isinstance(item, dict)
],
)
@dataclass(slots=True)
class CronExecutionResult:
response: str | None = None
task_id: str | None = None
run_id: str | None = None
notification_session_id: str | None = None
mode: CronPayloadMode = "notification"
def _schedule_display(schedule: CronSchedule) -> str:
if schedule.kind == "every":
seconds = int((schedule.every_ms or 0) / 1000)
return f"every {seconds}s"
if schedule.kind == "cron":
return schedule.expr or "cron"
return "one-time"
def _optional_str(value: Any) -> str | None:
if value in (None, ""):
return None
return str(value)
def _optional_int(value: Any) -> int | None:
if value in (None, ""):
return None
def _payload_mode(value: Any, *, default: CronPayloadMode = "notification") -> CronPayloadMode:
if value in (None, ""):
return default
cleaned = str(value or "").strip().lower()
if cleaned == "task":
return "task"
return "notification"
try:
return int(value)
except (TypeError, ValueError):
return None

View File

@ -0,0 +1,5 @@
"""AuthZ service client integration."""
from .client import AuthzClient
__all__ = ["AuthzClient"]

View File

@ -0,0 +1,50 @@
"""Small async client for the internal AuthZ service."""
from __future__ import annotations
from typing import Any
import httpx
class AuthzClient:
def __init__(self, base_url: str, timeout_seconds: int = 10) -> None:
self.base_url = base_url.rstrip("/")
self.timeout_seconds = timeout_seconds
async def _request(self, method: str, path: str, *, json_body: dict[str, Any] | None = None) -> Any:
async with httpx.AsyncClient(
timeout=self.timeout_seconds,
follow_redirects=True,
trust_env=False,
) as client:
response = await client.request(method, f"{self.base_url}{path}", json=json_body)
response.raise_for_status()
if not response.content:
return None
return response.json()
async def issue_token(
self,
*,
client_id: str,
client_secret: str,
audience: str,
scopes: list[str],
) -> dict[str, Any]:
data = await self._request(
"POST",
"/oauth/token",
json_body={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"aud": audience,
"scopes": list(scopes),
},
)
return data if isinstance(data, dict) else {}
async def get_permissions(self, backend_id: str) -> dict[str, Any]:
data = await self._request("GET", f"/backends/{backend_id}/permissions")
return data if isinstance(data, dict) else {}

View File

@ -1,2 +1,5 @@
"""MCP integration.""" """MCP integration."""
from .connection import MCPConnectionManager, test_mcp_server
__all__ = ["MCPConnectionManager", "test_mcp_server"]

View File

@ -0,0 +1,192 @@
"""MCP connection manager."""
from __future__ import annotations
import asyncio
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from typing import Any
import httpx
from beaver.foundation.config import AuthzConfig, BackendIdentityConfig, MCPServerConfig
from beaver.integrations.authz import AuthzClient
from beaver.tools.mcp.wrapper import MCPToolWrapper
from beaver.tools.registry import ToolRegistry
@dataclass(slots=True)
class MCPConnectionReport:
status: str = "disconnected"
last_error: str | None = None
tool_names: list[str] = field(default_factory=list)
tool_count: int = 0
transport: str = "http"
def to_dict(self) -> dict[str, Any]:
return {
"status": self.status,
"last_error": self.last_error,
"tool_names": list(self.tool_names),
"tool_count": self.tool_count,
"transport": self.transport,
}
class MCPConnectionManager:
def __init__(
self,
servers: dict[str, MCPServerConfig],
*,
authz_config: AuthzConfig | None = None,
backend_identity: BackendIdentityConfig | None = None,
) -> None:
self.servers = servers
self.authz_config = authz_config
self.backend_identity = backend_identity
self.stack = AsyncExitStack()
self.connected = False
self._connect_lock = asyncio.Lock()
self.report: dict[str, MCPConnectionReport] = {}
async def connect_all(self, registry: ToolRegistry) -> dict[str, dict[str, Any]]:
async with self._connect_lock:
if self.connected:
return {key: value.to_dict() for key, value in self.report.items()}
self.report = {}
for server_id, cfg in self.servers.items():
self.report[server_id] = MCPConnectionReport(transport=cfg.transport)
try:
if cfg.command:
await self._connect_stdio(server_id, cfg, registry)
elif cfg.url:
await self._connect_http(server_id, cfg, registry)
else:
raise ValueError("MCP server requires command or url")
self.report[server_id].status = "connected"
self.report[server_id].tool_count = len(self.report[server_id].tool_names)
except Exception as exc:
self.report[server_id].status = "error"
self.report[server_id].last_error = _describe_exception(exc, server_id=server_id, url=cfg.url or None)
self.connected = True
return {key: value.to_dict() for key, value in self.report.items()}
async def close(self) -> None:
await self.stack.aclose()
self.connected = False
async def _headers(self, server_id: str, cfg: MCPServerConfig) -> dict[str, str]:
headers = dict(cfg.headers or {})
if cfg.auth_mode != "oauth_backend_token":
return headers
if not (
self.authz_config
and self.authz_config.enabled
and self.authz_config.base_url
and self.backend_identity
and self.backend_identity.client_id
and self.backend_identity.client_secret
):
raise RuntimeError("oauth_backend_token requires AuthZ and backend identity")
audience = cfg.auth_audience or f"mcp:{server_id}"
client = AuthzClient(self.authz_config.base_url, timeout_seconds=self.authz_config.request_timeout_seconds)
token = await client.issue_token(
client_id=self.backend_identity.client_id,
client_secret=self.backend_identity.client_secret,
audience=audience,
scopes=list(cfg.auth_scopes),
)
access_token = str(token.get("access_token") or "").strip()
if not access_token:
raise RuntimeError("AuthZ did not return an access token")
headers["Authorization"] = f"Bearer {access_token}"
return headers
async def _open_http_session(self, cfg: MCPServerConfig, headers: dict[str, str]):
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
http_client = await self.stack.enter_async_context(
httpx.AsyncClient(headers=headers or None, follow_redirects=True, trust_env=False)
)
read, write, _ = await self.stack.enter_async_context(streamable_http_client(cfg.url, http_client=http_client))
session = await self.stack.enter_async_context(ClientSession(read, write))
await session.initialize()
return session
async def _connect_http(self, server_id: str, cfg: MCPServerConfig, registry: ToolRegistry) -> None:
headers = await self._headers(server_id, cfg)
session = await self._open_http_session(cfg, headers)
tools = await session.list_tools()
for tool_def in tools.tools:
async def call_tool(tool_name: str, args: dict[str, Any], *, _session=session) -> Any:
return await _session.call_tool(tool_name, arguments=args)
wrapper = MCPToolWrapper(
server_id,
tool_def,
call_tool,
cfg.tool_timeout,
cfg.sensitive,
cfg.kind,
cfg.category,
cfg.display_name,
)
registry.register(wrapper, replace=True)
if wrapper.spec.name not in self.report[server_id].tool_names:
self.report[server_id].tool_names.append(wrapper.spec.name)
async def _connect_stdio(self, server_id: str, cfg: MCPServerConfig, registry: ToolRegistry) -> None:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
params = StdioServerParameters(command=cfg.command, args=list(cfg.args), env=dict(cfg.env) or None)
read, write = await self.stack.enter_async_context(stdio_client(params))
session = await self.stack.enter_async_context(ClientSession(read, write))
await session.initialize()
tools = await session.list_tools()
for tool_def in tools.tools:
async def call_tool(tool_name: str, args: dict[str, Any], *, _session=session) -> Any:
return await _session.call_tool(tool_name, arguments=args)
wrapper = MCPToolWrapper(
server_id,
tool_def,
call_tool,
cfg.tool_timeout,
cfg.sensitive,
cfg.kind,
cfg.category,
cfg.display_name,
)
registry.register(wrapper, replace=True)
if wrapper.spec.name not in self.report[server_id].tool_names:
self.report[server_id].tool_names.append(wrapper.spec.name)
async def test_mcp_server(
server_id: str,
cfg: MCPServerConfig,
*,
authz_config: AuthzConfig | None = None,
backend_identity: BackendIdentityConfig | None = None,
) -> dict[str, Any]:
registry = ToolRegistry()
manager = MCPConnectionManager({server_id: cfg}, authz_config=authz_config, backend_identity=backend_identity)
try:
report = await manager.connect_all(registry)
return {"ok": report.get(server_id, {}).get("status") == "connected", "server": server_id, **report.get(server_id, {})}
finally:
await manager.close()
def _describe_exception(exc: BaseException, *, server_id: str, url: str | None = None) -> str:
target = f" ({url})" if url else ""
if isinstance(exc, httpx.TimeoutException):
return f"MCP server '{server_id}' timed out{target}"
if isinstance(exc, httpx.ConnectError):
return f"MCP server '{server_id}' is unreachable{target}"
if isinstance(exc, httpx.HTTPStatusError):
return f"MCP server '{server_id}' returned HTTP {exc.response.status_code}{target}"
detail = str(exc).strip() or exc.__class__.__name__
return f"MCP server '{server_id}' failed{target}: {detail}"

View File

@ -55,3 +55,37 @@ class MemoryChannelAdapter:
await self.bus.publish_inbound(message) await self.bus.publish_inbound(message)
return message return message
async def publish_external_text(
self,
content: str,
*,
chat_id: str,
message_id: str | None = None,
thread_id: str | None = None,
raw_payload: dict[str, Any] | None = None,
user_id: str | None = None,
title: str | None = None,
) -> InboundMessage:
"""Publish an old-style channel payload through the new adapter contract.
Real platform adapters should keep platform-specific fields here, build
a stable Beaver session_id, and pass the normalized InboundMessage to
the shared gateway bus.
"""
session_parts = [self.name, chat_id]
if thread_id:
session_parts.append(thread_id)
metadata = {
"chat_id": chat_id,
"message_id": message_id,
"thread_id": thread_id,
"raw_channel_payload": raw_payload or {},
}
return await self.publish_text(
content,
session_id=":".join(str(part) for part in session_parts if str(part)),
user_id=user_id,
title=title,
metadata=metadata,
)

View File

@ -1,5 +1,7 @@
"""CLI entry for Beaver.""" """CLI entry for Beaver."""
from pathlib import Path
try: try:
import typer import typer
except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments
@ -27,6 +29,8 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
typer = _FallbackTyper() # type: ignore[assignment] typer = _FallbackTyper() # type: ignore[assignment]
from beaver.services.agent_service import AgentService from beaver.services.agent_service import AgentService
from beaver.services.hermes_migration import HermesMigrationService
from beaver.skills.specs import SkillSpecStore
app = typer.Typer(help="Beaver backend CLI") if hasattr(typer, "Typer") else typer app = typer.Typer(help="Beaver backend CLI") if hasattr(typer, "Typer") else typer
@ -55,6 +59,26 @@ def run(
typer.echo(result.output_text) typer.echo(result.output_text)
@app.command("migrate-hermes")
def migrate_hermes(
repo: str = typer.Option(..., "--repo", help="Local checkout of https://github.com/NousResearch/hermes-agent."),
workspace: str | None = typer.Option(None, "--workspace", help="Workspace root to import skills into."),
manifest: str | None = typer.Option(None, "--manifest", help="Path for hermes_migration_manifest.json."),
dry_run: bool = typer.Option(False, "--dry-run", help="Only write the manifest without importing skills."),
) -> None:
"""Import no-credential Hermes Agent skills and write a manifest."""
service = AgentService(workspace=workspace)
loaded = service.create_loop().boot()
store = loaded.skill_spec_store or SkillSpecStore(loaded.workspace)
migration = HermesMigrationService(store, manifest_path=Path(manifest) if manifest else None)
result = migration.migrate(repo, dry_run=dry_run)
typer.echo(
f"Hermes migration complete: {len(result['included'])} included, "
f"{len(result['skipped'])} skipped."
)
def main() -> None: def main() -> None:
"""Project script entrypoint.""" """Project script entrypoint."""
app() app()

View File

@ -0,0 +1,192 @@
"""Beaver local tools as real stdio MCP servers."""
from __future__ import annotations
import argparse
import asyncio
import json
import os
from pathlib import Path
from typing import Any
import mcp.types as types
from mcp.server.lowlevel import Server
from mcp.server.lowlevel.server import NotificationOptions
from mcp.server.models import InitializationOptions
from mcp.server.stdio import stdio_server
from beaver.engine.session import SessionManager
from beaver.memory.curated.store import MemoryStore
from beaver.services.cron_service import CronService
from beaver.skills import SkillsLoader
from beaver.skills.drafts import DraftService
from beaver.skills.specs import SkillSpecStore
from beaver.tools.base import BaseTool, ObjectBackedTool, ToolContext
from beaver.tools.builtins import (
ClarifyTool,
CronTool,
DelegateTool,
ExecuteCodeTool,
ListDirectoryTool,
MemoryTool,
PatchFileTool,
ProcessTool,
ReadFileTool,
SearchFilesTool,
SendMessageTool,
SkillManageTool,
SkillViewTool,
SkillsListTool,
SpawnTool,
TerminalTool,
TodoTool,
WebFetchTool,
WebSearchTool,
WriteFileTool,
)
LOCAL_TOOL_CATEGORIES = {
"filesystem": "Beaver Local Filesystem Tools",
"runtime": "Beaver Local Runtime Tools",
"memory": "Beaver Local Memory Tools",
"skills": "Beaver Local Skills Tools",
"coordination": "Beaver Local Coordination Tools",
"scheduler": "Beaver Local Scheduler Tools",
"web": "Beaver Local Web Tools",
}
def _workspace_path(value: str | None = None) -> Path:
raw = value or os.getenv("BEAVER_WORKSPACE") or os.getenv("NANOBOT_WORKSPACE")
if raw:
return Path(raw).expanduser().resolve()
return Path.cwd()
def _json_content(value: str) -> dict[str, Any]:
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {"success": True, "result": parsed}
except json.JSONDecodeError:
return {"success": True, "content": value}
def _category_tools(category: str, workspace: Path) -> tuple[list[BaseTool], ToolContext]:
skill_store = SkillSpecStore(workspace)
skills_loader = SkillsLoader(workspace, skill_store=skill_store)
draft_service = DraftService(skill_store)
services = {
"skills_loader": skills_loader,
"draft_service": draft_service,
}
context = ToolContext(workspace=str(workspace), services=services)
if category == "filesystem":
tools: list[BaseTool] = [
ObjectBackedTool(ListDirectoryTool()),
ObjectBackedTool(ReadFileTool()),
ObjectBackedTool(SearchFilesTool()),
ObjectBackedTool(WriteFileTool()),
ObjectBackedTool(PatchFileTool()),
]
elif category == "runtime":
tools = [
ObjectBackedTool(TerminalTool()),
ObjectBackedTool(ProcessTool()),
ObjectBackedTool(ExecuteCodeTool()),
]
elif category == "memory":
session_manager = SessionManager(workspace)
memory_store = MemoryStore(workspace / "memory" / "curated")
memory_store.load_from_disk()
tools = [
ObjectBackedTool(MemoryTool(store=memory_store)),
ObjectBackedTool(__import__("beaver.tools.builtins.session_search", fromlist=["SessionSearchTool"]).SessionSearchTool(db=session_manager)),
]
elif category == "skills":
tools = [
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
SkillsListTool(),
SkillManageTool(),
]
elif category == "coordination":
tools = [
ObjectBackedTool(TodoTool()),
ObjectBackedTool(ClarifyTool()),
ObjectBackedTool(DelegateTool()),
ObjectBackedTool(SpawnTool()),
ObjectBackedTool(SendMessageTool()),
]
elif category == "scheduler":
services["cron_service"] = CronService(workspace / "cron" / "jobs.json")
tools = [CronTool()]
elif category == "web":
tools = [
ObjectBackedTool(WebFetchTool()),
ObjectBackedTool(WebSearchTool()),
]
else:
raise ValueError(f"Unknown local tool category: {category}")
return tools, context
def create_tools_server(*, category: str, workspace: str | None = None) -> Server:
workspace_path = _workspace_path(workspace)
tools, context = _category_tools(category, workspace_path)
tool_map = {tool.spec.name: tool for tool in tools}
server = Server(LOCAL_TOOL_CATEGORIES.get(category, f"Beaver Local {category} Tools"))
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name=tool.spec.name,
description=tool.spec.description,
inputSchema=tool.spec.input_schema,
)
for tool in tools
]
@server.call_tool(validate_input=True)
async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
tool = tool_map.get(name)
if tool is None:
return {"success": False, "error": f"Unknown tool: {name}"}
result = await tool.invoke(arguments or {}, context)
if result.raw_output is not None and isinstance(result.raw_output, dict):
return result.raw_output
payload = _json_content(result.content)
if "success" not in payload:
payload["success"] = bool(result.success)
if result.error and "error" not in payload:
payload["error"] = result.error
return payload
return server
async def _run_stdio(category: str, workspace: str | None) -> None:
server = create_tools_server(category=category, workspace=workspace)
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name=LOCAL_TOOL_CATEGORIES.get(category, f"beaver-{category}"),
server_version="0.1.0",
capabilities=server.get_capabilities(notification_options=NotificationOptions(), experimental_capabilities={}),
),
)
def main() -> None:
parser = argparse.ArgumentParser(description="Run a Beaver local tool category as a stdio MCP server.")
parser.add_argument("--category", choices=sorted(LOCAL_TOOL_CATEGORIES), required=True)
parser.add_argument("--workspace", default=None)
args = parser.parse_args()
asyncio.run(_run_stdio(args.category, args.workspace))
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@ -60,10 +60,13 @@ class WebChatRequest(BaseModel):
embedding_model: str | None = None embedding_model: str | None = None
temperature: float | None = None temperature: float | None = None
max_tokens: int | None = None max_tokens: int | None = None
thinking_enabled: bool | None = None
max_tool_iterations: int | None = None max_tool_iterations: int | None = None
fallback_target: WebProviderTarget | None = None fallback_target: WebProviderTarget | None = None
auxiliary_target: WebProviderTarget | None = None auxiliary_target: WebProviderTarget | None = None
embedding_target: WebProviderTarget | None = None embedding_target: WebProviderTarget | None = None
reply_to_scheduled_run_id: str | None = None
scheduled_reply_intent: str | None = None
class WebChatResponse(BaseModel): class WebChatResponse(BaseModel):

View File

@ -44,6 +44,29 @@ class RunMemoryStore:
def append_skill_effect(self, effect: SkillEffectRecord) -> None: def append_skill_effect(self, effect: SkillEffectRecord) -> None:
self._append_jsonl(self.effects_path, effect.to_dict()) self._append_jsonl(self.effects_path, effect.to_dict())
def update_skill_effects_for_run(self, run_id: str, **updates: object) -> list[SkillEffectRecord]:
effects = [SkillEffectRecord.from_dict(item) for item in self._read_jsonl(self.effects_path)]
updated: list[SkillEffectRecord] = []
for index, effect in enumerate(effects):
if effect.run_id != run_id:
continue
payload = effect.to_dict()
payload.update(updates)
next_effect = SkillEffectRecord.from_dict(payload)
effects[index] = next_effect
updated.append(next_effect)
if not updated:
return []
self.effects_path.parent.mkdir(parents=True, exist_ok=True)
self.effects_path.write_text(
"".join(
json.dumps(effect.to_dict(), ensure_ascii=False, sort_keys=True) + "\n"
for effect in effects
),
encoding="utf-8",
)
return updated
def list_runs(self) -> list[RunRecord]: def list_runs(self) -> list[RunRecord]:
return [RunRecord.from_dict(item) for item in self._read_jsonl(self.runs_path)] return [RunRecord.from_dict(item) for item in self._read_jsonl(self.runs_path)]

View File

@ -1,6 +1,6 @@
"""Application services for Beaver.""" """Application services for Beaver."""
__all__ = ["AgentService", "MemoryService"] __all__ = ["AgentService", "CronService", "MemoryService"]
def __getattr__(name: str): def __getattr__(name: str):
@ -12,4 +12,8 @@ def __getattr__(name: str):
from .memory_service import MemoryService from .memory_service import MemoryService
return MemoryService return MemoryService
if name == "CronService":
from .cron_service import CronService
return CronService
raise AttributeError(name) raise AttributeError(name)

View File

@ -21,9 +21,13 @@ from beaver.coordinator.models import ExecutionNode, TeamRunResult
from beaver.engine import AgentLoop, AgentProfile, AgentRunResult, EngineLoader from beaver.engine import AgentLoop, AgentProfile, AgentRunResult, EngineLoader
from beaver.engine.providers import make_provider_bundle from beaver.engine.providers import make_provider_bundle
from beaver.foundation.events import InboundMessage, OutboundMessage from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.foundation.models import CronJob, CronRunRecord
from beaver.tasks import MainAgentRouter, TaskExecutionPlan, TaskRecord, ValidationResult from beaver.tasks import MainAgentRouter, TaskExecutionPlan, TaskRecord, ValidationResult
NOTIFICATION_SESSION_ID = "notify:default:scheduled"
class AgentService: class AgentService:
"""面向 interfaces 的统一 agent 运行入口。 """面向 interfaces 的统一 agent 运行入口。
@ -50,15 +54,24 @@ class AgentService:
self._loop: AgentLoop | None = None self._loop: AgentLoop | None = None
self._run_task: asyncio.Task[None] | None = None self._run_task: asyncio.Task[None] | None = None
self._main_agent_router = MainAgentRouter() self._main_agent_router = MainAgentRouter()
self._runtime_services: dict[str, Any] = {}
def create_loop(self) -> AgentLoop: def create_loop(self) -> AgentLoop:
"""创建并缓存当前 service 使用的 AgentLoop。""" """创建并缓存当前 service 使用的 AgentLoop。"""
if self._loop is None: if self._loop is None:
self._loop = AgentLoop(profile=self.profile, loader=self.loader) self._loop = AgentLoop(profile=self.profile, loader=self.loader)
self._loop.runtime_services.update(self._runtime_services)
self._loop.boot() self._loop.boot()
return self._loop return self._loop
def register_runtime_service(self, name: str, service: Any) -> None:
"""Expose process-level services to tools during agent runs."""
self._runtime_services[name] = service
if self._loop is not None:
self._loop.runtime_services[name] = service
@property @property
def has_loop(self) -> bool: def has_loop(self) -> bool:
"""当前 service 是否已经创建过 loop。""" """当前 service 是否已经创建过 loop。"""
@ -196,6 +209,191 @@ class AgentService:
loop = self.create_loop() loop = self.create_loop()
return await self._process_with_main_agent(message, runner=loop.submit_direct, kwargs=kwargs) return await self._process_with_main_agent(message, runner=loop.submit_direct, kwargs=kwargs)
async def run_scheduled_task(
self,
message: str,
*,
session_id: str,
cron_job_id: str,
cron_job_name: str,
scheduled_run_id: str | None = None,
requires_followup: bool = False,
) -> AgentRunResult:
"""Run a cron trigger as a normal internal Task.
Scheduled jobs are product-level Tasks, not hidden one-off agent turns.
This entry bypasses the main-agent classifier and forces Task mode so
every trigger produces a TaskRecord, validation, feedback state, and a
run_id that the scheduled-task history can link to.
"""
loaded = self.create_loop().boot()
task_service = self._require_loaded(loaded, "task_service")
loop = self.create_loop()
task = task_service.create_task(
session_id=session_id,
description=message,
creator="cron",
metadata={
"source": "scheduled_cron",
"cron_job_id": cron_job_id,
"cron_job_name": cron_job_name,
"scheduled_run_id": scheduled_run_id,
"user_engaged": False,
"requires_followup": requires_followup,
},
)
execution_context = (
"This turn was triggered automatically by a scheduled task.\n\n"
f"Cron Job ID: {cron_job_id}\n"
f"Cron Job Name: {cron_job_name}\n"
f"Scheduled Run ID: {scheduled_run_id or 'unknown'}\n"
"Run it as a normal Beaver Task. Do not ask the user for confirmation; "
"execute the task and report the concrete outcome."
)
runner = loop.submit_direct if self.is_running else loop.process_direct
result = await self._run_task_mode(
message,
runner=runner,
task=task,
kwargs={
"session_id": session_id,
"source": "cron",
"user_id": "cron",
"title": cron_job_name,
"execution_context": execution_context,
},
)
loaded = self.create_loop().boot()
session_manager = self._require_loaded(loaded, "session_manager")
session_manager.update_latest_assistant_event_payload(
result.session_id,
result.run_id,
{
"message_type": "scheduled_reply",
"scheduled_job_id": job.id,
"scheduled_run_id": run.scheduled_run_id,
"cron_job_name": job.name,
"mode": "notification",
},
)
return result
async def run_scheduled_notification(
self,
message: str,
*,
session_id: str = NOTIFICATION_SESSION_ID,
cron_job_id: str,
cron_job_name: str,
scheduled_run_id: str,
) -> AgentRunResult:
"""Run a cron trigger as a notification result, not as an active Task."""
loop = self.create_loop()
loaded = loop.boot()
session_manager = self._require_loaded(loaded, "session_manager")
runner = loop.submit_direct if self.is_running else loop.process_direct
execution_context = (
"This turn was triggered automatically by a scheduled notification.\n\n"
f"Cron Job ID: {cron_job_id}\n"
f"Cron Job Name: {cron_job_name}\n"
f"Scheduled Run ID: {scheduled_run_id}\n"
"Generate the notification content directly for the user. Do not ask for confirmation."
)
result = await runner(
message,
session_id=session_id,
source="notification",
user_id="cron",
title=cron_job_name,
execution_context=execution_context,
)
session_manager.update_latest_assistant_event_payload(
result.session_id,
result.run_id,
{
"message_type": "scheduled_result",
"scheduled_job_id": cron_job_id,
"scheduled_run_id": scheduled_run_id,
"cron_job_name": cron_job_name,
"mode": "notification",
},
)
return result
def engage_scheduled_run(
self,
*,
job: CronJob,
run: CronRunRecord,
intent: str = "revise_once",
thinking_enabled: bool | None = None,
) -> TaskRecord:
"""Create or mark the Task that lets the user work on a scheduled result."""
loaded = self.create_loop().boot()
task_service = self._require_loaded(loaded, "task_service")
if run.task_id:
existing = task_service.get_task(run.task_id)
if existing is not None:
existing.metadata["user_engaged"] = True
existing.metadata["engage_intent"] = intent
task_service.store.upsert_task(existing)
return existing
task = task_service.create_task(
session_id=run.notification_session_id or NOTIFICATION_SESSION_ID,
description=f"修改定时通知:{job.name}",
creator="cron",
metadata={
"source": "scheduled_run",
"cron_job_id": job.id,
"cron_job_name": job.name,
"scheduled_run_id": run.scheduled_run_id,
"scheduled_output": run.output,
"user_engaged": True,
"engage_intent": intent,
},
)
return task
async def submit_scheduled_reply(
self,
message: str,
*,
job: CronJob,
run: CronRunRecord,
intent: str = "revise_once",
) -> AgentRunResult:
task = self.engage_scheduled_run(job=job, run=run, intent=intent)
loop = self.create_loop()
runner = loop.submit_direct if self.is_running else loop.process_direct
execution_context = (
"The user is replying to a scheduled notification result.\n\n"
f"Cron Job ID: {job.id}\n"
f"Cron Job Name: {job.name}\n"
f"Scheduled Run ID: {run.scheduled_run_id}\n"
f"Engagement intent: {intent}\n"
f"Original scheduled instruction: {job.payload.message}\n"
f"Original notification output:\n{run.output or ''}\n\n"
"Handle this as a Task continuation. If the intent is update_future, explain the durable change "
"that should apply to future notifications."
)
return await self._run_task_mode(
message,
runner=runner,
task=task,
kwargs={
"session_id": task.session_id,
"source": "notification",
"user_id": "web",
"title": job.name,
"execution_context": execution_context,
"thinking_enabled": thinking_enabled,
},
)
async def submit_feedback( async def submit_feedback(
self, self,
*, *,
@ -269,19 +467,51 @@ class AgentService:
generated_candidates = [] generated_candidates = []
validation = ValidationResult.from_dict(updated.validation_result) validation = ValidationResult.from_dict(updated.validation_result)
if not already_recorded:
run_memory_store = self._require_loaded(loaded, "run_memory_store")
feedback_payload = {
"feedback_type": normalized,
"comment": comment or "",
"task_status": updated.status,
}
run_memory_store.update_run_record(
run_id,
success=normalized == "satisfied",
feedback=feedback_payload,
)
run_memory_store.update_skill_effects_for_run(
run_id,
success=normalized == "satisfied",
feedback_score=self._feedback_score_for_learning(normalized, validation),
notes=(comment or normalized).strip(),
)
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
skill_learning_service.rescore_skill_versions()
if already_recorded: if already_recorded:
generated_candidates = [] generated_candidates = []
elif normalized == "satisfied" and validation is not None and validation.accepted: elif normalized == "satisfied" and validation is not None and validation.accepted:
skill_learning_service = self._require_loaded(loaded, "skill_learning_service") generated_candidates = [
generated_candidates = [item.to_dict() for item in skill_learning_service.build_learning_candidates()] item.to_dict()
for item in skill_learning_service.build_learning_candidates_for_task(
updated.task_id,
trigger_run_id=run_id,
)
]
elif normalized == "abandon": elif normalized == "abandon":
memory_service = self._require_loaded(loaded, "memory_service") session_manager.append_message(
memory_service.get_store().add( session_id,
"memory", run_id=run_id,
( role="system",
f"Failure memory: task {task.task_id} in session {session_id} was abandoned. " event_type="task_failure_evidence_recorded",
f"Reason: {(comment or 'not specified').strip()}" event_payload={
), "task_id": updated.task_id,
"feedback_type": normalized,
"comment": comment or "",
"task_status": updated.status,
"durable_memory_written": False,
},
content=(comment or "Task abandoned; retained as run/session failure evidence."),
context_visible=False,
) )
return { return {
@ -302,20 +532,46 @@ class AgentService:
) -> AgentRunResult: ) -> AgentRunResult:
loaded = self.create_loop().boot() loaded = self.create_loop().boot()
task_service = self._require_loaded(loaded, "task_service") task_service = self._require_loaded(loaded, "task_service")
session_manager = self._require_loaded(loaded, "session_manager")
session_id = kwargs.get("session_id") or uuid4().hex session_id = kwargs.get("session_id") or uuid4().hex
kwargs = dict(kwargs) kwargs = dict(kwargs)
kwargs["session_id"] = session_id kwargs["session_id"] = session_id
provider_bundle = kwargs.get("provider_bundle") or self._make_provider_bundle_for_task(loaded, kwargs)
kwargs["provider_bundle"] = provider_bundle
router_provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider
router_runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime
active_task = task_service.get_latest_open_task(session_id) active_task = task_service.get_latest_open_task(session_id)
decision = self._main_agent_router.classify(message, active_task=active_task) decision = await self._main_agent_router.classify(
message,
active_task=active_task,
provider=router_provider,
model=getattr(router_runtime, "model", None),
recent_messages=session_manager.get_messages_as_conversation(session_id),
thinking_enabled=kwargs.get("thinking_enabled"),
)
if active_task is not None and decision.short_title and not active_task.metadata.get("short_title"):
active_task.metadata["short_title"] = decision.short_title
task_service.store.upsert_task(active_task)
if active_task is not None and decision.closes_task:
task_service.close_task(active_task.task_id, reason=decision.reason)
return await runner(message, **kwargs)
if active_task is not None and decision.abandons_task:
task_service.abandon_task(active_task.task_id, reason=decision.reason)
return await runner(message, **kwargs)
if not decision.is_task: if not decision.is_task:
kwargs["include_skill_assembly"] = False
kwargs["include_tools"] = False
return await runner(message, **kwargs) return await runner(message, **kwargs)
task = ( task = (
task_service.create_task( task_service.create_task(
session_id=session_id, session_id=session_id,
description=message, description=message,
metadata={"router_reason": decision.reason}, metadata={
"router_reason": decision.reason,
**({"short_title": decision.short_title} if decision.short_title else {}),
},
) )
if active_task is None or decision.starts_new_task if active_task is None or decision.starts_new_task
else active_task else active_task
@ -420,7 +676,7 @@ class AgentService:
"task_id": task.task_id, "task_id": task.task_id,
"task_mode": True, "task_mode": True,
"attempt_index": attempt_index, "attempt_index": attempt_index,
"learning_candidate_enabled": False, "allow_candidate_generation": False,
} }
) )
if attempt_index == 2 and latest_validation is not None: if attempt_index == 2 and latest_validation is not None:
@ -433,6 +689,14 @@ class AgentService:
) )
elif team_execution_context: elif team_execution_context:
attempt_kwargs["execution_context"] = self._join_context(base_execution_context, team_execution_context) attempt_kwargs["execution_context"] = self._join_context(base_execution_context, team_execution_context)
attempt_kwargs["skill_selection_context"] = self._build_skill_selection_context(
task=task,
user_message=message,
attempt_index=attempt_index,
latest_validation=latest_validation,
plan=plan,
team_summaries=team_summaries,
)
result = await runner(message, **attempt_kwargs) result = await runner(message, **attempt_kwargs)
last_result = result last_result = result
@ -519,7 +783,7 @@ class AgentService:
parent_session_id=parent_session_id, parent_session_id=parent_session_id,
parent_run_id=None, parent_run_id=None,
provider_bundle_factory=provider_bundle_factory, provider_bundle_factory=provider_bundle_factory,
learning_candidate_enabled=False, allow_candidate_generation=False,
) )
return result, None return result, None
except Exception as exc: except Exception as exc:
@ -542,6 +806,93 @@ class AgentService:
return [receipt.skill_name for receipt in record.activated_skills] return [receipt.skill_name for receipt in record.activated_skills]
return [] return []
@staticmethod
def _feedback_score_for_learning(feedback_type: str, validation: ValidationResult | None) -> float:
if feedback_type == "satisfied":
if validation is not None:
return max(0.0, min(1.0, float(validation.score)))
return 1.0
if feedback_type == "revise":
return 0.5
return 0.0
@staticmethod
def _build_skill_selection_context(
*,
task: TaskRecord,
user_message: str,
attempt_index: int,
latest_validation: ValidationResult | None = None,
plan: TaskExecutionPlan | None = None,
team_summaries: list[str] | None = None,
) -> str:
phase = f"attempt_{attempt_index}"
if latest_validation is not None:
phase = f"revision_attempt_{attempt_index}"
elif plan is not None and plan.is_team:
phase = f"team_synthesis_attempt_{attempt_index}"
sections = [
f"Task goal:\n{task.goal or task.description}",
f"Task description:\n{task.description}",
f"Current user request:\n{user_message}",
f"Execution phase:\n{phase}",
f"Task status:\n{task.status}",
]
if task.constraints:
sections.append("Known constraints:\n" + "\n".join(f"- {item}" for item in task.constraints))
if task.skill_names:
sections.append(
"Previously activated skills (reuse bias, not pinned):\n"
+ "\n".join(f"- {item}" for item in task.skill_names)
)
else:
sections.append("Previously activated skills:\nNone")
if latest_validation is not None:
validation_lines = [
f"accepted: {latest_validation.accepted}",
f"score: {latest_validation.score}",
]
if latest_validation.issues:
validation_lines.append("issues:\n" + "\n".join(f"- {item}" for item in latest_validation.issues))
if latest_validation.missing_requirements:
validation_lines.append(
"missing requirements:\n"
+ "\n".join(f"- {item}" for item in latest_validation.missing_requirements)
)
if latest_validation.recommended_revision_prompt:
validation_lines.append(
"recommended revision:\n"
+ latest_validation.recommended_revision_prompt
)
sections.append("Validation feedback:\n" + "\n".join(validation_lines))
if plan is not None:
plan_lines = [
f"mode: {plan.mode}",
f"reason: {plan.reason}",
]
if plan.final_synthesis_instruction:
plan_lines.append(f"final synthesis instruction: {plan.final_synthesis_instruction}")
if plan.graph is not None:
plan_lines.append(f"strategy: {plan.graph.strategy}")
plan_lines.append(
"nodes:\n"
+ "\n".join(
f"- {node.node_id}: {node.task}"
for node in plan.graph.nodes
)
)
sections.append("Execution plan:\n" + "\n".join(plan_lines))
if team_summaries:
sections.append("Team execution summaries:\n" + "\n\n".join(team_summaries)[:2400])
sections.append(
"Skill selection instruction:\n"
"Prefer reusing previously activated skills when they still match the Task. "
"Select new skills only if the current request, revision, or execution plan needs a different capability. "
"If no published skill matches, return [] and let the run continue without skills."
)
return "\n\n".join(section for section in sections if section.strip())
@staticmethod @staticmethod
def _run_excerpt(session_manager: Any, session_id: str, run_id: str) -> str: def _run_excerpt(session_manager: Any, session_id: str, run_id: str) -> str:
lines = [] lines = []
@ -611,8 +962,8 @@ class AgentService:
skill.name for skill in node.inherited_pinned_skill_contexts skill.name for skill in node.inherited_pinned_skill_contexts
] ]
payload["skill_query"] = node.agent.metadata.get("skill_query") payload["skill_query"] = node.agent.metadata.get("skill_query")
payload["generated_skill_draft_id"] = node.agent.metadata.get("generated_skill_draft_id") payload["ephemeral_guidance_id"] = node.agent.metadata.get("ephemeral_guidance_id")
payload["generated_skill_name"] = node.agent.metadata.get("generated_skill_name") payload["ephemeral_guidance_name"] = node.agent.metadata.get("ephemeral_guidance_name")
payload["ephemeral_used"] = bool(node.inherited_pinned_skill_contexts) payload["ephemeral_used"] = bool(node.inherited_pinned_skill_contexts)
payloads.append(payload) payloads.append(payload)
return payloads return payloads

View File

@ -0,0 +1,508 @@
"""Cron scheduling service for Beaver scheduled Tasks."""
from __future__ import annotations
import asyncio
import inspect
import json
import os
import re
import tempfile
import threading
import time
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from uuid import uuid4
from zoneinfo import ZoneInfo
from beaver.foundation.models import CronExecutionResult, CronJob, CronPayload, CronRunRecord, CronSchedule
try: # pragma: no cover - exercised through cron schedule tests when installed
from croniter import croniter
except ModuleNotFoundError: # pragma: no cover - defensive dependency guard
croniter = None # type: ignore[assignment]
CronCallback = Callable[..., Awaitable[CronExecutionResult | str | None]]
_DURATION_RE = re.compile(
r"^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$",
re.IGNORECASE,
)
_CRON_FIELD_RE = re.compile(r"^[\d\*\?,\-/LW#]+$", re.IGNORECASE)
_MAX_HISTORY = 20
class CronService:
"""Persistent single-timer scheduler.
Hermes' cron implementation stores jobs as JSON and ticks safely in the
background. Beaver keeps that shape, but the callback is required to route
agent work through Task mode so every scheduled trigger is visible as a
normal Task.
"""
def __init__(self, store_path: str | Path, *, on_job: CronCallback | None = None) -> None:
self.store_path = Path(store_path)
self.on_job = on_job
self._jobs: list[CronJob] | None = None
self._lock = threading.Lock()
self._running = False
self._timer_task: asyncio.Task[None] | None = None
async def start(self) -> None:
self._running = True
self._load_jobs()
self._recompute_next_runs()
self._save_jobs()
self._arm_timer()
def stop(self) -> None:
self._running = False
if self._timer_task is not None:
self._timer_task.cancel()
self._timer_task = None
def status(self) -> dict[str, Any]:
jobs = self.list_jobs(include_disabled=True)
return {
"enabled": self._running,
"jobs": len(jobs),
"next_wake_at_ms": self._next_wake_ms(),
}
def list_jobs(self, *, include_disabled: bool = False) -> list[CronJob]:
jobs = list(self._load_jobs())
if not include_disabled:
jobs = [job for job in jobs if job.enabled]
return sorted(jobs, key=lambda job: job.next_run_at_ms or 9_999_999_999_999)
def get_job(self, job_id: str) -> CronJob | None:
for job in self._load_jobs():
if job.id == job_id:
return job
return None
def add_job(
self,
*,
name: str,
message: str,
schedule: CronSchedule,
session_key: str | None = None,
payload_kind: str = "agent_turn",
mode: str = "notification",
requires_followup: bool = False,
deliver: bool = False,
channel: str | None = None,
to: str | None = None,
delete_after_run: bool = False,
) -> CronJob:
cleaned_name = name.strip() or message[:50].strip() or "scheduled task"
cleaned_message = message.strip()
if not cleaned_message:
raise ValueError("message is required")
validate_schedule(schedule)
now = _now_ms()
job = CronJob(
id=uuid4().hex[:12],
name=cleaned_name,
enabled=True,
schedule=schedule,
payload=CronPayload(
kind=payload_kind if payload_kind in {"agent_turn", "system_event"} else "agent_turn", # type: ignore[arg-type]
mode="task" if mode == "task" else "notification",
message=cleaned_message,
session_key=session_key,
requires_followup=requires_followup,
deliver=deliver,
channel=channel,
to=to,
),
next_run_at_ms=compute_next_run(schedule, now_ms=now),
created_at_ms=now,
updated_at_ms=now,
delete_after_run=delete_after_run,
)
with self._lock:
jobs = self._load_jobs_unlocked()
jobs.append(job)
self._jobs = jobs
self._save_jobs_unlocked()
self._arm_timer()
return job
def update_enabled(self, job_id: str, enabled: bool) -> CronJob | None:
with self._lock:
jobs = self._load_jobs_unlocked()
for job in jobs:
if job.id != job_id:
continue
job.enabled = bool(enabled)
job.updated_at_ms = _now_ms()
job.next_run_at_ms = compute_next_run(job.schedule) if job.enabled else None
self._save_jobs_unlocked()
self._arm_timer()
return job
return None
def remove_job(self, job_id: str) -> bool:
with self._lock:
jobs = self._load_jobs_unlocked()
next_jobs = [job for job in jobs if job.id != job_id]
if len(next_jobs) == len(jobs):
return False
self._jobs = next_jobs
self._save_jobs_unlocked()
self._arm_timer()
return True
async def run_job(self, job_id: str, *, force: bool = False) -> bool:
job = self.get_job(job_id)
if job is None:
return False
if not force and not job.enabled:
return False
await self._execute_job(job)
self._save_jobs()
self._arm_timer()
return True
def list_runs(self) -> list[tuple[CronJob, CronRunRecord]]:
runs: list[tuple[CronJob, CronRunRecord]] = []
for job in self.list_jobs(include_disabled=True):
runs.extend((job, run) for run in job.history)
return sorted(runs, key=lambda item: item[1].started_at_ms, reverse=True)
def get_run(self, scheduled_run_id: str) -> tuple[CronJob, CronRunRecord] | None:
for job, run in self.list_runs():
if run.scheduled_run_id == scheduled_run_id:
return job, run
return None
def mark_run_engaged(
self,
scheduled_run_id: str,
*,
task_id: str,
intent: str,
) -> tuple[CronJob, CronRunRecord] | None:
with self._lock:
jobs = self._load_jobs_unlocked()
for job in jobs:
for run in job.history:
if run.scheduled_run_id != scheduled_run_id:
continue
run.engaged = True
run.engaged_at_ms = _now_ms()
run.engage_intent = intent
run.task_id = task_id
job.updated_at_ms = _now_ms()
self._save_jobs_unlocked()
return job, run
return None
def update_job_message(self, job_id: str, message: str) -> CronJob | None:
cleaned = message.strip()
if not cleaned:
raise ValueError("message is required")
with self._lock:
jobs = self._load_jobs_unlocked()
for job in jobs:
if job.id != job_id:
continue
job.payload.message = cleaned
job.updated_at_ms = _now_ms()
self._save_jobs_unlocked()
return job
return None
async def _on_timer(self) -> None:
now = _now_ms()
due_jobs = [
job
for job in self.list_jobs(include_disabled=False)
if job.next_run_at_ms is not None and job.next_run_at_ms <= now
]
for job in due_jobs:
await self._execute_job(job)
self._save_jobs()
self._arm_timer()
async def _execute_job(self, job: CronJob) -> None:
start_ms = _now_ms()
run_record = CronRunRecord(started_at_ms=start_ms, mode=job.payload.mode)
try:
result = CronExecutionResult(mode=job.payload.mode)
if self.on_job is not None:
raw = await self._call_on_job(job, run_record)
result = raw if isinstance(raw, CronExecutionResult) else CronExecutionResult(response=raw, mode=job.payload.mode)
run_record.status = "ok"
run_record.mode = result.mode
run_record.output = result.response
run_record.notification_session_id = result.notification_session_id
run_record.task_id = result.task_id
run_record.run_id = result.run_id
job.last_status = "ok"
job.last_error = None
except Exception as exc:
run_record.status = "error"
run_record.error = str(exc)
job.last_status = "error"
job.last_error = str(exc)
finally:
finish_ms = _now_ms()
run_record.finished_at_ms = finish_ms
job.last_run_at_ms = start_ms
job.updated_at_ms = finish_ms
job.history.append(run_record)
job.history = job.history[-_MAX_HISTORY:]
if job.schedule.kind == "at":
if job.delete_after_run:
with self._lock:
self._jobs = [item for item in self._load_jobs_unlocked() if item.id != job.id]
return
job.enabled = False
job.next_run_at_ms = None
return
job.next_run_at_ms = compute_next_run(job.schedule, now_ms=_now_ms(), last_run_at_ms=job.last_run_at_ms)
async def _call_on_job(self, job: CronJob, run_record: CronRunRecord) -> CronExecutionResult | str | None:
if self.on_job is None:
return None
try:
params = inspect.signature(self.on_job).parameters
except (TypeError, ValueError):
params = {}
if len(params) >= 2:
return await self.on_job(job, run_record)
return await self.on_job(job)
def _recompute_next_runs(self) -> None:
now = _now_ms()
changed = False
for job in self._load_jobs():
if not job.enabled:
continue
if job.next_run_at_ms is None or job.next_run_at_ms < now - 7_200_000:
job.next_run_at_ms = compute_next_run(job.schedule, now_ms=now, last_run_at_ms=job.last_run_at_ms)
changed = True
if changed:
self._save_jobs()
def _next_wake_ms(self) -> int | None:
candidates = [
job.next_run_at_ms
for job in self._load_jobs()
if job.enabled and job.next_run_at_ms is not None
]
return min(candidates) if candidates else None
def _arm_timer(self) -> None:
if self._timer_task is not None:
self._timer_task.cancel()
self._timer_task = None
if not self._running:
return
next_wake = self._next_wake_ms()
if next_wake is None:
return
async def tick() -> None:
await asyncio.sleep(max(0, next_wake - _now_ms()) / 1000)
if self._running:
await self._on_timer()
self._timer_task = asyncio.create_task(tick())
def _load_jobs(self) -> list[CronJob]:
with self._lock:
return list(self._load_jobs_unlocked())
def _load_jobs_unlocked(self) -> list[CronJob]:
if self._jobs is not None:
return self._jobs
self.store_path.parent.mkdir(parents=True, exist_ok=True)
_secure_dir(self.store_path.parent)
if not self.store_path.exists():
self._jobs = []
return self._jobs
payload = json.loads(self.store_path.read_text(encoding="utf-8"))
raw_jobs = payload.get("jobs") if isinstance(payload, dict) else []
self._jobs = [CronJob.from_dict(item) for item in raw_jobs or [] if isinstance(item, dict)]
return self._jobs
def _save_jobs(self) -> None:
with self._lock:
self._save_jobs_unlocked()
def _save_jobs_unlocked(self) -> None:
if self._jobs is None:
return
self.store_path.parent.mkdir(parents=True, exist_ok=True)
_secure_dir(self.store_path.parent)
fd, tmp_name = tempfile.mkstemp(prefix=".jobs-", suffix=".json", dir=str(self.store_path.parent))
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(
{"version": 1, "updated_at_ms": _now_ms(), "jobs": [job.to_dict() for job in self._jobs]},
handle,
ensure_ascii=False,
indent=2,
sort_keys=True,
)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, self.store_path)
_secure_file(self.store_path)
finally:
if tmp_path.exists():
tmp_path.unlink()
def parse_duration(value: str) -> int:
match = _DURATION_RE.match(value.strip())
if not match:
raise ValueError("duration must look like 30s, 15m, 2h, or 1d")
amount = int(match.group(1))
unit = match.group(2).lower()[0]
multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400}
return amount * multipliers[unit]
def parse_schedule(value: str) -> CronSchedule:
raw = value.strip()
lowered = raw.lower()
if lowered.startswith("every "):
seconds = parse_duration(raw[6:].strip())
return CronSchedule(kind="every", every_ms=seconds * 1000, display=f"every {seconds}s")
parts = raw.split()
if len(parts) in {5, 6} and all(_CRON_FIELD_RE.match(item) for item in parts[:5]):
schedule = CronSchedule(kind="cron", expr=raw, display=raw)
validate_schedule(schedule)
return schedule
if "T" in raw or re.match(r"^\d{4}-\d{2}-\d{2}", raw):
dt = _parse_datetime(raw)
return CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000), display=f"once at {dt:%Y-%m-%d %H:%M}")
seconds = parse_duration(raw)
at_ms = _now_ms() + seconds * 1000
return CronSchedule(kind="at", at_ms=at_ms, display=f"once in {raw}")
def schedule_from_api(payload: dict[str, Any]) -> CronSchedule:
if payload.get("schedule"):
return parse_schedule(str(payload["schedule"]))
if payload.get("every_seconds") not in (None, ""):
seconds = int(payload["every_seconds"])
if seconds <= 0:
raise ValueError("every_seconds must be greater than 0")
return CronSchedule(kind="every", every_ms=seconds * 1000, display=f"every {seconds}s")
if payload.get("cron_expr"):
expr = str(payload["cron_expr"]).strip()
schedule = CronSchedule(kind="cron", expr=expr, tz=_optional_str(payload.get("tz")), display=expr)
validate_schedule(schedule)
return schedule
if payload.get("at_iso"):
dt = _parse_datetime(str(payload["at_iso"]))
return CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000), display=f"once at {dt:%Y-%m-%d %H:%M}")
raise ValueError("one of schedule, every_seconds, cron_expr, or at_iso is required")
def validate_schedule(schedule: CronSchedule) -> None:
if schedule.kind == "every":
if not schedule.every_ms or schedule.every_ms <= 0:
raise ValueError("every schedule requires a positive every_ms")
return
if schedule.kind == "at":
if not schedule.at_ms:
raise ValueError("at schedule requires at_ms")
return
if schedule.kind == "cron":
if not schedule.expr:
raise ValueError("cron schedule requires expr")
if schedule.tz:
try:
ZoneInfo(schedule.tz)
except Exception as exc:
raise ValueError(f"unknown timezone: {schedule.tz}") from exc
if croniter is None:
raise ValueError("cron schedules require the croniter package")
try:
croniter(schedule.expr, _aware_now(schedule.tz))
except Exception as exc:
raise ValueError(f"invalid cron expression: {schedule.expr}") from exc
return
raise ValueError(f"unknown schedule kind: {schedule.kind}")
def compute_next_run(
schedule: CronSchedule,
*,
now_ms: int | None = None,
last_run_at_ms: int | None = None,
) -> int | None:
now_ms = now_ms or _now_ms()
if schedule.kind == "at":
return schedule.at_ms if schedule.at_ms and schedule.at_ms > now_ms else None
if schedule.kind == "every":
if not schedule.every_ms or schedule.every_ms <= 0:
return None
base = last_run_at_ms or now_ms
next_run = base + schedule.every_ms
while next_run <= now_ms:
next_run += schedule.every_ms
return next_run
if schedule.kind == "cron" and schedule.expr and croniter is not None:
base = datetime.fromtimestamp((last_run_at_ms or now_ms) / 1000, tz=_timezone(schedule.tz))
return int(croniter(schedule.expr, base).get_next(datetime).timestamp() * 1000)
return None
def _parse_datetime(value: str) -> datetime:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
if dt.tzinfo is None:
return dt.astimezone()
return dt
def _aware_now(tz_name: str | None = None) -> datetime:
return datetime.now(tz=_timezone(tz_name))
def _timezone(tz_name: str | None = None) -> Any:
if tz_name:
return ZoneInfo(tz_name)
return datetime.now().astimezone().tzinfo
def _now_ms() -> int:
return int(time.time() * 1000)
def _secure_dir(path: Path) -> None:
try:
os.chmod(path, 0o700)
except OSError:
pass
def _secure_file(path: Path) -> None:
try:
os.chmod(path, 0o600)
except OSError:
pass
def _optional_str(value: Any) -> str | None:
if value in (None, ""):
return None
return str(value).strip() or None

View File

@ -0,0 +1,262 @@
"""Import no-credential Hermes Agent skills into Beaver."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
import json
import re
import shutil
from pathlib import Path
from typing import Any
from beaver.skills.catalog.utils import parse_frontmatter, strip_frontmatter
from beaver.skills.specs import SkillSpec, SkillSpecStore, SkillVersion
from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content
HERMES_REPO_URL = "https://github.com/NousResearch/hermes-agent"
_CREDENTIAL_PATTERNS = [
re.compile(pattern, re.IGNORECASE)
for pattern in [
r"\bapi[_ -]?key\b",
r"\boauth\b",
r"\bbearer\s+token\b",
r"\baccess[_ -]?token\b",
r"\bclient[_ -]?secret\b",
r"\bsecret\b",
r"\bcredential",
r"\bspotify\b",
r"\bdiscord\b",
r"\bfeishu\b",
r"\bhome\s*assistant\b",
r"\bfal\b",
r"\bopenrouter\b",
r"\bwandb\b",
]
]
@dataclass(slots=True)
class HermesMigrationService:
store: SkillSpecStore
manifest_path: Path | None = None
included_tools: list[dict[str, Any]] = field(default_factory=list)
skipped_tools: list[dict[str, Any]] = field(default_factory=list)
def migrate(
self,
repo_path: str | Path,
*,
include_optional: bool = True,
dry_run: bool = False,
) -> dict[str, Any]:
repo = Path(repo_path)
if not repo.exists():
raise ValueError(f"Hermes repository not found: {repo}")
skill_files = self._discover_skill_files(repo, include_optional=include_optional)
included: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
for skill_file in skill_files:
result = self._migrate_skill(repo, skill_file, dry_run=dry_run)
if result["status"] in {"included", "unchanged"}:
included.append(result)
else:
skipped.append(result)
manifest = {
"source": "hermes-agent",
"repo_url": HERMES_REPO_URL,
"repo_path": str(repo),
"generated_at": datetime.now(timezone.utc).isoformat(),
"dry_run": dry_run,
"included": included,
"skipped": skipped,
"tools": self._tool_manifest(),
}
path = self.manifest_path or (self.store.workspace / "hermes_migration_manifest.json")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return manifest
def _discover_skill_files(self, repo: Path, *, include_optional: bool) -> list[Path]:
roots = [repo / "skills"]
if include_optional:
roots.append(repo / "optional-skills")
files: list[Path] = []
for root in roots:
if root.exists():
files.extend(sorted(root.glob("**/SKILL.md")))
return files
def _migrate_skill(self, repo: Path, skill_file: Path, *, dry_run: bool) -> dict[str, Any]:
relative = skill_file.relative_to(repo)
content = skill_file.read_text(encoding="utf-8")
frontmatter, body = parse_frontmatter(content)
skill_name = _safe_skill_name(str(frontmatter.get("name") or skill_file.parent.name))
if not skill_name:
return _skip(relative, "unsafe_skill_name")
credential_reason = _credential_reason(content)
if credential_reason:
return _skip(relative, credential_reason, skill_name=skill_name)
normalized = normalize_frontmatter(
{
**frontmatter,
"name": skill_name,
"description": frontmatter.get("description") or skill_name,
}
)
rendered = _render_skill_content(normalized, body)
content_hash = canonical_hash(rendered)
existing = self.store.read_published_skill(skill_name)
existing_spec = self.store.get_skill_spec(skill_name)
if existing is not None and existing.version.content_hash == content_hash:
return {
"status": "unchanged",
"skill_name": skill_name,
"version": existing.version.version,
"path": str(relative),
"reason": "same_content_hash",
}
next_version = self._next_version(skill_name)
if dry_run:
return {
"status": "included",
"skill_name": skill_name,
"version": next_version,
"path": str(relative),
"dry_run": True,
}
now = datetime.now(timezone.utc).isoformat()
skill_version = SkillVersion(
skill_name=skill_name,
version=next_version,
content_hash=content_hash,
summary_hash=canonical_hash(strip_frontmatter(rendered).strip()),
created_at=now,
created_by="hermes_migration",
change_reason=f"Import Hermes skill {relative}",
parent_version=existing.version.version if existing is not None else None,
review_state="published",
frontmatter=normalized,
summary=summarize_skill_content(body),
tool_hints=self.store._extract_tool_hints(normalized),
provenance={
"source": "hermes-agent",
"repo_url": HERMES_REPO_URL,
"repo_path": str(repo),
"relative_path": str(relative),
},
)
self.store.write_skill_version(skill_version, rendered)
self._copy_supporting_files(skill_file.parent, skill_name, next_version)
spec = existing_spec or SkillSpec(
name=skill_name,
display_name=skill_name,
description=str(normalized.get("description") or skill_name),
created_at=now,
updated_at=now,
current_version=next_version,
status="active",
tags=[],
owners=["hermes-agent"],
source_kind="hermes-agent",
lineage=[],
)
spec.current_version = next_version
spec.updated_at = now
spec.status = "active"
spec.source_kind = "hermes-agent"
if "hermes-agent" not in spec.owners:
spec.owners.append("hermes-agent")
self.store.write_skill_spec(spec)
self.store.set_current_version(skill_name, next_version)
published = self.store.read_index("published")
if skill_name not in published:
published.append(skill_name)
self.store.update_index("published", published)
return {
"status": "included",
"skill_name": skill_name,
"version": next_version,
"path": str(relative),
}
def _copy_supporting_files(self, source_dir: Path, skill_name: str, version: str) -> None:
target_root = self.store.root / skill_name / "versions" / version
for source in sorted(source_dir.rglob("*")):
if not source.is_file() or source.name == "SKILL.md" or source.is_symlink():
continue
relative = source.relative_to(source_dir)
if any(part in {"", ".", ".."} for part in relative.parts):
continue
target = target_root / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, target)
def _next_version(self, skill_name: str) -> str:
versions = [item for item in self.store.list_versions(skill_name) if item.startswith("v")]
numbers = [int(item[1:]) for item in versions if item[1:].isdigit()]
return f"v{(max(numbers) if numbers else 0) + 1:04d}"
def _tool_manifest(self) -> dict[str, list[dict[str, Any]]]:
included = self.included_tools or [
{"name": "todo", "reason": "implemented_builtin_no_api"},
{"name": "clarify", "reason": "implemented_builtin_no_api"},
{"name": "delegate", "reason": "implemented_builtin_no_api"},
{"name": "spawn", "reason": "implemented_builtin_no_api"},
{"name": "skills_list", "reason": "implemented_builtin_no_api"},
{"name": "skill_manage", "reason": "implemented_builtin_no_api"},
{"name": "terminal", "reason": "implemented_builtin_no_api"},
{"name": "process", "reason": "implemented_builtin_no_api"},
{"name": "patch", "reason": "implemented_builtin_no_api"},
{"name": "write_file", "reason": "implemented_builtin_no_api"},
{"name": "web_fetch", "reason": "implemented_builtin_no_api"},
{"name": "web_search", "reason": "implemented_builtin_no_api"},
{"name": "execute_code", "reason": "implemented_builtin_no_api"},
]
skipped = self.skipped_tools or [
{"name": "spotify", "reason": "requires_oauth"},
{"name": "discord", "reason": "requires_external_token"},
{"name": "feishu", "reason": "requires_external_token"},
{"name": "home_assistant", "reason": "requires_external_service_credentials"},
{"name": "fal_image_generation", "reason": "requires_api_key"},
{"name": "remote_web_providers", "reason": "requires_api_key_or_oauth"},
]
return {"included": included, "skipped": skipped}
def _credential_reason(content: str) -> str | None:
for pattern in _CREDENTIAL_PATTERNS:
if pattern.search(content):
return "requires_external_credentials"
return None
def _safe_skill_name(value: str) -> str:
cleaned = value.strip().replace(" ", "-")
if not cleaned or cleaned in {".", ".."} or "/" in cleaned or "\\" in cleaned:
return ""
if not re.fullmatch(r"[A-Za-z0-9_.-]+", cleaned):
return ""
return cleaned
def _skip(relative: Path, reason: str, *, skill_name: str | None = None) -> dict[str, Any]:
result = {"status": "skipped", "path": str(relative), "reason": reason}
if skill_name:
result["skill_name"] = skill_name
return result
def _render_skill_content(frontmatter: dict[str, Any], body: str) -> str:
lines = ["---"]
for key, value in normalize_frontmatter(frontmatter).items():
if isinstance(value, list):
lines.append(f"{key}:")
for item in value:
lines.append(f" - {item}")
else:
lines.append(f"{key}: {value}")
lines.extend(["---", "", body.strip()])
return "\n".join(lines).rstrip() + "\n"

View File

@ -16,6 +16,7 @@ class SessionProcessProjector:
run_records = {record.run_id: record for record in self.run_memory_store.list_runs()} run_records = {record.run_id: record for record in self.run_memory_store.list_runs()}
runs: dict[str, dict[str, Any]] = {} runs: dict[str, dict[str, Any]] = {}
events: list[dict[str, Any]] = [] events: list[dict[str, Any]] = []
artifacts: list[dict[str, Any]] = []
def add_event( def add_event(
*, *,
@ -84,7 +85,7 @@ class SessionProcessProjector:
"node_ids": node_ids, "node_ids": node_ids,
"skill_queries": payload.get("skill_queries") or [], "skill_queries": payload.get("skill_queries") or [],
"selected_skill_names": payload.get("selected_skill_names") or [], "selected_skill_names": payload.get("selected_skill_names") or [],
"generated_skill_draft_ids": payload.get("generated_skill_draft_ids") or [], "ephemeral_guidance_ids": payload.get("ephemeral_guidance_ids") or [],
"skill_resolution_report": payload.get("skill_resolution_report") or [], "skill_resolution_report": payload.get("skill_resolution_report") or [],
"fallback_error": payload.get("fallback_error"), "fallback_error": payload.get("fallback_error"),
} }
@ -151,13 +152,42 @@ class SessionProcessProjector:
"skill_query": item.get("skill_query"), "skill_query": item.get("skill_query"),
"selected_skill_names": item.get("selected_skill_names") or [], "selected_skill_names": item.get("selected_skill_names") or [],
"ephemeral_skill_names": item.get("ephemeral_skill_names") or [], "ephemeral_skill_names": item.get("ephemeral_skill_names") or [],
"generated_skill_draft_id": item.get("generated_skill_draft_id"), "ephemeral_guidance_id": item.get("ephemeral_guidance_id"),
"generated_skill_name": item.get("generated_skill_name"), "ephemeral_guidance_name": item.get("ephemeral_guidance_name"),
"ephemeral_used": bool(item.get("ephemeral_used")), "ephemeral_used": bool(item.get("ephemeral_used")),
"finish_reason": item.get("finish_reason"), "finish_reason": item.get("finish_reason"),
"error": item.get("error"), "error": item.get("error"),
}, },
} }
guidance_id = item.get("ephemeral_guidance_id")
if guidance_id:
guidance_name = str(item.get("ephemeral_guidance_name") or guidance_id)
artifacts.append(
{
"artifact_id": f"{node_run_id}:ephemeral-guidance:{guidance_id}",
"run_id": str(node_run_id),
"actor_type": "agent",
"actor_id": str(item.get("node_id") or "sub-agent"),
"actor_name": str(item.get("node_id") or "Sub-agent"),
"title": f"Ephemeral guidance: {guidance_name}",
"artifact_type": "markdown",
"content": (
f"# Ephemeral guidance\n\n"
f"- Guidance: {guidance_name}\n"
f"- Guidance ID: {guidance_id}\n"
f"- Scope: current delegated sub-agent run only"
),
"metadata": {
"task_id": task_id,
"attempt_index": attempt_index,
"node_id": item.get("node_id"),
"ephemeral_guidance_id": guidance_id,
"ephemeral_guidance_name": guidance_name,
"ephemeral_skill_names": item.get("ephemeral_skill_names") or [],
},
"created_at": created_at,
}
)
add_event( add_event(
event_id=f"{_event_id(record, 'node')}:{item.get('node_id')}", event_id=f"{_event_id(record, 'node')}:{item.get('node_id')}",
run_id=str(node_run_id), run_id=str(node_run_id),
@ -231,7 +261,7 @@ class SessionProcessProjector:
return { return {
"runs": sorted(runs.values(), key=lambda item: item.get("started_at") or ""), "runs": sorted(runs.values(), key=lambda item: item.get("started_at") or ""),
"events": sorted(events, key=lambda item: item.get("created_at") or ""), "events": sorted(events, key=lambda item: item.get("created_at") or ""),
"artifacts": [], "artifacts": sorted(artifacts, key=lambda item: item.get("created_at") or ""),
"agents": [], "agents": [],
} }

View File

@ -0,0 +1,208 @@
"""Import legacy and staged skills into the Beaver SkillSpecStore."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import io
import json
import re
import zipfile
from pathlib import Path
from typing import Any
from beaver.skills.catalog.utils import parse_frontmatter, strip_frontmatter
from beaver.skills.specs import SkillSpec, SkillSpecStore, SkillVersion
from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content
@dataclass(slots=True)
class SkillMigrationService:
store: SkillSpecStore
repo_root: Path | None = None
def migrate_all(self) -> dict[str, Any]:
included: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
for path in self._backend_old_skills():
self._migrate_skill_file(path, "backend-old", included, skipped)
for path in self._staged_skills():
self._migrate_skill_file(path, "stevenli-staged", included, skipped)
for path in self._skill_zips():
self._migrate_zip(path, included, skipped)
manifest = {
"generated_at": _now(),
"workspace": str(self.store.workspace),
"included": included,
"skipped": skipped,
}
manifest_path = self.store.workspace / "skill_migration_manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return manifest
def _backend_old_skills(self) -> list[Path]:
root = self._repo_root() / "app-instance" / "backend-old" / "nanobot" / "skills"
if not root.exists():
return []
return sorted(root.glob("*/SKILL.md"))
def _staged_skills(self) -> list[Path]:
root = self.store.workspace / "state" / "skill-reviews"
if not root.exists():
return []
return sorted(root.glob("*/staged/*/SKILL.md"))
def _skill_zips(self) -> list[Path]:
root = self.store.workspace / "skills"
if not root.exists():
return []
return sorted(root.glob("*.zip"))
def _repo_root(self) -> Path:
if self.repo_root is not None:
return self.repo_root
return Path(__file__).resolve().parents[4]
def _migrate_skill_file(self, path: Path, source: str, included: list[dict[str, Any]], skipped: list[dict[str, Any]]) -> None:
try:
content = path.read_text(encoding="utf-8")
result = self._publish_content(content, source=source, source_path=str(path))
included.append(result)
except Exception as exc:
skipped.append({"source": source, "source_path": str(path), "reason": str(exc)})
def _migrate_zip(self, path: Path, included: list[dict[str, Any]], skipped: list[dict[str, Any]]) -> None:
try:
with zipfile.ZipFile(io.BytesIO(path.read_bytes()), "r") as archive:
entries = [info for info in archive.infolist() if not info.is_dir()]
skill_entry = _find_skill_entry(entries)
content = archive.read(skill_entry).decode("utf-8", errors="replace")
result = self._publish_content(content, source="stevenli-zip", source_path=str(path))
skill_name = result["skill_name"]
version = result["version"]
top = Path(skill_entry).parts[0] if len(Path(skill_entry).parts) == 2 else ""
for info in entries:
raw = info.filename.replace("\\", "/")
if raw == skill_entry or raw.startswith("/") or "__MACOSX" in Path(raw).parts:
continue
parts = Path(raw).parts
rel_parts = parts[1:] if top and parts and parts[0] == top else parts
if not rel_parts or any(part in {"", ".", ".."} for part in rel_parts):
continue
target = self.store.root / skill_name / "versions" / version / "/".join(rel_parts)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(archive.read(info))
included.append(result)
except Exception as exc:
skipped.append({"source": "stevenli-zip", "source_path": str(path), "reason": str(exc)})
def _publish_content(self, content: str, *, source: str, source_path: str) -> dict[str, Any]:
frontmatter, body = parse_frontmatter(content)
skill_name = _safe_name(str(frontmatter.get("name") or Path(source_path).parent.name))
if not skill_name:
raise ValueError("unsafe or missing skill name")
normalized = normalize_frontmatter(
{
**frontmatter,
"name": skill_name,
"description": frontmatter.get("description") or skill_name,
}
)
rendered = _render_skill_content(normalized, body)
content_hash = canonical_hash(rendered)
existing = self.store.read_published_skill(skill_name)
if existing is not None and existing.version.content_hash == content_hash:
return {
"status": "unchanged",
"skill_name": skill_name,
"version": existing.version.version,
"source": source,
"source_path": source_path,
}
version_id = self._next_version(skill_name)
now = _now()
skill_version = SkillVersion(
skill_name=skill_name,
version=version_id,
content_hash=content_hash,
summary_hash=canonical_hash(strip_frontmatter(rendered).strip()),
created_at=now,
created_by="migration",
change_reason=f"Import skill from {source}",
parent_version=existing.version.version if existing is not None else None,
review_state="published",
frontmatter=normalized,
summary=summarize_skill_content(body),
tool_hints=self.store._extract_tool_hints(normalized),
provenance={"source": source, "source_path": source_path, "imported_at": now},
)
self.store.write_skill_version(skill_version, rendered)
spec = self.store.get_skill_spec(skill_name) or SkillSpec(
name=skill_name,
display_name=skill_name,
description=str(normalized.get("description") or skill_name),
created_at=now,
updated_at=now,
current_version=version_id,
status="active",
tags=[],
owners=["migration"],
source_kind=source,
lineage=[],
)
spec.current_version = version_id
spec.updated_at = now
spec.status = "active"
spec.source_kind = source
if "migration" not in spec.owners:
spec.owners.append("migration")
self.store.write_skill_spec(spec)
self.store.set_current_version(skill_name, version_id)
published = self.store.read_index("published")
if skill_name not in published:
published.append(skill_name)
self.store.update_index("published", published)
return {"status": "included", "skill_name": skill_name, "version": version_id, "source": source, "source_path": source_path}
def _next_version(self, skill_name: str) -> str:
versions = [item for item in self.store.list_versions(skill_name) if item.startswith("v")]
numbers = [int(item[1:]) for item in versions if item[1:].isdigit()]
return f"v{(max(numbers) if numbers else 0) + 1:04d}"
def _find_skill_entry(entries: list[zipfile.ZipInfo]) -> str:
candidates = []
for info in entries:
raw = info.filename.replace("\\", "/")
parts = Path(raw).parts
if raw.startswith("/") or any(part in {"", ".", ".."} for part in parts):
raise ValueError(f"unsafe archive entry: {info.filename}")
if parts and parts[-1] == "SKILL.md" and len(parts) in (1, 2):
candidates.append(raw)
if not candidates:
raise ValueError("zip has no root SKILL.md")
return candidates[0]
def _safe_name(value: str) -> str:
cleaned = value.strip().replace(" ", "-")
if not cleaned or cleaned in {".", ".."} or "/" in cleaned or "\\" in cleaned:
return ""
return cleaned if re.fullmatch(r"[A-Za-z0-9_.-]+", cleaned) else ""
def _render_skill_content(frontmatter: dict[str, Any], body: str) -> str:
lines = ["---"]
for key, value in normalize_frontmatter(frontmatter).items():
if isinstance(value, list):
lines.append(f"{key}:")
for item in value:
lines.append(f" - {item}")
else:
lines.append(f"{key}: {value}")
lines.extend(["---", "", body.strip()])
return "\n".join(lines).rstrip() + "\n"
def _now() -> str:
return datetime.now(timezone.utc).isoformat()

View File

@ -0,0 +1,248 @@
"""SkillHub marketplace client and installer."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import posixpath
from typing import Any
import httpx
from beaver.skills.catalog.utils import parse_frontmatter, strip_frontmatter
from beaver.skills.specs import SkillSpec, SkillSpecStore, SkillVersion
from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content
SKILLHUB_BASE_URL = "https://skillhub.bwgdi.com"
SKILLHUB_API_BASE = f"{SKILLHUB_BASE_URL}/api/web"
@dataclass(slots=True)
class SkillHubService:
store: SkillSpecStore
timeout_seconds: int = 30
async def search(
self,
*,
q: str = "",
sort: str = "relevance",
page: int = 0,
size: int = 12,
namespace: str | None = None,
) -> dict[str, Any]:
params = {
"q": q,
"sort": sort,
"page": str(max(0, page)),
"size": str(max(1, min(size, 50))),
}
if namespace:
params["namespace"] = namespace.removeprefix("@")
data = await self._get_json("/skills", params=params)
payload = _unwrap(data)
if not isinstance(payload, dict):
payload = {}
items = [self._with_install_state(item) for item in list(payload.get("items") or [])]
return {
"items": items,
"total": int(payload.get("total") or len(items)),
"page": int(payload.get("page") or page),
"size": int(payload.get("size") or size),
}
async def detail(self, namespace: str, slug: str) -> dict[str, Any]:
data = await self._get_json(f"/skills/{namespace.removeprefix('@')}/{slug}")
payload = _unwrap(data)
item = self._with_install_state(payload if isinstance(payload, dict) else {})
return item
async def version(self, namespace: str, slug: str, version: str) -> dict[str, Any]:
namespace = namespace.removeprefix("@")
detail = _unwrap(await self._get_json(f"/skills/{namespace}/{slug}/versions/{version}"))
files = _unwrap(await self._get_json(f"/skills/{namespace}/{slug}/versions/{version}/files"))
if not isinstance(detail, dict):
detail = {}
if not isinstance(files, list):
files = []
return {"detail": detail, "files": files}
async def install(self, namespace: str, slug: str, version: str | None = None) -> dict[str, Any]:
namespace = namespace.removeprefix("@")
skill = await self.detail(namespace, slug)
selected_version = version or _published_version(skill)
if not selected_version:
raise ValueError("SkillHub skill has no published version")
version_payload = await self.version(namespace, slug, selected_version)
files = list(version_payload.get("files") or [])
contents: dict[str, str] = {}
for item in files:
file_path = _safe_posix_path(str(item.get("filePath") or item.get("path") or ""))
contents[file_path] = await self._get_text(
f"/skills/{namespace}/{slug}/versions/{selected_version}/file",
params={"path": file_path},
)
skill_content = contents.get("SKILL.md")
if not skill_content:
raise ValueError("SkillHub version does not contain SKILL.md")
frontmatter, body = parse_frontmatter(skill_content)
skill_name = str(frontmatter.get("name") or skill.get("slug") or slug).strip()
if not skill_name or "/" in skill_name or "\\" in skill_name or skill_name in {".", ".."}:
raise ValueError(f"Unsafe skill name from SkillHub: {skill_name}")
normalized_frontmatter = normalize_frontmatter(
{
**frontmatter,
"name": skill_name,
"description": frontmatter.get("description") or skill.get("summary") or skill_name,
}
)
rendered = _render_skill_content(normalized_frontmatter, body)
content_hash = canonical_hash(rendered)
existing = self.store.read_published_skill(skill_name)
existing_spec = self.store.get_skill_spec(skill_name)
if existing is not None and existing.version.content_hash == content_hash:
return {
"ok": True,
"skill_name": skill_name,
"version": existing.version.version,
"source": "skillhub",
"namespace": namespace,
"slug": slug,
"installed_path": str(self.store.root / skill_name),
"already_installed": True,
}
next_version = self._next_version(skill_name)
now = datetime.now(timezone.utc).isoformat()
skill_version = SkillVersion(
skill_name=skill_name,
version=next_version,
content_hash=content_hash,
summary_hash=canonical_hash(strip_frontmatter(rendered).strip()),
created_at=now,
created_by="skillhub",
change_reason=f"Install SkillHub {namespace}/{slug}@{selected_version}",
parent_version=existing.version.version if existing is not None else None,
review_state="published",
frontmatter=normalized_frontmatter,
summary=summarize_skill_content(body),
tool_hints=self.store._extract_tool_hints(normalized_frontmatter),
provenance={
"source": "skillhub",
"namespace": namespace,
"slug": slug,
"skillhub_version": selected_version,
"source_url": f"{SKILLHUB_BASE_URL}/space/{namespace}/{slug}",
},
)
self.store.write_skill_version(skill_version, rendered)
for file_path, content in contents.items():
if file_path == "SKILL.md":
continue
target = self.store.root / skill_name / "versions" / next_version / file_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
spec = existing_spec or SkillSpec(
name=skill_name,
display_name=str(skill.get("displayName") or skill_name),
description=str(normalized_frontmatter.get("description") or skill_name),
created_at=now,
updated_at=now,
current_version=next_version,
status="active",
tags=[],
owners=["skillhub"],
source_kind="skillhub",
lineage=[],
)
spec.current_version = next_version
spec.updated_at = now
spec.status = "active"
spec.source_kind = "skillhub"
if "skillhub" not in spec.owners:
spec.owners.append("skillhub")
self.store.write_skill_spec(spec)
self.store.set_current_version(skill_name, next_version)
published = self.store.read_index("published")
if skill_name not in published:
published.append(skill_name)
self.store.update_index("published", published)
return {
"ok": True,
"skill_name": skill_name,
"version": next_version,
"source": "skillhub",
"namespace": namespace,
"slug": slug,
"installed_path": str(self.store.root / skill_name),
"already_installed": False,
}
async def _get_json(self, path: str, *, params: dict[str, str] | None = None) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True, trust_env=False) as client:
response = await client.get(f"{SKILLHUB_API_BASE}{path}", params=params)
response.raise_for_status()
data = response.json()
return data if isinstance(data, dict) else {}
async def _get_text(self, path: str, *, params: dict[str, str]) -> str:
async with httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True, trust_env=False) as client:
response = await client.get(f"{SKILLHUB_API_BASE}{path}", params=params)
response.raise_for_status()
return response.text
def _with_install_state(self, item: dict[str, Any]) -> dict[str, Any]:
result = dict(item)
slug = str(result.get("slug") or result.get("displayName") or "")
namespace = str(result.get("namespace") or "").removeprefix("@")
installed = self.store.get_skill_spec(slug) or self._find_installed_skillhub_spec(namespace, slug)
result["installed"] = installed is not None and installed.status == "active"
result["installed_version"] = installed.current_version if installed is not None else None
return result
def _find_installed_skillhub_spec(self, namespace: str, slug: str) -> SkillSpec | None:
for spec in self.store.list_skill_specs():
loaded = self.store.read_published_skill(spec.name)
provenance = loaded.version.provenance if loaded is not None else {}
if provenance.get("source") == "skillhub" and provenance.get("namespace") == namespace and provenance.get("slug") == slug:
return spec
return None
def _next_version(self, skill_name: str) -> str:
versions = [item for item in self.store.list_versions(skill_name) if item.startswith("v")]
numbers = [int(item[1:]) for item in versions if item[1:].isdigit()]
return f"v{(max(numbers) if numbers else 0) + 1:04d}"
def _unwrap(payload: dict[str, Any]) -> Any:
if "data" in payload:
return payload["data"]
return payload
def _published_version(item: dict[str, Any]) -> str | None:
for key in ("publishedVersion", "headlineVersion"):
value = item.get(key)
if isinstance(value, dict) and value.get("version"):
return str(value["version"])
return None
def _safe_posix_path(value: str) -> str:
cleaned = posixpath.normpath(value.replace("\\", "/")).lstrip("/")
if cleaned in {"", ".", ".."} or cleaned.startswith("../") or "/../" in cleaned:
raise ValueError(f"Unsafe SkillHub file path: {value}")
return cleaned
def _render_skill_content(frontmatter: dict[str, Any], body: str) -> str:
lines = ["---"]
for key, value in normalize_frontmatter(frontmatter).items():
if isinstance(value, list):
lines.append(f"{key}:")
for item in value:
lines.append(f" - {item}")
else:
lines.append(f"{key}: {value}")
lines.extend(["---", "", body.strip()])
return "\n".join(lines).rstrip() + "\n"

View File

@ -32,7 +32,7 @@ class TeamService:
provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None = None, provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None = None,
inherited_pinned_skills: list[str] | None = None, inherited_pinned_skills: list[str] | None = None,
inherited_pinned_skill_contexts: list["SkillContext"] | None = None, inherited_pinned_skill_contexts: list["SkillContext"] | None = None,
learning_candidate_enabled: bool = False, allow_candidate_generation: bool = False,
) -> TeamRunResult: ) -> TeamRunResult:
"""Run a team graph inside the parent task context.""" """Run a team graph inside the parent task context."""
@ -46,7 +46,7 @@ class TeamService:
provider_bundle_factory=provider_bundle_factory, provider_bundle_factory=provider_bundle_factory,
inherited_pinned_skills=inherited_pinned_skills, inherited_pinned_skills=inherited_pinned_skills,
inherited_pinned_skill_contexts=inherited_pinned_skill_contexts, inherited_pinned_skill_contexts=inherited_pinned_skill_contexts,
learning_candidate_enabled=learning_candidate_enabled, allow_candidate_generation=allow_candidate_generation,
) )
self._attach_runs_to_parent_task(result) self._attach_runs_to_parent_task(result)
return result return result

View File

@ -1,19 +1,22 @@
"""LLM-driven skill assembler. """LLM-driven skill assembler.
这层现在不再自己做规则打分,而是直接把: 这层现在不再自己做规则打分,而是分两步把:
1. task description 1. task description
2. embedding 召回后的候选 skill 摘要 2. embedding 召回后的候选 skill 摘要
3. 粗选候选的完整 skill 正文
交给一个模型来决定本轮要激活哪些 skill。 交给一个模型来决定本轮要激活哪些 skill。
当前目标非常克制: 当前目标非常克制:
- 输入尽量简单 - 主 agent 不拿 skill_view也不动态探索技能库
- SkillAssembler 可以在系统侧内部读取候选 skill 正文
- 输出只要 skill 名称 - 输出只要 skill 名称
- 没有命中就返回空 skills - 没有命中就返回空 skills
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
import json import json
from typing import Any from typing import Any
@ -31,6 +34,7 @@ class SkillAssemblyResult:
"""一次装配后真正要注入当前 run 的 skills。""" """一次装配后真正要注入当前 run 的 skills。"""
activated_skills: list[SkillContext] = field(default_factory=list) activated_skills: list[SkillContext] = field(default_factory=list)
llm_interactions: list[dict[str, Any]] = field(default_factory=list)
class SkillAssembler: class SkillAssembler:
@ -40,9 +44,14 @@ class SkillAssembler:
self, self,
loader: SkillsLoader, loader: SkillsLoader,
retriever: SkillEmbeddingRetriever | None = None, retriever: SkillEmbeddingRetriever | None = None,
*,
max_detailed_candidates: int = 5,
max_candidate_content_chars: int = 6000,
) -> None: ) -> None:
self.loader = loader self.loader = loader
self.retriever = retriever or SkillEmbeddingRetriever() self.retriever = retriever or SkillEmbeddingRetriever()
self.max_detailed_candidates = max(1, max_detailed_candidates)
self.max_candidate_content_chars = max(1000, max_candidate_content_chars)
async def assemble( async def assemble(
self, self,
@ -51,6 +60,7 @@ class SkillAssembler:
provider: LLMProvider, provider: LLMProvider,
model: str, model: str,
embedding_runtime: ProviderRuntime | None = None, embedding_runtime: ProviderRuntime | None = None,
thinking_enabled: bool | None = None,
top_k: int = 12, top_k: int = 12,
) -> SkillAssemblyResult: ) -> SkillAssemblyResult:
candidates = self.loader.build_selection_candidates() candidates = self.loader.build_selection_candidates()
@ -71,15 +81,39 @@ class SkillAssembler:
) )
if not candidates: if not candidates:
return SkillAssemblyResult() return SkillAssemblyResult()
llm_interactions: list[dict[str, Any]] = []
if len(candidates) <= self.max_detailed_candidates:
shortlisted_names = [item["name"] for item in candidates]
else:
shortlisted_names = await self._select_skill_names(
task_description=task_description,
candidates=candidates,
provider=provider,
model=model,
thinking_enabled=thinking_enabled,
max_selected=self.max_detailed_candidates,
selection_stage="shortlist",
llm_interactions=llm_interactions,
)
if not shortlisted_names:
return SkillAssemblyResult(llm_interactions=llm_interactions)
detailed_candidates = self._build_detailed_candidates(
candidates=candidates,
selected_names=shortlisted_names,
)
selected_names = await self._select_skill_names( selected_names = await self._select_skill_names(
task_description=task_description, task_description=task_description,
candidates=candidates, candidates=detailed_candidates,
provider=provider, provider=provider,
model=model, model=model,
thinking_enabled=thinking_enabled,
selection_stage="final",
llm_interactions=llm_interactions,
) )
if not selected_names: if not selected_names:
return SkillAssemblyResult() return SkillAssemblyResult(llm_interactions=llm_interactions)
activated_skills: list[SkillContext] = [] activated_skills: list[SkillContext] = []
for name in selected_names: for name in selected_names:
@ -99,7 +133,7 @@ class SkillAssembler:
) )
) )
return SkillAssemblyResult(activated_skills=activated_skills) return SkillAssemblyResult(activated_skills=activated_skills, llm_interactions=llm_interactions)
async def _select_skill_names( async def _select_skill_names(
self, self,
@ -108,17 +142,28 @@ class SkillAssembler:
candidates: list[dict[str, str]], candidates: list[dict[str, str]],
provider: LLMProvider, provider: LLMProvider,
model: str, model: str,
thinking_enabled: bool | None = None,
max_selected: int | None = None,
selection_stage: str = "final",
llm_interactions: list[dict[str, Any]] | None = None,
timeout_seconds: float = 8.0,
) -> list[str]: ) -> list[str]:
candidate_summary = self._render_candidates(candidates) candidate_summary = self._render_candidates(candidates)
candidate_names = {item["name"] for item in candidates} candidate_names = {item["name"] for item in candidates}
selection_instruction = (
f"Return at most {max_selected} names for detailed inspection. "
if max_selected is not None
else "Return the final skill names to activate. "
)
messages = [ messages = [
{ {
"role": "system", "role": "system",
"content": ( "content": (
"You select Beaver skills for a single run. " "You select Beaver skills for a single run. "
"Given a task description and candidate skill summaries, " "Given a task description and candidate skill information, "
"return only a JSON array of skill names to activate. " "return only a JSON array of skill names to activate. "
"Do not invent names. If nothing matches, return []." "Do not invent names. If nothing matches, return []. "
f"Selection stage: {selection_stage}. {selection_instruction}"
), ),
}, },
{ {
@ -130,13 +175,34 @@ class SkillAssembler:
), ),
}, },
] ]
response = await provider.chat( chat_kwargs: dict[str, Any] = {
messages=messages, "messages": messages,
tools=None, "tools": None,
model=model, "model": model,
max_tokens=512, "max_tokens": 256,
temperature=0, "temperature": 0,
) }
if thinking_enabled is not None:
chat_kwargs["thinking_enabled"] = thinking_enabled
try:
response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=timeout_seconds)
except Exception:
return []
if llm_interactions is not None:
llm_interactions.append(
{
"stage": selection_stage,
"model": model,
"messages": messages,
"response": {
"content": response.content,
"finish_reason": response.finish_reason,
"provider_name": response.provider_name,
"model": response.model,
"usage": response.usage,
},
}
)
if response.finish_reason == "error" or not response.content: if response.finish_reason == "error" or not response.content:
return [] return []
@ -149,15 +215,42 @@ class SkillAssembler:
for name in parsed: for name in parsed:
if name in candidate_names and name not in filtered: if name in candidate_names and name not in filtered:
filtered.append(name) filtered.append(name)
return filtered return filtered[:max_selected] if max_selected is not None else filtered
@staticmethod @staticmethod
def _render_candidates(candidates: list[dict[str, str]]) -> str: def _render_candidates(candidates: list[dict[str, str]]) -> str:
lines: list[str] = [] lines: list[str] = []
for item in candidates: for item in candidates:
lines.append(f"- {item['name']}: {item['description']}") content = item.get("content")
if content:
lines.append(
f"## {item['name']}\n"
f"Description: {item['description']}\n"
f"Skill content:\n{content}"
)
else:
lines.append(f"- {item['name']}: {item['description']}")
return "\n".join(lines) return "\n".join(lines)
def _build_detailed_candidates(
self,
*,
candidates: list[dict[str, str]],
selected_names: list[str],
) -> list[dict[str, str]]:
by_name = {item["name"]: item for item in candidates}
detailed: list[dict[str, str]] = []
for name in selected_names:
candidate = by_name.get(name)
if candidate is None:
continue
raw_content = self.loader.load_published_skill(name)
content = strip_frontmatter(raw_content).strip() if raw_content else ""
if len(content) > self.max_candidate_content_chars:
content = content[: self.max_candidate_content_chars].rstrip() + "\n...[truncated]"
detailed.append({**candidate, "content": content})
return detailed
@staticmethod @staticmethod
def _parse_selected_names(content: str) -> list[str]: def _parse_selected_names(content: str) -> list[str]:
cleaned = content.strip() cleaned = content.strip()

View File

@ -244,12 +244,10 @@ class SkillsLoader:
meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", "")) meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", ""))
available = check_requirements(meta_blob) available = check_requirements(meta_blob)
description = frontmatter.get("description") or record.description or record.name description = frontmatter.get("description") or record.description or record.name
load_hint = f'Use skill_view(name="{record.name}") to load the full skill.'
lines.append(f' <skill available="{str(available).lower()}">') lines.append(f' <skill available="{str(available).lower()}">')
lines.append(f" <name>{escape_xml(record.name)}</name>") lines.append(f" <name>{escape_xml(record.name)}</name>")
lines.append(f" <description>{escape_xml(description)}</description>") lines.append(f" <description>{escape_xml(description)}</description>")
lines.append(f" <version>{escape_xml(record.version)}</version>") lines.append(f" <version>{escape_xml(record.version)}</version>")
lines.append(f" <load_hint>{escape_xml(load_hint)}</load_hint>")
support_files = self.list_skill_supporting_files(record.name) support_files = self.list_skill_supporting_files(record.name)
if support_files: if support_files:
lines.append(" <supporting_files>") lines.append(" <supporting_files>")

View File

@ -124,6 +124,9 @@ class DraftService:
def get_draft(self, skill_name: str, draft_id: str) -> SkillDraft | None: def get_draft(self, skill_name: str, draft_id: str) -> SkillDraft | None:
return self.store.read_draft(skill_name, draft_id) return self.store.read_draft(skill_name, draft_id)
def delete_draft(self, skill_name: str, draft_id: str) -> bool:
return self.store.delete_draft(skill_name, draft_id)
def _utc_now() -> str: def _utc_now() -> str:
from datetime import datetime, timezone from datetime import datetime, timezone

View File

@ -2,7 +2,12 @@
from .evidence import EvidencePacket, EvidenceSelector from .evidence import EvidencePacket, EvidenceSelector
from .eval import SkillDraftEvaluator from .eval import SkillDraftEvaluator
from .missing_skill import MissingSkillDraftResult, MissingSkillSynthesizer from .missing_skill import (
EphemeralGuidanceResult,
EphemeralGuidanceSynthesizer,
MissingSkillDraftResult,
MissingSkillSynthesizer,
)
from .pipeline import SkillLearningPipelineService from .pipeline import SkillLearningPipelineService
from .service import RunReceiptContext, SkillLearningService from .service import RunReceiptContext, SkillLearningService
from .synthesizer import SkillDraftSynthesizer from .synthesizer import SkillDraftSynthesizer
@ -12,6 +17,8 @@ __all__ = [
"EvidencePacket", "EvidencePacket",
"EvidenceSelector", "EvidenceSelector",
"SkillDraftEvaluator", "SkillDraftEvaluator",
"EphemeralGuidanceResult",
"EphemeralGuidanceSynthesizer",
"MissingSkillDraftResult", "MissingSkillDraftResult",
"MissingSkillSynthesizer", "MissingSkillSynthesizer",
"RunReceiptContext", "RunReceiptContext",

View File

@ -1,4 +1,4 @@
"""Synthesize draft-only skills for missing sub-agent guidance.""" """Synthesize ephemeral guidance for missing sub-agent skills."""
from __future__ import annotations from __future__ import annotations
@ -6,11 +6,10 @@ import json
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from uuid import uuid4
from beaver.engine.context import SkillContext from beaver.engine.context import SkillContext
from beaver.engine.providers import ProviderBundle from beaver.engine.providers import ProviderBundle
from beaver.skills.drafts import DraftService
from beaver.skills.specs import SkillDraft
from beaver.skills.specs.serialization import canonical_hash from beaver.skills.specs.serialization import canonical_hash
if TYPE_CHECKING: if TYPE_CHECKING:
@ -18,13 +17,14 @@ if TYPE_CHECKING:
@dataclass(slots=True) @dataclass(slots=True)
class MissingSkillDraftResult: class EphemeralGuidanceResult:
draft: SkillDraft guidance_id: str
guidance_name: str
skill_context: SkillContext skill_context: SkillContext
class MissingSkillSynthesizer: class EphemeralGuidanceSynthesizer:
"""Create a draft skill and an ephemeral SkillContext for the current run.""" """Create one-run guidance for the current delegated sub-agent."""
async def synthesize( async def synthesize(
self, self,
@ -37,8 +37,7 @@ class MissingSkillSynthesizer:
skill_query: str, skill_query: str,
required_capabilities: list[str], required_capabilities: list[str],
provider_bundle: ProviderBundle, provider_bundle: ProviderBundle,
draft_service: DraftService, ) -> EphemeralGuidanceResult:
) -> MissingSkillDraftResult:
provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider
runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime
model = getattr(runtime, "model", None) model = getattr(runtime, "model", None)
@ -49,14 +48,14 @@ class MissingSkillSynthesizer:
{ {
"role": "system", "role": "system",
"content": ( "content": (
"You create concise Beaver skill drafts. Return only JSON with keys: " "You create concise Beaver ephemeral guidance. Return only JSON with keys: "
"skill_name, description, content, tags." "guidance_name, description, content, tags."
), ),
}, },
{ {
"role": "user", "role": "user",
"content": ( "content": (
"Create a procedural skill draft for this missing Task sub-agent guidance.\n\n" "Create procedural guidance for this missing Task sub-agent capability.\n\n"
f"Task goal:\n{task.goal}\n\n" f"Task goal:\n{task.goal}\n\n"
f"Current user request:\n{user_message}\n\n" f"Current user request:\n{user_message}\n\n"
f"Node id: {node_id}\n" f"Node id: {node_id}\n"
@ -64,62 +63,37 @@ class MissingSkillSynthesizer:
f"Skill query:\n{skill_query}\n" f"Skill query:\n{skill_query}\n"
f"Required capabilities: {required_capabilities}\n\n" f"Required capabilities: {required_capabilities}\n\n"
"The content must be actionable guidance for a temporary sub-agent. " "The content must be actionable guidance for a temporary sub-agent. "
"Do not include implementation claims or publish metadata." "Do not include implementation claims, review metadata, or publish metadata."
), ),
}, },
], ],
tools=None, tools=None,
model=model, model=model,
max_tokens=1200, max_tokens=4096,
temperature=0, temperature=0,
) )
payload = self._parse_payload(response.content or "") or payload payload = self._parse_payload(response.content or "") or payload
except Exception: except Exception:
payload = payload payload = payload
skill_name = _slug(str(payload.get("skill_name") or skill_query or node_id)) guidance_name = _slug(str(payload.get("guidance_name") or payload.get("skill_name") or skill_query or node_id))
guidance_id = f"eg_{uuid4().hex}"
content = str(payload.get("content") or "").strip() content = str(payload.get("content") or "").strip()
if not content: if not content:
content = str(self._fallback_payload(skill_query=skill_query, node_task=node_task, capabilities=required_capabilities)["content"]) content = str(self._fallback_payload(skill_query=skill_query, node_task=node_task, capabilities=required_capabilities)["content"])
frontmatter = {
"description": str(payload.get("description") or f"Draft guidance for {skill_query or node_id}").strip(),
"tags": [str(item) for item in payload.get("tags") or ["generated", "task-sub-agent"]],
"metadata": {
"origin": "missing_task_subagent_skill",
"task_id": task.task_id,
"node_id": node_id,
"attempt_index": attempt_index,
"skill_query": skill_query,
"required_capabilities": list(required_capabilities),
},
}
draft = draft_service.create_new_skill_draft(
skill_name=skill_name,
proposed_content=content,
proposed_frontmatter=frontmatter,
created_by="task-skill-resolver",
reason="generated_for_missing_task_subagent_skill",
trigger_session_id=task.session_id,
evidence_refs=[
{
"task_id": task.task_id,
"session_id": task.session_id,
"attempt_index": attempt_index,
"node_id": node_id,
"skill_query": skill_query,
"required_capabilities": list(required_capabilities),
}
],
)
context = SkillContext( context = SkillContext(
name=f"draft:{draft.skill_name}", name=f"ephemeral:{guidance_name}",
content=draft.proposed_content, content=content,
version=f"draft:{draft.draft_id}", version=f"ephemeral:{guidance_id}",
content_hash=canonical_hash(draft.proposed_content), content_hash=canonical_hash(content),
activation_reason="generated_missing_skill", activation_reason="ephemeral_guidance",
tool_hints=[], tool_hints=[],
) )
return MissingSkillDraftResult(draft=draft, skill_context=context) return EphemeralGuidanceResult(
guidance_id=guidance_id,
guidance_name=guidance_name,
skill_context=context,
)
@staticmethod @staticmethod
def _parse_payload(text: str) -> dict[str, Any] | None: def _parse_payload(text: str) -> dict[str, Any] | None:
@ -145,7 +119,7 @@ class MissingSkillSynthesizer:
title = skill_query or node_task or "task subagent guidance" title = skill_query or node_task or "task subagent guidance"
capability_lines = "\n".join(f"- {item}" for item in capabilities) or "- Follow the node task precisely." capability_lines = "\n".join(f"- {item}" for item in capabilities) or "- Follow the node task precisely."
return { return {
"skill_name": _slug(title), "guidance_name": _slug(title),
"description": f"Draft guidance for {title}.", "description": f"Draft guidance for {title}.",
"tags": ["generated", "task-sub-agent"], "tags": ["generated", "task-sub-agent"],
"content": ( "content": (
@ -163,4 +137,8 @@ class MissingSkillSynthesizer:
def _slug(value: str) -> str: def _slug(value: str) -> str:
cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-") cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
return cleaned[:64].strip("-") or "generated-task-subagent-skill" return cleaned[:64].strip("-") or "generated-task-subagent-guidance"
MissingSkillDraftResult = EphemeralGuidanceResult
MissingSkillSynthesizer = EphemeralGuidanceSynthesizer

View File

@ -14,6 +14,12 @@ from beaver.skills.publisher import SkillPublisher
from beaver.skills.reviews import ReviewService from beaver.skills.reviews import ReviewService
from beaver.skills.specs import SkillDraft, SkillReviewRecord, SkillReviewState, SkillSpec, SkillVersion from beaver.skills.specs import SkillDraft, SkillReviewRecord, SkillReviewState, SkillSpec, SkillVersion
_REJECTABLE_DRAFT_STATUSES = {
SkillReviewState.DRAFT.value,
SkillReviewState.IN_REVIEW.value,
SkillReviewState.APPROVED.value,
}
class SkillLearningPipelineService: class SkillLearningPipelineService:
"""Coordinates candidate -> draft -> review -> publish lifecycle.""" """Coordinates candidate -> draft -> review -> publish lifecycle."""
@ -161,6 +167,9 @@ class SkillLearningPipelineService:
requested_by: str = "system", requested_by: str = "system",
notes: str = "", notes: str = "",
) -> SkillReviewRecord: ) -> SkillReviewRecord:
draft = self.get_draft(skill_name, draft_id)
if draft.status != SkillReviewState.DRAFT.value:
raise ValueError("Draft must be in draft status before review submission")
safety = self.get_safety_report(skill_name, draft_id) safety = self.get_safety_report(skill_name, draft_id)
if safety is not None and (not safety.passed or safety.risk_level == "critical"): if safety is not None and (not safety.passed or safety.risk_level == "critical"):
raise ValueError("Draft cannot enter review because safety check failed") raise ValueError("Draft cannot enter review because safety check failed")
@ -179,6 +188,12 @@ class SkillLearningPipelineService:
reviewer: str = "system", reviewer: str = "system",
notes: str = "", notes: str = "",
) -> SkillReviewRecord: ) -> SkillReviewRecord:
draft = self.get_draft(skill_name, draft_id)
if draft.status != SkillReviewState.IN_REVIEW.value:
raise ValueError("Draft must be in review before approval")
safety = self.get_safety_report(skill_name, draft_id)
if safety is not None and (not safety.passed or safety.risk_level == "critical"):
raise ValueError("Draft cannot be approved because safety check failed")
review = self.review_service.approve(skill_name, draft_id, reviewer=reviewer, notes=notes) review = self.review_service.approve(skill_name, draft_id, reviewer=reviewer, notes=notes)
self._mark_candidate_by_draft(skill_name, draft_id, "approved", "approved") self._mark_candidate_by_draft(skill_name, draft_id, "approved", "approved")
return review return review
@ -191,6 +206,9 @@ class SkillLearningPipelineService:
reviewer: str = "system", reviewer: str = "system",
notes: str = "", notes: str = "",
) -> SkillReviewRecord: ) -> SkillReviewRecord:
draft = self.get_draft(skill_name, draft_id)
if draft.status not in _REJECTABLE_DRAFT_STATUSES:
raise ValueError("Draft is not rejectable from its current status")
review = self.review_service.reject(skill_name, draft_id, reviewer=reviewer, notes=notes) review = self.review_service.reject(skill_name, draft_id, reviewer=reviewer, notes=notes)
self._mark_candidate_by_draft(skill_name, draft_id, "rejected", "rejected") self._mark_candidate_by_draft(skill_name, draft_id, "rejected", "rejected")
return review return review

View File

@ -69,6 +69,94 @@ class SkillLearningService:
existing_ids.add(candidate.candidate_id) existing_ids.add(candidate.candidate_id)
return candidates return candidates
def build_learning_candidates_for_task(self, task_id: str, *, trigger_run_id: str) -> list[SkillLearningCandidate]:
"""Build candidates scoped to a single validated and satisfied Task run."""
runs = [record for record in self.run_store.list_runs() if record.task_id == task_id]
trigger_run = next((record for record in runs if record.run_id == trigger_run_id), None)
if trigger_run is None or not self._is_confirmed_positive_run(trigger_run):
return []
source_runs = [record for record in runs if self._is_confirmed_positive_run(record)]
if not source_runs:
return []
candidates: list[SkillLearningCandidate] = []
published_receipts = [
receipt
for record in source_runs
for receipt in record.activated_skills
if self._is_published_skill_receipt(receipt)
]
source_run_ids = [record.run_id for record in source_runs]
source_session_ids = list(dict.fromkeys(record.session_id for record in source_runs))
if not published_receipts:
candidates.append(
SkillLearningCandidate(
candidate_id=f"new:task:{task_id}",
kind="new_skill",
source_run_ids=source_run_ids,
source_session_ids=source_session_ids,
related_skill_names=[],
reason=f"Task {task_id} completed successfully without a published skill; consider extracting reusable guidance.",
evidence={"task_id": task_id, "trigger_run_id": trigger_run_id, "theme": self._task_theme(trigger_run.task_text)},
status="open",
priority=1,
confidence=0.8,
trigger_reason="validation_accepted_and_user_satisfied",
)
)
else:
seen: set[tuple[str, str]] = set()
for receipt in published_receipts:
key = (receipt.skill_name, receipt.skill_version)
if key in seen:
continue
seen.add(key)
skill_runs = [
record
for record in source_runs
if any(
item.skill_name == receipt.skill_name
and item.skill_version == receipt.skill_version
and self._is_published_skill_receipt(item)
for item in record.activated_skills
)
]
candidates.append(
SkillLearningCandidate(
candidate_id=f"revise:{receipt.skill_name}:{receipt.skill_version}:task:{task_id}",
kind="revise_skill",
source_run_ids=[record.run_id for record in skill_runs],
source_session_ids=list(dict.fromkeys(record.session_id for record in skill_runs)),
related_skill_names=[receipt.skill_name],
reason=(
f"Task {task_id} succeeded with published skill "
f"{receipt.skill_name}/{receipt.skill_version}; consider whether the skill should capture this evidence."
),
evidence={
"task_id": task_id,
"trigger_run_id": trigger_run_id,
"skill_version": receipt.skill_version,
},
status="open",
priority=1,
confidence=0.7,
trigger_reason="validation_accepted_and_user_satisfied",
)
)
existing_ids = {item.candidate_id for item in self.learning_store.list_learning_candidates()}
created: list[SkillLearningCandidate] = []
for candidate in candidates:
if candidate.candidate_id in existing_ids:
continue
self.learning_store.record_learning_candidate(candidate)
existing_ids.add(candidate.candidate_id)
created.append(candidate)
return created
async def synthesize_draft(self, candidate_id: str, provider_bundle: ProviderBundle) -> Any: async def synthesize_draft(self, candidate_id: str, provider_bundle: ProviderBundle) -> Any:
candidates = {item.candidate_id: item for item in self.learning_store.list_learning_candidates()} candidates = {item.candidate_id: item for item in self.learning_store.list_learning_candidates()}
candidate = candidates.get(candidate_id) candidate = candidates.get(candidate_id)
@ -181,7 +269,7 @@ class SkillLearningService:
groups.setdefault(key, []).append(record) groups.setdefault(key, []).append(record)
candidates: list[SkillLearningCandidate] = [] candidates: list[SkillLearningCandidate] = []
for theme, runs in groups.items(): for theme, runs in groups.items():
successful = [record for record in runs if record.success] successful = [record for record in runs if self._is_confirmed_positive_run(record)]
if len(successful) < 2: if len(successful) < 2:
continue continue
if any(record.activated_skills for record in successful): if any(record.activated_skills for record in successful):
@ -202,6 +290,8 @@ class SkillLearningService:
def _build_merge_candidates(self) -> list[SkillLearningCandidate]: def _build_merge_candidates(self) -> list[SkillLearningCandidate]:
pair_counts: dict[tuple[str, str], list[RunRecord]] = {} pair_counts: dict[tuple[str, str], list[RunRecord]] = {}
for record in self.run_store.list_runs(): for record in self.run_store.list_runs():
if not self._is_confirmed_positive_run(record):
continue
unique = sorted({receipt.skill_name for receipt in record.activated_skills}) unique = sorted({receipt.skill_name for receipt in record.activated_skills})
for pair in combinations(unique, 2): for pair in combinations(unique, 2):
pair_counts.setdefault(pair, []).append(record) pair_counts.setdefault(pair, []).append(record)
@ -260,6 +350,25 @@ class SkillLearningService:
effects.extend(self.run_store.list_skill_effects(receipt.skill_name, version=receipt.skill_version)) effects.extend(self.run_store.list_skill_effects(receipt.skill_name, version=receipt.skill_version))
return effects return effects
@staticmethod
def _is_confirmed_positive_run(record: RunRecord) -> bool:
validation = record.validation_result or {}
feedback = record.feedback or {}
return (
bool(record.success)
and bool(record.task_id)
and validation.get("accepted") is True
and feedback.get("feedback_type") == "satisfied"
)
@staticmethod
def _is_published_skill_receipt(receipt: SkillActivationReceipt) -> bool:
return (
not receipt.skill_name.startswith(("draft:", "ephemeral:"))
and not receipt.skill_version.startswith(("draft:", "ephemeral:"))
and receipt.activation_reason not in {"generated_missing_skill", "ephemeral_guidance"}
)
@staticmethod @staticmethod
def _candidate_id(kind: str, *parts: str) -> str: def _candidate_id(kind: str, *parts: str) -> str:
return f"{kind}:{'|'.join(parts)}" return f"{kind}:{'|'.join(parts)}"

View File

@ -60,7 +60,7 @@ class SkillDraftSynthesizer:
], ],
tools=None, tools=None,
model=model, model=model,
max_tokens=1500, max_tokens=4096,
temperature=0, temperature=0,
) )
payload = self._parse_payload(response.content or "") payload = self._parse_payload(response.content or "")

View File

@ -2,6 +2,9 @@
from __future__ import annotations from __future__ import annotations
import shutil
from pathlib import Path
from beaver.skills.catalog.utils import strip_frontmatter from beaver.skills.catalog.utils import strip_frontmatter
from beaver.skills.specs import SkillDraft, SkillReviewState, SkillSpec, SkillSpecStore, SkillStatus, SkillVersion from beaver.skills.specs import SkillDraft, SkillReviewState, SkillSpec, SkillSpecStore, SkillStatus, SkillVersion
from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content
@ -44,6 +47,7 @@ class SkillPublisher:
}, },
) )
self.store.write_skill_version(version, content) self.store.write_skill_version(version, content)
self._copy_uploaded_supporting_files(draft, next_version)
self.store.set_current_version(skill_name, next_version) self.store.set_current_version(skill_name, next_version)
spec = self.store.get_skill_spec(skill_name) spec = self.store.get_skill_spec(skill_name)
@ -169,6 +173,27 @@ class SkillPublisher:
self.store.update_index("published", published) self.store.update_index("published", published)
self.store.update_index("disabled", disabled) self.store.update_index("disabled", disabled)
def _copy_uploaded_supporting_files(self, draft: SkillDraft, version: str) -> None:
for evidence in draft.evidence_refs:
if not isinstance(evidence, dict) or evidence.get("kind") != "upload":
continue
raw_dir = evidence.get("supporting_upload_dir")
if not raw_dir:
continue
source_root = Path(str(raw_dir))
if not source_root.exists() or not source_root.is_dir():
continue
target_root = self.store.root / draft.skill_name / "versions" / version
for source in sorted(source_root.rglob("*")):
if not source.is_file() or source.is_symlink():
continue
relative = source.relative_to(source_root)
if any(part in {"", ".", ".."} for part in relative.parts):
continue
target = target_root / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, target)
def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft: def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft:
draft = self.store.read_draft(skill_name, draft_id) draft = self.store.read_draft(skill_name, draft_id)
if draft is None: if draft is None:

View File

@ -47,8 +47,6 @@ class ReviewService:
def reject(self, skill_name: str, draft_id: str, reviewer: str, notes: str = "") -> SkillReviewRecord: def reject(self, skill_name: str, draft_id: str, reviewer: str, notes: str = "") -> SkillReviewRecord:
draft = self._require_draft(skill_name, draft_id) draft = self._require_draft(skill_name, draft_id)
draft.status = SkillReviewState.REJECTED.value
self.store.write_draft(draft)
review = SkillReviewRecord( review = SkillReviewRecord(
review_id=uuid4().hex, review_id=uuid4().hex,
draft_id=draft_id, draft_id=draft_id,
@ -61,6 +59,7 @@ class ReviewService:
notes=notes, notes=notes,
) )
self.store.write_review(review) self.store.write_review(review)
self.store.delete_draft(skill_name, draft_id)
return review return review
def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft: def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft:

View File

@ -87,6 +87,11 @@ class SkillSpecStore:
return str(self._read_json(current_path).get("current_version") or "") or None return str(self._read_json(current_path).get("current_version") or "") or None
if (directory / "SKILL.md").exists(): if (directory / "SKILL.md").exists():
return "legacy" return "legacy"
versions_dir = directory / "versions"
if versions_dir.exists():
versions = [child.name for child in sorted(versions_dir.iterdir()) if child.is_dir()]
if versions:
return versions[-1]
spec = self.get_skill_spec(name) spec = self.get_skill_spec(name)
if spec is not None and spec.current_version: if spec is not None and spec.current_version:
return spec.current_version return spec.current_version
@ -182,6 +187,13 @@ class SkillSpecStore:
drafts_dir.mkdir(parents=True, exist_ok=True) drafts_dir.mkdir(parents=True, exist_ok=True)
self._write_json(drafts_dir / f"draft-{draft.draft_id}.json", draft.to_dict()) self._write_json(drafts_dir / f"draft-{draft.draft_id}.json", draft.to_dict())
def delete_draft(self, skill_name: str, draft_id: str) -> bool:
path = self._skill_dir(skill_name) / "drafts" / f"draft-{draft_id}.json"
if not path.exists():
return False
path.unlink()
return True
def list_reviews(self, skill_name: str, draft_id: str | None = None) -> list[SkillReviewRecord]: def list_reviews(self, skill_name: str, draft_id: str | None = None) -> list[SkillReviewRecord]:
reviews_dir = self._skill_dir(skill_name) / "reviews" reviews_dir = self._skill_dir(skill_name) / "reviews"
if not reviews_dir.exists(): if not reviews_dir.exists():
@ -199,6 +211,19 @@ class SkillSpecStore:
reviews_dir.mkdir(parents=True, exist_ok=True) reviews_dir.mkdir(parents=True, exist_ok=True)
self._write_json(reviews_dir / f"review-{review.review_id}.json", review.to_dict()) self._write_json(reviews_dir / f"review-{review.review_id}.json", review.to_dict())
def delete_reviews_for_draft(self, skill_name: str, draft_id: str) -> int:
reviews_dir = self._skill_dir(skill_name) / "reviews"
if not reviews_dir.exists():
return 0
deleted = 0
for path in sorted(reviews_dir.glob("review-*.json")):
record = SkillReviewRecord.from_dict(self._read_json(path))
if record.draft_id != draft_id:
continue
path.unlink()
deleted += 1
return deleted
def update_index(self, index_name: str, values: list[str]) -> None: def update_index(self, index_name: str, values: list[str]) -> None:
self._write_json(self.index_dir / f"{index_name}.json", {"items": list(dict.fromkeys(values))}) self._write_json(self.index_dir / f"{index_name}.json", {"items": list(dict.fromkeys(values))})

View File

@ -160,6 +160,9 @@ class MainAgentDecision:
mode: str mode: str
reason: str reason: str
starts_new_task: bool = False starts_new_task: bool = False
closes_task: bool = False
abandons_task: bool = False
short_title: str | None = None
@property @property
def is_task(self) -> bool: def is_task(self) -> bool:

View File

@ -50,10 +50,10 @@ class TaskExecutionPlan:
for node in nodes for node in nodes
for name in node.inherited_pinned_skills for name in node.inherited_pinned_skills
], ],
"generated_skill_draft_ids": [ "ephemeral_guidance_ids": [
item.generated_skill_draft_id item.ephemeral_guidance_id
for item in self.skill_resolution_report for item in self.skill_resolution_report
if item.generated_skill_draft_id if item.ephemeral_guidance_id
], ],
"skill_resolution_report": [item.to_dict() for item in self.skill_resolution_report], "skill_resolution_report": [item.to_dict() for item in self.skill_resolution_report],
"fallback_error": self.fallback_error, "fallback_error": self.fallback_error,
@ -108,7 +108,7 @@ class TaskExecutionPlanner:
], ],
tools=None, tools=None,
model=model, model=model,
max_tokens=1200, max_tokens=4096,
temperature=0.0, temperature=0.0,
) )
plan = self.from_json(response.content or "") plan = self.from_json(response.content or "")

View File

@ -1,40 +1,144 @@
"""Main Agent routing between simple chat and internal Task mode.""" """LLM-based routing between simple chat and internal Task mode."""
from __future__ import annotations from __future__ import annotations
import re import asyncio
import json
from typing import Any
from .models import MainAgentDecision, TaskRecord from .models import MainAgentDecision, TaskRecord
class MainAgentRouter: class MainAgentRouter:
"""Small deterministic classifier used before the main AgentLoop. """Semantic router for deciding whether a message belongs to a Task."""
The first version intentionally avoids a mandatory model call so the router async def classify(
stays reliable during provider outages. The rule set is conservative: self,
anything that implies execution, files, tools, iteration, or validation message: str,
becomes Task mode. *,
""" active_task: TaskRecord | None = None,
provider: Any | None = None,
model: str | None = None,
recent_messages: list[dict[str, Any]] | None = None,
thinking_enabled: bool | None = None,
timeout_seconds: float = 8.0,
) -> MainAgentDecision:
if provider is None:
return self._fallback(active_task=active_task, reason="router_provider_unavailable")
try:
chat_kwargs: dict[str, Any] = {
"messages": [
{
"role": "system",
"content": (
"You route user messages for Beaver's internal Task mode. "
"Return only compact JSON. Do not explain."
),
},
{
"role": "user",
"content": self._prompt(
message=message,
active_task=active_task,
recent_messages=recent_messages or [],
),
},
],
"tools": None,
"model": model,
"max_tokens": 256,
"temperature": 0.0,
}
if thinking_enabled is not None:
chat_kwargs["thinking_enabled"] = thinking_enabled
response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=timeout_seconds)
return self.from_json(response.content or "", active_task=active_task)
except Exception as exc:
return self._fallback(active_task=active_task, reason=f"router_failed: {exc}")
_TASK_PATTERNS = [ def from_json(self, text: str, *, active_task: TaskRecord | None = None) -> MainAgentDecision:
r"\b(implement|fix|debug|refactor|migrate|build|create|write|edit|update|test|validate|deploy)\b", payload = self._parse_json_object(text)
r"\b(file|repo|code|project|backend|frontend|api|database|migration|pull request|ci|bug)\b", raw_action = str(payload.get("action") or payload.get("mode") or "").strip().lower()
r"\b(step|multi-step|workflow|plan and|then)\b", reason = str(payload.get("reason") or raw_action or "llm_router")
r"(实现|修复|调试|重构|迁移|构建|创建|编写|修改|更新|测试|验证|部署|文件|代码|项目|前端|后端|接口|数据库|多步|任务)", short_title = _clean_short_title(payload.get("short_title") or payload.get("title"))
]
_NEW_TASK_PATTERNS = [
r"\b(new task|another task|different task|start over)\b",
r"(新任务|另一个任务|换个任务|重新开始)",
]
def classify(self, message: str, *, active_task: TaskRecord | None = None) -> MainAgentDecision: if raw_action in {"continue_task", "continue", "task"}:
text = message.strip() return MainAgentDecision(mode="task", reason=reason, short_title=short_title)
lowered = text.lower() if raw_action in {"new_task", "new"}:
starts_new = any(re.search(pattern, lowered, re.IGNORECASE) for pattern in self._NEW_TASK_PATTERNS) return MainAgentDecision(mode="task", reason=reason, starts_new_task=True, short_title=short_title)
if active_task is not None and active_task.status in {"awaiting_feedback", "needs_revision"} and not starts_new: if raw_action in {"close_task", "close", "done", "finish"}:
return MainAgentDecision(mode="task", reason="continuing_open_task", starts_new_task=False) return MainAgentDecision(mode="simple", reason=reason, closes_task=active_task is not None, short_title=short_title)
if any(re.search(pattern, lowered, re.IGNORECASE) for pattern in self._TASK_PATTERNS): if raw_action in {"abandon_task", "abandon", "cancel_task"}:
return MainAgentDecision(mode="task", reason="task_pattern_matched", starts_new_task=starts_new) return MainAgentDecision(mode="simple", reason=reason, abandons_task=active_task is not None, short_title=short_title)
if len(text) > 240: return MainAgentDecision(mode="simple", reason=reason or "simple_chat", short_title=short_title)
return MainAgentDecision(mode="task", reason="long_request", starts_new_task=starts_new)
return MainAgentDecision(mode="simple", reason="simple_question", starts_new_task=False) def _fallback(self, *, active_task: TaskRecord | None, reason: str) -> MainAgentDecision:
if active_task is not None:
return MainAgentDecision(mode="task", reason=reason)
return MainAgentDecision(mode="simple", reason=reason)
@staticmethod
def _prompt(
*,
message: str,
active_task: TaskRecord | None,
recent_messages: list[dict[str, Any]],
) -> str:
active_task_payload = None
if active_task is not None:
active_task_payload = {
"task_id": active_task.task_id,
"description": active_task.description,
"goal": active_task.goal,
"status": active_task.status,
"short_title": active_task.metadata.get("short_title"),
}
recent = [
{"role": item.get("role"), "content": str(item.get("content") or "")[:500]}
for item in recent_messages[-8:]
if item.get("role") in {"user", "assistant"}
]
return (
"Decide how to route the current user message.\n\n"
"Actions:\n"
"- simple_chat: no Task should be created or continued.\n"
"- continue_task: keep the user in the active Task.\n"
"- new_task: start a separate new Task.\n"
"- close_task: user explicitly says the active Task is done/satisfactory/finished.\n"
"- abandon_task: user explicitly says to stop, cancel, abandon, or no longer do the active Task.\n\n"
"Critical policy:\n"
"- If there is an active Task, choose continue_task unless the user's topic is completely unrelated "
"to that Task or the user explicitly closes/abandons it.\n"
"- Follow-up questions, corrections, partial changes, extra constraints, and result discussion stay in continue_task.\n"
"- Use new_task only when the user clearly asks to start a different task.\n"
"- If there is no active Task, choose new_task only for work that requires execution, iteration, tools, files, "
"implementation, validation, or multi-step completion. Otherwise choose simple_chat.\n"
"- short_title must be 5-15 Chinese characters or a similarly short English phrase when a Task is involved.\n\n"
"Return JSON only with keys: action, reason, short_title.\n\n"
f"Active task:\n{json.dumps(active_task_payload, ensure_ascii=False)}\n\n"
f"Recent conversation:\n{json.dumps(recent, ensure_ascii=False)}\n\n"
f"Current user message:\n{message}"
)
@staticmethod
def _parse_json_object(text: str) -> dict[str, Any]:
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:].strip()
start = cleaned.find("{")
end = cleaned.rfind("}")
if start >= 0 and end >= start:
cleaned = cleaned[start : end + 1]
payload = json.loads(cleaned)
if not isinstance(payload, dict):
raise ValueError("router response must be a JSON object")
return payload
def _clean_short_title(value: Any) -> str | None:
if value in (None, ""):
return None
title = " ".join(str(value).strip().split())
return title[:40] or None

View File

@ -24,6 +24,8 @@ class TaskService:
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
) -> TaskRecord: ) -> TaskRecord:
now = self._now() now = self._now()
task_metadata = dict(metadata or {})
task_metadata.setdefault("short_title", short_task_title(description))
task = TaskRecord( task = TaskRecord(
task_id=uuid4().hex, task_id=uuid4().hex,
session_id=session_id, session_id=session_id,
@ -35,7 +37,7 @@ class TaskService:
creator=creator, creator=creator,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
metadata=dict(metadata or {}), metadata=task_metadata,
) )
self.store.upsert_task(task) self.store.upsert_task(task)
self._event(task, "created", payload={"description": description}) self._event(task, "created", payload={"description": description})
@ -44,11 +46,45 @@ class TaskService:
def get_task(self, task_id: str) -> TaskRecord | None: def get_task(self, task_id: str) -> TaskRecord | None:
return self.store.get_task(task_id) return self.store.get_task(task_id)
def list_tasks(self) -> list[TaskRecord]:
return sorted(self.store.list_tasks(), key=lambda item: item.updated_at, reverse=True)
def list_events(self, task_id: str) -> list[TaskEvent]:
return self.store.list_events(task_id=task_id)
def get_task_by_run_id(self, run_id: str) -> TaskRecord | None: def get_task_by_run_id(self, run_id: str) -> TaskRecord | None:
return self.store.get_task_by_run_id(run_id) return self.store.get_task_by_run_id(run_id)
def get_latest_open_task(self, session_id: str) -> TaskRecord | None: def get_latest_open_task(self, session_id: str, *, include_unengaged_scheduled: bool = False) -> TaskRecord | None:
return self.store.get_latest_open_task(session_id) tasks = [
task
for task in self.store.list_tasks()
if task.session_id == session_id and task.is_open
]
if not include_unengaged_scheduled:
tasks = [task for task in tasks if self._is_user_visible_active_task(task)]
if not tasks:
return None
return sorted(tasks, key=lambda item: item.updated_at)[-1]
def active_task_view(self, session_id: str) -> dict[str, Any] | None:
task = self.get_latest_open_task(session_id)
if task is None:
return None
return self.to_api_dict(task)
def to_api_dict(self, task: TaskRecord) -> dict[str, Any]:
payload = task.to_dict()
payload["short_title"] = self.ensure_short_title(task).metadata.get("short_title")
payload["is_open"] = task.is_open
return payload
def ensure_short_title(self, task: TaskRecord) -> TaskRecord:
if task.metadata.get("short_title"):
return task
task.metadata["short_title"] = short_task_title(task.description or task.goal or task.task_id)
self.store.upsert_task(task)
return task
def start_run(self, task_id: str, *, user_message: str, attempt_index: int) -> TaskRecord: def start_run(self, task_id: str, *, user_message: str, attempt_index: int) -> TaskRecord:
task = self._require(task_id) task = self._require(task_id)
@ -136,6 +172,38 @@ class TaskService:
self._event(task, f"feedback_{feedback_type}", run_id=run_id, payload=entry) self._event(task, f"feedback_{feedback_type}", run_id=run_id, payload=entry)
return task return task
def close_task(self, task_id: str, *, reason: str = "closed") -> TaskRecord:
task = self._require(task_id)
now = self._now()
task.status = "closed"
task.closed_at = now
task.close_reason = reason
task.updated_at = now
self.store.upsert_task(task)
self._event(task, "closed", payload={"reason": reason})
return task
def abandon_task(self, task_id: str, *, reason: str = "abandoned") -> TaskRecord:
task = self._require(task_id)
now = self._now()
task.status = "abandoned"
task.closed_at = now
task.close_reason = reason
task.updated_at = now
self.store.upsert_task(task)
self._event(task, "abandoned", payload={"reason": reason})
return task
def delete_task(self, task_id: str) -> bool:
return self.store.delete_task(task_id)
@staticmethod
def _is_user_visible_active_task(task: TaskRecord) -> bool:
if task.creator != "cron":
return True
metadata = task.metadata or {}
return bool(metadata.get("user_engaged") or metadata.get("requires_followup"))
def _require(self, task_id: str) -> TaskRecord: def _require(self, task_id: str) -> TaskRecord:
task = self.store.get_task(task_id) task = self.store.get_task(task_id)
if task is None: if task is None:
@ -165,3 +233,15 @@ class TaskService:
@staticmethod @staticmethod
def _now() -> str: def _now() -> str:
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
def short_task_title(text: str) -> str:
cleaned = " ".join((text or "").strip().split())
if not cleaned:
return "当前任务"
if any("\u4e00" <= char <= "\u9fff" for char in cleaned):
return cleaned[:15]
words = cleaned.split()
if len(words) <= 4:
return cleaned[:40]
return " ".join(words[:4])[:40]

View File

@ -11,7 +11,7 @@ from beaver.engine.providers import ProviderBundle
from beaver.skills.assembler.embedding_retriever import SkillEmbeddingRetriever from beaver.skills.assembler.embedding_retriever import SkillEmbeddingRetriever
from beaver.skills.catalog.loader import SkillsLoader from beaver.skills.catalog.loader import SkillsLoader
from beaver.skills.drafts import DraftService from beaver.skills.drafts import DraftService
from beaver.skills.learning import MissingSkillSynthesizer from beaver.skills.learning import EphemeralGuidanceSynthesizer
from beaver.tasks.models import TaskRecord from beaver.tasks.models import TaskRecord
@ -21,8 +21,8 @@ class SkillResolutionReport:
skill_query: str skill_query: str
required_capabilities: list[str] = field(default_factory=list) required_capabilities: list[str] = field(default_factory=list)
selected_skill_names: list[str] = field(default_factory=list) selected_skill_names: list[str] = field(default_factory=list)
generated_skill_draft_id: str | None = None ephemeral_guidance_id: str | None = None
generated_skill_name: str | None = None ephemeral_guidance_name: str | None = None
ephemeral_used: bool = False ephemeral_used: bool = False
reason: str = "" reason: str = ""
@ -32,15 +32,15 @@ class SkillResolutionReport:
"skill_query": self.skill_query, "skill_query": self.skill_query,
"required_capabilities": list(self.required_capabilities), "required_capabilities": list(self.required_capabilities),
"selected_skill_names": list(self.selected_skill_names), "selected_skill_names": list(self.selected_skill_names),
"generated_skill_draft_id": self.generated_skill_draft_id, "ephemeral_guidance_id": self.ephemeral_guidance_id,
"generated_skill_name": self.generated_skill_name, "ephemeral_guidance_name": self.ephemeral_guidance_name,
"ephemeral_used": self.ephemeral_used, "ephemeral_used": self.ephemeral_used,
"reason": self.reason, "reason": self.reason,
} }
class TaskSkillResolver: class TaskSkillResolver:
"""Pins published or draft-only skills onto generic team nodes.""" """Pins published skills or one-run guidance onto generic team nodes."""
def __init__( def __init__(
self, self,
@ -48,12 +48,12 @@ class TaskSkillResolver:
skills_loader: SkillsLoader, skills_loader: SkillsLoader,
draft_service: DraftService, draft_service: DraftService,
retriever: SkillEmbeddingRetriever | None = None, retriever: SkillEmbeddingRetriever | None = None,
missing_skill_synthesizer: MissingSkillSynthesizer | None = None, missing_skill_synthesizer: EphemeralGuidanceSynthesizer | None = None,
) -> None: ) -> None:
self.skills_loader = skills_loader self.skills_loader = skills_loader
self.draft_service = draft_service self.draft_service = draft_service
self.retriever = retriever or SkillEmbeddingRetriever() self.retriever = retriever or SkillEmbeddingRetriever()
self.missing_skill_synthesizer = missing_skill_synthesizer or MissingSkillSynthesizer() self.missing_skill_synthesizer = missing_skill_synthesizer or EphemeralGuidanceSynthesizer()
async def resolve_graph( async def resolve_graph(
self, self,
@ -138,7 +138,6 @@ class TaskSkillResolver:
skill_query=skill_query, skill_query=skill_query,
required_capabilities=required_capabilities, required_capabilities=required_capabilities,
provider_bundle=provider_bundle, provider_bundle=provider_bundle,
draft_service=self.draft_service,
) )
resolved = self._generic_node( resolved = self._generic_node(
node, node,
@ -149,8 +148,8 @@ class TaskSkillResolver:
"skill_query": skill_query, "skill_query": skill_query,
"required_capabilities": required_capabilities, "required_capabilities": required_capabilities,
"selected_skill_names": [], "selected_skill_names": [],
"generated_skill_draft_id": missing.draft.draft_id, "ephemeral_guidance_id": missing.guidance_id,
"generated_skill_name": missing.draft.skill_name, "ephemeral_guidance_name": missing.guidance_name,
"ephemeral_skill_names": [missing.skill_context.name], "ephemeral_skill_names": [missing.skill_context.name],
}, },
) )
@ -158,10 +157,10 @@ class TaskSkillResolver:
node_id=node.node_id, node_id=node.node_id,
skill_query=skill_query, skill_query=skill_query,
required_capabilities=required_capabilities, required_capabilities=required_capabilities,
generated_skill_draft_id=missing.draft.draft_id, ephemeral_guidance_id=missing.guidance_id,
generated_skill_name=missing.draft.skill_name, ephemeral_guidance_name=missing.guidance_name,
ephemeral_used=True, ephemeral_used=True,
reason="generated draft-only skill for missing sub-agent guidance", reason="generated ephemeral guidance for missing sub-agent capability",
) )
async def _select_published_skills(self, *, query: str, provider_bundle: ProviderBundle) -> list[str]: async def _select_published_skills(self, *, query: str, provider_bundle: ProviderBundle) -> list[str]:
@ -215,7 +214,7 @@ class TaskSkillResolver:
], ],
tools=None, tools=None,
model=model, model=model,
max_tokens=512, max_tokens=2048,
temperature=0, temperature=0,
) )
parsed = self._parse_names(response.content or "") parsed = self._parse_names(response.content or "")

View File

@ -40,7 +40,7 @@ class TaskStore:
tasks = [ tasks = [
task task
for task in self.list_tasks() for task in self.list_tasks()
if task.session_id == session_id and task.status in {"awaiting_feedback", "needs_revision", "open", "running"} if task.session_id == session_id and task.is_open
] ]
if not tasks: if not tasks:
return None return None
@ -52,6 +52,25 @@ class TaskStore:
payload[task.task_id] = task.to_dict() payload[task.task_id] = task.to_dict()
self._write_tasks_unlocked(payload) self._write_tasks_unlocked(payload)
def delete_task(self, task_id: str) -> bool:
with self._lock:
payload = self._read_tasks_unlocked()
if task_id not in payload:
return False
payload.pop(task_id, None)
self._write_tasks_unlocked(payload)
if self.events_path.exists():
kept = []
for line in self.events_path.read_text(encoding="utf-8").splitlines():
cleaned = line.strip()
if not cleaned:
continue
event_payload = json.loads(cleaned)
if not isinstance(event_payload, dict) or str(event_payload.get("task_id")) != task_id:
kept.append(cleaned)
self.events_path.write_text(("\n".join(kept) + "\n") if kept else "", encoding="utf-8")
return True
def append_event(self, event: TaskEvent) -> None: def append_event(self, event: TaskEvent) -> None:
self.events_path.parent.mkdir(parents=True, exist_ok=True) self.events_path.parent.mkdir(parents=True, exist_ok=True)
with self._lock: with self._lock:

View File

@ -84,7 +84,7 @@ class ValidationService:
], ],
tools=None, tools=None,
model=model, model=model,
max_tokens=800, max_tokens=4096,
temperature=0.0, temperature=0.0,
) )
payload = self._parse_json_object(response.content or "") payload = self._parse_json_object(response.content or "")

View File

@ -29,7 +29,7 @@ class ToolAssembler:
always_tool_names: Sequence[str] | None = None, always_tool_names: Sequence[str] | None = None,
) -> None: ) -> None:
self.retriever = retriever or EmbeddingRetriever() self.retriever = retriever or EmbeddingRetriever()
self.always_tool_names = tuple(always_tool_names or ("memory", "session_search", "skill_view")) self.always_tool_names = tuple(always_tool_names or ("memory", "session_search"))
async def assemble( async def assemble(
self, self,

View File

@ -39,6 +39,7 @@ class ToolSpec:
input_schema: dict[str, Any] input_schema: dict[str, Any]
toolset: str = "core" toolset: str = "core"
always_available: bool = False always_available: bool = False
metadata: dict[str, Any] = field(default_factory=dict)
def to_mcp_descriptor(self) -> dict[str, Any]: def to_mcp_descriptor(self) -> dict[str, Any]:
"""导出 MCP ListTools 风格的工具描述。 """导出 MCP ListTools 风格的工具描述。
@ -180,6 +181,8 @@ class ObjectBackedTool(BaseTool):
arguments["current_session_id"] = context.session_id arguments["current_session_id"] = context.session_id
if "workspace" not in arguments and hasattr(self.backend, "workspace"): if "workspace" not in arguments and hasattr(self.backend, "workspace"):
arguments["workspace"] = context.workspace arguments["workspace"] = context.workspace
if "metadata" not in arguments:
arguments["metadata"] = context.metadata
@staticmethod @staticmethod
def _normalize_output(content: Any) -> dict[str, Any]: def _normalize_output(content: Any) -> dict[str, Any]:

View File

@ -1,19 +1,39 @@
"""Built-in Beaver tools.""" """Built-in Beaver tools."""
from .cron import CronTool
from .echo import EchoTool, echo_tool from .echo import EchoTool, echo_tool
from .filesystem import ListDirectoryTool, ReadFileTool, SearchFilesTool from .filesystem import ListDirectoryTool, PatchFileTool, ReadFileTool, SearchFilesTool, WriteFileTool
from .memory import MemoryTool, memory_tool from .memory import MemoryTool, memory_tool
from .skills_admin import SkillManageTool, SkillsListTool
from .skill_view import SkillViewTool, skill_view from .skill_view import SkillViewTool, skill_view
from .session_search import SessionSearchTool, session_search from .session_search import SessionSearchTool, session_search
from .terminal import ExecuteCodeTool, ProcessTool, TerminalTool
from .utility import ClarifyTool, DelegateTool, SendMessageTool, SpawnTool, TodoTool
from .web import WebFetchTool, WebSearchTool
__all__ = [ __all__ = [
"EchoTool", "EchoTool",
"ExecuteCodeTool",
"CronTool",
"DelegateTool",
"ListDirectoryTool", "ListDirectoryTool",
"MemoryTool", "MemoryTool",
"PatchFileTool",
"ProcessTool",
"ReadFileTool", "ReadFileTool",
"SearchFilesTool", "SearchFilesTool",
"SendMessageTool",
"SpawnTool",
"SkillManageTool",
"SkillsListTool",
"SkillViewTool", "SkillViewTool",
"SessionSearchTool", "SessionSearchTool",
"TerminalTool",
"TodoTool",
"ClarifyTool",
"WebFetchTool",
"WebSearchTool",
"WriteFileTool",
"echo_tool", "echo_tool",
"memory_tool", "memory_tool",
"skill_view", "skill_view",

View File

@ -0,0 +1,163 @@
"""Built-in cron tool for managing scheduled Beaver Tasks."""
from __future__ import annotations
import json
from typing import Any
from beaver.services.cron_service import CronService, schedule_from_api
from beaver.tools.base import BaseTool, ToolContext, ToolResult, ToolSpec
CRON_TOOL_DESCRIPTION = (
"Create and manage scheduled Beaver notifications or Tasks. Notification mode "
"sends scheduled results to the fixed notification session; task mode creates "
"a Task run. Actions: add, list, remove, toggle, run."
)
CRON_TOOL_PARAMETERS: dict[str, Any] = {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "list", "remove", "toggle", "run"],
"description": "The scheduled-task operation to perform.",
},
"name": {
"type": "string",
"description": "Short scheduled-task name. Optional for add.",
},
"message": {
"type": "string",
"description": "The task instruction to run when the schedule triggers. Required for add.",
},
"schedule": {
"type": "string",
"description": "Hermes-style schedule, for example 'every 15m', '0 9 * * *', or an ISO datetime.",
},
"every_seconds": {
"type": "integer",
"minimum": 1,
"description": "Fixed interval in seconds for recurring scheduled tasks.",
},
"cron_expr": {
"type": "string",
"description": "Cron expression such as '0 9 * * *'.",
},
"tz": {
"type": "string",
"description": "IANA timezone for cron_expr, for example 'Asia/Shanghai'.",
},
"at_iso": {
"type": "string",
"description": "ISO datetime for one-time scheduled tasks.",
},
"job_id": {
"type": "string",
"description": "Scheduled-task ID for remove, toggle, or run.",
},
"enabled": {
"type": "boolean",
"description": "Whether the scheduled task should be enabled when action is toggle.",
},
"mode": {
"type": "string",
"enum": ["notification", "task"],
"description": "Use notification for reminders/reports; use task only when the scheduled work requires Task tracking.",
},
"requires_followup": {
"type": "boolean",
"description": "Whether a task-mode scheduled run should appear as an active task awaiting user follow-up.",
},
},
"required": ["action"],
}
class CronTool(BaseTool):
"""Tool-facing wrapper around the process CronService."""
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="cron",
description=CRON_TOOL_DESCRIPTION,
input_schema=CRON_TOOL_PARAMETERS,
toolset="cron",
always_available=False,
)
async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult:
try:
result = await self._invoke(arguments, context)
return ToolResult(
success=bool(result.get("success", True)),
content=json.dumps(result, ensure_ascii=False),
tool_name=self.spec.name,
error=str(result.get("error")) if result.get("error") else None,
raw_output=result,
)
except Exception as exc:
return ToolResult(
success=False,
content=json.dumps({"success": False, "error": str(exc)}, ensure_ascii=False),
tool_name=self.spec.name,
error=str(exc),
)
async def _invoke(self, arguments: dict[str, Any], context: ToolContext) -> dict[str, Any]:
service = self._resolve_cron_service(context)
action = str(arguments.get("action") or "").strip().lower()
if action == "add":
schedule = schedule_from_api(arguments)
job = service.add_job(
name=str(arguments.get("name") or "").strip(),
message=str(arguments.get("message") or "").strip(),
schedule=schedule,
session_key=str(arguments.get("session_key") or context.session_id or "").strip() or None,
payload_kind="agent_turn",
mode=str(arguments.get("mode") or "notification").strip().lower(),
requires_followup=bool(arguments.get("requires_followup", False)),
)
return {"success": True, "job": job.to_api_dict()}
if action == "list":
include_disabled = bool(arguments.get("include_disabled", True))
return {
"success": True,
"jobs": [job.to_api_dict() for job in service.list_jobs(include_disabled=include_disabled)],
}
if action == "remove":
job_id = _required_job_id(arguments)
return {"success": service.remove_job(job_id), "job_id": job_id}
if action == "toggle":
job_id = _required_job_id(arguments)
job = service.update_enabled(job_id, bool(arguments.get("enabled", True)))
if job is None:
return {"success": False, "error": f"Scheduled task {job_id!r} was not found."}
return {"success": True, "job": job.to_api_dict()}
if action == "run":
job_id = _required_job_id(arguments)
ok = await service.run_job(job_id, force=True)
job = service.get_job(job_id)
return {
"success": ok,
"job_id": job_id,
"job": job.to_api_dict() if job is not None else None,
}
return {"success": False, "error": "action must be one of: add, list, remove, toggle, run"}
@staticmethod
def _resolve_cron_service(context: ToolContext) -> CronService:
service = context.get("cron_service")
if isinstance(service, CronService):
return service
if not context.workspace:
raise RuntimeError("Cron service is unavailable for this runtime.")
return CronService(f"{context.workspace}/cron/jobs.json")
def _required_job_id(arguments: dict[str, Any]) -> str:
job_id = str(arguments.get("job_id") or "").strip()
if not job_id:
raise ValueError("job_id is required")
return job_id

View File

@ -116,6 +116,25 @@ SEARCH_FILES_PARAMETERS: dict[str, Any] = {
"required": ["query"], "required": ["query"],
} }
WRITE_FILE_PARAMETERS: dict[str, Any] = {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path relative to the current workspace."},
"content": {"type": "string", "description": "Full file content to write."},
},
"required": ["path", "content"],
}
PATCH_FILE_PARAMETERS: dict[str, Any] = {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path relative to the current workspace."},
"old_text": {"type": "string", "description": "Exact text to replace."},
"new_text": {"type": "string", "description": "Replacement text."},
},
"required": ["path", "old_text", "new_text"],
}
class WorkspacePathError(ValueError): class WorkspacePathError(ValueError):
"""Raised when a requested path escapes the configured workspace.""" """Raised when a requested path escapes the configured workspace."""
@ -158,6 +177,20 @@ def _resolve_existing_path(workspace: str | None, user_path: str | None) -> tupl
return root, resolved return root, resolved
def _resolve_writable_path(workspace: str | None, user_path: str | None) -> tuple[Path, Path]:
root = _workspace_root(workspace)
if not user_path or not str(user_path).strip():
raise WorkspacePathError("path is required")
raw_path = Path(str(user_path)).expanduser()
candidate = raw_path if raw_path.is_absolute() else root / raw_path
parent = candidate.parent.resolve(strict=True)
try:
parent.relative_to(root)
except ValueError as exc:
raise WorkspacePathError(f"path escapes workspace: {user_path}") from exc
return root, parent / candidate.name
def _relative_path(root: Path, path: Path) -> str: def _relative_path(root: Path, path: Path) -> str:
try: try:
return str(path.relative_to(root)) or "." return str(path.relative_to(root)) or "."
@ -440,3 +473,73 @@ class SearchFilesTool:
) )
except (OSError, WorkspacePathError, ValueError) as exc: except (OSError, WorkspacePathError, ValueError) as exc:
return _json_result(False, error=str(exc), path=path) return _json_result(False, error=str(exc), path=path)
@dataclass(slots=True)
class WriteFileTool:
"""Write a UTF-8 text file inside the current workspace."""
name: str = "write_file"
description: str = (
"Write a UTF-8 text file inside the current workspace, replacing the full file. "
"Use patch_file for targeted edits. Paths outside the workspace are rejected."
)
toolset: str = "filesystem"
always_available: bool = False
workspace: str | None = None
parameters: dict[str, Any] = field(default_factory=lambda: dict(WRITE_FILE_PARAMETERS))
async def execute(self, *, path: str, content: str, workspace: str | None = None) -> str:
try:
root, resolved = _resolve_writable_path(workspace, path)
resolved.parent.mkdir(parents=True, exist_ok=True)
resolved.write_text(str(content), encoding="utf-8")
return _json_result(True, path=_relative_path(root, resolved), bytes=len(str(content).encode("utf-8")))
except (OSError, WorkspacePathError, ValueError) as exc:
return _json_result(False, error=str(exc), path=path)
@dataclass(slots=True)
class PatchFileTool:
"""Replace an exact text fragment inside a workspace file."""
name: str = "patch_file"
description: str = (
"Replace an exact text fragment inside a UTF-8 workspace file. "
"Fails if old_text is missing or ambiguous."
)
toolset: str = "filesystem"
always_available: bool = False
workspace: str | None = None
parameters: dict[str, Any] = field(default_factory=lambda: dict(PATCH_FILE_PARAMETERS))
async def execute(
self,
*,
path: str,
old_text: str,
new_text: str,
workspace: str | None = None,
) -> str:
try:
root, resolved = _resolve_existing_path(workspace, path)
if not resolved.is_file():
return _json_result(False, error="not_a_file", path=path)
content = _read_text_file(resolved)
occurrences = content.count(old_text)
if occurrences == 0:
return _json_result(False, error="old_text_not_found", path=path)
if occurrences > 1:
return _json_result(False, error="old_text_ambiguous", occurrences=occurrences, path=path)
updated = content.replace(old_text, new_text, 1)
resolved.write_text(updated, encoding="utf-8")
return _json_result(
True,
path=_relative_path(root, resolved),
old_bytes=len(old_text.encode("utf-8")),
new_bytes=len(new_text.encode("utf-8")),
)
except UnicodeDecodeError:
return _json_result(False, error="file is not valid UTF-8 text", path=path)
except (OSError, WorkspacePathError, ValueError) as exc:
return _json_result(False, error=str(exc), path=path)

View File

@ -0,0 +1,87 @@
"""Runtime tools for listing and managing skills."""
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Any
from beaver.tools.base import BaseTool, ToolContext, ToolResult, ToolSpec
def _result(tool_name: str, success: bool, **payload: Any) -> ToolResult:
return ToolResult(
success=success,
tool_name=tool_name,
content=json.dumps({"success": success, **payload}, ensure_ascii=False, indent=2),
error=None if success else str(payload.get("error") or "failed"),
)
@dataclass(slots=True)
class SkillsListTool(BaseTool):
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="skills_list",
description="List available skills with descriptions.",
input_schema={"type": "object", "properties": {}},
toolset="skills",
)
async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult:
loader = context.get("skills_loader")
if loader is None:
return _result(self.spec.name, False, error="skills_loader is unavailable")
skills = [
{
"name": record.name,
"description": record.description,
"source": record.source,
"version": record.version,
"tool_hints": list(record.tool_hints),
}
for record in loader.list_skills(filter_unavailable=False)
]
return _result(self.spec.name, True, skills=skills)
@dataclass(slots=True)
class SkillManageTool(BaseTool):
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="skill_manage",
description="Create a new skill draft. Publishing still goes through the normal review/publish APIs.",
input_schema={
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["create_draft"]},
"name": {"type": "string"},
"description": {"type": "string"},
"content": {"type": "string"},
},
"required": ["action", "name", "content"],
},
toolset="skills",
)
async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult:
if arguments.get("action") != "create_draft":
return _result(self.spec.name, False, error="only create_draft is supported")
draft_service = context.get("draft_service")
if draft_service is None:
return _result(self.spec.name, False, error="draft_service is unavailable")
name = str(arguments.get("name") or "").strip()
content = str(arguments.get("content") or "").strip()
if not name or not content:
return _result(self.spec.name, False, error="name and content are required")
draft = draft_service.create_new_skill_draft(
skill_name=name,
proposed_content=content,
proposed_frontmatter={"description": str(arguments.get("description") or name)},
created_by=context.user_id or "agent",
reason="created by skill_manage tool",
trigger_session_id=context.session_id,
)
return _result(self.spec.name, True, draft=draft.to_dict())

View File

@ -0,0 +1,213 @@
"""Local terminal and background process tools."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
import json
from pathlib import Path
import sys
from typing import Any
from uuid import uuid4
def _json_result(success: bool, **payload: Any) -> str:
return json.dumps({"success": success, **payload}, ensure_ascii=False, indent=2)
class BackgroundProcessStore:
def __init__(self) -> None:
self._processes: dict[str, asyncio.subprocess.Process] = {}
self._logs: dict[str, bytes] = {}
async def start(self, command: str, cwd: str | None = None) -> str:
process_id = uuid4().hex[:12]
proc = await asyncio.create_subprocess_shell(
command,
cwd=cwd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
self._processes[process_id] = proc
self._logs[process_id] = b""
asyncio.create_task(self._drain(process_id, proc))
return process_id
async def _drain(self, process_id: str, proc: asyncio.subprocess.Process) -> None:
if proc.stdout is None:
return
while True:
chunk = await proc.stdout.read(4096)
if not chunk:
break
self._logs[process_id] = (self._logs.get(process_id, b"") + chunk)[-200_000:]
def list(self) -> list[dict[str, Any]]:
rows = []
for process_id, proc in self._processes.items():
rows.append({"process_id": process_id, "returncode": proc.returncode, "running": proc.returncode is None})
return rows
def log(self, process_id: str, limit: int = 12000) -> str:
return self._logs.get(process_id, b"")[-limit:].decode("utf-8", errors="replace")
async def kill(self, process_id: str) -> bool:
proc = self._processes.get(process_id)
if proc is None:
return False
if proc.returncode is None:
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=5)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return True
GLOBAL_PROCESS_STORE = BackgroundProcessStore()
def _workspace_cwd(workspace: str | None, working_dir: str | None) -> str | None:
if not workspace:
return None
root = Path(workspace).expanduser().resolve()
raw = Path(working_dir or ".").expanduser()
candidate = raw if raw.is_absolute() else root / raw
resolved = candidate.resolve()
resolved.relative_to(root)
return str(resolved)
@dataclass(slots=True)
class TerminalTool:
name: str = "terminal"
description: str = "Execute a shell command. Set background=true for long-running commands."
toolset: str = "terminal"
always_available: bool = False
parameters: dict[str, Any] = field(
default_factory=lambda: {
"type": "object",
"properties": {
"command": {"type": "string"},
"working_dir": {"type": "string", "default": "."},
"timeout": {"type": "integer", "default": 60, "minimum": 1, "maximum": 600},
"background": {"type": "boolean", "default": False},
},
"required": ["command"],
}
)
async def execute(
self,
*,
command: str,
working_dir: str | None = None,
timeout: int = 60,
background: bool = False,
workspace: str | None = None,
) -> str:
try:
if not command.strip():
raise ValueError("command is required")
cwd = _workspace_cwd(workspace, working_dir)
if background:
process_id = await GLOBAL_PROCESS_STORE.start(command, cwd=cwd)
return _json_result(True, process_id=process_id, background=True)
proc = await asyncio.create_subprocess_shell(
command,
cwd=cwd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
output, _ = await asyncio.wait_for(proc.communicate(), timeout=max(1, min(int(timeout or 60), 600)))
text = output.decode("utf-8", errors="replace")
return _json_result(True, returncode=proc.returncode, output=text[-50000:])
except Exception as exc:
return _json_result(False, error=str(exc))
@dataclass(slots=True)
class ProcessTool:
name: str = "process"
description: str = "Manage background processes started with terminal(background=true)."
toolset: str = "terminal"
always_available: bool = False
parameters: dict[str, Any] = field(
default_factory=lambda: {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "log", "kill"]},
"process_id": {"type": "string"},
},
"required": ["action"],
}
)
async def execute(self, *, action: str, process_id: str | None = None, **_: Any) -> str:
if action == "list":
return _json_result(True, processes=GLOBAL_PROCESS_STORE.list())
if action == "log":
if not process_id:
return _json_result(False, error="process_id is required")
return _json_result(True, process_id=process_id, output=GLOBAL_PROCESS_STORE.log(process_id))
if action == "kill":
if not process_id:
return _json_result(False, error="process_id is required")
return _json_result(await GLOBAL_PROCESS_STORE.kill(process_id), process_id=process_id)
return _json_result(False, error=f"unknown action: {action}")
@dataclass(slots=True)
class ExecuteCodeTool:
name: str = "execute_code"
description: str = "Execute small Python snippets locally without external APIs."
toolset: str = "terminal"
always_available: bool = False
parameters: dict[str, Any] = field(
default_factory=lambda: {
"type": "object",
"properties": {
"language": {"type": "string", "enum": ["python"], "default": "python"},
"code": {"type": "string"},
"timeout": {"type": "integer", "default": 30, "minimum": 1, "maximum": 120},
"working_dir": {"type": "string", "default": "."},
},
"required": ["code"],
}
)
async def execute(
self,
*,
code: str,
language: str = "python",
timeout: int = 30,
working_dir: str | None = None,
workspace: str | None = None,
) -> str:
try:
if language != "python":
raise ValueError("Only python is supported")
cwd = _workspace_cwd(workspace, working_dir)
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-I",
"-",
cwd=cwd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
output, _ = await asyncio.wait_for(
proc.communicate(code.encode("utf-8")),
timeout=max(1, min(int(timeout or 30), 120)),
)
return _json_result(
True,
language="python",
returncode=proc.returncode,
output=output.decode("utf-8", errors="replace")[-50000:],
)
except Exception as exc:
return _json_result(False, error=str(exc))

View File

@ -0,0 +1,137 @@
"""Small local utility tools."""
from __future__ import annotations
from dataclasses import dataclass, field
import json
from typing import Any
def _json_result(success: bool, **payload: Any) -> str:
return json.dumps({"success": success, **payload}, ensure_ascii=False, indent=2)
@dataclass(slots=True)
class TodoTool:
name: str = "todo"
description: str = "Manage a lightweight task list for the current session."
toolset: str = "planning"
always_available: bool = False
parameters: dict[str, Any] = field(
default_factory=lambda: {
"type": "object",
"properties": {
"todos": {"type": "array", "items": {"type": "object"}},
"merge": {"type": "boolean", "default": False},
},
}
)
async def execute(self, *, todos: list[dict[str, Any]] | None = None, merge: bool = False, **kwargs: Any) -> str:
metadata = kwargs.get("metadata") if isinstance(kwargs.get("metadata"), dict) else {}
current = list(metadata.get("todos") or [])
if todos is None:
return _json_result(True, todos=current)
next_todos = [dict(item) for item in todos if isinstance(item, dict)]
metadata["todos"] = [*current, *next_todos] if merge else next_todos
return _json_result(True, todos=metadata["todos"])
@dataclass(slots=True)
class ClarifyTool:
name: str = "clarify"
description: str = "Ask the user for clarification by returning a structured question."
toolset: str = "planning"
always_available: bool = False
parameters: dict[str, Any] = field(
default_factory=lambda: {
"type": "object",
"properties": {
"question": {"type": "string"},
"choices": {"type": "array", "items": {"type": "string"}},
},
"required": ["question"],
}
)
async def execute(self, *, question: str, choices: list[str] | None = None, **_: Any) -> str:
return _json_result(True, question=question, choices=[str(item) for item in (choices or [])])
@dataclass(slots=True)
class SendMessageTool:
name: str = "send_message"
description: str = "Return a message payload for an external channel. Actual delivery is handled by configured services."
toolset: str = "messaging"
always_available: bool = False
parameters: dict[str, Any] = field(
default_factory=lambda: {
"type": "object",
"properties": {
"target": {"type": "string"},
"message": {"type": "string"},
},
"required": ["target", "message"],
}
)
async def execute(self, *, target: str, message: str, **_: Any) -> str:
return _json_result(True, target=target, message=message, delivered=False)
@dataclass(slots=True)
class DelegateTool:
name: str = "delegate"
description: str = "Create a structured delegation request for a sub-agent or teammate."
toolset: str = "coordination"
always_available: bool = False
parameters: dict[str, Any] = field(
default_factory=lambda: {
"type": "object",
"properties": {
"task": {"type": "string"},
"agent": {"type": "string"},
"context": {"type": "object"},
},
"required": ["task"],
}
)
async def execute(self, *, task: str, agent: str | None = None, context: dict[str, Any] | None = None, **_: Any) -> str:
return _json_result(
True,
task=task,
agent=agent or "default",
context=dict(context or {}),
queued=False,
note="Delegation request recorded; runtime execution is handled by configured agent services.",
)
@dataclass(slots=True)
class SpawnTool:
name: str = "spawn"
description: str = "Create a structured request to spawn a bounded subtask."
toolset: str = "coordination"
always_available: bool = False
parameters: dict[str, Any] = field(
default_factory=lambda: {
"type": "object",
"properties": {
"task": {"type": "string"},
"role": {"type": "string", "default": "worker"},
"write_scope": {"type": "array", "items": {"type": "string"}},
},
"required": ["task"],
}
)
async def execute(self, *, task: str, role: str = "worker", write_scope: list[str] | None = None, **_: Any) -> str:
return _json_result(
True,
task=task,
role=role,
write_scope=[str(item) for item in (write_scope or [])],
queued=False,
note="Spawn request recorded; runtime execution is handled by configured agent services.",
)

View File

@ -0,0 +1,117 @@
"""No-key web search and fetch tools."""
from __future__ import annotations
from dataclasses import dataclass, field
from html import unescape
import json
import re
from typing import Any
from urllib.parse import quote_plus, urlparse
import httpx
def _json_result(success: bool, **payload: Any) -> str:
return json.dumps({"success": success, **payload}, ensure_ascii=False, indent=2)
def _strip_html(value: str) -> str:
text = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", value)
text = re.sub(r"(?s)<[^>]+>", " ", text)
text = unescape(text)
return re.sub(r"\s+", " ", text).strip()
def _safe_url(url: str) -> str:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("url must be an http(s) URL")
return url
@dataclass(slots=True)
class WebFetchTool:
name: str = "web_fetch"
description: str = "Fetch a public HTTP(S) page and return readable text. No API key required."
toolset: str = "web"
always_available: bool = False
parameters: dict[str, Any] = field(
default_factory=lambda: {
"type": "object",
"properties": {
"url": {"type": "string", "description": "HTTP(S) URL to fetch."},
"max_chars": {"type": "integer", "default": 12000, "minimum": 1000, "maximum": 50000},
},
"required": ["url"],
}
)
async def execute(self, *, url: str, max_chars: int = 12000, **_: Any) -> str:
try:
safe_url = _safe_url(url)
limit = max(1000, min(int(max_chars or 12000), 50000))
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client:
response = await client.get(
safe_url,
headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"},
)
response.raise_for_status()
content_type = response.headers.get("content-type", "")
raw = response.text
text = _strip_html(raw) if "html" in content_type.lower() else raw
truncated = len(text) > limit
return _json_result(
True,
url=str(response.url),
status_code=response.status_code,
content_type=content_type,
content=text[:limit],
truncated=truncated,
)
except Exception as exc:
return _json_result(False, url=url, error=str(exc))
@dataclass(slots=True)
class WebSearchTool:
name: str = "web_search"
description: str = "Search the web using DuckDuckGo HTML results. No API key required."
toolset: str = "web"
always_available: bool = False
parameters: dict[str, Any] = field(
default_factory=lambda: {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query."},
"limit": {"type": "integer", "default": 5, "minimum": 1, "maximum": 10},
},
"required": ["query"],
}
)
async def execute(self, *, query: str, limit: int = 5, **_: Any) -> str:
try:
if not str(query).strip():
raise ValueError("query is required")
bounded = max(1, min(int(limit or 5), 10))
url = f"https://duckduckgo.com/html/?q={quote_plus(query)}"
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client:
response = await client.get(url, headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"})
response.raise_for_status()
html = response.text
results: list[dict[str, str]] = []
pattern = re.compile(
r'<a[^>]+class="result__a"[^>]+href="(?P<url>[^"]+)"[^>]*>(?P<title>.*?)</a>',
re.I | re.S,
)
for match in pattern.finditer(html):
title = _strip_html(match.group("title"))
result_url = unescape(match.group("url"))
if title and result_url:
results.append({"title": title, "url": result_url, "snippet": ""})
if len(results) >= bounded:
break
return _json_result(True, query=query, results=results)
except Exception as exc:
return _json_result(False, query=query, error=str(exc))

View File

@ -1,2 +1,5 @@
"""MCP-backed tool integrations.""" """MCP-backed tool integrations."""
from .wrapper import MCPToolWrapper
__all__ = ["MCPToolWrapper"]

View File

@ -0,0 +1,88 @@
"""MCP tool wrappers for Beaver's tool contract."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import json
from typing import Any, Awaitable, Callable
from beaver.tools.base import BaseTool, ToolContext, ToolResult, ToolSpec
def _tool_schema(tool_def: Any) -> dict[str, Any]:
schema = getattr(tool_def, "inputSchema", None) or getattr(tool_def, "input_schema", None)
if isinstance(schema, dict):
return schema
return {"type": "object", "properties": {}}
def _tool_name(tool_def: Any) -> str:
return str(getattr(tool_def, "name", "") or "")
def _tool_description(tool_def: Any) -> str:
return str(getattr(tool_def, "description", "") or _tool_name(tool_def))
def _mcp_result_to_text(result: Any) -> str:
parts: list[str] = []
for block in list(getattr(result, "content", []) or []):
text = getattr(block, "text", None)
parts.append(str(text if text is not None else block))
if not parts and getattr(result, "structuredContent", None) is not None:
return json.dumps(getattr(result, "structuredContent"), ensure_ascii=False, indent=2)
return "\n".join(parts) or "(no output)"
@dataclass(slots=True)
class MCPToolWrapper(BaseTool):
server_id: str
tool_def: Any
call_tool: Callable[[str, dict[str, Any]], Awaitable[Any]]
tool_timeout: int = 30
sensitive: bool = False
kind: str = "online"
category: str = "online"
display_name: str = ""
@property
def original_name(self) -> str:
return _tool_name(self.tool_def)
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name=f"mcp_{self.server_id}_{self.original_name}",
description=_tool_description(self.tool_def),
input_schema=_tool_schema(self.tool_def),
toolset=f"mcp-{self.server_id}",
metadata={
"server_id": self.server_id,
"original_tool_name": self.original_name,
"kind": self.kind,
"category": self.category,
"display_name": self.display_name or self.server_id,
"transport": "mcp",
},
)
async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult:
try:
result = await asyncio.wait_for(
self.call_tool(self.original_name, dict(arguments or {})),
timeout=max(1, int(self.tool_timeout or 30)),
)
return ToolResult(
success=True,
content=_mcp_result_to_text(result),
tool_name=self.spec.name,
raw_output=result,
)
except Exception as exc:
return ToolResult(
success=False,
content=f"MCP tool {self.server_id}.{self.original_name} failed: {exc}",
tool_name=self.spec.name,
error=str(exc),
)

View File

@ -74,7 +74,7 @@
4. Agent Team 已融入 Task mode 内部执行策略。 4. Agent Team 已融入 Task mode 内部执行策略。
- `TaskExecutionPlanner` 先用 LLM JSON 规划 `single / team` - `TaskExecutionPlanner` 先用 LLM JSON 规划 `single / team`
- team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设。 - team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设。
- `TaskSkillResolver` 为每个 generic sub-agent 选择 published skill未命中时生成 draft-only skill并作为本次 run 的 ephemeral pinned instruction 使用。 - `TaskSkillResolver` 为每个 generic sub-agent 选择 published skill未命中时生成 ephemeral guidance并作为本次 run 的 pinned guidance 使用。
- team 模式调用 `TeamService.run_team(...)` 产生 sub-agent runs。 - team 模式调用 `TeamService.run_team(...)` 产生 sub-agent runs。
- Team 输出只作为主 Agent synthesis run 的内部上下文。 - Team 输出只作为主 Agent synthesis run 的内部上下文。
- 用户可见最终回答仍由主 Agent 生成,并继续走验证、反馈和学习门控。 - 用户可见最终回答仍由主 Agent 生成,并继续走验证、反馈和学习门控。
@ -914,15 +914,15 @@ app-instance/backend/
- sub-agent 是临时 generic worker不承载固定角色人设。 - sub-agent 是临时 generic worker不承载固定角色人设。
- `TaskExecutionPlanner` 的 team node 输出 `skill_query / required_capabilities / expected_output` - `TaskExecutionPlanner` 的 team node 输出 `skill_query / required_capabilities / expected_output`
- `TaskSkillResolver` 从 published skill catalog 中选择合适 skill并写入 node pinned skills。 - `TaskSkillResolver` 从 published skill catalog 中选择合适 skill并写入 node pinned skills。
- 如果没有命中 published skill会创建 draft-only skill并把 draft 内容作为本次 sub-agent 的 ephemeral pinned skill context 使用。 - 如果没有命中 published skill会创建 ephemeral guidance作为本次 sub-agent 的 pinned skill context 使用。
- draft 不自动 approve/publish不进入 runtime catalog;后续仍走 review/publish - ephemeral guidance 不写入 draft store不自动 approve/publish不进入 runtime catalog。
- agent registry / target resolver 不参与 Task sub-agent strategy可作为未来外部 agent/A2A 管理面保留。 - agent registry / target resolver 不参与 Task sub-agent strategy可作为未来外部 agent/A2A 管理面保留。
2. **Task Team Process Projection** 2. **Task Team Process Projection**
- Task attempt 隐藏事件增加 `skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report / node_results / task_synthesis_completed` - Task attempt 隐藏事件增加 `skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report / node_results / task_synthesis_completed`
- 新增 `GET /api/sessions/{session_id}/process` - 新增 `GET /api/sessions/{session_id}/process`
- 前端 `ChatWorkbench` 已接入 `ProcessLane` 和移动端 `Process` tab。 - 前端 `ChatWorkbench` 已接入 `ProcessLane` 和移动端 `Process` tab。
- 展示规划、skill selection、draft-only ephemeral guidance、team node、main synthesis、validation/retry不把 team summary 直接当最终回答。 - 展示规划、skill selection、ephemeral guidance、team node、main synthesis、validation/retry不把 team summary 直接当最终回答。
3. **Learning Pipeline 闭环** 3. **Learning Pipeline 闭环**
- 新增 `SkillLearningPipelineService` - 新增 `SkillLearningPipelineService`

View File

@ -18,7 +18,7 @@
└─ future channels未来扩展入口 └─ future channels未来扩展入口
└─ AgentService统一服务层所有入口都先汇总到这里 └─ AgentService统一服务层所有入口都先汇总到这里
├─ MainAgentRouter自动判断 simple / task ├─ MainAgentRouterLLM 语义判断 simple / continue task / new task / close / abandon
├─ create_loop()(创建 AgentLoop 运行核心) ├─ create_loop()(创建 AgentLoop 运行核心)
├─ start()(启动后台运行模式) ├─ start()(启动后台运行模式)
├─ submit_direct()(把任务提交到运行队列) ├─ submit_direct()(把任务提交到运行队列)
@ -73,15 +73,20 @@ AgentService.process_direct / submit_direct聊天入口统一进入服务层
├─ resolve session_id复用请求 session或生成新 session ├─ resolve session_id复用请求 session或生成新 session
├─ task_service.get_latest_open_task(session_id)(查找同会话未关闭 Task ├─ task_service.get_latest_open_task(session_id)(查找同会话未关闭 Task
├─ MainAgentRouter.classify(message, active_task)(自动分类) ├─ MainAgentRouter.classify(message, active_task, recent_messages)LLM 语义分类)
│ ├─ simple简单问题 │ ├─ simple简单问题
│ │ └─ runner(message)(直接走原有 AgentLoop不创建 Task │ │ └─ runner(message, include_skill_assembly=False, include_tools=False)(不创建 Task不跑 skills/tools
│ │ │ │
task复杂任务 continue_task继续当前 Task
├─ if no active task or user starts new task └─ reuse active Task(只要话题没有完全无关,就继续当前 open Task
│ └─ TaskService.create_task(...)(内部创建 Task
├─ else ├─ new_task明确开启新任务
│ └─ reuse active Task复用 awaiting_feedback / needs_revision Task │ │ └─ TaskService.create_task(...)(内部创建 Task并保存 short_title
│ │
│ ├─ close_task / abandon_task用户明确结束或放弃
│ │ └─ TaskService.close_task / abandon_task关闭当前 Task
│ │
│ └─ task execution
│ └─ AgentService._run_task_mode(...)(进入 Task 模式执行) │ └─ AgentService._run_task_mode(...)(进入 Task 模式执行)
``` ```
@ -92,6 +97,7 @@ TaskService内部 Task 状态机)
│ ├─ task_id │ ├─ task_id
│ ├─ session_id │ ├─ session_id
│ ├─ goal / description / constraints │ ├─ goal / description / constraints
│ ├─ metadata.short_title5-15 字左右的短标题,用于前端当前任务标识)
│ ├─ status │ ├─ status
│ │ ├─ open │ │ ├─ open
│ │ ├─ running │ │ ├─ running
@ -167,17 +173,32 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
│ ├─ auxiliary_provider辅助模型调用器用于选 skill 等) │ ├─ auxiliary_provider辅助模型调用器用于选 skill 等)
│ └─ embedding_runtime向量模型配置用于语义召回 │ └─ embedding_runtime向量模型配置用于语义召回
├─ skill_assembler.assemble(...)(选择本轮应该激活哪些 skill ├─ if include_skill_assembly=Falsesimple_chat 默认关闭
SkillsLoader.build_selection_candidates()(列出候选技能摘要 skip SkillAssembler不激活 skill不注入 skill 正文
├─ embedding retrieve skill candidates用向量召回相关技能
│ ├─ LLM select activated skills让模型从候选里选择技能 ├─ if include_skill_assembly=TrueTask mode 默认开启,在 Task 创建/复用和规划之后执行
│ └─ 返回 activated skills返回本轮被激活的技能 │ └─ skill_assembler.assemble(...)(选择本轮应该激活哪些 published skill
│ ├─ name技能名称 │ ├─ input task_description = skill_selection_context or current user input
├─ content技能正文 │ ├─ Task goal / description
├─ version技能版本 │ ├─ current user request
├─ content_hash技能内容哈希用于追踪 │ ├─ attempt / revision / team synthesis phase
├─ activation_reason为什么激活 │ ├─ validation feedback重试时
└─ tool_hints技能建议使用哪些工具 │ ├─ team summary / planteam synthesis 时
│ │ └─ previously activated skills只作为 reuse bias不是 pinned
│ ├─ SkillsLoader.build_selection_candidates()(列出候选技能摘要)
│ ├─ embedding retrieve skill candidates用向量召回相关技能
│ ├─ LLM shortlist candidate names先用摘要粗选少量候选
│ │ └─ if retrieved candidates <= max_detailed_candidates -> skip shortlist
│ ├─ SkillsLoader.load_published_skill(...)(系统侧内部读取粗选候选正文,不暴露 skill_view 给主 Agent
│ ├─ LLM final select activated skills结合候选正文做最终选择
│ ├─ if no matching skill -> return [] and continue run without skills
│ └─ 返回 activated skills返回本轮被激活的技能
│ ├─ name技能名称
│ ├─ content技能正文
│ ├─ version技能版本
│ ├─ content_hash技能内容哈希用于追踪
│ ├─ activation_reason为什么激活
│ └─ tool_hints技能建议使用哪些工具
├─ ContextBuilder.build_skill_activation_messages(...)(把激活技能变成模型可读消息) ├─ ContextBuilder.build_skill_activation_messages(...)(把激活技能变成模型可读消息)
├─ 构造 SkillActivationReceipt[](构造技能激活收据) ├─ 构造 SkillActivationReceipt[](构造技能激活收据)
@ -188,7 +209,7 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
│ ├─ receipts技能激活收据 │ ├─ receipts技能激活收据
│ └─ activation_messages实际注入给模型的技能消息 │ └─ activation_messages实际注入给模型的技能消息
├─ tool_assembler.assemble(...)(选择本轮应该暴露哪些工具) ├─ tool_assembler.assemble(...)(选择本轮应该暴露哪些工具simple_chat 默认跳过
│ ├─ always tools默认总是可用的工具 │ ├─ always tools默认总是可用的工具
│ ├─ activated skill tool hints被激活技能推荐的工具 │ ├─ activated skill tool hints被激活技能推荐的工具
│ ├─ embedding retrieve tools用向量召回相关工具 │ ├─ embedding retrieve tools用向量召回相关工具
@ -207,6 +228,7 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
│ └─ append current user input追加当前用户输入 │ └─ append current user input追加当前用户输入
├─ session_manager.update_system_prompt(...)(把本轮 system prompt 快照写回会话) ├─ session_manager.update_system_prompt(...)(把本轮 system prompt 快照写回会话)
├─ session_manager.append_message(event_type="skill_selection_context_snapshotted", hidden)(完整记录 skill query
├─ session_manager.append_message(event_type="system_prompt_snapshotted", hidden)记录隐藏事件system prompt 快照) ├─ session_manager.append_message(event_type="system_prompt_snapshotted", hidden)记录隐藏事件system prompt 快照)
├─ session_manager.append_message(event_type="user_message_added")(记录可见事件:用户消息) ├─ session_manager.append_message(event_type="user_message_added")(记录可见事件:用户消息)
@ -214,12 +236,12 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
├─ 成功时(模型正常结束) ├─ 成功时(模型正常结束)
│ ├─ session_manager.append_message(event_type="run_completed", hidden)(记录隐藏事件:运行完成) │ ├─ session_manager.append_message(event_type="run_completed", hidden)(记录隐藏事件:运行完成)
│ └─ _record_skill_learning(...)(记录技能使用效果,进入学习闭环 │ └─ _record_run_receipts(...)(记录运行证据,不生成学习候选
├─ 失败时(运行中出现异常) ├─ 失败时(运行中出现异常)
│ ├─ append assistant error message写入 assistant 错误消息) │ ├─ append assistant error message写入 assistant 错误消息)
│ ├─ session_manager.append_message(event_type="run_failed", hidden)(记录隐藏事件:运行失败) │ ├─ session_manager.append_message(event_type="run_failed", hidden)(记录隐藏事件:运行失败)
│ └─ _record_skill_learning(...)(即使失败也记录技能效果 │ └─ _record_run_receipts(...)(即使失败也记录运行证据
└─ return AgentRunResult返回本轮结果 └─ return AgentRunResult返回本轮结果
├─ session_id会话编号 ├─ session_id会话编号
@ -242,6 +264,7 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
```text ```text
tool loop工具调用循环 tool loop工具调用循环
├─ session_manager.append_message(event_type="llm_request_snapshotted", hidden)(完整记录本次 provider messages / tools
├─ provider.chat(messages, tools=schemas)(把消息和工具 schema 发给模型) ├─ provider.chat(messages, tools=schemas)(把消息和工具 schema 发给模型)
├─ session_manager.update_usage(...)(累计 token 用量) ├─ session_manager.update_usage(...)(累计 token 用量)
├─ session_manager.append_message(event_type="assistant_message_added")(记录 assistant 回复) ├─ session_manager.append_message(event_type="assistant_message_added")(记录 assistant 回复)
@ -259,10 +282,10 @@ tool loop工具调用循环
--- ---
## 6. Skills Learning Baseline ## 6. Run Evidence / Skill Effect Recording
```text ```text
AgentLoop._record_skill_learning(...)(记录本轮技能效果 AgentLoop._record_run_receipts(...)(记录本轮运行证据;不直接学习
├─ 构造 RunRecord构造本轮运行记录 ├─ 构造 RunRecord构造本轮运行记录
│ ├─ run_id运行编号 │ ├─ run_id运行编号
@ -286,11 +309,7 @@ AgentLoop._record_skill_learning(...)(记录本轮技能效果)
│ ├─ RunMemoryStore.append_skill_effect(...)(把 SkillEffectRecord 写入 memory/runs/skill-effects.jsonl │ ├─ RunMemoryStore.append_skill_effect(...)(把 SkillEffectRecord 写入 memory/runs/skill-effects.jsonl
│ ├─ SkillLearningService.rescore_skill_versions()(重新统计每个技能版本表现) │ ├─ SkillLearningService.rescore_skill_versions()(重新统计每个技能版本表现)
│ │ └─ SkillLearningStore.update_performance_snapshot(...)(更新表现快照) │ │ └─ SkillLearningStore.update_performance_snapshot(...)(更新表现快照)
│ └─ optionally build learning candidates(默认不生成;只由反馈门控显式触发 │ └─ never build learning candidates in runtime hot path运行完成时永不生成候选
│ ├─ revise_skill建议修改已有技能
│ ├─ new_skill建议创建新技能
│ ├─ merge_skills建议合并相似技能
│ └─ retire_skill建议退役长期不用的技能
└─ session_manager.append_message(...)(记录隐藏事件:技能效果快照) └─ session_manager.append_message(...)(记录隐藏事件:技能效果快照)
├─ event_type="skill_effects_snapshotted"(技能效果已快照) ├─ event_type="skill_effects_snapshotted"(技能效果已快照)
@ -298,10 +317,23 @@ AgentLoop._record_skill_learning(...)(记录本轮技能效果)
└─ payload隐藏数据 └─ payload隐藏数据
├─ run_record本轮运行记录 ├─ run_record本轮运行记录
├─ skill_effects技能效果记录 ├─ skill_effects技能效果记录
├─ learning_candidate_enabled本轮是否允许生成候选,默认 false ├─ candidate_generation_allowed本轮是否允许生成候选runtime 固定 false
└─ learning_candidates学习候选默认空 └─ learning_candidates学习候选默认空
``` ```
```text
runtime invariant运行期不直接学习
├─ run completed / run failed
│ └─ 只写 RunRecord + SkillEffectRecord + performance snapshot
├─ simple chat
│ └─ 不创建 Task不触发 learning candidate
└─ Task attempt / sub-agent run
└─ 只留下证据,等待 feedback gate 决定是否学习
```
--- ---
## 7. Chat Feedback / Learning Gate ## 7. Chat Feedback / Learning Gate
@ -328,17 +360,20 @@ POST /api/chat/feedback聊天反馈接口不是 Task 管理 API
├─ satisfied ├─ satisfied
│ ├─ if validation accepted │ ├─ if validation accepted
│ │ ├─ Task status -> closed │ │ ├─ Task status -> closed
│ │ └─ SkillLearningService.build_learning_candidates() │ │ └─ SkillLearningService.build_learning_candidates_for_task(task_id, trigger_run_id)
│ └─ if validation not accepted │ └─ if validation not accepted
│ └─ 记录人工接受但保留验证风险 │ └─ 记录人工接受但保留验证风险;不自动生成 learning candidate
├─ revise ├─ revise
│ ├─ Task status -> needs_revision │ ├─ Task status -> needs_revision
下一条用户消息默认复用该 Task 更新 run / skill effect 为需修订证据
│ └─ 下一条用户消息默认复用该 Task不生成 learning candidate
└─ abandon └─ abandon
├─ Task status -> abandoned ├─ Task status -> abandoned
write Failure Memory不生成成功 Skill draft 更新 run / skill effect 为失败证据
├─ 追加 task_failure_evidence_recorded 隐藏事件
└─ 默认不写主 memory不生成成功 Skill draft
``` ```
--- ---
@ -356,15 +391,15 @@ TeamService.run_team(...)(内部 team 执行入口,不暴露产品级 Task A
│ │ └─ reserved strategies: moa / hierarchy / heavy / group_chat / forest / maker / router │ │ └─ reserved strategies: moa / hierarchy / heavy / group_chat / forest / maker / router
│ ├─ provider_bundle_factory(node)(推荐:每个节点拿 fresh provider bundle │ ├─ provider_bundle_factory(node)(推荐:每个节点拿 fresh provider bundle
│ ├─ inherited_pinned_skills主 agent 明确委派给 sub-agent 的 pinned skills │ ├─ inherited_pinned_skills主 agent 明确委派给 sub-agent 的 pinned skills
│ ├─ inherited_pinned_skill_contextsmissing skill draft 生成的 ephemeral skill guidance │ ├─ inherited_pinned_skill_contextsmissing skill 生成的一次性 ephemeral guidance
│ └─ learning_candidate_enabled=False默认只写 receipts不绕过 Task feedback gate │ └─ allow_candidate_generation=False默认只写 receipts不绕过 Task feedback gate
├─ LocalAgentRunner.run(envelope) ├─ LocalAgentRunner.run(envelope)
│ ├─ 生成 child_session_id │ ├─ 生成 child_session_id
│ ├─ parent_session_id -> 主 session建立 session lineage │ ├─ parent_session_id -> 主 session建立 session lineage
│ ├─ AgentLoop.process_direct / submit_direct(...)(复用主 AgentLoop / ContextBuilder / ToolAssembler / SkillAssembler / MemoryService │ ├─ AgentLoop.process_direct / submit_direct(...)(复用主 AgentLoop / ContextBuilder / ToolAssembler / SkillAssembler / MemoryService
│ ├─ pinned_skill_names -> AgentLooppublished pinned skill 必须注入) │ ├─ pinned_skill_names -> AgentLooppublished pinned skill 必须注入)
│ ├─ pinned_skill_contexts -> AgentLoopdraft-only ephemeral skill 必须注入) │ ├─ pinned_skill_contexts -> AgentLoopephemeral guidance 只在本次 run 注入)
│ └─ provider_bundle + node model/provider override 禁止混用 │ └─ provider_bundle + node model/provider override 禁止混用
├─ strategy execution ├─ strategy execution
@ -522,7 +557,6 @@ SkillsLoader技能加载器
├─ build_skills_summary()(构造技能摘要索引) ├─ build_skills_summary()(构造技能摘要索引)
├─ build_selection_candidates()(构造给 SkillAssembler 的候选摘要) ├─ build_selection_candidates()(构造给 SkillAssembler 的候选摘要)
├─ list_skill_supporting_files()(列出技能支持文件) ├─ list_skill_supporting_files()(列出技能支持文件)
├─ view_skill()(查看技能正文或支持文件)
└─ get_always_skills()(获取 always 类型技能) └─ get_always_skills()(获取 always 类型技能)
``` ```
@ -530,13 +564,17 @@ SkillsLoader技能加载器
SkillAssembler技能选择器 SkillAssembler技能选择器
├─ input输入 ├─ input输入
│ ├─ task_description用户任务描述 │ ├─ task_descriptionTask-aware queryTask 描述 / 当前用户消息 / previous skills / attempt context / validation revision context / team context
│ ├─ candidate skill summaries候选技能摘要 │ ├─ candidate skill summaries候选技能摘要
│ ├─ embedding runtime向量模型配置 │ ├─ embedding runtime向量模型配置
│ └─ selector provider/model用于选择技能的模型 │ └─ selector provider/model用于选择技能的模型
├─ embedding retrieve candidates先用向量召回相关技能 ├─ embedding retrieve candidates先用向量召回相关技能
├─ LLM select names再让 LLM 选择技能名 ├─ LLM shortlist names用摘要粗选需要查看正文的候选
│ └─ skip when candidate count <= max_detailed_candidates候选很少时直接读取正文
├─ internal load shortlisted SKILL.mdSkillAssembler 内部读取候选正文)
├─ LLM final select names结合候选正文选择最终技能名
├─ no match returns [](没有对应 published skill 时返回空,不阻塞任务)
└─ return SkillContext[](返回技能上下文) └─ return SkillContext[](返回技能上下文)
├─ name技能名 ├─ name技能名
├─ content技能正文 ├─ content技能正文
@ -586,7 +624,6 @@ ToolRegistry工具注册表
├─ echo回显工具 ├─ echo回显工具
├─ memory写入/管理长期记忆) ├─ memory写入/管理长期记忆)
├─ skill_view查看完整 skill
├─ session_search搜索会话历史 ├─ session_search搜索会话历史
├─ list_directory列目录 ├─ list_directory列目录
├─ read_file读文件 ├─ read_file读文件
@ -599,7 +636,7 @@ ToolAssembler工具选择器
├─ selected = always tools先加入默认工具 ├─ selected = always tools先加入默认工具
├─ selected += activated skill tool hints再加入技能推荐工具 ├─ selected += activated skill tool hints再加入技能推荐工具
├─ selected += embedding top-k tools再用向量召回任务相关工具 ├─ selected += embedding top-k tools再用向量召回任务相关工具
└─ return ToolSpec[](返回本轮可用工具列表) └─ return ToolSpec[](返回本轮可用工具列表;不通过工具动态加载 skill
``` ```
```text ```text
@ -703,11 +740,11 @@ TaskExecutionPlannerTask 内部执行规划)
│ ├─ 从 published skill catalog 检索候选 │ ├─ 从 published skill catalog 检索候选
│ ├─ 按 skill_query / required_capabilities / node task 选择 skill │ ├─ 按 skill_query / required_capabilities / node task 选择 skill
│ ├─ 命中 published skill 后写入 graph.nodes[].inherited_pinned_skills │ ├─ 命中 published skill 后写入 graph.nodes[].inherited_pinned_skills
│ └─ 无命中时创建 draft-only skill,并写入 graph.nodes[].inherited_pinned_skill_contexts │ └─ 无命中时创建 ephemeral guidance,并写入 graph.nodes[].inherited_pinned_skill_contexts
└─ TaskExecutionPlan └─ TaskExecutionPlan
├─ graph.nodes[].agent 只是 generic runtime trace identity ├─ graph.nodes[].agent 只是 generic runtime trace identity
└─ to_event_payload() 写入 skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report └─ to_event_payload() 写入 skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report
``` ```
```text ```text
@ -748,8 +785,21 @@ Frontend process projection
```text ```text
Learning pipeline Learning pipeline
├─ evidence recording
│ ├─ every run -> RunRecord
│ ├─ activated skills -> SkillEffectRecord
│ └─ no candidates generated here
├─ feedback gate ├─ feedback gate
─ validation accepted + satisfied 才生成 learning candidate ─ validation accepted + satisfied -> scoped learning candidate
│ ├─ validation rejected + satisfied -> 记录人工接受风险,不生成候选
│ ├─ revise -> 保持 Task 打开,不生成候选
│ └─ abandon -> 失败证据,不写主 memory不生成成功候选
├─ scoped candidate generation
│ ├─ source = current task run_ids
│ ├─ no published skill -> new_skill
│ └─ published skill used -> revise_skill
├─ SkillLearningPipelineService ├─ SkillLearningPipelineService
│ ├─ candidate -> queued / synthesizing │ ├─ candidate -> queued / synthesizing
@ -799,6 +849,12 @@ Web网页入口
│ ├─ agent_service.submit_direct(...)(把用户消息提交给 AgentService │ ├─ agent_service.submit_direct(...)(把用户消息提交给 AgentService
│ └─ return WebChatResponse返回模型回复 + run/task/validation 元数据) │ └─ return WebChatResponse返回模型回复 + run/task/validation 元数据)
├─ WS /ws/{session_id}(网页 WebSocket 适配层)
│ ├─ ping -> pong
│ ├─ message -> agent_service.submit_direct(...)
│ ├─ return status / assistant message携带 run/task/validation 元数据)
│ └─ return session_updated通知前端刷新 session/process
└─ POST /api/chat/feedback聊天反馈接口 └─ POST /api/chat/feedback聊天反馈接口
├─ validate WebChatFeedbackRequest ├─ validate WebChatFeedbackRequest
├─ agent_service.submit_feedback(...) ├─ agent_service.submit_feedback(...)
@ -820,8 +876,10 @@ Skills learning admin API
Gateway消息通道入口 Gateway消息通道入口
├─ MessageBus内部消息总线 ├─ MessageBus内部消息总线
├─ ChannelAdapterTelegram / Slack / Email / WhatsApp 等只作为 adapter
├─ inbound -> AgentService.handle_inbound_message(...)(外部消息进入 AgentService ├─ inbound -> AgentService.handle_inbound_message(...)(外部消息进入 AgentService
─ outbound <- OutboundMessageAgentService 返回结构化输出消息) ─ outbound <- OutboundMessageAgentService 返回结构化输出消息)
└─ ChannelManager按 message.channel 分发 outbound
``` ```
--- ---

View File

@ -5,6 +5,7 @@ description = "Beaver backend skeleton"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"anthropic>=0.51.0,<1.0.0", "anthropic>=0.51.0,<1.0.0",
"croniter>=6.0.0,<7.0.0",
"fastmcp>=3.0.0,<4.0.0", "fastmcp>=3.0.0,<4.0.0",
"fastapi>=0.115.0,<1.0.0", "fastapi>=0.115.0,<1.0.0",
"httpx>=0.28.0,<1.0.0", "httpx>=0.28.0,<1.0.0",
@ -12,6 +13,7 @@ dependencies = [
"litellm>=1.79.0,<2.0.0", "litellm>=1.79.0,<2.0.0",
"openai>=1.79.0,<2.0.0", "openai>=1.79.0,<2.0.0",
"pydantic>=2.12.0,<3.0.0", "pydantic>=2.12.0,<3.0.0",
"python-multipart>=0.0.20,<1.0.0",
"typer>=0.20.0,<1.0.0", "typer>=0.20.0,<1.0.0",
"uvicorn[standard]>=0.34.0,<1.0.0", "uvicorn[standard]>=0.34.0,<1.0.0",
] ]

View File

@ -0,0 +1,80 @@
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
def test_active_task_api_returns_open_task_and_hides_closed(tmp_path: Path) -> None:
service = AgentService(workspace=tmp_path)
loaded = service.create_loop().boot()
task = loaded.task_service.create_task( # type: ignore[union-attr]
session_id="web:active",
description="实现任务连续性",
metadata={"short_title": "任务连续性"},
)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
active = client.get("/api/sessions/web:active/active-task")
listed = client.get("/api/tasks")
loaded.task_service.close_task(task.task_id, reason="done") # type: ignore[union-attr]
inactive = client.get("/api/sessions/web:active/active-task")
assert active.status_code == 200
assert active.json()["task_id"] == task.task_id
assert active.json()["short_title"] == "任务连续性"
assert listed.json()[0]["short_title"] == "任务连续性"
assert inactive.status_code == 200
assert inactive.json() is None
def test_active_task_api_hides_unengaged_cron_task(tmp_path: Path) -> None:
service = AgentService(workspace=tmp_path)
loaded = service.create_loop().boot()
hidden = loaded.task_service.create_task( # type: ignore[union-attr]
session_id="web:cron",
description="提醒用户喝水",
creator="cron",
metadata={"source": "scheduled_cron", "user_engaged": False},
)
visible = loaded.task_service.create_task( # type: ignore[union-attr]
session_id="web:engaged",
description="修改新闻总结",
creator="cron",
metadata={"source": "scheduled_run", "user_engaged": True},
)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
hidden_response = client.get("/api/sessions/web:cron/active-task")
visible_response = client.get("/api/sessions/web:engaged/active-task")
assert hidden_response.status_code == 200
assert hidden_response.json() is None
assert visible_response.status_code == 200
assert visible_response.json()["task_id"] == visible.task_id
assert hidden.task_id != visible.task_id
def test_task_delete_api_removes_backend_task(tmp_path: Path) -> None:
service = AgentService(workspace=tmp_path)
loaded = service.create_loop().boot()
task = loaded.task_service.create_task( # type: ignore[union-attr]
session_id="web:delete",
description="删除这个任务",
)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
deleted = client.delete(f"/api/tasks/{task.task_id}")
listed = client.get("/api/tasks")
missing = client.get(f"/api/tasks/{task.task_id}")
assert deleted.status_code == 200
assert deleted.json()["task_id"] == task.task_id
assert all(item["task_id"] != task.task_id for item in listed.json())
assert missing.status_code == 404

View File

@ -59,7 +59,7 @@ class BlockingSkillAssembler:
self.release_first = asyncio.Event() self.release_first = asyncio.Event()
async def assemble(self, **kwargs) -> SkillAssemblyResult: async def assemble(self, **kwargs) -> SkillAssemblyResult:
if kwargs["task_description"] == "task first": if "task first" in kwargs["task_description"]:
self.first_started.set() self.first_started.set()
await self.release_first.wait() await self.release_first.wait()
return SkillAssemblyResult() return SkillAssemblyResult()

View File

@ -1,4 +1,5 @@
import json import json
from pathlib import Path
from beaver.engine import AgentLoop, EngineLoader from beaver.engine import AgentLoop, EngineLoader
from beaver.engine.providers import make_provider_bundle from beaver.engine.providers import make_provider_bundle
@ -42,6 +43,37 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None:
assert target["extra_headers"] == {"X-Test": "1"} assert target["extra_headers"] == {"X-Test": "1"}
def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"workspace": str(tmp_path / "workspace"),
"model": "qwen-plus",
"provider": "custom",
}
},
"providers": {
"custom": {},
"openai": {
"apiKey": "sk-test",
"apiBase": "https://oai.example.com/v1",
},
},
}
),
encoding="utf-8",
)
config = load_config(config_path=config_path)
assert config.resolve_provider_target()["provider_name"] == "openai"
assert config.resolve_provider_target(provider_name="custom")["provider_name"] == "openai"
assert config.resolve_provider_target(provider_name="deepseek")["provider_name"] == "openai"
def test_engine_loader_uses_config_workspace(tmp_path) -> None: def test_engine_loader_uses_config_workspace(tmp_path) -> None:
workspace = tmp_path / "workspace" workspace = tmp_path / "workspace"
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
@ -105,3 +137,40 @@ def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
assert bundle.main_runtime.api_base == "https://oai.example.com/v1" assert bundle.main_runtime.api_base == "https://oai.example.com/v1"
assert isinstance(bundle.main_provider, LiteLLMProvider) assert isinstance(bundle.main_provider, LiteLLMProvider)
assert bundle.main_provider._resolve_model("qwen-plus") == "openai/qwen-plus" assert bundle.main_provider._resolve_model("qwen-plus") == "openai/qwen-plus"
def test_load_config_reads_stevenli_mcp_authz_identity() -> None:
repo_root = Path(__file__).resolve().parents[4]
config_path = repo_root / "app-instance" / "runtime" / "instances" / "stevenli" / "nanobot-home" / "config.json"
config = load_config(config_path=config_path)
server = config.tools.mcp_servers["outlook_mcp"]
assert server.transport == "http"
assert server.url == "http://10.6.80.29:8000/mcp"
assert server.auth_mode == "oauth_backend_token"
assert server.auth_audience == "mcp:outlook_mcp"
assert "tool:mail_list_messages" in server.auth_scopes
assert server.tool_timeout == 60
assert server.sensitive is True
assert config.authz.enabled is True
assert config.authz.base_url == "http://nano-authz-service:19090"
assert config.backend_identity.backend_id == "stevenli"
assert config.backend_identity.client_id == "stevenli"
def test_load_config_adds_managed_local_mcp_servers(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"tools": {"mcpServers": {}}}),
encoding="utf-8",
)
config = load_config(config_path=config_path)
local = config.tools.mcp_servers["local_filesystem_mcp"]
assert local.transport == "stdio"
assert local.kind == "local"
assert local.category == "filesystem"
assert local.managed is True
assert "beaver.interfaces.mcp.tools_server" in local.args

View File

@ -0,0 +1,126 @@
import asyncio
from beaver.foundation.models import CronExecutionResult, CronRunRecord, CronSchedule
from beaver.tools.base import ToolContext
from beaver.tools.builtins import CronTool
from beaver.services.cron_service import CronService, compute_next_run, parse_schedule, schedule_from_api
def test_parse_hermes_style_schedules() -> None:
interval = parse_schedule("every 15m")
assert interval.kind == "every"
assert interval.every_ms == 15 * 60 * 1000
one_shot = parse_schedule("30s")
assert one_shot.kind == "at"
assert one_shot.at_ms is not None
cron = parse_schedule("0 9 * * *")
assert cron.kind == "cron"
assert cron.expr == "0 9 * * *"
def test_schedule_from_frontend_payload() -> None:
every = schedule_from_api({"every_seconds": 60})
assert every.kind == "every"
assert every.every_ms == 60_000
cron = schedule_from_api({"cron_expr": "0 10 * * *"})
assert cron.kind == "cron"
def test_compute_next_run_skips_missed_interval() -> None:
schedule = CronSchedule(kind="every", every_ms=60_000)
assert compute_next_run(schedule, now_ms=1_000_000, last_run_at_ms=0) > 1_000_000
def test_manual_run_records_task_history(tmp_path) -> None:
async def on_job(job):
return CronExecutionResult(response="done", task_id=f"task-{job.id}", run_id="run-1")
service = CronService(tmp_path / "jobs.json", on_job=on_job)
job = service.add_job(
name="Daily check",
message="Check the project",
schedule=CronSchedule(kind="every", every_ms=3600_000),
session_key="web:default",
)
assert asyncio.run(service.run_job(job.id, force=True)) is True
updated = service.get_job(job.id)
assert updated is not None
assert updated.last_status == "ok"
assert updated.history[-1].task_id == f"task-{job.id}"
assert updated.to_api_dict()["last_task_id"] == f"task-{job.id}"
def test_manual_run_records_scheduled_run_output(tmp_path) -> None:
async def on_job(job, run):
return CronExecutionResult(
response=f"notification for {run.scheduled_run_id}",
run_id="run-notify",
notification_session_id="notify:default:scheduled",
mode="notification",
)
service = CronService(tmp_path / "jobs.json", on_job=on_job)
job = service.add_job(
name="Daily news",
message="Summarize news",
schedule=CronSchedule(kind="every", every_ms=3600_000),
)
assert asyncio.run(service.run_job(job.id, force=True)) is True
updated = service.get_job(job.id)
assert updated is not None
run = updated.history[-1]
assert run.scheduled_run_id
assert run.output == f"notification for {run.scheduled_run_id}"
assert run.notification_session_id == "notify:default:scheduled"
assert updated.to_api_dict()["last_scheduled_run_id"] == run.scheduled_run_id
def test_cron_tool_uses_runtime_service(tmp_path) -> None:
service = CronService(tmp_path / "jobs.json")
tool = CronTool()
result = asyncio.run(
tool.invoke(
{
"action": "add",
"name": "Tool-created task",
"message": "Check the queue",
"every_seconds": 300,
},
ToolContext(session_id="session-1", services={"cron_service": service}),
)
)
assert result.success is True
jobs = service.list_jobs(include_disabled=True)
assert len(jobs) == 1
assert jobs[0].payload.session_key == "session-1"
def test_mark_run_engaged_links_task(tmp_path) -> None:
service = CronService(tmp_path / "jobs.json")
job = service.add_job(
name="Daily news",
message="Summarize news",
schedule=CronSchedule(kind="every", every_ms=3600_000),
)
run = CronRunRecord(
started_at_ms=1,
status="ok",
output="news summary",
notification_session_id="notify:default:scheduled",
)
job.history.append(run)
service._save_jobs()
linked = service.mark_run_engaged(run.scheduled_run_id, task_id="task-1", intent="revise_once")
assert linked is not None
updated = service.get_run(run.scheduled_run_id)
assert updated is not None
assert updated[1].engaged is True
assert updated[1].task_id == "task-1"

View File

@ -0,0 +1,67 @@
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
def test_debug_chat_logs_group_events_by_run(tmp_path: Path) -> None:
service = AgentService(workspace=tmp_path)
loaded = service.create_loop().boot()
manager = loaded.session_manager
session_id = "web:debug"
run_id = "run-debug"
manager.ensure_session(session_id, source="web", title="Debug")
manager.append_message(
session_id,
run_id=run_id,
role="system",
event_type="run_started",
event_payload={"source": "web", "task_id": "task-1", "attempt_index": 1},
content="hello",
context_visible=False,
)
manager.append_message(
session_id,
run_id=run_id,
role="system",
event_type="llm_request_snapshotted",
event_payload={"messages": [{"role": "user", "content": "hello"}], "tools": []},
content='{"messages":[{"role":"user","content":"hello"}],"tools":[]}',
context_visible=False,
)
manager.append_message(
session_id,
run_id=run_id,
role="user",
event_type="user_message_added",
content="hello",
)
manager.append_message(
session_id,
run_id=run_id,
role="assistant",
event_type="assistant_message_added",
content="hi",
finish_reason="stop",
)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
response = client.get("/api/debug/chat-logs")
assert response.status_code == 200
sessions = response.json()["sessions"]
run = sessions[0]["runs"][0]
assert run["run_id"] == run_id
assert run["user_input"] == "hello"
assert [event["event_type"] for event in run["events"]] == [
"run_started",
"llm_request_snapshotted",
"user_message_added",
"assistant_message_added",
]
assert run["events"][1]["event_payload"]["messages"][0]["content"] == "hello"

View File

@ -17,6 +17,9 @@ class FakeResult:
provider_name: str | None = "fake" provider_name: str | None = "fake"
model: str | None = "fake-model" model: str | None = "fake-model"
usage: dict[str, Any] = field(default_factory=dict) usage: dict[str, Any] = field(default_factory=dict)
task_id: str | None = "task-1"
task_status: str | None = "awaiting_feedback"
validation_result: dict[str, Any] | None = field(default_factory=lambda: {"accepted": True})
class FakeService: class FakeService:
@ -75,6 +78,9 @@ def test_gateway_routes_memory_channel_roundtrip() -> None:
assert message.content == "echo:hello" assert message.content == "echo:hello"
assert message.session_id == "s1" assert message.session_id == "s1"
assert message.finish_reason == "stop" assert message.finish_reason == "stop"
assert message.metadata["task_id"] == "task-1"
assert message.metadata["task_status"] == "awaiting_feedback"
assert message.metadata["validation_result"] == {"accepted": True}
stop_event.set() stop_event.set()
await asyncio.wait_for(task, timeout=2) await asyncio.wait_for(task, timeout=2)
@ -183,6 +189,50 @@ def test_agent_service_maps_stopped_runtime_to_stopped_outbound() -> None:
asyncio.run(run()) asyncio.run(run())
def test_channel_manager_keeps_unknown_channel_outbound_undeliverable() -> None:
async def run() -> None:
bus = MessageBus()
manager = ChannelManager(bus)
stop_event = asyncio.Event()
await bus.publish_outbound(
AgentService.build_outbound_message(
InboundMessage(channel="missing", content="hello", session_id="missing:1"),
FakeResult(session_id="missing:1", output_text="ok"),
)
)
stop_event.set()
await manager.dispatch_outbound(stop_event)
assert len(manager.undeliverable) == 1
assert manager.undeliverable[0].channel == "missing"
assert manager.undeliverable[0].session_id == "missing:1"
asyncio.run(run())
def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
async def run() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus, name="telegram")
inbound = await channel.publish_external_text(
"hello",
chat_id="chat-1",
message_id="message-1",
raw_payload={"platform": "telegram", "text": "hello"},
)
queued = await bus.consume_inbound()
assert queued is inbound
assert queued.channel == "telegram"
assert queued.session_id == "telegram:chat-1"
assert queued.metadata["chat_id"] == "chat-1"
assert queued.metadata["message_id"] == "message-1"
assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"}
asyncio.run(run())
def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None: def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
class StartedChannel: class StartedChannel:
name = "started" name = "started"

View File

@ -0,0 +1,145 @@
from __future__ import annotations
import asyncio
import pytest
from types import SimpleNamespace
from beaver.engine.providers.litellm import LiteLLMProvider
def test_qwen_thinking_mode_is_sent_as_chat_template_kwargs(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict = {}
class Message:
content = "可以"
reasoning_content = ""
tool_calls = []
class Choice:
message = Message()
finish_reason = "stop"
class Response:
choices = [Choice()]
usage = None
async def fake_acompletion(**kwargs):
captured.update(kwargs)
return Response()
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
provider = LiteLLMProvider(
api_key="sk-test",
api_base="https://oai.example.com/v1",
default_model="Qwen3.6-35B",
provider_name="openai",
)
response = asyncio.run(
provider.chat(
[{"role": "user", "content": "只回复可以"}],
model="Qwen3.6-35B",
thinking_enabled=False,
)
)
assert response.content == "可以"
assert captured["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}}
def test_non_qwen_thinking_mode_is_not_sent(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict = {}
class Message:
content = "ok"
reasoning_content = None
tool_calls = []
class Choice:
message = Message()
finish_reason = "stop"
class Response:
choices = [Choice()]
usage = None
async def fake_acompletion(**kwargs):
captured.update(kwargs)
return Response()
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
provider = LiteLLMProvider(
api_key="sk-test",
api_base="https://oai.example.com/v1",
default_model="gpt-4.1-mini",
provider_name="openai",
)
asyncio.run(
provider.chat(
[{"role": "user", "content": "reply ok"}],
model="gpt-4.1-mini",
thinking_enabled=False,
)
)
assert "extra_body" not in captured
def test_litellm_provider_sanitizes_tool_call_arguments(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict = {}
class Message:
content = "ok"
reasoning_content = None
tool_calls = []
class Choice:
message = Message()
finish_reason = "stop"
class Response:
choices = [Choice()]
usage = None
async def fake_acompletion(**kwargs):
captured.update(kwargs)
return Response()
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
provider = LiteLLMProvider(
api_key="sk-test",
api_base="https://oai.example.com/v1",
default_model="Qwen3.6-35B",
provider_name="openai",
)
asyncio.run(
provider.chat(
[
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {
"name": "cron",
"arguments": {"action": "add", "mode": "notification"},
},
}
],
},
{"role": "tool", "tool_call_id": "call-1", "name": "cron", "content": "done"},
],
model="Qwen3.6-35B",
thinking_enabled=False,
)
)
tool_call = captured["messages"][0]["tool_calls"][0]
assert tool_call["function"]["arguments"] == '{"action": "add", "mode": "notification"}'

View File

@ -0,0 +1,116 @@
from __future__ import annotations
import asyncio
from beaver.engine.providers.base import LLMProvider, LLMResponse
from beaver.tasks import MainAgentRouter, TaskRecord
class RouterProvider(LLMProvider):
def __init__(self, response: str | Exception) -> None:
super().__init__()
self.response = response
self.calls: list[dict] = []
async def chat(
self,
messages: list[dict],
tools: list[dict] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
self.calls.append(
{
"max_tokens": max_tokens,
"temperature": temperature,
"model": model,
"thinking_enabled": thinking_enabled,
}
)
if isinstance(self.response, Exception):
raise self.response
return LLMResponse(content=self.response, finish_reason="stop", provider_name="stub", model="stub-model")
def get_default_model(self) -> str:
return "stub-model"
def _task() -> TaskRecord:
return TaskRecord(
task_id="task-1",
session_id="web:task",
description="实现任务连续性",
goal="实现任务连续性",
constraints=[],
priority=0,
status="awaiting_feedback",
creator="test",
created_at="now",
updated_at="now",
metadata={"short_title": "任务连续性"},
)
def test_router_continues_active_task_from_llm_decision() -> None:
provider = RouterProvider('{"action":"continue_task","reason":"related","short_title":"任务连续性"}')
decision = asyncio.run(
MainAgentRouter().classify(
"再把输入框标识也补上",
active_task=_task(),
provider=provider,
)
)
assert decision.is_task
assert decision.starts_new_task is False
assert decision.short_title == "任务连续性"
assert provider.calls[0]["max_tokens"] == 256
def test_router_receives_thinking_mode() -> None:
provider = RouterProvider('{"action":"simple_chat","reason":"simple"}')
decision = asyncio.run(
MainAgentRouter().classify(
"你好",
provider=provider,
thinking_enabled=False,
)
)
assert not decision.is_task
assert provider.calls[0]["thinking_enabled"] is False
def test_router_closes_active_task_from_llm_decision() -> None:
decision = asyncio.run(
MainAgentRouter().classify(
"这个任务结束了",
active_task=_task(),
provider=RouterProvider('{"action":"close_task","reason":"user said done"}'),
)
)
assert not decision.is_task
assert decision.closes_task is True
def test_router_fallback_keeps_active_task_but_not_new_task() -> None:
active = asyncio.run(
MainAgentRouter().classify(
"继续",
active_task=_task(),
provider=RouterProvider(RuntimeError("provider down")),
)
)
inactive = asyncio.run(
MainAgentRouter().classify(
"implement something",
active_task=None,
provider=RouterProvider(RuntimeError("provider down")),
)
)
assert active.is_task
assert not inactive.is_task

View File

@ -0,0 +1,142 @@
import asyncio
import io
import json
import zipfile
from types import SimpleNamespace
import pytest
from beaver.interfaces.web.app import _create_skill_upload_draft
from beaver.services.hermes_migration import HermesMigrationService
from beaver.services.skillhub_service import SkillHubService
from beaver.skills.drafts import DraftService
from beaver.skills.specs import SkillSpecStore
from beaver.tools.mcp.wrapper import MCPToolWrapper
class FakeSkillHubService(SkillHubService):
async def _get_json(self, path, *, params=None):
if path == "/skills":
return {
"data": {
"items": [
{
"slug": "multi-search-engine",
"displayName": "multi-search-engine",
"summary": "search",
"namespace": "global",
"downloadCount": 1,
"starCount": 0,
"publishedVersion": {"version": "20260413.065325"},
}
],
"total": 1,
"page": 0,
"size": 12,
}
}
if path == "/skills/global/multi-search-engine":
return {
"data": {
"slug": "multi-search-engine",
"displayName": "multi-search-engine",
"summary": "search",
"namespace": "global",
"downloadCount": 1,
"starCount": 0,
"publishedVersion": {"version": "20260413.065325"},
}
}
if path == "/skills/global/multi-search-engine/versions/20260413.065325":
return {"data": {"version": "20260413.065325"}}
if path == "/skills/global/multi-search-engine/versions/20260413.065325/files":
return {"data": [{"filePath": "SKILL.md", "fileSize": 93}, {"filePath": "references/a.txt", "fileSize": 2}]}
raise AssertionError(path)
async def _get_text(self, path, *, params):
if params["path"] == "SKILL.md":
return "---\nname: multi-search-engine\ndescription: Multi search\ntools:\n - web_search\n---\nUse search.\n"
return "ok"
def test_skillhub_search_detail_do_not_install_until_post_install(tmp_path):
store = SkillSpecStore(tmp_path)
service = FakeSkillHubService(store)
search = asyncio.run(service.search(q="multi-search-engine"))
detail = asyncio.run(service.detail("global", "multi-search-engine"))
assert search["items"][0]["installed"] is False
assert detail["installed"] is False
assert store.get_skill_spec("multi-search-engine") is None
install = asyncio.run(service.install("global", "multi-search-engine"))
assert install["ok"] is True
assert store.get_skill_spec("multi-search-engine") is not None
assert (tmp_path / "skills" / "multi-search-engine" / "versions" / install["version"] / "references" / "a.txt").read_text() == "ok"
def test_upload_skill_zip_rejects_path_traversal(tmp_path):
store = SkillSpecStore(tmp_path)
loaded = SimpleNamespace(skill_spec_store=store, draft_service=DraftService(store))
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("skill/SKILL.md", "---\nname: skill\n---\nBody\n")
archive.writestr("skill/../evil.txt", "x")
with pytest.raises(ValueError, match="Unsafe archive entry"):
_create_skill_upload_draft(loaded, "skill.zip", buffer.getvalue())
def test_upload_skill_zip_keeps_supporting_files_on_draft(tmp_path):
store = SkillSpecStore(tmp_path)
loaded = SimpleNamespace(skill_spec_store=store, draft_service=DraftService(store))
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("skill/SKILL.md", "---\nname: skill\n---\nBody\n")
archive.writestr("skill/references/a.txt", "context")
draft = _create_skill_upload_draft(loaded, "skill.zip", buffer.getvalue())
upload_dir = draft["evidence_refs"][0]["supporting_upload_dir"]
assert (tmp_path / "skills" / "skill" / "draft_uploads" / draft["draft_id"] / "references" / "a.txt").read_text() == "context"
assert upload_dir.endswith(draft["draft_id"])
def test_hermes_migration_manifest_includes_no_credential_skill_and_skips_api_skill(tmp_path):
repo = tmp_path / "hermes"
safe = repo / "skills" / "safe"
unsafe = repo / "skills" / "unsafe"
safe.mkdir(parents=True)
unsafe.mkdir(parents=True)
safe.joinpath("SKILL.md").write_text("---\nname: safe\n---\nUse local files only.\n", encoding="utf-8")
unsafe.joinpath("SKILL.md").write_text("---\nname: unsafe\n---\nRequires API_KEY.\n", encoding="utf-8")
store = SkillSpecStore(tmp_path / "workspace")
manifest = HermesMigrationService(store).migrate(repo)
included = {item["skill_name"] for item in manifest["included"]}
skipped = {item.get("skill_name"): item["reason"] for item in manifest["skipped"]}
assert "safe" in included
assert skipped["unsafe"] == "requires_external_credentials"
assert store.get_skill_spec("safe") is not None
manifest_path = tmp_path / "workspace" / "hermes_migration_manifest.json"
assert json.loads(manifest_path.read_text(encoding="utf-8"))["source"] == "hermes-agent"
def test_mcp_wrapper_metadata_preserves_server_id_with_underscores():
tool_def = SimpleNamespace(name="auth_status", description="Auth", inputSchema={"type": "object", "properties": {}})
async def call_tool(_name, _arguments):
return SimpleNamespace(content=[], structuredContent={"ok": True})
wrapper = MCPToolWrapper(
"outlook_mcp",
tool_def,
call_tool,
kind="online",
category="outlook",
display_name="Outlook",
)
assert wrapper.spec.name == "mcp_outlook_mcp_auth_status"
assert wrapper.spec.metadata["server_id"] == "outlook_mcp"
assert wrapper.spec.metadata["original_tool_name"] == "auth_status"

View File

@ -298,8 +298,29 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path:
ended_at=recent, ended_at=recent,
success=True, success=True,
finish_reason="stop", finish_reason="stop",
feedback={"feedback_type": "satisfied"},
activated_skills=[],
task_id=f"task-new-{index}",
attempt_index=1,
validation_result={"accepted": True, "score": 0.9},
)
)
for index in range(2):
run_store.append_run_record(
RunRecord(
run_id=f"simple-chat-{index}",
session_id="session-simple",
task_text="你是谁",
started_at=recent,
ended_at=recent,
success=True,
finish_reason="stop",
feedback={}, feedback={},
activated_skills=[], activated_skills=[],
task_id=None,
attempt_index=None,
validation_result=None,
) )
) )
@ -329,8 +350,11 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path:
ended_at=recent, ended_at=recent,
success=True, success=True,
finish_reason="stop", finish_reason="stop",
feedback={}, feedback={"feedback_type": "satisfied"},
activated_skills=receipts, activated_skills=receipts,
task_id=f"task-merge-{index}",
attempt_index=1,
validation_result={"accepted": True, "score": 0.9},
) )
) )
for receipt in receipts: for receipt in receipts:
@ -382,6 +406,9 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path:
kinds = {candidate.kind for candidate in candidates} kinds = {candidate.kind for candidate in candidates}
assert {"revise_skill", "new_skill", "merge_skills", "retire_skill"} <= kinds assert {"revise_skill", "new_skill", "merge_skills", "retire_skill"} <= kinds
new_candidates = [candidate for candidate in candidates if candidate.kind == "new_skill"]
assert new_candidates
assert all("simple-chat" not in run_id for candidate in new_candidates for run_id in candidate.source_run_ids)
retire_candidate = next(candidate for candidate in candidates if candidate.kind == "retire_skill") retire_candidate = next(candidate for candidate in candidates if candidate.kind == "retire_skill")
retire_draft = asyncio.run( retire_draft = asyncio.run(
@ -396,6 +423,100 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path:
assert store.read_draft("svn-migration", retire_draft.draft_id) is not None assert store.read_draft("svn-migration", retire_draft.draft_id) is not None
def test_skill_learning_service_generates_task_scoped_candidates(tmp_path: Path) -> None:
store = SkillSpecStore(tmp_path)
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
learning_store = SkillLearningStore(tmp_path / "memory" / "skills")
service = SkillLearningService(
run_store=run_store,
learning_store=learning_store,
draft_service=DraftService(store),
evidence_selector=EvidenceSelector(run_store),
)
now = datetime.now(timezone.utc).isoformat()
receipt = _receipt(
run_id="task-run-1",
session_id="session-task",
skill_name="api-review",
skill_version="v0001",
activated_at=now,
)
run_store.append_run_record(
RunRecord(
run_id="task-run-1",
session_id="session-task",
task_id="task-1",
attempt_index=1,
task_text="Review API compatibility",
started_at=now,
ended_at=now,
success=True,
finish_reason="stop",
feedback={"feedback_type": "satisfied"},
activated_skills=[receipt],
validation_result={"accepted": True, "score": 0.9},
)
)
run_store.append_run_record(
RunRecord(
run_id="other-task-run",
session_id="session-other",
task_id="task-2",
attempt_index=1,
task_text="Review API compatibility",
started_at=now,
ended_at=now,
success=True,
finish_reason="stop",
feedback={"feedback_type": "satisfied"},
activated_skills=[],
validation_result={"accepted": True, "score": 0.9},
)
)
candidates = service.build_learning_candidates_for_task("task-1", trigger_run_id="task-run-1")
assert [candidate.candidate_id for candidate in candidates] == ["revise:api-review:v0001:task:task-1"]
assert candidates[0].source_run_ids == ["task-run-1"]
assert candidates[0].related_skill_names == ["api-review"]
assert candidates[0].evidence["task_id"] == "task-1"
def test_skill_learning_service_generates_new_skill_for_task_without_published_skills(tmp_path: Path) -> None:
store = SkillSpecStore(tmp_path)
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
learning_store = SkillLearningStore(tmp_path / "memory" / "skills")
service = SkillLearningService(
run_store=run_store,
learning_store=learning_store,
draft_service=DraftService(store),
evidence_selector=EvidenceSelector(run_store),
)
now = datetime.now(timezone.utc).isoformat()
run_store.append_run_record(
RunRecord(
run_id="task-run-1",
session_id="session-task",
task_id="task-1",
attempt_index=1,
task_text="Generate migration checklist",
started_at=now,
ended_at=now,
success=True,
finish_reason="stop",
feedback={"feedback_type": "satisfied"},
activated_skills=[],
validation_result={"accepted": True, "score": 0.9},
)
)
candidates = service.build_learning_candidates_for_task("task-1", trigger_run_id="task-run-1")
assert [candidate.candidate_id for candidate in candidates] == ["new:task:task-1"]
assert candidates[0].kind == "new_skill"
assert candidates[0].source_run_ids == ["task-run-1"]
def test_agent_loop_records_skill_receipts_and_effects(tmp_path: Path) -> None: def test_agent_loop_records_skill_receipts_and_effects(tmp_path: Path) -> None:
skill = SkillContext( skill = SkillContext(
name="docker-debug", name="docker-debug",
@ -446,7 +567,7 @@ def test_agent_loop_records_skill_receipts_and_effects(tmp_path: Path) -> None:
skill_effects = next(event for event in events if event.event_type == "skill_effects_snapshotted") skill_effects = next(event for event in events if event.event_type == "skill_effects_snapshotted")
assert skill_effects.event_payload["run_record"]["activated_skills"][0]["skill_version"] == "v0007" assert skill_effects.event_payload["run_record"]["activated_skills"][0]["skill_version"] == "v0007"
assert skill_effects.event_payload["skill_effects"][0]["skill_name"] == "docker-debug" assert skill_effects.event_payload["skill_effects"][0]["skill_name"] == "docker-debug"
assert skill_effects.event_payload["learning_candidate_enabled"] is False assert skill_effects.event_payload["candidate_generation_allowed"] is False
assert skill_effects.event_payload["learning_candidates"] == [] assert skill_effects.event_payload["learning_candidates"] == []
run_records = loaded.run_memory_store.list_runs() run_records = loaded.run_memory_store.list_runs()

View File

@ -53,7 +53,8 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
"node_id": "research", "node_id": "research",
"skill_query": "research workflow", "skill_query": "research workflow",
"selected_skill_names": ["research-workflow"], "selected_skill_names": ["research-workflow"],
"generated_skill_draft_id": None, "ephemeral_guidance_id": None,
"ephemeral_guidance_name": None,
"ephemeral_used": False, "ephemeral_used": False,
"reason": "matched published skill", "reason": "matched published skill",
} }
@ -80,7 +81,8 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
"skill_query": "research workflow", "skill_query": "research workflow",
"selected_skill_names": ["research-workflow"], "selected_skill_names": ["research-workflow"],
"ephemeral_skill_names": [], "ephemeral_skill_names": [],
"generated_skill_draft_id": None, "ephemeral_guidance_id": None,
"ephemeral_guidance_name": None,
"ephemeral_used": False, "ephemeral_used": False,
"finish_reason": "stop", "finish_reason": "stop",
} }
@ -118,5 +120,83 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
sub_run = next(run for run in projection["runs"] if run["run_id"] == "sub-run") sub_run = next(run for run in projection["runs"] if run["run_id"] == "sub-run")
assert sub_run["metadata"]["selected_skill_names"] == ["research-workflow"] assert sub_run["metadata"]["selected_skill_names"] == ["research-workflow"]
assert sub_run["metadata"]["skill_query"] == "research workflow" assert sub_run["metadata"]["skill_query"] == "research workflow"
assert sub_run["metadata"]["ephemeral_guidance_id"] is None
assert any(event["actor_name"] == "Validator" for event in projection["events"]) assert any(event["actor_name"] == "Validator" for event in projection["events"])
assert any(run["session_id"] == "web:test" for run in projection["runs"]) assert any(run["session_id"] == "web:test" for run in projection["runs"])
def test_process_projection_exposes_ephemeral_guidance_artifacts(tmp_path: Path) -> None:
session = SessionManager(tmp_path)
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
run_store.append_run_record(
RunRecord(
run_id="sub-run",
session_id="sub-session",
task_id="task-1",
attempt_index=1,
task_text="sub task",
started_at="2026-01-01T00:00:01+00:00",
ended_at="2026-01-01T00:00:02+00:00",
success=True,
finish_reason="stop",
)
)
session.append_message(
"web:test",
role="system",
event_type="task_execution_planned",
event_payload={
"task_id": "task-1",
"attempt_index": 1,
"plan_mode": "team",
"strategy": "sequence",
"node_ids": ["research"],
"ephemeral_guidance_ids": ["eg_123"],
"skill_resolution_report": [
{
"node_id": "research",
"skill_query": "research workflow",
"selected_skill_names": [],
"ephemeral_guidance_id": "eg_123",
"ephemeral_guidance_name": "research-workflow",
"ephemeral_used": True,
"reason": "generated ephemeral guidance",
}
],
},
context_visible=False,
)
session.append_message(
"web:test",
role="system",
event_type="task_team_run_completed",
event_payload={
"task_id": "task-1",
"attempt_index": 1,
"team_success": True,
"team_run_ids": ["sub-run"],
"node_results": [
{
"node_id": "research",
"success": True,
"output_text": "evidence",
"run_id": "sub-run",
"skill_query": "research workflow",
"selected_skill_names": [],
"ephemeral_skill_names": ["ephemeral:research-workflow"],
"ephemeral_guidance_id": "eg_123",
"ephemeral_guidance_name": "research-workflow",
"ephemeral_used": True,
"finish_reason": "stop",
}
],
},
context_visible=False,
)
projection = SessionProcessProjector(session, run_store).project("web:test")
sub_run = next(run for run in projection["runs"] if run["run_id"] == "sub-run")
assert sub_run["metadata"]["ephemeral_guidance_id"] == "eg_123"
assert projection["artifacts"][0]["artifact_id"] == "sub-run:ephemeral-guidance:eg_123"
assert projection["artifacts"][0]["metadata"]["ephemeral_guidance_name"] == "research-workflow"

View File

@ -0,0 +1,107 @@
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
from beaver.engine.session import SessionManager
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
def test_archived_sessions_can_be_hidden_from_default_web_list(tmp_path: Path) -> None:
manager = SessionManager(tmp_path)
manager.ensure_session("web:keep", source="web")
manager.ensure_session("web:archived", source="web")
manager.end_session("web:archived", "archived")
visible = manager.list_sessions_rich(exclude_end_reasons=["archived"])
visible_ids = {row["id"] for row in visible}
assert "web:keep" in visible_ids
assert "web:archived" not in visible_ids
assert manager.get_session("web:archived")["end_reason"] == "archived"
def test_archived_sessions_remain_available_to_history_search(tmp_path: Path) -> None:
manager = SessionManager(tmp_path)
manager.ensure_session("web:archived", source="web")
manager.end_session("web:archived", "archived")
all_sessions = manager.list_sessions_rich()
assert {row["id"] for row in all_sessions} == {"web:archived"}
def test_visible_history_excludes_error_and_incomplete_runs(tmp_path: Path) -> None:
manager = SessionManager(tmp_path)
manager.ensure_session("web:history", source="web")
manager.append_message("web:history", run_id="ok-run", role="user", content="hello")
manager.append_message("web:history", run_id="ok-run", role="assistant", content="hi", finish_reason="stop")
manager.append_message(
"web:history",
run_id="ok-run",
role="assistant",
content=None,
tool_calls=[{"id": "call-1", "type": "function", "function": {"name": "echo", "arguments": "{}"}}],
)
manager.append_message(
"web:history",
run_id="ok-run",
role="tool",
content="tool result",
tool_call_id="call-1",
)
manager.append_message(
"web:history",
run_id="ok-run",
role="system",
event_type="run_completed",
content="hi",
context_visible=False,
)
manager.append_message("web:history", run_id="error-run", role="user", content="bad")
manager.append_message(
"web:history",
run_id="error-run",
role="assistant",
content="Error: provider failed",
finish_reason="error",
)
manager.append_message(
"web:history",
run_id="error-run",
role="system",
event_type="run_completed",
content="Error: provider failed",
finish_reason="error",
context_visible=False,
)
manager.append_message("web:history", run_id="pending-run", role="user", content="pending")
history = manager.get_visible_history("web:history")
assert [(message["role"], message["content"]) for message in history] == [
("user", "hello"),
("assistant", "hi"),
]
def test_web_archive_route_does_not_create_archive_suffix_session(tmp_path: Path) -> None:
service = AgentService(workspace=tmp_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
create_response = client.post("/api/sessions/web:alpha")
archive_response = client.post("/api/sessions/web:alpha/archive")
sessions_response = client.get("/api/sessions")
assert create_response.status_code == 200
assert archive_response.status_code == 200
assert archive_response.json() == {"ok": True, "archived": True}
assert sessions_response.status_code == 200
loaded = service.create_loop().boot()
assert loaded.session_manager.get_session("web:alpha")["end_reason"] == "archived" # type: ignore[union-attr]
assert loaded.session_manager.get_session("web:alpha/archive") is None # type: ignore[union-attr]
assert sessions_response.json() == []

View File

@ -0,0 +1,157 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from beaver.engine.providers.base import LLMProvider, LLMResponse
from beaver.skills.assembler.task_assembler import SkillAssembler
class RecordingProvider(LLMProvider):
def __init__(self) -> None:
super().__init__()
self.thinking_enabled: bool | None = None
async def chat(
self,
messages: list[dict],
tools: list[dict] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
self.thinking_enabled = thinking_enabled
return LLMResponse(content='["daily-news"]', provider_name="stub", model="stub-model")
def get_default_model(self) -> str:
return "stub-model"
class SequencedProvider(LLMProvider):
def __init__(self, responses: list[str]) -> None:
super().__init__()
self.responses = list(responses)
self.messages: list[list[dict]] = []
async def chat(
self,
messages: list[dict],
tools: list[dict] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
self.messages.append(messages)
content = self.responses.pop(0)
return LLMResponse(content=content, provider_name="stub", model="stub-model")
def get_default_model(self) -> str:
return "stub-model"
class StaticRetriever:
async def retrieve(self, **kwargs):
return kwargs["candidates"][: kwargs["top_k"]]
class LoaderWithFullSkill:
def build_selection_candidates(self) -> list[dict[str, str]]:
return [
{
"name": "docker-debug",
"description": "General container tips.",
"version": "v1",
"content_hash": "abc",
}
]
def load_published_skill(self, name: str) -> str | None:
if name != "docker-debug":
return None
return """---
description: General container tips.
tools:
- search_files
---
# Docker Debug
Use this skill when doing Docker log triage and container failure analysis.
"""
def get_skill_record(self, name: str):
return SimpleNamespace(version="v1", content_hash="abc", tool_hints=["search_files"])
def test_skill_selection_receives_thinking_mode() -> None:
provider = RecordingProvider()
assembler = SkillAssembler(loader=SimpleNamespace())
selected = asyncio.run(
assembler._select_skill_names(
task_description="summarize daily news",
candidates=[{"name": "daily-news", "description": "Summarize news"}],
provider=provider,
model="Qwen3.6-35B",
thinking_enabled=False,
)
)
assert selected == ["daily-news"]
assert provider.thinking_enabled is False
def test_skill_assembler_loads_detail_directly_for_small_candidate_sets() -> None:
provider = SequencedProvider(['["docker-debug"]'])
assembler = SkillAssembler(loader=LoaderWithFullSkill(), retriever=StaticRetriever())
result = asyncio.run(
assembler.assemble(
task_description="debug a failing Docker container",
provider=provider,
model="stub-model",
)
)
assert [skill.name for skill in result.activated_skills] == ["docker-debug"]
assert result.activated_skills[0].tool_hints == ["search_files"]
assert [item["stage"] for item in result.llm_interactions] == ["final"]
assert len(provider.messages) == 1
first_user_prompt = provider.messages[0][1]["content"]
assert "Use this skill when doing Docker log triage" in first_user_prompt
def test_skill_assembler_shortlists_before_loading_detail_for_large_candidate_sets() -> None:
provider = SequencedProvider(['["docker-debug"]', '["docker-debug"]'])
loader = LoaderWithFullSkill()
original_candidates = loader.build_selection_candidates
loader.build_selection_candidates = lambda: [
*original_candidates(),
{
"name": "other-skill",
"description": "Other workflow.",
"version": "v1",
"content_hash": "def",
},
]
assembler = SkillAssembler(
loader=loader,
retriever=StaticRetriever(),
max_detailed_candidates=1,
)
result = asyncio.run(
assembler.assemble(
task_description="debug a failing Docker container",
provider=provider,
model="stub-model",
)
)
assert [skill.name for skill in result.activated_skills] == ["docker-debug"]
assert [item["stage"] for item in result.llm_interactions] == ["shortlist", "final"]
assert len(provider.messages) == 2
assert "Use this skill when doing Docker log triage" not in provider.messages[0][1]["content"]
assert "Use this skill when doing Docker log triage" in provider.messages[1][1]["content"]

View File

@ -90,6 +90,7 @@ def test_eval_pass_allows_publish_after_safety_and_review(tmp_path: Path) -> Non
report = asyncio.run(pipeline.evaluate_draft("candidate-1", draft.skill_name, draft.draft_id, provider_bundle=_bundle())) report = asyncio.run(pipeline.evaluate_draft("candidate-1", draft.skill_name, draft.draft_id, provider_bundle=_bundle()))
safety = pipeline.check_safety(draft.skill_name, draft.draft_id) safety = pipeline.check_safety(draft.skill_name, draft.draft_id)
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester") pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
published = pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester") published = pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
@ -111,6 +112,7 @@ def test_eval_regression_blocks_publish(tmp_path: Path) -> None:
report = asyncio.run(pipeline.evaluate_draft("candidate-1", draft.skill_name, draft.draft_id, provider_bundle=_bundle())) report = asyncio.run(pipeline.evaluate_draft("candidate-1", draft.skill_name, draft.draft_id, provider_bundle=_bundle()))
pipeline.check_safety(draft.skill_name, draft.draft_id) pipeline.check_safety(draft.skill_name, draft.draft_id)
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester") pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
assert report.passed is False assert report.passed is False

View File

@ -68,6 +68,39 @@ def test_pipeline_lists_candidates_and_moves_draft_through_review(tmp_path: Path
assert pipeline.get_draft(draft.skill_name, draft.draft_id).status == SkillReviewState.PUBLISHED.value assert pipeline.get_draft(draft.skill_name, draft.draft_id).status == SkillReviewState.PUBLISHED.value
def test_pipeline_approve_requires_submitted_review(tmp_path: Path) -> None:
pipeline = _pipeline(tmp_path)
draft = pipeline.draft_service.create_new_skill_draft(
skill_name="needs-review",
proposed_content="# Needs Review\n\nDo the thing.",
proposed_frontmatter={"description": "needs review"},
created_by="test",
reason="test",
)
with pytest.raises(ValueError, match="in review before approval"):
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
def test_pipeline_does_not_resubmit_terminal_draft(tmp_path: Path) -> None:
pipeline = _pipeline(tmp_path)
draft = pipeline.draft_service.create_new_skill_draft(
skill_name="already-published",
proposed_content="# Already Published\n\nDo the thing.",
proposed_frontmatter={"description": "already published"},
created_by="test",
reason="test",
)
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
pipeline.check_safety(draft.skill_name, draft.draft_id)
pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
with pytest.raises(ValueError, match="draft status before review submission"):
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
def test_pipeline_reject_blocks_publish(tmp_path: Path) -> None: def test_pipeline_reject_blocks_publish(tmp_path: Path) -> None:
pipeline = _pipeline(tmp_path) pipeline = _pipeline(tmp_path)
draft = pipeline.draft_service.create_new_skill_draft( draft = pipeline.draft_service.create_new_skill_draft(
@ -80,5 +113,22 @@ def test_pipeline_reject_blocks_publish(tmp_path: Path) -> None:
pipeline.reject(draft.skill_name, draft.draft_id, reviewer="tester") pipeline.reject(draft.skill_name, draft.draft_id, reviewer="tester")
with pytest.raises(ValueError, match="approved"): with pytest.raises(ValueError, match="Draft not found"):
pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester") pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
assert pipeline.draft_service.get_draft(draft.skill_name, draft.draft_id) is None
def test_pipeline_reject_removes_draft_from_review_list(tmp_path: Path) -> None:
pipeline = _pipeline(tmp_path)
draft = pipeline.draft_service.create_new_skill_draft(
skill_name="remove-skill",
proposed_content="# Remove\n\nNo longer needed.",
proposed_frontmatter={"description": "remove"},
created_by="test",
reason="test",
)
review = pipeline.reject(draft.skill_name, draft.draft_id, reviewer="tester")
assert review.status == SkillReviewState.REJECTED.value
assert pipeline.list_drafts() == []

View File

@ -65,6 +65,7 @@ def test_safety_marks_dangerous_tools_high_and_requires_confirm(tmp_path: Path)
) )
report = pipeline.check_safety(draft.skill_name, draft.draft_id) report = pipeline.check_safety(draft.skill_name, draft.draft_id)
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester") pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
assert report.passed is True assert report.passed is True
@ -84,6 +85,7 @@ def test_publish_requires_safety_report(tmp_path: Path) -> None:
created_by="test", created_by="test",
reason="test", reason="test",
) )
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester") pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
with pytest.raises(ValueError, match="safety report"): with pytest.raises(ValueError, match="safety report"):

View File

@ -12,6 +12,7 @@ from beaver.engine.context.builder import ContextBuilder, ContextBuildInput
from beaver.engine.providers.base import LLMProvider, LLMResponse from beaver.engine.providers.base import LLMProvider, LLMResponse
from beaver.engine.providers.factory import ProviderBundle from beaver.engine.providers.factory import ProviderBundle
from beaver.services.agent_service import AgentService from beaver.services.agent_service import AgentService
from beaver.skills.assembler import SkillAssemblyResult
from beaver.tasks import TaskExecutionPlan, TaskService, ValidationResult, ValidationService from beaver.tasks import TaskExecutionPlan, TaskService, ValidationResult, ValidationService
@ -67,7 +68,25 @@ class FakeLearningCandidate:
return {"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"} return {"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
def _bundle(*responses: str) -> ProviderBundle: class RecordingSkillAssembler:
def __init__(self) -> None:
self.task_descriptions: list[str] = []
async def assemble(self, **kwargs) -> SkillAssemblyResult:
self.task_descriptions.append(kwargs["task_description"])
return SkillAssemblyResult()
def _route_response(action: str = "new_task", short_title: str = "Test task") -> LLMResponse:
return LLMResponse(
content=f'{{"action":"{action}","reason":"test route","short_title":"{short_title}"}}',
finish_reason="stop",
provider_name="stub",
model="stub-model",
)
def _bundle(*responses: str, route_action: str = "new_task") -> ProviderBundle:
return ProviderBundle( return ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"), main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=StubProvider( main_provider=StubProvider(
@ -81,6 +100,8 @@ def _bundle(*responses: str) -> ProviderBundle:
for response in responses for response in responses
] ]
), ),
auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
auxiliary_provider=StubProvider([_route_response(route_action)]),
) )
@ -110,6 +131,25 @@ def _provider_bundle(provider: StubProvider) -> ProviderBundle:
return ProviderBundle( return ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"), main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=provider, main_provider=provider,
auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
auxiliary_provider=StubProvider([_route_response("new_task")]),
)
def _main_only_bundle(*responses: str) -> ProviderBundle:
return ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=StubProvider(
[
LLMResponse(
content=response,
finish_reason="stop",
provider_name="stub",
model="stub-model",
)
for response in responses
]
),
) )
@ -126,7 +166,7 @@ def test_simple_question_does_not_create_task(tmp_path: Path) -> None:
service.process_direct( service.process_direct(
"hello?", "hello?",
session_id="web:simple", session_id="web:simple",
provider_bundle=_bundle("hi"), provider_bundle=_bundle("hi", route_action="simple_chat"),
) )
) )
loaded = service.create_loop().boot() loaded = service.create_loop().boot()
@ -165,8 +205,89 @@ def test_complex_request_creates_task_and_records_validation(tmp_path: Path) ->
assert any(event.event_type == "task_validation_snapshotted" for event in events) assert any(event.event_type == "task_validation_snapshotted" for event in events)
assert run_record.task_id == result.task_id assert run_record.task_id == result.task_id
assert run_record.validation_result["accepted"] is True assert run_record.validation_result["accepted"] is True
assert skill_effects.event_payload["learning_candidate_enabled"] is False assert skill_effects.event_payload["candidate_generation_allowed"] is False
assert skill_effects.event_payload["learning_candidates"] == [] assert skill_effects.event_payload["learning_candidates"] == []
assert task.metadata["short_title"] == "Test task"
def test_task_mode_uses_task_aware_skill_selection_context(tmp_path: Path) -> None:
skill_assembler = RecordingSkillAssembler()
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=_single_planner(),
validation_service=StubValidationService(
[ValidationResult(passed=True, score=1.0, validator="test")]
),
skill_assembler=skill_assembler,
)
)
result = asyncio.run(
service.process_direct(
"继续按刚才的方案改",
session_id="web:task-skill-query",
provider_bundle=_bundle("done", route_action="new_task"),
)
)
assert result.task_id
assert skill_assembler.task_descriptions
query = skill_assembler.task_descriptions[0]
assert "Task goal:" in query
assert "Current user request:" in query
assert "Previously activated skills:" in query
assert "If no published skill matches, return []" in query
def test_active_task_continues_until_llm_closes_it(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=_single_planner(),
validation_service=StubValidationService(
[
ValidationResult(passed=True, score=0.9, validator="test"),
ValidationResult(passed=True, score=0.9, validator="test"),
]
),
)
)
first = asyncio.run(
service.process_direct(
"implement the search workflow",
session_id="web:continue",
provider_bundle=_bundle("first done", route_action="new_task"),
)
)
second = asyncio.run(
service.process_direct(
"also add tests for it",
session_id="web:continue",
provider_bundle=_bundle("tests added", route_action="continue_task"),
)
)
loaded = service.create_loop().boot()
task = loaded.task_service.get_task(first.task_id)
assert task is not None
assert second.task_id == first.task_id
assert len(task.run_ids) == 2
closed = asyncio.run(
service.process_direct(
"这个任务结束了",
session_id="web:continue",
provider_bundle=_bundle("好的,已结束。", route_action="close_task"),
)
)
task = loaded.task_service.get_task(first.task_id)
assert closed.task_id is None
assert task is not None
assert task.status == "closed"
assert loaded.task_service.active_task_view("web:continue") is None
def test_validation_failure_retries_once(tmp_path: Path) -> None: def test_validation_failure_retries_once(tmp_path: Path) -> None:
@ -229,11 +350,11 @@ def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None:
loaded = service.create_loop().boot() loaded = service.create_loop().boot()
learning_calls = [] learning_calls = []
def build_learning_candidates() -> list[FakeLearningCandidate]: def build_learning_candidates_for_task(task_id: str, *, trigger_run_id: str) -> list[FakeLearningCandidate]:
learning_calls.append("called") learning_calls.append((task_id, trigger_run_id))
return [FakeLearningCandidate()] return [FakeLearningCandidate()]
loaded.skill_learning_service.build_learning_candidates = build_learning_candidates loaded.skill_learning_service.build_learning_candidates_for_task = build_learning_candidates_for_task
feedback = asyncio.run( feedback = asyncio.run(
service.submit_feedback( service.submit_feedback(
@ -247,7 +368,7 @@ def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None:
assert feedback["learning_candidates"] == [ assert feedback["learning_candidates"] == [
{"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"} {"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
] ]
assert learning_calls == ["called"] assert learning_calls == [(result.task_id, result.run_id)]
service2 = AgentService( service2 = AgentService(
loader=EngineLoader( loader=EngineLoader(
@ -279,6 +400,14 @@ def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None:
assert abandon_feedback["task_status"] == "abandoned" assert abandon_feedback["task_status"] == "abandoned"
assert abandon_feedback["learning_candidates"] == [] assert abandon_feedback["learning_candidates"] == []
loaded2 = service2.create_loop().boot()
failure_events = [
event
for event in loaded2.session_manager.get_run_event_records(abandoned.session_id, abandoned.run_id)
if event.event_type == "task_failure_evidence_recorded"
]
assert len(failure_events) == 1
assert loaded2.memory_service.get_store().memory_entries == []
def test_feedback_is_idempotent_and_projected_to_assistant_message(tmp_path: Path) -> None: def test_feedback_is_idempotent_and_projected_to_assistant_message(tmp_path: Path) -> None:
@ -466,7 +595,7 @@ def test_task_mode_team_retry_hides_first_synthesis_run(tmp_path: Path) -> None:
events = loaded.session_manager.get_run_event_records(record.session_id, run_id) events = loaded.session_manager.get_run_event_records(record.session_id, run_id)
skill_effects = [event for event in events if event.event_type == "skill_effects_snapshotted"] skill_effects = [event for event in events if event.event_type == "skill_effects_snapshotted"]
assert skill_effects assert skill_effects
assert skill_effects[-1].event_payload["learning_candidate_enabled"] is False assert skill_effects[-1].event_payload["candidate_generation_allowed"] is False
def test_context_builder_strips_ui_projection_fields_from_provider_history() -> None: def test_context_builder_strips_ui_projection_fields_from_provider_history() -> None:
@ -490,17 +619,43 @@ def test_context_builder_strips_ui_projection_fields_from_provider_history() ->
assert assistant == {"role": "assistant", "content": "done"} assert assistant == {"role": "assistant", "content": "done"}
def test_context_builder_normalizes_persisted_tool_arguments() -> None:
result = ContextBuilder().build_messages(
ContextBuildInput(
history=[
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {
"name": "cron",
"arguments": {"action": "add", "mode": "notification"},
},
}
],
}
],
)
)
tool_call = result.messages[-1]["tool_calls"][0]
assert tool_call["function"]["arguments"] == '{"action": "add", "mode": "notification"}'
def test_llm_validator_parse_failure_is_not_accepted(tmp_path: Path) -> None: def test_llm_validator_parse_failure_is_not_accepted(tmp_path: Path) -> None:
task_service = TaskService(tmp_path / "tasks") task_service = TaskService(tmp_path / "tasks")
task = task_service.create_task(session_id="web:validator", description="implement validator handling") task = task_service.create_task(session_id="web:validator", description="implement validator handling")
validation = asyncio.run( validation = asyncio.run(
ValidationService().validate_task_result( ValidationService().validate_task_result(
task=task, task=task,
user_message="implement validator handling", user_message="implement validator handling",
final_output="done", final_output="done",
provider_bundle=_bundle("not json"), provider_bundle=_main_only_bundle("not json"),
)
) )
)
assert validation.accepted is False assert validation.accepted is False
assert validation.validator == "llm_error" assert validation.validator == "llm_error"

View File

@ -9,7 +9,7 @@ from beaver.engine.context import SkillContext
from beaver.engine.providers.base import LLMProvider, LLMResponse from beaver.engine.providers.base import LLMProvider, LLMResponse
from beaver.engine.providers.factory import ProviderBundle from beaver.engine.providers.factory import ProviderBundle
from beaver.skills.drafts import DraftService from beaver.skills.drafts import DraftService
from beaver.skills.learning import MissingSkillSynthesizer from beaver.skills.learning import EphemeralGuidanceSynthesizer
from beaver.skills.publisher import SkillPublisher from beaver.skills.publisher import SkillPublisher
from beaver.skills.reviews import ReviewService from beaver.skills.reviews import ReviewService
from beaver.skills.specs import SkillSpecStore from beaver.skills.specs import SkillSpecStore
@ -116,12 +116,12 @@ def test_task_skill_resolver_pins_matching_published_skill(tmp_path: Path) -> No
assert reports[0].ephemeral_used is False assert reports[0].ephemeral_used is False
def test_task_skill_resolver_generates_draft_only_ephemeral_skill_when_missing(tmp_path: Path) -> None: def test_task_skill_resolver_generates_ephemeral_guidance_when_missing(tmp_path: Path) -> None:
provider = RecordingProvider( provider = RecordingProvider(
[ [
""" """
{ {
"skill_name": "api-compatibility-review", "guidance_name": "api-compatibility-review",
"description": "Review API compatibility", "description": "Review API compatibility",
"content": "# API Compatibility Review\\n\\nCheck schema compatibility.", "content": "# API Compatibility Review\\n\\nCheck schema compatibility.",
"tags": ["api", "review"] "tags": ["api", "review"]
@ -133,7 +133,7 @@ def test_task_skill_resolver_generates_draft_only_ephemeral_skill_when_missing(t
resolver = TaskSkillResolver( resolver = TaskSkillResolver(
skills_loader=SkillsLoader(tmp_path), skills_loader=SkillsLoader(tmp_path),
draft_service=DraftService(store), draft_service=DraftService(store),
missing_skill_synthesizer=MissingSkillSynthesizer(), missing_skill_synthesizer=EphemeralGuidanceSynthesizer(),
) )
graph = ExecutionGraph( graph = ExecutionGraph(
strategy="sequence", strategy="sequence",
@ -163,13 +163,14 @@ def test_task_skill_resolver_generates_draft_only_ephemeral_skill_when_missing(t
) )
drafts = store.list_drafts("api-compatibility-review") drafts = store.list_drafts("api-compatibility-review")
assert len(drafts) == 1 assert drafts == []
assert store.list_published_skill_names() == [] assert store.list_published_skill_names() == []
assert resolved.nodes[0].inherited_pinned_skills == [] assert resolved.nodes[0].inherited_pinned_skills == []
assert len(resolved.nodes[0].inherited_pinned_skill_contexts) == 1 assert len(resolved.nodes[0].inherited_pinned_skill_contexts) == 1
context: SkillContext = resolved.nodes[0].inherited_pinned_skill_contexts[0] context: SkillContext = resolved.nodes[0].inherited_pinned_skill_contexts[0]
assert context.name == "draft:api-compatibility-review" assert context.name == "ephemeral:api-compatibility-review"
assert context.version == f"draft:{drafts[0].draft_id}" assert context.version.startswith("ephemeral:eg_")
assert context.activation_reason == "generated_missing_skill" assert context.activation_reason == "ephemeral_guidance"
assert reports[0].generated_skill_draft_id == drafts[0].draft_id assert reports[0].ephemeral_guidance_id is not None
assert reports[0].ephemeral_guidance_name == "api-compatibility-review"
assert reports[0].ephemeral_used is True assert reports[0].ephemeral_used is True

View File

@ -83,7 +83,6 @@ tools:
registry = ToolRegistry() registry = ToolRegistry()
registry.register(DummyTool("memory", toolset="memory", always_available=True)) registry.register(DummyTool("memory", toolset="memory", always_available=True))
registry.register(DummyTool("skill_view", toolset="skills", always_available=True))
registry.register(DummyTool("terminal", toolset="shell")) registry.register(DummyTool("terminal", toolset="shell"))
registry.register(DummyTool("search_files", toolset="file")) registry.register(DummyTool("search_files", toolset="file"))
registry.register(DummyTool("echo", toolset="debug")) registry.register(DummyTool("echo", toolset="debug"))
@ -100,7 +99,7 @@ tools:
) )
) )
assert [spec.name for spec in selected] == ["memory", "skill_view", "terminal", "search_files"] assert [spec.name for spec in selected] == ["memory", "terminal", "search_files"]
def test_embedding_fallback_can_return_all_or_top_k() -> None: def test_embedding_fallback_can_return_all_or_top_k() -> None:

View File

@ -0,0 +1,132 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from fastapi.testclient import TestClient
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
@dataclass(slots=True)
class StubRunResult:
session_id: str
run_id: str = "run-1"
output_text: str = "ok"
finish_reason: str = "stop"
tool_iterations: int = 0
provider_name: str | None = "stub"
model: str | None = "stub-model"
usage: dict[str, Any] = field(default_factory=lambda: {"total_tokens": 3})
task_id: str | None = "task-1"
task_status: str | None = "awaiting_feedback"
validation_result: dict[str, Any] | None = field(default_factory=lambda: {"accepted": True})
class StubAgentService(AgentService):
def __init__(self, *, fail: bool = False) -> None:
super().__init__()
self.fail = fail
self.calls: list[dict[str, Any]] = []
async def submit_direct(self, message: str, **kwargs: Any) -> StubRunResult: # type: ignore[override]
self.calls.append({"message": message, **kwargs})
if self.fail:
raise RuntimeError("boom")
return StubRunResult(
session_id=kwargs.get("session_id") or "web:default",
output_text=f"echo:{message}",
)
def test_websocket_ping_pong() -> None:
app = create_app(service=StubAgentService(), manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/ws/web:alpha") as websocket:
websocket.send_json({"type": "ping"})
assert websocket.receive_json() == {"type": "pong"}
def test_websocket_message_returns_chat_metadata_and_session_updated() -> None:
service = StubAgentService()
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/ws/web:alpha") as websocket:
websocket.send_json(
{
"type": "message",
"content": "hello",
"metadata": {"source": "test"},
"attachments": [{"file_id": "file-1", "name": "a.txt"}],
}
)
assert websocket.receive_json() == {"type": "status", "status": "thinking"}
message = websocket.receive_json()
session_updated = websocket.receive_json()
assert service.calls == [
{
"message": "hello",
"session_id": "web:alpha",
"source": "websocket",
"user_id": None,
"title": None,
"execution_context": None,
"model": None,
"provider_name": None,
"embedding_model": None,
}
]
assert message["type"] == "message"
assert message["role"] == "assistant"
assert message["content"] == "echo:hello"
assert message["session_id"] == "web:alpha"
assert message["run_id"] == "run-1"
assert message["task_id"] == "task-1"
assert message["task_status"] == "awaiting_feedback"
assert message["validation_result"] == {"accepted": True}
assert message["validation_status"] == "passed"
assert message["metadata"]["input_metadata"] == {
"source": "test",
"attachments": [{"file_id": "file-1", "name": "a.txt"}],
}
assert session_updated == {
"type": "session_updated",
"session_id": "web:alpha",
"source": "websocket",
}
def test_websocket_empty_content_returns_error_without_runtime_call() -> None:
service = StubAgentService()
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/ws/web:alpha") as websocket:
websocket.send_json({"type": "message", "content": " "})
assert websocket.receive_json() == {"type": "error", "error": "'content' is required"}
assert service.calls == []
def test_websocket_runtime_error_returns_assistant_error_message() -> None:
service = StubAgentService(fail=True)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/ws/web:alpha") as websocket:
websocket.send_json({"type": "message", "content": "hello"})
assert websocket.receive_json() == {"type": "status", "status": "thinking"}
message = websocket.receive_json()
websocket.send_json({"type": "ping"})
pong = websocket.receive_json()
assert message["type"] == "message"
assert message["role"] == "assistant"
assert message["session_id"] == "web:alpha"
assert message["finish_reason"] == "error"
assert "boom" in message["content"]
assert pong == {"type": "pong"}

View File

@ -238,6 +238,7 @@ version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "anthropic" }, { name = "anthropic" },
{ name = "croniter" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "fastmcp" }, { name = "fastmcp" },
{ name = "httpx" }, { name = "httpx" },
@ -245,6 +246,7 @@ dependencies = [
{ name = "litellm" }, { name = "litellm" },
{ name = "openai" }, { name = "openai" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-multipart" },
{ name = "typer" }, { name = "typer" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
@ -257,6 +259,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "anthropic", specifier = ">=0.51.0,<1.0.0" }, { name = "anthropic", specifier = ">=0.51.0,<1.0.0" },
{ name = "croniter", specifier = ">=6.0.0,<7.0.0" },
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" }, { name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
{ name = "fastmcp", specifier = ">=3.0.0,<4.0.0" }, { name = "fastmcp", specifier = ">=3.0.0,<4.0.0" },
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" }, { name = "httpx", specifier = ">=0.28.0,<1.0.0" },
@ -265,6 +268,7 @@ requires-dist = [
{ name = "openai", specifier = ">=1.79.0,<2.0.0" }, { name = "openai", specifier = ">=1.79.0,<2.0.0" },
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" }, { name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
{ name = "python-multipart", specifier = ">=0.0.20,<1.0.0" },
{ name = "typer", specifier = ">=0.20.0,<1.0.0" }, { name = "typer", specifier = ">=0.20.0,<1.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" },
] ]
@ -493,6 +497,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "croniter"
version = "6.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" },
]
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "48.0.0" version = "48.0.0"
@ -1927,6 +1943,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
@ -2317,6 +2345,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"

View File

@ -35,7 +35,7 @@
- `task_id` - `task_id`
- `task_mode` - `task_mode`
- `attempt_index` - `attempt_index`
- `learning_candidate_enabled` - `allow_candidate_generation`
4. `RunRecord` 已记录: 4. `RunRecord` 已记录:
- `task_id` - `task_id`
- `attempt_index` - `attempt_index`
@ -55,7 +55,7 @@
8. 学习触发已经收紧。 8. 学习触发已经收紧。
- Task 模式 run 不再直接生成成功学习候选 - Task 模式 run 不再直接生成成功学习候选
- 只有“自动验证通过 + 用户点击满意”才触发成功学习候选 - 只有“自动验证通过 + 用户点击满意”才触发成功学习候选
- “放弃”写 Failure Memory不生成成功 Skill draft - “放弃”只写失败证据,不默认写主 memory不生成成功 Skill draft
9. Agent Team v1 已落地为 Beaver 自有轻量 coordinator。 9. Agent Team v1 已落地为 Beaver 自有轻量 coordinator。
- 新增 `AgentDescriptor / DelegationEnvelope / ExecutionNode / ExecutionGraph / TeamRunResult` - 新增 `AgentDescriptor / DelegationEnvelope / ExecutionNode / ExecutionGraph / TeamRunResult`
- 新增 `TeamService.run_team(...)` 作为内部服务入口 - 新增 `TeamService.run_team(...)` 作为内部服务入口
@ -72,7 +72,7 @@
- `TaskExecutionPlanner` 使用 LLM JSON 规划 `single / team` - `TaskExecutionPlanner` 使用 LLM JSON 规划 `single / team`
- team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设 - team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设
- 新增 `beaver/tasks/skill_resolver.py` - 新增 `beaver/tasks/skill_resolver.py`
- `TaskSkillResolver` 为 generic sub-agent 选择 published skill未命中时生成 draft-only skill并作为本次 run 的 ephemeral pinned instruction 使用 - `TaskSkillResolver` 为 generic sub-agent 选择 published skill未命中时生成 ephemeral guidance并作为本次 run 的 pinned guidance 使用
- 只允许 v1 已实现的 `sequence / parallel / dag` - 只允许 v1 已实现的 `sequence / parallel / dag`
- planner 失败或 graph 非法时降级为 `single` - planner 失败或 graph 非法时降级为 `single`
- team run 先作为 sub-agent 内部执行,输出注入主 Agent synthesis run - team run 先作为 sub-agent 内部执行,输出注入主 Agent synthesis run
@ -1407,7 +1407,7 @@ Hermes 官方公开说明里,明确把这些能力作为它的核心区别:
│ ├─ provider/chat/tool loop │ ├─ provider/chat/tool loop
│ ├─ sessions.append_message(event_type="run_completed" 或 "run_failed", hidden) │ ├─ sessions.append_message(event_type="run_completed" 或 "run_failed", hidden)
│ │ │ │
│ └─ AgentLoop._record_skill_learning(...) │ └─ AgentLoop._record_run_receipts(...)
│ ├─ 构造 `RunRecord` │ ├─ 构造 `RunRecord`
│ ├─ 构造 `SkillEffectRecord[]` │ ├─ 构造 `SkillEffectRecord[]`
│ ├─ 默认只记录 receipts/effects不生成学习候选 │ ├─ 默认只记录 receipts/effects不生成学习候选
@ -1750,7 +1750,10 @@ app-instance 镜像也已经切到新 Beaver 后端:
- 当前 channel 职责很窄: - 当前 channel 职责很窄:
- 把外部输入发布成 `InboundMessage` - 把外部输入发布成 `InboundMessage`
- 接收并投递 `OutboundMessage` - 接收并投递 `OutboundMessage`
- old-style 平台字段(如 `chat_id/message_id/thread_id/raw_channel_payload`)只能在 adapter 层映射和保留
- adapter 负责生成稳定 `session_id`,例如 `telegram:{chat_id}` / `slack:{channel_id}:{thread_ts}`
- `MemoryChannelAdapter` 只用于本地测试和内嵌接入,不是正式消息 broker - `MemoryChannelAdapter` 只用于本地测试和内嵌接入,不是正式消息 broker
- WebSocket 是 Web 入口适配层,不是 Gateway channel真实多渠道仍统一走 `ChannelAdapter -> MessageBus -> AgentService.handle_inbound_message(...)`
所以现在已经明确: 所以现在已经明确:
@ -2191,13 +2194,13 @@ app-instance 镜像也已经切到新 Beaver 后端:
1. planner team JSON 支持 `skill_query / required_capabilities`,不要求 agent role。 1. planner team JSON 支持 `skill_query / required_capabilities`,不要求 agent role。
2. `TaskSkillResolver` 命中 published skill 时,写入 `ExecutionNode.inherited_pinned_skills`。 2. `TaskSkillResolver` 命中 published skill 时,写入 `ExecutionNode.inherited_pinned_skills`。
3. sub-agent run 的 published pinned skill receipt 记录 `activation_reason=pinned_delegation`。 3. sub-agent run 的 published pinned skill receipt 记录 `activation_reason=pinned_delegation`。
4. 未命中 skill 时创建 draft-only skill,并写入 `ExecutionNode.inherited_pinned_skill_contexts`。 4. 未命中 skill 时创建 ephemeral guidance,并写入 `ExecutionNode.inherited_pinned_skill_contexts`。
5. draft-only skill receipt 记录 `activation_reason=generated_missing_skill`。 5. ephemeral guidance receipt 记录 `activation_reason=ephemeral_guidance`。
6. missing skill draft 不自动 approve/publish不进入 runtime skill catalog。 6. ephemeral guidance 不写入 draft store不自动 approve/publish不进入 runtime skill catalog。
7. plan event 写入 `skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report`。 7. plan event 写入 `skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report`。
8. `/api/sessions/{session_id}/process` 能把隐藏 Task/team/validation 事件投影成 `processRuns / processEvents`。 8. `/api/sessions/{session_id}/process` 能把隐藏 Task/team/validation 事件投影成 `processRuns / processEvents`。
9. ChatWorkbench 桌面端有 `ProcessLane`,移动端有 `Process` tab。 9. ChatWorkbench 桌面端有 `ProcessLane`,移动端有 `Process` tab。
10. process view 展示 selected skills、generated draft id、ephemeral skill used不展示 specialist agent selection。 10. process view 展示 selected skills、ephemeral guidance id、ephemeral skill used不展示 specialist agent selection。
11. team 部分失败时process view 显示失败节点,但最终回答仍来自主 Agent。 11. team 部分失败时process view 显示失败节点,但最终回答仍来自主 Agent。
12. `SkillLearningPipelineService` 能串起 candidate -> draft -> safety/eval -> review -> approve/reject -> publish。 12. `SkillLearningPipelineService` 能串起 candidate -> draft -> safety/eval -> review -> approve/reject -> publish。
13. rejected draft 不能 publish。 13. rejected draft 不能 publish。

View File

@ -1,419 +1,5 @@
'use client'; import { redirect } from 'next/navigation';
import React, { useEffect, useState } from 'react'; export default function CronRedirectPage() {
import { redirect('/tasks?tab=scheduled');
Clock,
Plus,
Trash2,
Play,
RefreshCw,
Loader2,
AlertCircle,
X,
} from 'lucide-react';
import {
listCronJobs,
addCronJob,
removeCronJob,
toggleCronJob,
runCronJob,
} from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
import type { CronJob } from '@/types';
export default function CronPage() {
const { locale } = useAppI18n();
const sessionId = useChatStore((s) => s.sessionId);
const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const targetSessionKey = sessionId.startsWith('web:') ? sessionId : 'web:default';
const loadJobs = async () => {
setLoading(true);
setError(null);
try {
const data = await listCronJobs(true);
setJobs(data);
} catch (err: any) {
setError(err.message || pickAppText(locale, '加载任务失败', 'Failed to load jobs'));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadJobs();
}, []);
const handleToggle = async (jobId: string, enabled: boolean) => {
try {
await toggleCronJob(jobId, enabled);
loadJobs();
} catch {
// ignore
}
};
const handleDelete = async (jobId: string) => {
try {
await removeCronJob(jobId);
loadJobs();
} catch {
// ignore
}
};
const handleRun = async (jobId: string) => {
try {
await runCronJob(jobId);
loadJobs();
} catch {
// ignore
}
};
const handleAdd = async (params: {
name: string;
message: string;
every_seconds?: number;
cron_expr?: string;
}) => {
try {
await addCronJob({
...params,
session_key: targetSessionKey,
});
setShowAdd(false);
loadJobs();
} catch (err: any) {
setError(err.message);
}
};
const formatTime = (ms: number | null) => {
if (!ms) return '-';
return new Date(ms).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
<TaskManagementTabs />
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Clock className="w-6 h-6" />
{pickAppText(locale, '定时任务', 'Scheduled tasks')}
</h1>
<div className="flex items-center gap-2">
<Button onClick={loadJobs} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
<Button onClick={() => setShowAdd(true)} size="sm">
<Plus className="w-4 h-4 mr-2" />
{pickAppText(locale, '新建任务', 'New job')}
</Button>
</div>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
</CardContent>
</Card>
)}
{/* Add Job Form */}
{showAdd && (
<AddJobForm
targetSessionKey={targetSessionKey}
onAdd={handleAdd}
onCancel={() => setShowAdd(false)}
/>
)}
{/* Jobs Table */}
<Card>
<CardContent className="p-0">
{jobs.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<Clock className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">{pickAppText(locale, '暂无定时任务', 'No scheduled tasks yet')}</p>
<p className="text-sm mt-1">{pickAppText(locale, '新建一个任务,让智能体按计划自动执行。', 'Create a job to let the agent run on a schedule.')}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">{pickAppText(locale, '启用', 'Enabled')}</TableHead>
<TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead>
<TableHead>{pickAppText(locale, '计划', 'Schedule')}</TableHead>
<TableHead>{pickAppText(locale, '消息', 'Message')}</TableHead>
<TableHead>{pickAppText(locale, '上次运行', 'Last run')}</TableHead>
<TableHead>{pickAppText(locale, '下次运行', 'Next run')}</TableHead>
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
<TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((job) => (
<TableRow key={job.id}>
<TableCell>
<Switch
checked={job.enabled}
onCheckedChange={(checked) =>
handleToggle(job.id, checked)
}
/>
</TableCell>
<TableCell className="font-medium">
<div>
<span>{job.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{job.id}
</span>
</div>
</TableCell>
<TableCell>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{job.schedule_display}
</code>
</TableCell>
<TableCell>
<span className="text-sm truncate max-w-[200px] block">
{job.message}
</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatTime(job.last_run_at_ms)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatTime(job.next_run_at_ms)}
</TableCell>
<TableCell>
{job.last_status === 'ok' && (
<Badge variant="default" className="text-xs bg-green-600">
OK
</Badge>
)}
{job.last_status === 'error' && (
<Badge variant="destructive" className="text-xs">
{pickAppText(locale, '错误', 'Error')}
</Badge>
)}
{!job.last_status && (
<span className="text-xs text-muted-foreground">
-
</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleRun(job.id)}
title={pickAppText(locale, '立即执行', 'Run now')}
>
<Play className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(job.id)}
title={pickAppText(locale, '删除', 'Delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function AddJobForm({
targetSessionKey,
onAdd,
onCancel,
}: {
targetSessionKey: string;
onAdd: (params: {
name: string;
message: string;
every_seconds?: number;
cron_expr?: string;
}) => void;
onCancel: () => void;
}) {
const { locale } = useAppI18n();
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [scheduleType, setScheduleType] = useState<'every' | 'cron'>('every');
const [everySeconds, setEverySeconds] = useState('3600');
const [cronExpr, setCronExpr] = useState('0 9 * * *');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !message.trim()) return;
const params: any = { name: name.trim(), message: message.trim() };
if (scheduleType === 'every') {
params.every_seconds = parseInt(everySeconds, 10) || 3600;
} else {
params.cron_expr = cronExpr.trim();
}
onAdd(params);
};
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<X className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">{pickAppText(locale, '任务名称', 'Job name')}</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={pickAppText(locale, '例如:日报汇总', 'Example: daily summary')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="schedule-type">{pickAppText(locale, '调度类型', 'Schedule type')}</Label>
<Select
value={scheduleType}
onValueChange={(v) => setScheduleType(v as 'every' | 'cron')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="every">{pickAppText(locale, '固定间隔(每 N 秒)', 'Fixed interval (every N seconds)')}</SelectItem>
<SelectItem value="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{scheduleType === 'every' ? (
<div className="space-y-2">
<Label htmlFor="every">{pickAppText(locale, '间隔(秒)', 'Interval (seconds)')}</Label>
<Input
id="every"
type="number"
value={everySeconds}
onChange={(e) => setEverySeconds(e.target.value)}
min="10"
placeholder="3600"
/>
<p className="text-xs text-muted-foreground">
{parseInt(everySeconds, 10) >= 3600
? pickAppText(locale, `${Math.floor(parseInt(everySeconds, 10) / 3600)} 小时 ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}`, `About ${Math.floor(parseInt(everySeconds, 10) / 3600)}h ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}m`)
: parseInt(everySeconds, 10) >= 60
? pickAppText(locale, `${Math.floor(parseInt(everySeconds, 10) / 60)}${parseInt(everySeconds, 10) % 60}`, `About ${Math.floor(parseInt(everySeconds, 10) / 60)}m ${parseInt(everySeconds, 10) % 60}s`)
: ''}
</p>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</Label>
<Input
id="cron"
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
placeholder="0 9 * * *"
/>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '格式:分钟 小时 日 月 周', 'Format: minute hour day month weekday')}
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="message">{pickAppText(locale, '发送给智能体的消息', 'Message for the agent')}</Label>
<Input
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={pickAppText(locale, '例如:检查我的邮件并生成摘要', 'Example: check my email and generate a summary')}
/>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '任务结果会自动回写到当前 Web 会话:', 'Results are written back to the current web session:')} <code className="bg-muted px-1 py-0.5 rounded">{targetSessionKey}</code>
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
<Button type="submit" disabled={!name.trim() || !message.trim()}>
<Plus className="w-4 h-4 mr-2" />
{pickAppText(locale, '创建任务', 'Create job')}
</Button>
</div>
</form>
</CardContent>
</Card>
);
} }

View File

@ -1,21 +1,9 @@
import Header from '@/components/Header'; import { AppShell } from '@/components/AppShell';
import AuthGuard from '@/components/AuthGuard';
import { AppRuntimeBridge } from '@/components/AppRuntimeBridge';
export default function AppLayout({ export default function AppLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return <AppShell>{children}</AppShell>;
<div className="min-h-screen bg-background text-foreground">
<Header />
<main className="pt-16">
<AuthGuard>
<AppRuntimeBridge />
{children}
</AuthGuard>
</main>
</div>
);
} }

View File

@ -0,0 +1,205 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { AlertCircle, Bot, Braces, ChevronDown, Loader2, MessageSquare, RefreshCw, TerminalSquare } from 'lucide-react';
import { getChatLogs } from '@/lib/api';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import type { ChatLogEvent, ChatLogSession } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
function eventLabel(event: ChatLogEvent): string {
return event.event_type || event.role || 'event';
}
function eventIcon(event: ChatLogEvent) {
if (event.event_type === 'llm_request_snapshotted') return Braces;
if (event.role === 'assistant') return Bot;
if (event.role === 'tool') return TerminalSquare;
return MessageSquare;
}
function formatPayload(value: unknown): string {
if (value === null || value === undefined || value === '') return '';
if (typeof value === 'string') return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function eventBody(event: ChatLogEvent): string {
const content = event.content?.trim();
if (content) return content;
return formatPayload(event.event_payload);
}
function timestampLabel(value?: string | null): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
export default function LogsPage() {
const { locale } = useAppI18n();
const [sessions, setSessions] = useState<ChatLogSession[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedRuns, setExpandedRuns] = useState<Set<string>>(() => new Set());
const runs = useMemo(
() =>
sessions.flatMap((session) =>
session.runs.map((run) => ({
...run,
sessionTitle: session.title || session.session_id,
}))
),
[sessions]
);
const loadLogs = async () => {
setLoading(true);
setError(null);
try {
const data = await getChatLogs(80);
setSessions(data.sessions || []);
const firstRun = data.sessions?.[0]?.runs?.[0]?.run_id;
if (firstRun) {
setExpandedRuns((current) => (current.size ? current : new Set([firstRun])));
}
} catch (err: any) {
setError(err.message || pickAppText(locale, '读取日志失败', 'Failed to load logs'));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadLogs();
}, []);
const toggleRun = (runId: string) => {
setExpandedRuns((current) => {
const next = new Set(current);
if (next.has(runId)) next.delete(runId);
else next.add(runId);
return next;
});
};
return (
<div className="mx-auto max-w-7xl p-6 space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-bold">{pickAppText(locale, '运行日志', 'Runtime Logs')}</h1>
<p className="mt-1 text-sm text-muted-foreground">
{pickAppText(
locale,
'按每一次用户输入分组,查看 LLM 请求、回复、工具结果和隐藏运行快照。',
'Grouped by each user input, with LLM requests, responses, tool results, and hidden runtime snapshots.'
)}
</p>
</div>
<Button onClick={loadLogs} variant="outline" size="sm" disabled={loading}>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
</div>
{error ? (
<Card className="border-destructive">
<CardContent className="flex items-start gap-3 pt-6 text-destructive">
<AlertCircle className="mt-0.5 h-5 w-5" />
<div>
<p className="text-sm font-medium">{pickAppText(locale, '无法读取日志', 'Unable to load logs')}</p>
<p className="mt-1 text-sm text-muted-foreground">{error}</p>
</div>
</CardContent>
</Card>
) : null}
{loading && !runs.length ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : null}
{!loading && !runs.length && !error ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
{pickAppText(locale, '还没有可展示的运行日志。', 'No runtime logs are available yet.')}
</CardContent>
</Card>
) : null}
<div className="space-y-3">
{runs.map((run) => {
const expanded = expandedRuns.has(run.run_id);
return (
<Card key={`${run.session_id}:${run.run_id}`} className="overflow-hidden">
<button
type="button"
onClick={() => toggleRun(run.run_id)}
className="flex w-full items-start justify-between gap-4 border-b px-5 py-4 text-left transition-colors hover:bg-muted/40"
>
<span className="min-w-0 space-y-2">
<span className="flex flex-wrap items-center gap-2">
<Badge variant={run.task_mode ? 'default' : 'secondary'}>
{run.task_mode ? 'Task' : 'Chat'}
</Badge>
{run.source ? <Badge variant="outline">{run.source}</Badge> : null}
{run.attempt_index ? <Badge variant="outline">attempt {run.attempt_index}</Badge> : null}
<span className="text-xs text-muted-foreground">{timestampLabel(run.started_at)}</span>
</span>
<span className="block truncate text-sm font-semibold text-foreground">
{run.user_input || run.title || run.run_id}
</span>
<span className="block truncate text-xs text-muted-foreground">
{run.sessionTitle} · {run.run_id}
</span>
</span>
<ChevronDown className={`mt-1 h-4 w-4 shrink-0 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} />
</button>
{expanded ? (
<CardContent className="space-y-3 p-5">
{run.events.map((event, index) => {
const Icon = eventIcon(event);
const body = eventBody(event);
return (
<div
key={`${event.message_id ?? index}:${event.event_type}`}
className="rounded-lg border border-border bg-background"
>
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">{eventLabel(event)}</span>
<Badge variant={event.context_visible ? 'secondary' : 'outline'}>
{event.context_visible ? 'visible' : 'hidden'}
</Badge>
{event.tool_name ? <Badge variant="outline">{event.tool_name}</Badge> : null}
</div>
<span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span>
</div>
<pre className="max-h-[520px] overflow-auto whitespace-pre-wrap break-words p-3 text-xs leading-5 text-foreground">
{body || formatPayload(event)}
</pre>
</div>
);
})}
</CardContent>
) : null}
</Card>
);
})}
</div>
</div>
);
}

View File

@ -1,439 +1,256 @@
'use client'; 'use client';
import React, { useEffect, useState, useCallback } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertCircle, ArrowLeft, Check, Download, Loader2, Search, Star } from 'lucide-react';
import { import {
Store, getSkillHubDetail,
RefreshCw, getSkillHubVersion,
Loader2, installSkillHubSkill,
AlertCircle, searchSkillHubSkills,
Plus,
Trash2,
Download,
Check,
X,
Globe,
FolderOpen,
} from 'lucide-react';
import {
listMarketplaces,
addMarketplace,
removeMarketplace,
updateMarketplace,
listMarketplacePlugins,
installMarketplacePlugin,
uninstallPlugin,
} from '@/lib/api'; } from '@/lib/api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import type { Marketplace, MarketplacePlugin } from '@/types'; import type { SkillHubSearchItem, SkillHubVersionResponse } from '@/types';
import { pickAppText } from '@/lib/i18n/core'; import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider'; import { useAppI18n } from '@/lib/i18n/provider';
type SortMode = 'relevance' | 'downloads' | 'newest';
function publishedVersion(skill: SkillHubSearchItem | null): string {
return skill?.publishedVersion?.version || skill?.headlineVersion?.version || '';
}
export default function MarketplacePage() { export default function MarketplacePage() {
const { locale } = useAppI18n(); const { locale } = useAppI18n();
const [marketplaces, setMarketplaces] = useState<Marketplace[]>([]); const t = useCallback((zh: string, en: string) => pickAppText(locale, zh, en), [locale]);
const [selectedMarketplace, setSelectedMarketplace] = useState<string | null>(null); const [query, setQuery] = useState('');
const [plugins, setPlugins] = useState<MarketplacePlugin[]>([]); const [sort, setSort] = useState<SortMode>('newest');
const [starredOnly, setStarredOnly] = useState(false);
const [page, setPage] = useState(0);
const [items, setItems] = useState<SkillHubSearchItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [pluginsLoading, setPluginsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showAddForm, setShowAddForm] = useState(false); const [selected, setSelected] = useState<SkillHubSearchItem | null>(null);
const [addSource, setAddSource] = useState(''); const [versionDetail, setVersionDetail] = useState<SkillHubVersionResponse | null>(null);
const [adding, setAdding] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [actionPlugin, setActionPlugin] = useState<string | null>(null); const [installing, setInstalling] = useState(false);
const [updatingMarketplace, setUpdatingMarketplace] = useState<string | null>(null);
const loadMarketplaces = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await listMarketplaces(); const result = await searchSkillHubSkills({ q: query, sort, page, size: 12 });
const list = Array.isArray(data) ? data : []; const nextItems = Array.isArray(result.items) ? result.items : [];
setMarketplaces(list); setItems(starredOnly ? nextItems.filter((item) => (item.starCount || 0) > 0) : nextItems);
// Auto-select first marketplace if none selected or selected was removed setTotal(result.total || 0);
if (list.length > 0) {
setSelectedMarketplace((prev) => {
if (prev && list.some((m) => m.name === prev)) return prev;
return list[0].name;
});
} else {
setSelectedMarketplace(null);
setPlugins([]);
}
} catch (err: any) { } catch (err: any) {
setError(err.message || pickAppText(locale, '加载市场失败', 'Failed to load marketplaces')); setError(err.message || t('加载 SkillHub 失败', 'Failed to load SkillHub'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [page, query, sort, starredOnly, t]);
const loadPlugins = useCallback(async (marketplaceName: string) => {
setPluginsLoading(true);
try {
const data = await listMarketplacePlugins(marketplaceName);
setPlugins(Array.isArray(data) ? data : []);
} catch (err: any) {
setError(err.message || pickAppText(locale, '加载插件失败', 'Failed to load plugins'));
} finally {
setPluginsLoading(false);
}
}, []);
useEffect(() => { useEffect(() => {
loadMarketplaces(); void load();
}, [loadMarketplaces]); }, [load]);
useEffect(() => { const openDetail = async (item: SkillHubSearchItem) => {
if (selectedMarketplace) { setSelected(item);
loadPlugins(selectedMarketplace); setVersionDetail(null);
} setDetailLoading(true);
}, [selectedMarketplace, loadPlugins]);
const handleAdd = async () => {
if (!addSource.trim()) return;
setAdding(true);
setError(null); setError(null);
try { try {
const marketplace = await addMarketplace(addSource.trim()); const detail = await getSkillHubDetail(item.namespace, item.slug);
setAddSource(''); setSelected(detail);
setShowAddForm(false); const version = publishedVersion(detail);
await loadMarketplaces(); if (version) {
setSelectedMarketplace(marketplace.name); setVersionDetail(await getSkillHubVersion(detail.namespace, detail.slug, version));
} catch (err: any) {
setError(err.message || pickAppText(locale, '添加市场失败', 'Failed to add the marketplace'));
} finally {
setAdding(false);
}
};
const handleRemove = async (name: string) => {
setError(null);
try {
await removeMarketplace(name);
if (selectedMarketplace === name) {
setSelectedMarketplace(null);
setPlugins([]);
}
await loadMarketplaces();
} catch (err: any) {
setError(err.message || pickAppText(locale, '移除市场失败', 'Failed to remove the marketplace'));
}
};
const handleUpdateMarketplace = async (name: string) => {
setUpdatingMarketplace(name);
setError(null);
try {
await updateMarketplace(name);
await loadPlugins(name);
} catch (err: any) {
setError(err.message || pickAppText(locale, '更新市场失败', 'Failed to update the marketplace'));
} finally {
setUpdatingMarketplace(null);
}
};
const handleUpdatePlugin = async (marketplaceName: string, pluginName: string) => {
setActionPlugin(pluginName);
setError(null);
try {
await installMarketplacePlugin(marketplaceName, pluginName);
await loadPlugins(marketplaceName);
} catch (err: any) {
setError(err.message || pickAppText(locale, '更新插件失败', 'Failed to update the plugin'));
} finally {
setActionPlugin(null);
}
};
const handleInstall = async (marketplaceName: string, pluginName: string) => {
setActionPlugin(pluginName);
setError(null);
try {
await installMarketplacePlugin(marketplaceName, pluginName);
await loadPlugins(marketplaceName);
} catch (err: any) {
setError(err.message || pickAppText(locale, '安装插件失败', 'Failed to install the plugin'));
} finally {
setActionPlugin(null);
}
};
const handleUninstall = async (pluginName: string) => {
setActionPlugin(pluginName);
setError(null);
try {
await uninstallPlugin(pluginName);
if (selectedMarketplace) {
await loadPlugins(selectedMarketplace);
} }
} catch (err: any) { } catch (err: any) {
setError(err.message || pickAppText(locale, '卸载插件失败', 'Failed to uninstall the plugin')); setError(err.message || t('加载技能详情失败', 'Failed to load skill details'));
} finally { } finally {
setActionPlugin(null); setDetailLoading(false);
} }
}; };
const handleRefresh = async () => { const installSelected = async () => {
await loadMarketplaces(); if (!selected) return;
if (selectedMarketplace) { setInstalling(true);
await loadPlugins(selectedMarketplace); setError(null);
try {
const result = await installSkillHubSkill(selected.namespace, selected.slug, publishedVersion(selected));
setSelected({ ...selected, installed: true, installed_version: result.version });
await load();
} catch (err: any) {
setError(err.message || t('安装技能失败', 'Failed to install skill'));
} finally {
setInstalling(false);
} }
}; };
if (loading) { const totalPages = useMemo(() => Math.max(1, Math.ceil(total / 12)), [total]);
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return ( return (
<div className="max-w-5xl mx-auto p-6 space-y-6"> <div className="mx-auto max-w-7xl p-6">
{/* Page header */} <div className="mx-auto mb-10 max-w-4xl">
<div className="flex items-center justify-between"> <form
<div> className="flex gap-3"
<h1 className="text-2xl font-bold flex items-center gap-2"> onSubmit={(event) => {
<Store className="w-6 h-6" /> event.preventDefault();
{pickAppText(locale, '插件市场', 'Plugin marketplace')} setPage(0);
</h1> void load();
<p className="text-sm text-muted-foreground mt-1"> }}
{pickAppText(locale, '浏览并安装已注册市场中的插件', 'Browse and install plugins from registered marketplaces')} >
</p> <div className="relative flex-1">
</div> <Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<div className="flex items-center gap-2"> <Input
<Button value={query}
onClick={() => setShowAddForm((v) => !v)} onChange={(event) => setQuery(event.target.value)}
variant="outline" placeholder={t('搜索技能...', 'Search skills...')}
size="sm" className="h-14 rounded-2xl pl-12 text-base"
> />
<Plus className="w-4 h-4 mr-2" /> </div>
{pickAppText(locale, '添加市场', 'Add marketplace')} <Button type="submit" className="h-14 rounded-2xl px-10 text-base">
{t('搜索', 'Search')}
</Button> </Button>
<Button onClick={handleRefresh} variant="outline" size="sm"> </form>
<RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
</div>
</div> </div>
{/* Error */}
{error && ( {error && (
<Card className="border-destructive"> <Card className="mb-6 border-destructive">
<CardContent className="pt-6"> <CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
<div className="flex items-center justify-between gap-2 text-destructive text-sm"> <AlertCircle className="h-4 w-4" />
<div className="flex items-center gap-2"> {error}
<AlertCircle className="w-4 h-4 shrink-0" /> </CardContent>
{error} </Card>
)}
{selected ? (
<div className="space-y-5">
<Button variant="ghost" onClick={() => setSelected(null)}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('返回搜索', 'Back to search')}
</Button>
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="mb-2 flex items-center gap-2">
<Badge variant="outline">@{selected.namespace}</Badge>
{selected.installed && (
<Badge variant="secondary" className="gap-1">
<Check className="h-3 w-3" />
{t('已安装', 'Installed')}
</Badge>
)}
</div>
<CardTitle className="text-2xl">{selected.displayName || selected.slug}</CardTitle>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">{selected.summary}</p>
</div>
<Button onClick={installSelected} disabled={installing || detailLoading}>
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
{selected.installed ? t('重新安装/更新', 'Reinstall/update') : t('安装', 'Install')}
</Button>
</div> </div>
<Button </CardHeader>
variant="ghost" <CardContent className="space-y-4">
size="sm" {detailLoading ? (
className="shrink-0 h-6 w-6 p-0" <div className="flex justify-center py-10">
onClick={() => setError(null)} <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
> </div>
<X className="w-4 h-4" /> ) : (
</Button> <>
</div> <div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
</CardContent> <Badge variant="outline">v{publishedVersion(selected) || '-'}</Badge>
</Card> <span>{t('下载', 'Downloads')}: {selected.downloadCount || 0}</span>
)} <span>{t('收藏', 'Stars')}: {selected.starCount || 0}</span>
</div>
{/* Add marketplace form */} <div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
{showAddForm && ( <div className="rounded-lg border border-border bg-muted/20 p-4">
<Card> <div className="mb-2 text-sm font-medium">SKILL.md</div>
<CardContent className="pt-6"> <pre className="max-h-[520px] overflow-auto whitespace-pre-wrap text-xs">
<div className="flex items-center gap-2"> {versionDetail?.detail?.parsedMetadataJson || t('暂无预览', 'No preview available')}
<Input </pre>
placeholder={pickAppText(locale, '本地路径或 Git 地址(例如 /path/to/marketplace 或 https://github.com/...', 'Local path or Git URL (for example /path/to/marketplace or https://github.com/...)')} </div>
value={addSource} <div className="rounded-lg border border-border bg-muted/20 p-4">
onChange={(e) => setAddSource(e.target.value)} <div className="mb-3 text-sm font-medium">{t('版本文件', 'Version files')}</div>
onKeyDown={(e) => { <div className="space-y-2">
if (e.key === 'Enter') handleAdd(); {(versionDetail?.files || []).map((file) => (
}} <div key={file.filePath} className="flex items-center justify-between gap-3 rounded-md bg-background px-3 py-2 text-xs">
disabled={adding} <span className="break-all font-mono">{file.filePath}</span>
className="flex-1" <span className="shrink-0 text-muted-foreground">{file.fileSize} B</span>
/> </div>
<Button onClick={handleAdd} disabled={adding || !addSource.trim()} size="sm"> ))}
{adding ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
{pickAppText(locale, '添加', 'Add')}
</Button>
<Button
onClick={() => {
setShowAddForm(false);
setAddSource('');
}}
variant="ghost"
size="sm"
>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Marketplace tabs */}
{marketplaces.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{marketplaces.map((marketplace) => (
<div key={marketplace.name} className="flex items-center gap-0.5">
<Button
variant={selectedMarketplace === marketplace.name ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedMarketplace(marketplace.name)}
className="gap-1.5"
>
{marketplace.type === 'git' ? (
<Globe className="w-3.5 h-3.5" />
) : (
<FolderOpen className="w-3.5 h-3.5" />
)}
{marketplace.name}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-primary"
disabled={updatingMarketplace === marketplace.name}
onClick={() => handleUpdateMarketplace(marketplace.name)}
title={pickAppText(locale, '更新市场', 'Update marketplace')}
>
{updatingMarketplace === marketplace.name ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRemove(marketplace.name)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
))}
</div>
)}
{/* Empty state */}
{marketplaces.length === 0 && !error && (
<Card>
<CardContent className="py-16 text-center text-muted-foreground">
<Store className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p className="font-medium">{pickAppText(locale, '还没有注册任何市场', 'No marketplaces are registered yet')}</p>
<p className="text-sm mt-2 max-w-sm mx-auto">
{pickAppText(locale, '点击上方的', 'Use the')}<strong>{pickAppText(locale, '添加市场', 'Add marketplace')}</strong>{pickAppText(locale, ',填入本地路径或 Git 地址即可开始使用。', ' action above and provide a local path or Git URL to get started.')}
</p>
</CardContent>
</Card>
)}
{/* Plugin list */}
{selectedMarketplace && (
<>
{pluginsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : plugins.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Store className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">{pickAppText(locale, '暂无可用插件', 'No plugins available')}</p>
<p className="text-sm mt-1">{pickAppText(locale, '这个市场里暂时还没有插件。', 'There are no plugins in this marketplace yet.')}</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{plugins.map((plugin) => (
<Card key={plugin.name}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="text-base font-semibold">
{plugin.name}
</CardTitle>
{plugin.installed && (
<Badge variant="secondary" className="text-xs gap-1">
<Check className="w-3 h-3" />
{pickAppText(locale, '已安装', 'Installed')}
</Badge>
)}
</div>
{plugin.description && (
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{plugin.description}
</p>
)}
</div>
<div className="shrink-0 flex items-center gap-2">
{plugin.installed ? (
<>
<Button
variant="outline"
size="sm"
disabled={actionPlugin === plugin.name}
onClick={() =>
handleUpdatePlugin(plugin.marketplace_name, plugin.name)
}
>
{actionPlugin === plugin.name ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
{pickAppText(locale, '更新', 'Update')}
</Button>
<Button
variant="outline"
size="sm"
disabled={actionPlugin === plugin.name}
onClick={() => handleUninstall(plugin.name)}
>
{actionPlugin === plugin.name ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
{pickAppText(locale, '卸载', 'Uninstall')}
</Button>
</>
) : (
<Button
variant="default"
size="sm"
disabled={actionPlugin === plugin.name}
onClick={() =>
handleInstall(plugin.marketplace_name, plugin.name)
}
>
{actionPlugin === plugin.name ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Download className="w-4 h-4 mr-2" />
)}
{pickAppText(locale, '安装', 'Install')}
</Button>
)}
</div> </div>
</div> </div>
</div>
</>
)}
</CardContent>
</Card>
</div>
) : (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">{t('排序:', 'Sort:')}</span>
{([
['relevance', t('相关性', 'Relevance')],
['downloads', t('下载量', 'Downloads')],
['newest', t('最新', 'Newest')],
] as Array<[SortMode, string]>).map(([value, label]) => (
<Button key={value} size="sm" variant={sort === value ? 'default' : 'outline'} onClick={() => { setSort(value); setPage(0); }}>
{label}
</Button>
))}
<span className="ml-4 text-sm font-medium text-muted-foreground">{t('筛选:', 'Filter:')}</span>
<Button size="sm" variant={starredOnly ? 'default' : 'outline'} onClick={() => setStarredOnly((value) => !value)}>
<Star className="mr-2 h-4 w-4" />
{t('只看已收藏', 'Starred only')}
</Button>
</div>
{loading ? (
<div className="flex justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<Card key={`${item.namespace}/${item.slug}`} className="cursor-pointer transition hover:border-primary" onClick={() => void openDetail(item)}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<CardTitle className="text-xl">{item.displayName || item.slug}</CardTitle>
<Badge variant="outline">@{item.namespace}</Badge>
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-5">
<p className="line-clamp-3 min-h-[4.5rem] text-sm leading-6 text-muted-foreground">{item.summary}</p>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<Badge variant="secondary">v{publishedVersion(item) || '-'}</Badge>
<span>{item.downloadCount || 0}</span>
<span>{item.starCount || 0}</span>
{item.installed && <Badge variant="outline">{t('已安装', 'Installed')}</Badge>}
</div>
</CardContent>
</Card> </Card>
))} ))}
</div> </div>
)} )}
</>
<div className="flex items-center justify-center gap-3">
<Button variant="outline" disabled={page <= 0} onClick={() => setPage((value) => Math.max(0, value - 1))}>
{t('上一页', 'Previous')}
</Button>
<span className="text-sm text-muted-foreground">{page + 1} / {totalPages}</span>
<Button variant="outline" disabled={page + 1 >= totalPages} onClick={() => setPage((value) => value + 1)}>
{t('下一页', 'Next')}
</Button>
</div>
</div>
)} )}
</div> </div>
); );

Some files were not shown because too many files have changed in this diff Show More