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

View File

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

View File

@ -21,7 +21,7 @@ class LocalAgentRunner:
envelope: DelegationEnvelope,
*,
provider_bundle: ProviderBundle | None = None,
learning_candidate_enabled: bool = False,
allow_candidate_generation: bool = False,
) -> NodeRunResult:
if provider_bundle is not None and (envelope.agent.model or envelope.agent.provider_name):
raise ValueError(
@ -37,6 +37,7 @@ class LocalAgentRunner:
source=f"team:{envelope.agent.name}",
title=envelope.agent.role or envelope.agent.name,
execution_context=self._execution_context(envelope),
skill_selection_context=self._skill_selection_context(envelope),
model=envelope.agent.model,
provider_name=envelope.agent.provider_name,
provider_bundle=provider_bundle,
@ -44,7 +45,7 @@ class LocalAgentRunner:
task_mode=bool(envelope.parent_task_id),
pinned_skill_names=envelope.inherited_pinned_skills,
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"
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))
if envelope.inherited_pinned_skill_contexts:
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)
)
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
import json
from dataclasses import dataclass, field
from typing import Any
@ -224,8 +225,29 @@ class ContextBuilder:
clean = {key: value for key, value in message.items() if key in allowed}
if "name" not in clean and 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
@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(
self,
messages: list[dict[str, Any]],
@ -278,7 +300,7 @@ class ContextBuilder:
"content": content,
}
if tool_calls:
message["tool_calls"] = tool_calls
message["tool_calls"] = self._provider_tool_calls(tool_calls)
if reasoning_content is not None:
message["reasoning_content"] = reasoning_content
messages.append(message)

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass, field
from pathlib import Path
@ -11,6 +12,7 @@ from beaver.coordinator.registry import AgentRegistry
from beaver.engine.context import ContextBuilder
from beaver.engine.session import SessionManager
from beaver.foundation.config import BeaverConfig, load_config
from beaver.integrations.mcp import MCPConnectionManager
from beaver.memory.curated.store import MemoryStore
from beaver.memory.runs import RunMemoryStore
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.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry
from beaver.tools.builtins import (
ClarifyTool,
CronTool,
DelegateTool,
EchoTool,
ExecuteCodeTool,
ListDirectoryTool,
MemoryTool,
PatchFileTool,
ProcessTool,
ReadFileTool,
SearchFilesTool,
SendMessageTool,
SpawnTool,
SessionSearchTool,
SkillViewTool,
SkillManageTool,
SkillsListTool,
TerminalTool,
TodoTool,
WebFetchTool,
WebSearchTool,
WriteFileTool,
)
@ -76,6 +92,8 @@ class EngineLoadResult:
task_service: TaskService | None = None
task_execution_planner: TaskExecutionPlanner | 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)
closed: bool = False
@ -198,11 +216,25 @@ class EngineLoader:
[
ObjectBackedTool(EchoTool()),
ObjectBackedTool(MemoryTool(store=memory_service.get_store())),
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
ObjectBackedTool(SessionSearchTool(db=session_manager)),
ObjectBackedTool(ListDirectoryTool()),
ObjectBackedTool(ReadFileTool()),
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_execution_planner = self._task_execution_planner or TaskExecutionPlanner(task_skill_resolver=task_skill_resolver)
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(
workspace=workspace,
@ -270,7 +307,18 @@ class EngineLoader:
task_service=task_service,
task_execution_planner=task_execution_planner,
validation_service=validation_service,
mcp_manager=mcp_manager,
)
if self._session_manager is None:
result.register_closeable("session_manager", session_manager.close)
result.register_closeable("mcp_manager", lambda: _close_mcp_manager(mcp_manager))
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
import asyncio
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
@ -64,6 +65,7 @@ class AgentLoop:
self.profile = profile or AgentProfile()
self.loader = loader or EngineLoader()
self.loaded: EngineLoadResult | None = None
self.runtime_services: dict[str, Any] = {}
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
self._running = False
self._stop_requested = False
@ -190,6 +192,7 @@ class AgentLoop:
user_id: str | None = None,
title: str | None = None,
execution_context: str | None = None,
skill_selection_context: str | None = None,
model: str | None = None,
provider_name: str | None = None,
api_key: str | None = None,
@ -202,6 +205,9 @@ class AgentLoop:
embedding_model: str | None = None,
max_tokens: int | 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,
provider_bundle: ProviderBundle | None = None,
parent_session_id: str | None = None,
@ -210,7 +216,7 @@ class AgentLoop:
attempt_index: int | None = None,
pinned_skill_names: list[str] | None = None,
pinned_skill_contexts: list[SkillContext] | None = None,
learning_candidate_enabled: bool = False,
allow_candidate_generation: bool = False,
) -> AgentRunResult:
"""跑通最小 direct run 主链。
@ -234,6 +240,7 @@ class AgentLoop:
user_id=user_id,
title=title,
execution_context=execution_context,
skill_selection_context=skill_selection_context,
model=model,
provider_name=provider_name,
api_key=api_key,
@ -246,6 +253,9 @@ class AgentLoop:
embedding_model=embedding_model,
max_tokens=max_tokens,
temperature=temperature,
thinking_enabled=thinking_enabled,
include_skill_assembly=include_skill_assembly,
include_tools=include_tools,
max_tool_iterations=max_tool_iterations,
provider_bundle=provider_bundle,
parent_session_id=parent_session_id,
@ -254,7 +264,7 @@ class AgentLoop:
attempt_index=attempt_index,
pinned_skill_names=pinned_skill_names,
pinned_skill_contexts=pinned_skill_contexts,
learning_candidate_enabled=learning_candidate_enabled,
allow_candidate_generation=allow_candidate_generation,
)
async def _process_direct_impl(
@ -266,6 +276,7 @@ class AgentLoop:
user_id: str | None = None,
title: str | None = None,
execution_context: str | None = None,
skill_selection_context: str | None = None,
model: str | None = None,
provider_name: str | None = None,
api_key: str | None = None,
@ -278,6 +289,9 @@ class AgentLoop:
embedding_model: str | None = None,
max_tokens: int | 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,
provider_bundle: ProviderBundle | None = None,
parent_session_id: str | None = None,
@ -286,7 +300,7 @@ class AgentLoop:
attempt_index: int | None = None,
pinned_skill_names: list[str] | None = None,
pinned_skill_contexts: list[SkillContext] | None = None,
learning_candidate_enabled: bool = False,
allow_candidate_generation: bool = False,
) -> AgentRunResult:
"""真正执行一轮 direct run 的内部实现。
@ -306,6 +320,10 @@ class AgentLoop:
skills_loader = self._require_loaded("skills_loader")
skill_assembler = self._require_loaded("skill_assembler")
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
configured_provider = config.resolve_provider_target(model=model, provider_name=provider_name)
@ -357,6 +375,9 @@ class AgentLoop:
"task_id": task_id,
"task_mode": task_mode,
"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,
"pinned_skill_names": list(pinned_skill_names 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
else bundle.main_runtime.model
)
assembled_skills = await skill_assembler.assemble(
task_description=task,
provider=skill_selector_provider,
model=skill_selector_model,
embedding_runtime=bundle.embedding_runtime,
)
activated_skills = self._merge_skill_contexts(
[
*(pinned_skill_contexts or []),
*self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []),
],
assembled_skills.activated_skills,
)
pinned_skills = [
*(pinned_skill_contexts or []),
*self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []),
]
if not include_skill_assembly or thinking_enabled is False:
activated_skills = self._merge_skill_contexts(pinned_skills, [])
else:
skill_query = skill_selection_context or task
assembled_skills = await skill_assembler.assemble(
task_description=skill_query,
provider=skill_selector_provider,
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(
activated_skills
)
@ -444,14 +485,19 @@ class AgentLoop:
user_id=user_id,
)
selected_tool_specs = await tool_assembler.assemble(
task_description=task,
registry=tool_registry,
skills_loader=skills_loader,
activated_skills=activated_skills,
embedding_runtime=bundle.embedding_runtime,
top_k=10,
)
if not include_tools:
selected_tool_specs = []
elif thinking_enabled is False:
selected_tool_specs = tool_registry.list_specs()
else:
selected_tool_specs = await tool_assembler.assemble(
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)
session_manager.append_message(
resolved_session_id,
@ -486,6 +532,25 @@ class AgentLoop:
execution_context=execution_context,
)
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.append_message(
resolved_session_id,
@ -528,6 +593,9 @@ class AgentLoop:
"memory_service": memory_service,
"memory_store": memory_service.get_store(),
"tool_registry": tool_registry,
"skills_loader": skills_loader,
"draft_service": getattr(loaded, "draft_service", None),
**self.runtime_services,
},
metadata={
"source": source,
@ -541,13 +609,45 @@ class AgentLoop:
final_model = bundle.main_runtime.model
while True:
response = await provider.chat(
messages=messages,
tools=tool_schemas,
chat_kwargs: dict[str, Any] = {
"messages": messages,
"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,
max_tokens=resolved_max_tokens,
temperature=resolved_temperature,
user_id=user_id,
)
response = await provider.chat(**chat_kwargs)
final_provider_name = response.provider_name or final_provider_name
final_model = response.model or final_model
final_usage = self._merge_usage(final_usage, response.usage or {})
@ -650,7 +750,7 @@ class AgentLoop:
model=final_model,
user_id=user_id,
)
self._record_skill_learning(
self._record_run_receipts(
skill_learning_service=skill_learning_service,
session_manager=session_manager,
session_id=resolved_session_id,
@ -663,7 +763,7 @@ class AgentLoop:
success=(final_finish_reason == "stop"),
task_id=task_id,
attempt_index=attempt_index,
generate_candidates=learning_candidate_enabled,
allow_candidate_generation=False,
)
return AgentRunResult(
session_id=resolved_session_id,
@ -703,7 +803,7 @@ class AgentLoop:
usage=final_usage,
task_id=task_id,
)
self._record_skill_learning(
self._record_run_receipts(
skill_learning_service=skill_learning_service,
session_manager=session_manager,
session_id=resolved_session_id,
@ -716,7 +816,7 @@ class AgentLoop:
success=False,
task_id=task_id,
attempt_index=attempt_index,
generate_candidates=learning_candidate_enabled,
allow_candidate_generation=False,
)
return result
@ -771,13 +871,16 @@ class AgentLoop:
def _serialize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]:
payload: list[dict[str, Any]] = []
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(
{
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.name,
"arguments": tool_call.arguments,
"arguments": arguments,
},
}
)
@ -877,7 +980,7 @@ class AgentLoop:
)
@staticmethod
def _record_skill_learning(
def _record_run_receipts(
*,
skill_learning_service: Any,
session_manager: Any,
@ -891,7 +994,7 @@ class AgentLoop:
success: bool,
task_id: str | None = None,
attempt_index: int | None = None,
generate_candidates: bool = False,
allow_candidate_generation: bool = False,
) -> None:
run_record = RunRecord(
run_id=run_id,
@ -921,7 +1024,7 @@ class AgentLoop:
try:
candidates = skill_learning_service.collect_run_receipts(
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
session_manager.append_message(
@ -948,7 +1051,7 @@ class AgentLoop:
"run_record": run_record.to_dict(),
"skill_effects": [item.to_dict() for item in effect_records],
"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).",
context_visible=False,

View File

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

View File

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

View File

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

View File

@ -41,6 +41,7 @@ class OpenAICodexProvider(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
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")

View File

@ -49,6 +49,7 @@ class CustomProvider(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
client = self._client_or_raise()
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}
if clean.get("role") == "assistant" and "content" not in clean:
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)
return sanitized
@ -155,6 +174,18 @@ class LiteLLMProvider(LLMProvider):
if 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(
self,
messages: list[dict[str, Any]],
@ -162,6 +193,7 @@ class LiteLLMProvider(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
if acompletion is None:
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,
"max_tokens": max(1, max_tokens),
"temperature": temperature,
"timeout": self.request_timeout_seconds or 45.0,
}
if self.api_key:
kwargs["api_key"] = self.api_key
@ -186,6 +219,7 @@ class LiteLLMProvider(LLMProvider):
kwargs["tool_choice"] = "auto"
self._apply_model_overrides(original_model, 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)
try:

View File

@ -121,7 +121,37 @@ class SessionManager:
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:]
for index, message in enumerate(sliced):
if message.get("role") == "user":

View File

@ -88,6 +88,15 @@ class MessageRecord:
payload["feedback_state"] = self.event_payload.get("feedback_state")
if 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:
payload["tool_name"] = self.tool_name
if self.tool_calls:

View File

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

View File

@ -128,19 +128,46 @@ class SessionStore:
self._conn.executescript(SCHEMA_SQL)
try:
self._conn.execute("SELECT * FROM messages_fts LIMIT 0")
except sqlite3.OperationalError:
self._conn.executescript(FTS_TABLE_SQL)
self._conn.executescript(FTS_TRIGGER_SQL)
self._conn.executescript(FTS_TRIGGER_SQL)
except sqlite3.Error:
self._rebuild_fts_index()
return
# 旧版本可能把 hidden 事件也写进了 FTS初始化时顺手清掉这些噪声项。
self._conn.execute(
"""
INSERT INTO messages_fts(messages_fts, rowid, content)
SELECT 'delete', id, content
FROM messages
WHERE context_visible = 0 AND content IS NOT NULL
"""
)
self._conn.commit()
try:
self._conn.execute(
"""
INSERT INTO messages_fts(messages_fts, rowid, content)
SELECT 'delete', id, content
FROM messages
WHERE context_visible = 0 AND content IS NOT NULL
"""
)
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:
with self._lock:

View File

@ -1,13 +1,26 @@
"""Configuration models and loaders."""
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__ = [
"AgentDefaultsConfig",
"AuthzConfig",
"BackendIdentityConfig",
"BeaverConfig",
"EmbeddingConfig",
"MCPServerConfig",
"ProviderConfig",
"ToolsConfig",
"default_config_path",
"load_config",
]

View File

@ -4,10 +4,30 @@ from __future__ import annotations
import json
import os
import sys
from pathlib import Path
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:
@ -57,6 +77,9 @@ def load_config(
agents_defaults=_parse_agent_defaults(data),
providers=_parse_providers(data.get("providers")),
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,
)
@ -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]:
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}
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:
if value in (None, ""):
return None
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
@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)
class BeaverConfig:
"""Config loaded once per backend sandbox instance."""
@ -46,6 +105,9 @@ class BeaverConfig:
agents_defaults: AgentDefaultsConfig = field(default_factory=AgentDefaultsConfig)
providers: dict[str, ProviderConfig] = field(default_factory=dict)
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
@property
@ -69,7 +131,13 @@ class BeaverConfig:
"""
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
payload: dict[str, Any] = {
"model": resolved_model,
@ -115,22 +183,36 @@ class BeaverConfig:
def _infer_provider(self, model: str | None) -> str | None:
configured_provider = _clean(self.agents_defaults.provider)
if configured_provider:
if configured_provider and configured_provider != "custom":
return configured_provider
if model and "/" in model:
prefix = model.split("/", 1)[0]
if prefix in self.providers:
if prefix in self._enabled_provider_names():
return prefix
if len(self.providers) == 1:
return next(iter(self.providers))
enabled_providers = self._enabled_provider_names()
if len(enabled_providers) == 1:
return enabled_providers[0]
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:
if value is None:
return None
value = str(value).strip()
return value or None

View File

@ -19,7 +19,7 @@ class EmbeddingRetriever:
api_key_env: str = "OPENAI_API_KEY",
api_base_env: str = "OPENAI_API_BASE",
model: str = "text-embedding-v4",
timeout_seconds: float = 20.0,
timeout_seconds: float = 3.0,
) -> None:
self.api_key_env = api_key_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."""
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)
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."""
from pathlib import Path
try:
import typer
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]
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
@ -55,6 +59,26 @@ def run(
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:
"""Project script entrypoint."""
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
temperature: float | None = None
max_tokens: int | None = None
thinking_enabled: bool | None = None
max_tool_iterations: int | None = None
fallback_target: WebProviderTarget | None = None
auxiliary_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):

View File

@ -44,6 +44,29 @@ class RunMemoryStore:
def append_skill_effect(self, effect: SkillEffectRecord) -> None:
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]:
return [RunRecord.from_dict(item) for item in self._read_jsonl(self.runs_path)]

View File

@ -1,6 +1,6 @@
"""Application services for Beaver."""
__all__ = ["AgentService", "MemoryService"]
__all__ = ["AgentService", "CronService", "MemoryService"]
def __getattr__(name: str):
@ -12,4 +12,8 @@ def __getattr__(name: str):
from .memory_service import MemoryService
return MemoryService
if name == "CronService":
from .cron_service import CronService
return CronService
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.providers import make_provider_bundle
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.foundation.models import CronJob, CronRunRecord
from beaver.tasks import MainAgentRouter, TaskExecutionPlan, TaskRecord, ValidationResult
NOTIFICATION_SESSION_ID = "notify:default:scheduled"
class AgentService:
"""面向 interfaces 的统一 agent 运行入口。
@ -50,15 +54,24 @@ class AgentService:
self._loop: AgentLoop | None = None
self._run_task: asyncio.Task[None] | None = None
self._main_agent_router = MainAgentRouter()
self._runtime_services: dict[str, Any] = {}
def create_loop(self) -> AgentLoop:
"""创建并缓存当前 service 使用的 AgentLoop。"""
if self._loop is None:
self._loop = AgentLoop(profile=self.profile, loader=self.loader)
self._loop.runtime_services.update(self._runtime_services)
self._loop.boot()
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
def has_loop(self) -> bool:
"""当前 service 是否已经创建过 loop。"""
@ -196,6 +209,191 @@ class AgentService:
loop = self.create_loop()
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(
self,
*,
@ -269,19 +467,51 @@ class AgentService:
generated_candidates = []
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:
generated_candidates = []
elif normalized == "satisfied" and validation is not None and validation.accepted:
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
generated_candidates = [item.to_dict() for item in skill_learning_service.build_learning_candidates()]
generated_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":
memory_service = self._require_loaded(loaded, "memory_service")
memory_service.get_store().add(
"memory",
(
f"Failure memory: task {task.task_id} in session {session_id} was abandoned. "
f"Reason: {(comment or 'not specified').strip()}"
),
session_manager.append_message(
session_id,
run_id=run_id,
role="system",
event_type="task_failure_evidence_recorded",
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 {
@ -302,20 +532,46 @@ class AgentService:
) -> AgentRunResult:
loaded = self.create_loop().boot()
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
kwargs = dict(kwargs)
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)
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:
kwargs["include_skill_assembly"] = False
kwargs["include_tools"] = False
return await runner(message, **kwargs)
task = (
task_service.create_task(
session_id=session_id,
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
else active_task
@ -420,7 +676,7 @@ class AgentService:
"task_id": task.task_id,
"task_mode": True,
"attempt_index": attempt_index,
"learning_candidate_enabled": False,
"allow_candidate_generation": False,
}
)
if attempt_index == 2 and latest_validation is not None:
@ -433,6 +689,14 @@ class AgentService:
)
elif 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)
last_result = result
@ -519,7 +783,7 @@ class AgentService:
parent_session_id=parent_session_id,
parent_run_id=None,
provider_bundle_factory=provider_bundle_factory,
learning_candidate_enabled=False,
allow_candidate_generation=False,
)
return result, None
except Exception as exc:
@ -542,6 +806,93 @@ class AgentService:
return [receipt.skill_name for receipt in record.activated_skills]
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
def _run_excerpt(session_manager: Any, session_id: str, run_id: str) -> str:
lines = []
@ -611,8 +962,8 @@ class AgentService:
skill.name for skill in node.inherited_pinned_skill_contexts
]
payload["skill_query"] = node.agent.metadata.get("skill_query")
payload["generated_skill_draft_id"] = node.agent.metadata.get("generated_skill_draft_id")
payload["generated_skill_name"] = node.agent.metadata.get("generated_skill_name")
payload["ephemeral_guidance_id"] = node.agent.metadata.get("ephemeral_guidance_id")
payload["ephemeral_guidance_name"] = node.agent.metadata.get("ephemeral_guidance_name")
payload["ephemeral_used"] = bool(node.inherited_pinned_skill_contexts)
payloads.append(payload)
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()}
runs: dict[str, dict[str, Any]] = {}
events: list[dict[str, Any]] = []
artifacts: list[dict[str, Any]] = []
def add_event(
*,
@ -84,7 +85,7 @@ class SessionProcessProjector:
"node_ids": node_ids,
"skill_queries": payload.get("skill_queries") 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 [],
"fallback_error": payload.get("fallback_error"),
}
@ -151,13 +152,42 @@ class SessionProcessProjector:
"skill_query": item.get("skill_query"),
"selected_skill_names": item.get("selected_skill_names") or [],
"ephemeral_skill_names": item.get("ephemeral_skill_names") or [],
"generated_skill_draft_id": item.get("generated_skill_draft_id"),
"generated_skill_name": item.get("generated_skill_name"),
"ephemeral_guidance_id": item.get("ephemeral_guidance_id"),
"ephemeral_guidance_name": item.get("ephemeral_guidance_name"),
"ephemeral_used": bool(item.get("ephemeral_used")),
"finish_reason": item.get("finish_reason"),
"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(
event_id=f"{_event_id(record, 'node')}:{item.get('node_id')}",
run_id=str(node_run_id),
@ -231,7 +261,7 @@ class SessionProcessProjector:
return {
"runs": sorted(runs.values(), key=lambda item: item.get("started_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": [],
}

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,
inherited_pinned_skills: list[str] | None = None,
inherited_pinned_skill_contexts: list["SkillContext"] | None = None,
learning_candidate_enabled: bool = False,
allow_candidate_generation: bool = False,
) -> TeamRunResult:
"""Run a team graph inside the parent task context."""
@ -46,7 +46,7 @@ class TeamService:
provider_bundle_factory=provider_bundle_factory,
inherited_pinned_skills=inherited_pinned_skills,
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)
return result

View File

@ -1,19 +1,22 @@
"""LLM-driven skill assembler.
这层现在不再自己做规则打分,而是直接把:
这层现在不再自己做规则打分,而是分两步把:
1. task description
2. embedding 召回后的候选 skill 摘要
3. 粗选候选的完整 skill 正文
交给一个模型来决定本轮要激活哪些 skill。
当前目标非常克制:
- 输入尽量简单
- 主 agent 不拿 skill_view也不动态探索技能库
- SkillAssembler 可以在系统侧内部读取候选 skill 正文
- 输出只要 skill 名称
- 没有命中就返回空 skills
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
import json
from typing import Any
@ -31,6 +34,7 @@ class SkillAssemblyResult:
"""一次装配后真正要注入当前 run 的 skills。"""
activated_skills: list[SkillContext] = field(default_factory=list)
llm_interactions: list[dict[str, Any]] = field(default_factory=list)
class SkillAssembler:
@ -40,9 +44,14 @@ class SkillAssembler:
self,
loader: SkillsLoader,
retriever: SkillEmbeddingRetriever | None = None,
*,
max_detailed_candidates: int = 5,
max_candidate_content_chars: int = 6000,
) -> None:
self.loader = loader
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(
self,
@ -51,6 +60,7 @@ class SkillAssembler:
provider: LLMProvider,
model: str,
embedding_runtime: ProviderRuntime | None = None,
thinking_enabled: bool | None = None,
top_k: int = 12,
) -> SkillAssemblyResult:
candidates = self.loader.build_selection_candidates()
@ -71,15 +81,39 @@ class SkillAssembler:
)
if not candidates:
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(
task_description=task_description,
candidates=candidates,
candidates=detailed_candidates,
provider=provider,
model=model,
thinking_enabled=thinking_enabled,
selection_stage="final",
llm_interactions=llm_interactions,
)
if not selected_names:
return SkillAssemblyResult()
return SkillAssemblyResult(llm_interactions=llm_interactions)
activated_skills: list[SkillContext] = []
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(
self,
@ -108,17 +142,28 @@ class SkillAssembler:
candidates: list[dict[str, str]],
provider: LLMProvider,
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]:
candidate_summary = self._render_candidates(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 = [
{
"role": "system",
"content": (
"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. "
"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(
messages=messages,
tools=None,
model=model,
max_tokens=512,
temperature=0,
)
chat_kwargs: dict[str, Any] = {
"messages": messages,
"tools": None,
"model": model,
"max_tokens": 256,
"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:
return []
@ -149,15 +215,42 @@ class SkillAssembler:
for name in parsed:
if name in candidate_names and name not in filtered:
filtered.append(name)
return filtered
return filtered[:max_selected] if max_selected is not None else filtered
@staticmethod
def _render_candidates(candidates: list[dict[str, str]]) -> str:
lines: list[str] = []
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)
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
def _parse_selected_names(content: str) -> list[str]:
cleaned = content.strip()

View File

@ -244,12 +244,10 @@ class SkillsLoader:
meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", ""))
available = check_requirements(meta_blob)
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" <name>{escape_xml(record.name)}</name>")
lines.append(f" <description>{escape_xml(description)}</description>")
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)
if support_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:
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:
from datetime import datetime, timezone

View File

@ -2,7 +2,12 @@
from .evidence import EvidencePacket, EvidenceSelector
from .eval import SkillDraftEvaluator
from .missing_skill import MissingSkillDraftResult, MissingSkillSynthesizer
from .missing_skill import (
EphemeralGuidanceResult,
EphemeralGuidanceSynthesizer,
MissingSkillDraftResult,
MissingSkillSynthesizer,
)
from .pipeline import SkillLearningPipelineService
from .service import RunReceiptContext, SkillLearningService
from .synthesizer import SkillDraftSynthesizer
@ -12,6 +17,8 @@ __all__ = [
"EvidencePacket",
"EvidenceSelector",
"SkillDraftEvaluator",
"EphemeralGuidanceResult",
"EphemeralGuidanceSynthesizer",
"MissingSkillDraftResult",
"MissingSkillSynthesizer",
"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
@ -6,11 +6,10 @@ import json
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from beaver.engine.context import SkillContext
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
if TYPE_CHECKING:
@ -18,13 +17,14 @@ if TYPE_CHECKING:
@dataclass(slots=True)
class MissingSkillDraftResult:
draft: SkillDraft
class EphemeralGuidanceResult:
guidance_id: str
guidance_name: str
skill_context: SkillContext
class MissingSkillSynthesizer:
"""Create a draft skill and an ephemeral SkillContext for the current run."""
class EphemeralGuidanceSynthesizer:
"""Create one-run guidance for the current delegated sub-agent."""
async def synthesize(
self,
@ -37,8 +37,7 @@ class MissingSkillSynthesizer:
skill_query: str,
required_capabilities: list[str],
provider_bundle: ProviderBundle,
draft_service: DraftService,
) -> MissingSkillDraftResult:
) -> EphemeralGuidanceResult:
provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider
runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime
model = getattr(runtime, "model", None)
@ -49,14 +48,14 @@ class MissingSkillSynthesizer:
{
"role": "system",
"content": (
"You create concise Beaver skill drafts. Return only JSON with keys: "
"skill_name, description, content, tags."
"You create concise Beaver ephemeral guidance. Return only JSON with keys: "
"guidance_name, description, content, tags."
),
},
{
"role": "user",
"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"Current user request:\n{user_message}\n\n"
f"Node id: {node_id}\n"
@ -64,62 +63,37 @@ class MissingSkillSynthesizer:
f"Skill query:\n{skill_query}\n"
f"Required capabilities: {required_capabilities}\n\n"
"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,
model=model,
max_tokens=1200,
max_tokens=4096,
temperature=0,
)
payload = self._parse_payload(response.content or "") or payload
except Exception:
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()
if not 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(
name=f"draft:{draft.skill_name}",
content=draft.proposed_content,
version=f"draft:{draft.draft_id}",
content_hash=canonical_hash(draft.proposed_content),
activation_reason="generated_missing_skill",
name=f"ephemeral:{guidance_name}",
content=content,
version=f"ephemeral:{guidance_id}",
content_hash=canonical_hash(content),
activation_reason="ephemeral_guidance",
tool_hints=[],
)
return MissingSkillDraftResult(draft=draft, skill_context=context)
return EphemeralGuidanceResult(
guidance_id=guidance_id,
guidance_name=guidance_name,
skill_context=context,
)
@staticmethod
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"
capability_lines = "\n".join(f"- {item}" for item in capabilities) or "- Follow the node task precisely."
return {
"skill_name": _slug(title),
"guidance_name": _slug(title),
"description": f"Draft guidance for {title}.",
"tags": ["generated", "task-sub-agent"],
"content": (
@ -163,4 +137,8 @@ class MissingSkillSynthesizer:
def _slug(value: str) -> str:
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.specs import SkillDraft, SkillReviewRecord, SkillReviewState, SkillSpec, SkillVersion
_REJECTABLE_DRAFT_STATUSES = {
SkillReviewState.DRAFT.value,
SkillReviewState.IN_REVIEW.value,
SkillReviewState.APPROVED.value,
}
class SkillLearningPipelineService:
"""Coordinates candidate -> draft -> review -> publish lifecycle."""
@ -161,6 +167,9 @@ class SkillLearningPipelineService:
requested_by: str = "system",
notes: str = "",
) -> 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)
if safety is not None and (not safety.passed or safety.risk_level == "critical"):
raise ValueError("Draft cannot enter review because safety check failed")
@ -179,6 +188,12 @@ class SkillLearningPipelineService:
reviewer: str = "system",
notes: str = "",
) -> 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)
self._mark_candidate_by_draft(skill_name, draft_id, "approved", "approved")
return review
@ -191,6 +206,9 @@ class SkillLearningPipelineService:
reviewer: str = "system",
notes: str = "",
) -> 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)
self._mark_candidate_by_draft(skill_name, draft_id, "rejected", "rejected")
return review

View File

@ -69,6 +69,94 @@ class SkillLearningService:
existing_ids.add(candidate.candidate_id)
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:
candidates = {item.candidate_id: item for item in self.learning_store.list_learning_candidates()}
candidate = candidates.get(candidate_id)
@ -181,7 +269,7 @@ class SkillLearningService:
groups.setdefault(key, []).append(record)
candidates: list[SkillLearningCandidate] = []
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:
continue
if any(record.activated_skills for record in successful):
@ -202,6 +290,8 @@ class SkillLearningService:
def _build_merge_candidates(self) -> list[SkillLearningCandidate]:
pair_counts: dict[tuple[str, str], list[RunRecord]] = {}
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})
for pair in combinations(unique, 2):
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))
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
def _candidate_id(kind: str, *parts: str) -> str:
return f"{kind}:{'|'.join(parts)}"

View File

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

View File

@ -2,6 +2,9 @@
from __future__ import annotations
import shutil
from pathlib import Path
from beaver.skills.catalog.utils import strip_frontmatter
from beaver.skills.specs import SkillDraft, SkillReviewState, SkillSpec, SkillSpecStore, SkillStatus, SkillVersion
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._copy_uploaded_supporting_files(draft, next_version)
self.store.set_current_version(skill_name, next_version)
spec = self.store.get_skill_spec(skill_name)
@ -169,6 +173,27 @@ class SkillPublisher:
self.store.update_index("published", published)
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:
draft = self.store.read_draft(skill_name, draft_id)
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:
draft = self._require_draft(skill_name, draft_id)
draft.status = SkillReviewState.REJECTED.value
self.store.write_draft(draft)
review = SkillReviewRecord(
review_id=uuid4().hex,
draft_id=draft_id,
@ -61,6 +59,7 @@ class ReviewService:
notes=notes,
)
self.store.write_review(review)
self.store.delete_draft(skill_name, draft_id)
return review
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
if (directory / "SKILL.md").exists():
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)
if spec is not None and spec.current_version:
return spec.current_version
@ -182,6 +187,13 @@ class SkillSpecStore:
drafts_dir.mkdir(parents=True, exist_ok=True)
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]:
reviews_dir = self._skill_dir(skill_name) / "reviews"
if not reviews_dir.exists():
@ -199,6 +211,19 @@ class SkillSpecStore:
reviews_dir.mkdir(parents=True, exist_ok=True)
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:
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
reason: str
starts_new_task: bool = False
closes_task: bool = False
abandons_task: bool = False
short_title: str | None = None
@property
def is_task(self) -> bool:

View File

@ -50,10 +50,10 @@ class TaskExecutionPlan:
for node in nodes
for name in node.inherited_pinned_skills
],
"generated_skill_draft_ids": [
item.generated_skill_draft_id
"ephemeral_guidance_ids": [
item.ephemeral_guidance_id
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],
"fallback_error": self.fallback_error,
@ -108,7 +108,7 @@ class TaskExecutionPlanner:
],
tools=None,
model=model,
max_tokens=1200,
max_tokens=4096,
temperature=0.0,
)
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
import re
import asyncio
import json
from typing import Any
from .models import MainAgentDecision, TaskRecord
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
stays reliable during provider outages. The rule set is conservative:
anything that implies execution, files, tools, iteration, or validation
becomes Task mode.
"""
async def classify(
self,
message: str,
*,
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 = [
r"\b(implement|fix|debug|refactor|migrate|build|create|write|edit|update|test|validate|deploy)\b",
r"\b(file|repo|code|project|backend|frontend|api|database|migration|pull request|ci|bug)\b",
r"\b(step|multi-step|workflow|plan and|then)\b",
r"(实现|修复|调试|重构|迁移|构建|创建|编写|修改|更新|测试|验证|部署|文件|代码|项目|前端|后端|接口|数据库|多步|任务)",
]
_NEW_TASK_PATTERNS = [
r"\b(new task|another task|different task|start over)\b",
r"(新任务|另一个任务|换个任务|重新开始)",
]
def from_json(self, text: str, *, active_task: TaskRecord | None = None) -> MainAgentDecision:
payload = self._parse_json_object(text)
raw_action = str(payload.get("action") or payload.get("mode") or "").strip().lower()
reason = str(payload.get("reason") or raw_action or "llm_router")
short_title = _clean_short_title(payload.get("short_title") or payload.get("title"))
def classify(self, message: str, *, active_task: TaskRecord | None = None) -> MainAgentDecision:
text = message.strip()
lowered = text.lower()
starts_new = any(re.search(pattern, lowered, re.IGNORECASE) for pattern in self._NEW_TASK_PATTERNS)
if active_task is not None and active_task.status in {"awaiting_feedback", "needs_revision"} and not starts_new:
return MainAgentDecision(mode="task", reason="continuing_open_task", starts_new_task=False)
if any(re.search(pattern, lowered, re.IGNORECASE) for pattern in self._TASK_PATTERNS):
return MainAgentDecision(mode="task", reason="task_pattern_matched", starts_new_task=starts_new)
if len(text) > 240:
return MainAgentDecision(mode="task", reason="long_request", starts_new_task=starts_new)
return MainAgentDecision(mode="simple", reason="simple_question", starts_new_task=False)
if raw_action in {"continue_task", "continue", "task"}:
return MainAgentDecision(mode="task", reason=reason, short_title=short_title)
if raw_action in {"new_task", "new"}:
return MainAgentDecision(mode="task", reason=reason, starts_new_task=True, short_title=short_title)
if raw_action in {"close_task", "close", "done", "finish"}:
return MainAgentDecision(mode="simple", reason=reason, closes_task=active_task is not None, short_title=short_title)
if raw_action in {"abandon_task", "abandon", "cancel_task"}:
return MainAgentDecision(mode="simple", reason=reason, abandons_task=active_task is not None, short_title=short_title)
return MainAgentDecision(mode="simple", reason=reason or "simple_chat", short_title=short_title)
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,
) -> TaskRecord:
now = self._now()
task_metadata = dict(metadata or {})
task_metadata.setdefault("short_title", short_task_title(description))
task = TaskRecord(
task_id=uuid4().hex,
session_id=session_id,
@ -35,7 +37,7 @@ class TaskService:
creator=creator,
created_at=now,
updated_at=now,
metadata=dict(metadata or {}),
metadata=task_metadata,
)
self.store.upsert_task(task)
self._event(task, "created", payload={"description": description})
@ -44,11 +46,45 @@ class TaskService:
def get_task(self, task_id: str) -> TaskRecord | None:
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:
return self.store.get_task_by_run_id(run_id)
def get_latest_open_task(self, session_id: str) -> TaskRecord | None:
return self.store.get_latest_open_task(session_id)
def get_latest_open_task(self, session_id: str, *, include_unengaged_scheduled: bool = False) -> TaskRecord | None:
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:
task = self._require(task_id)
@ -136,6 +172,38 @@ class TaskService:
self._event(task, f"feedback_{feedback_type}", run_id=run_id, payload=entry)
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:
task = self.store.get_task(task_id)
if task is None:
@ -165,3 +233,15 @@ class TaskService:
@staticmethod
def _now() -> str:
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.catalog.loader import SkillsLoader
from beaver.skills.drafts import DraftService
from beaver.skills.learning import MissingSkillSynthesizer
from beaver.skills.learning import EphemeralGuidanceSynthesizer
from beaver.tasks.models import TaskRecord
@ -21,8 +21,8 @@ class SkillResolutionReport:
skill_query: str
required_capabilities: list[str] = field(default_factory=list)
selected_skill_names: list[str] = field(default_factory=list)
generated_skill_draft_id: str | None = None
generated_skill_name: str | None = None
ephemeral_guidance_id: str | None = None
ephemeral_guidance_name: str | None = None
ephemeral_used: bool = False
reason: str = ""
@ -32,15 +32,15 @@ class SkillResolutionReport:
"skill_query": self.skill_query,
"required_capabilities": list(self.required_capabilities),
"selected_skill_names": list(self.selected_skill_names),
"generated_skill_draft_id": self.generated_skill_draft_id,
"generated_skill_name": self.generated_skill_name,
"ephemeral_guidance_id": self.ephemeral_guidance_id,
"ephemeral_guidance_name": self.ephemeral_guidance_name,
"ephemeral_used": self.ephemeral_used,
"reason": self.reason,
}
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__(
self,
@ -48,12 +48,12 @@ class TaskSkillResolver:
skills_loader: SkillsLoader,
draft_service: DraftService,
retriever: SkillEmbeddingRetriever | None = None,
missing_skill_synthesizer: MissingSkillSynthesizer | None = None,
missing_skill_synthesizer: EphemeralGuidanceSynthesizer | None = None,
) -> None:
self.skills_loader = skills_loader
self.draft_service = draft_service
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(
self,
@ -138,7 +138,6 @@ class TaskSkillResolver:
skill_query=skill_query,
required_capabilities=required_capabilities,
provider_bundle=provider_bundle,
draft_service=self.draft_service,
)
resolved = self._generic_node(
node,
@ -149,8 +148,8 @@ class TaskSkillResolver:
"skill_query": skill_query,
"required_capabilities": required_capabilities,
"selected_skill_names": [],
"generated_skill_draft_id": missing.draft.draft_id,
"generated_skill_name": missing.draft.skill_name,
"ephemeral_guidance_id": missing.guidance_id,
"ephemeral_guidance_name": missing.guidance_name,
"ephemeral_skill_names": [missing.skill_context.name],
},
)
@ -158,10 +157,10 @@ class TaskSkillResolver:
node_id=node.node_id,
skill_query=skill_query,
required_capabilities=required_capabilities,
generated_skill_draft_id=missing.draft.draft_id,
generated_skill_name=missing.draft.skill_name,
ephemeral_guidance_id=missing.guidance_id,
ephemeral_guidance_name=missing.guidance_name,
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]:
@ -215,7 +214,7 @@ class TaskSkillResolver:
],
tools=None,
model=model,
max_tokens=512,
max_tokens=2048,
temperature=0,
)
parsed = self._parse_names(response.content or "")

View File

@ -40,7 +40,7 @@ class TaskStore:
tasks = [
task
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:
return None
@ -52,6 +52,25 @@ class TaskStore:
payload[task.task_id] = task.to_dict()
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:
self.events_path.parent.mkdir(parents=True, exist_ok=True)
with self._lock:

View File

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

View File

@ -29,7 +29,7 @@ class ToolAssembler:
always_tool_names: Sequence[str] | None = None,
) -> None:
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(
self,

View File

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

View File

@ -1,19 +1,39 @@
"""Built-in Beaver tools."""
from .cron import CronTool
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 .skills_admin import SkillManageTool, SkillsListTool
from .skill_view import SkillViewTool, skill_view
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__ = [
"EchoTool",
"ExecuteCodeTool",
"CronTool",
"DelegateTool",
"ListDirectoryTool",
"MemoryTool",
"PatchFileTool",
"ProcessTool",
"ReadFileTool",
"SearchFilesTool",
"SendMessageTool",
"SpawnTool",
"SkillManageTool",
"SkillsListTool",
"SkillViewTool",
"SessionSearchTool",
"TerminalTool",
"TodoTool",
"ClarifyTool",
"WebFetchTool",
"WebSearchTool",
"WriteFileTool",
"echo_tool",
"memory_tool",
"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"],
}
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):
"""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
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:
try:
return str(path.relative_to(root)) or "."
@ -440,3 +473,73 @@ class SearchFilesTool:
)
except (OSError, WorkspacePathError, ValueError) as exc:
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."""
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),
)