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:
145
agents/registry.json
Normal file
145
agents/registry.json
Normal file
@ -0,0 +1,145 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": "researcher",
|
||||
"capabilities": [
|
||||
"research",
|
||||
"analysis",
|
||||
"source review",
|
||||
"requirements"
|
||||
],
|
||||
"created_at": "2026-05-11T03:13:06.912240+00:00",
|
||||
"description": "Finds facts, references, constraints, and implementation options.",
|
||||
"display_name": "Researcher",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "researcher",
|
||||
"priority": 50,
|
||||
"provider_name": null,
|
||||
"role": "research",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
|
||||
"tags": [
|
||||
"planning",
|
||||
"research"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-11T03:13:06.912247+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "implementer",
|
||||
"capabilities": [
|
||||
"implementation",
|
||||
"coding",
|
||||
"refactor",
|
||||
"integration"
|
||||
],
|
||||
"created_at": "2026-05-11T03:13:06.912250+00:00",
|
||||
"description": "Builds scoped implementation slices and proposes concrete changes.",
|
||||
"display_name": "Implementer",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "implementer",
|
||||
"priority": 45,
|
||||
"provider_name": null,
|
||||
"role": "implementation",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.",
|
||||
"tags": [
|
||||
"coding",
|
||||
"build"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-11T03:13:06.912251+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "reviewer",
|
||||
"capabilities": [
|
||||
"review",
|
||||
"quality",
|
||||
"risk",
|
||||
"verification"
|
||||
],
|
||||
"created_at": "2026-05-11T03:13:06.912252+00:00",
|
||||
"description": "Reviews plans, code, outputs, and risks before final synthesis.",
|
||||
"display_name": "Reviewer",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "reviewer",
|
||||
"priority": 45,
|
||||
"provider_name": null,
|
||||
"role": "review",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.",
|
||||
"tags": [
|
||||
"review",
|
||||
"quality"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-11T03:13:06.912253+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "tester",
|
||||
"capabilities": [
|
||||
"testing",
|
||||
"verification",
|
||||
"regression",
|
||||
"qa"
|
||||
],
|
||||
"created_at": "2026-05-11T03:13:06.912255+00:00",
|
||||
"description": "Designs and executes verification checks for task outputs.",
|
||||
"display_name": "Tester",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "tester",
|
||||
"priority": 40,
|
||||
"provider_name": null,
|
||||
"role": "testing",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.",
|
||||
"tags": [
|
||||
"test",
|
||||
"quality"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-11T03:13:06.912256+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "documenter",
|
||||
"capabilities": [
|
||||
"documentation",
|
||||
"explanation",
|
||||
"migration notes",
|
||||
"release notes"
|
||||
],
|
||||
"created_at": "2026-05-11T03:13:06.912257+00:00",
|
||||
"description": "Writes and reconciles user-facing and internal documentation updates.",
|
||||
"display_name": "Documenter",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "documenter",
|
||||
"priority": 35,
|
||||
"provider_name": null,
|
||||
"role": "documentation",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.",
|
||||
"tags": [
|
||||
"docs",
|
||||
"communication"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-11T03:13:06.912258+00:00"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
}
|
||||
145
app-instance/agents/registry.json
Normal file
145
app-instance/agents/registry.json
Normal file
@ -0,0 +1,145 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": "researcher",
|
||||
"capabilities": [
|
||||
"research",
|
||||
"analysis",
|
||||
"source review",
|
||||
"requirements"
|
||||
],
|
||||
"created_at": "2026-05-11T03:13:06.921512+00:00",
|
||||
"description": "Finds facts, references, constraints, and implementation options.",
|
||||
"display_name": "Researcher",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "researcher",
|
||||
"priority": 50,
|
||||
"provider_name": null,
|
||||
"role": "research",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
|
||||
"tags": [
|
||||
"planning",
|
||||
"research"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-11T03:13:06.921520+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "implementer",
|
||||
"capabilities": [
|
||||
"implementation",
|
||||
"coding",
|
||||
"refactor",
|
||||
"integration"
|
||||
],
|
||||
"created_at": "2026-05-11T03:13:06.921522+00:00",
|
||||
"description": "Builds scoped implementation slices and proposes concrete changes.",
|
||||
"display_name": "Implementer",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "implementer",
|
||||
"priority": 45,
|
||||
"provider_name": null,
|
||||
"role": "implementation",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.",
|
||||
"tags": [
|
||||
"coding",
|
||||
"build"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-11T03:13:06.921523+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "reviewer",
|
||||
"capabilities": [
|
||||
"review",
|
||||
"quality",
|
||||
"risk",
|
||||
"verification"
|
||||
],
|
||||
"created_at": "2026-05-11T03:13:06.921527+00:00",
|
||||
"description": "Reviews plans, code, outputs, and risks before final synthesis.",
|
||||
"display_name": "Reviewer",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "reviewer",
|
||||
"priority": 45,
|
||||
"provider_name": null,
|
||||
"role": "review",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.",
|
||||
"tags": [
|
||||
"review",
|
||||
"quality"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-11T03:13:06.921528+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "tester",
|
||||
"capabilities": [
|
||||
"testing",
|
||||
"verification",
|
||||
"regression",
|
||||
"qa"
|
||||
],
|
||||
"created_at": "2026-05-11T03:13:06.921529+00:00",
|
||||
"description": "Designs and executes verification checks for task outputs.",
|
||||
"display_name": "Tester",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "tester",
|
||||
"priority": 40,
|
||||
"provider_name": null,
|
||||
"role": "testing",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.",
|
||||
"tags": [
|
||||
"test",
|
||||
"quality"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-11T03:13:06.921530+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "documenter",
|
||||
"capabilities": [
|
||||
"documentation",
|
||||
"explanation",
|
||||
"migration notes",
|
||||
"release notes"
|
||||
],
|
||||
"created_at": "2026-05-11T03:13:06.921533+00:00",
|
||||
"description": "Writes and reconciles user-facing and internal documentation updates.",
|
||||
"display_name": "Documenter",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "documenter",
|
||||
"priority": 35,
|
||||
"provider_name": null,
|
||||
"role": "documentation",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.",
|
||||
"tags": [
|
||||
"docs",
|
||||
"communication"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-11T03:13:06.921534+00:00"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
}
|
||||
@ -10,7 +10,7 @@
|
||||
2. 聊天入口支持 Main Agent 自动 Task 化、验证、反馈门控。
|
||||
3. skills 已有版本化、receipt/effect 记录、学习候选门控,以及后台 assisted learning pipeline。
|
||||
4. Agent Team v1 已支持内部 `sequence / parallel / dag` coordinator。
|
||||
5. Task mode 已能通过 `TaskExecutionPlanner` 按需调用 sub-agent/team;team node 由 `TaskSkillResolver` 绑定 published skill,缺失时生成 draft-only ephemeral skill,最终仍由主 Agent synthesis 生成用户回答。
|
||||
5. Task mode 已能通过 `TaskExecutionPlanner` 按需调用 sub-agent/team;team node 由 `TaskSkillResolver` 绑定 published skill,缺失时生成 ephemeral guidance,最终仍由主 Agent synthesis 生成用户回答。
|
||||
6. Skill Learning 已支持后台 run-once/worker 自动生成 draft、safety report、eval report、人工审核发布和前端审核工作台;worker 不会自动 approve/publish。
|
||||
|
||||
## 当前结构
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
"""统一聊天接口。"""
|
||||
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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] = {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
266
app-instance/backend/beaver/foundation/models/cron.py
Normal file
266
app-instance/backend/beaver/foundation/models/cron.py
Normal 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
|
||||
@ -0,0 +1,5 @@
|
||||
"""AuthZ service client integration."""
|
||||
|
||||
from .client import AuthzClient
|
||||
|
||||
__all__ = ["AuthzClient"]
|
||||
50
app-instance/backend/beaver/integrations/authz/client.py
Normal file
50
app-instance/backend/beaver/integrations/authz/client.py
Normal 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 {}
|
||||
@ -1,2 +1,5 @@
|
||||
"""MCP integration."""
|
||||
|
||||
from .connection import MCPConnectionManager, test_mcp_server
|
||||
|
||||
__all__ = ["MCPConnectionManager", "test_mcp_server"]
|
||||
|
||||
192
app-instance/backend/beaver/integrations/mcp/connection.py
Normal file
192
app-instance/backend/beaver/integrations/mcp/connection.py
Normal 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}"
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
192
app-instance/backend/beaver/interfaces/mcp/tools_server.py
Normal file
192
app-instance/backend/beaver/interfaces/mcp/tools_server.py
Normal 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
@ -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):
|
||||
|
||||
@ -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)]
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
508
app-instance/backend/beaver/services/cron_service.py
Normal file
508
app-instance/backend/beaver/services/cron_service.py
Normal 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
|
||||
262
app-instance/backend/beaver/services/hermes_migration.py
Normal file
262
app-instance/backend/beaver/services/hermes_migration.py
Normal 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"
|
||||
@ -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": [],
|
||||
}
|
||||
|
||||
|
||||
208
app-instance/backend/beaver/services/skill_migration.py
Normal file
208
app-instance/backend/beaver/services/skill_migration.py
Normal 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()
|
||||
248
app-instance/backend/beaver/services/skillhub_service.py
Normal file
248
app-instance/backend/beaver/services/skillhub_service.py
Normal 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"
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}"
|
||||
|
||||
@ -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 "")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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))})
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 "")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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 "")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 "")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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",
|
||||
|
||||
163
app-instance/backend/beaver/tools/builtins/cron.py
Normal file
163
app-instance/backend/beaver/tools/builtins/cron.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
87
app-instance/backend/beaver/tools/builtins/skills_admin.py
Normal file
87
app-instance/backend/beaver/tools/builtins/skills_admin.py
Normal 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())
|
||||
213
app-instance/backend/beaver/tools/builtins/terminal.py
Normal file
213
app-instance/backend/beaver/tools/builtins/terminal.py
Normal 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))
|
||||
137
app-instance/backend/beaver/tools/builtins/utility.py
Normal file
137
app-instance/backend/beaver/tools/builtins/utility.py
Normal 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.",
|
||||
)
|
||||
117
app-instance/backend/beaver/tools/builtins/web.py
Normal file
117
app-instance/backend/beaver/tools/builtins/web.py
Normal 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))
|
||||
@ -1,2 +1,5 @@
|
||||
"""MCP-backed tool integrations."""
|
||||
|
||||
from .wrapper import MCPToolWrapper
|
||||
|
||||
__all__ = ["MCPToolWrapper"]
|
||||
|
||||
88
app-instance/backend/beaver/tools/mcp/wrapper.py
Normal file
88
app-instance/backend/beaver/tools/mcp/wrapper.py
Normal 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),
|
||||
)
|
||||
@ -74,7 +74,7 @@
|
||||
4. Agent Team 已融入 Task mode 内部执行策略。
|
||||
- `TaskExecutionPlanner` 先用 LLM JSON 规划 `single / team`。
|
||||
- team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设。
|
||||
- `TaskSkillResolver` 为每个 generic sub-agent 选择 published skill;未命中时生成 draft-only skill,并作为本次 run 的 ephemeral pinned instruction 使用。
|
||||
- `TaskSkillResolver` 为每个 generic sub-agent 选择 published skill;未命中时生成 ephemeral guidance,并作为本次 run 的 pinned guidance 使用。
|
||||
- team 模式调用 `TeamService.run_team(...)` 产生 sub-agent runs。
|
||||
- Team 输出只作为主 Agent synthesis run 的内部上下文。
|
||||
- 用户可见最终回答仍由主 Agent 生成,并继续走验证、反馈和学习门控。
|
||||
@ -914,15 +914,15 @@ app-instance/backend/
|
||||
- sub-agent 是临时 generic worker,不承载固定角色人设。
|
||||
- `TaskExecutionPlanner` 的 team node 输出 `skill_query / required_capabilities / expected_output`。
|
||||
- `TaskSkillResolver` 从 published skill catalog 中选择合适 skill,并写入 node pinned skills。
|
||||
- 如果没有命中 published skill,会创建 draft-only skill,并把 draft 内容作为本次 sub-agent 的 ephemeral pinned skill context 使用。
|
||||
- draft 不自动 approve/publish,不进入 runtime catalog;后续仍走 review/publish。
|
||||
- 如果没有命中 published skill,会创建 ephemeral guidance,并作为本次 sub-agent 的 pinned skill context 使用。
|
||||
- ephemeral guidance 不写入 draft store,不自动 approve/publish,不进入 runtime catalog。
|
||||
- agent registry / target resolver 不参与 Task sub-agent strategy,可作为未来外部 agent/A2A 管理面保留。
|
||||
|
||||
2. **Task Team Process Projection**
|
||||
- Task attempt 隐藏事件增加 `skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report / node_results / task_synthesis_completed`。
|
||||
- Task attempt 隐藏事件增加 `skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report / node_results / task_synthesis_completed`。
|
||||
- 新增 `GET /api/sessions/{session_id}/process`。
|
||||
- 前端 `ChatWorkbench` 已接入 `ProcessLane` 和移动端 `Process` tab。
|
||||
- 展示规划、skill selection、draft-only ephemeral guidance、team node、main synthesis、validation/retry,不把 team summary 直接当最终回答。
|
||||
- 展示规划、skill selection、ephemeral guidance、team node、main synthesis、validation/retry,不把 team summary 直接当最终回答。
|
||||
|
||||
3. **Learning Pipeline 闭环**
|
||||
- 新增 `SkillLearningPipelineService`。
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
└─ future channels(未来扩展入口)
|
||||
│
|
||||
└─ AgentService(统一服务层:所有入口都先汇总到这里)
|
||||
├─ MainAgentRouter(自动判断 simple / task)
|
||||
├─ MainAgentRouter(LLM 语义判断 simple / continue task / new task / close / abandon)
|
||||
├─ create_loop()(创建 AgentLoop 运行核心)
|
||||
├─ start()(启动后台运行模式)
|
||||
├─ submit_direct()(把任务提交到运行队列)
|
||||
@ -73,15 +73,20 @@ AgentService.process_direct / submit_direct(聊天入口统一进入服务层
|
||||
│
|
||||
├─ resolve session_id(复用请求 session,或生成新 session)
|
||||
├─ task_service.get_latest_open_task(session_id)(查找同会话未关闭 Task)
|
||||
├─ MainAgentRouter.classify(message, active_task)(自动分类)
|
||||
├─ MainAgentRouter.classify(message, active_task, recent_messages)(LLM 语义分类)
|
||||
│ ├─ simple(简单问题)
|
||||
│ │ └─ runner(message)(直接走原有 AgentLoop,不创建 Task)
|
||||
│ │ └─ runner(message, include_skill_assembly=False, include_tools=False)(不创建 Task,不跑 skills/tools)
|
||||
│ │
|
||||
│ └─ task(复杂任务)
|
||||
│ ├─ if no active task or user starts new task
|
||||
│ │ └─ TaskService.create_task(...)(内部创建 Task)
|
||||
│ ├─ else
|
||||
│ │ └─ reuse active Task(复用 awaiting_feedback / needs_revision Task)
|
||||
│ ├─ continue_task(继续当前 Task)
|
||||
│ │ └─ reuse active Task(只要话题没有完全无关,就继续当前 open Task)
|
||||
│ │
|
||||
│ ├─ new_task(明确开启新任务)
|
||||
│ │ └─ TaskService.create_task(...)(内部创建 Task,并保存 short_title)
|
||||
│ │
|
||||
│ ├─ close_task / abandon_task(用户明确结束或放弃)
|
||||
│ │ └─ TaskService.close_task / abandon_task(关闭当前 Task)
|
||||
│ │
|
||||
│ └─ task execution
|
||||
│ └─ AgentService._run_task_mode(...)(进入 Task 模式执行)
|
||||
```
|
||||
|
||||
@ -92,6 +97,7 @@ TaskService(内部 Task 状态机)
|
||||
│ ├─ task_id
|
||||
│ ├─ session_id
|
||||
│ ├─ goal / description / constraints
|
||||
│ ├─ metadata.short_title(5-15 字左右的短标题,用于前端当前任务标识)
|
||||
│ ├─ status
|
||||
│ │ ├─ open
|
||||
│ │ ├─ running
|
||||
@ -167,17 +173,32 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
|
||||
│ ├─ auxiliary_provider(辅助模型调用器,用于选 skill 等)
|
||||
│ └─ embedding_runtime(向量模型配置,用于语义召回)
|
||||
│
|
||||
├─ skill_assembler.assemble(...)(选择本轮应该激活哪些 skill)
|
||||
│ ├─ SkillsLoader.build_selection_candidates()(列出候选技能摘要)
|
||||
│ ├─ embedding retrieve skill candidates(用向量召回相关技能)
|
||||
│ ├─ LLM select activated skills(让模型从候选里选择技能)
|
||||
│ └─ 返回 activated skills(返回本轮被激活的技能)
|
||||
│ ├─ name(技能名称)
|
||||
│ ├─ content(技能正文)
|
||||
│ ├─ version(技能版本)
|
||||
│ ├─ content_hash(技能内容哈希,用于追踪)
|
||||
│ ├─ activation_reason(为什么激活)
|
||||
│ └─ tool_hints(技能建议使用哪些工具)
|
||||
├─ if include_skill_assembly=False(simple_chat 默认关闭)
|
||||
│ └─ skip SkillAssembler(不激活 skill,不注入 skill 正文)
|
||||
│
|
||||
├─ if include_skill_assembly=True(Task mode 默认开启,在 Task 创建/复用和规划之后执行)
|
||||
│ └─ skill_assembler.assemble(...)(选择本轮应该激活哪些 published skill)
|
||||
│ ├─ input task_description = skill_selection_context or current user input
|
||||
│ │ ├─ Task goal / description
|
||||
│ │ ├─ current user request
|
||||
│ │ ├─ attempt / revision / team synthesis phase
|
||||
│ │ ├─ validation feedback(重试时)
|
||||
│ │ ├─ team summary / plan(team synthesis 时)
|
||||
│ │ └─ previously activated skills(只作为 reuse bias,不是 pinned)
|
||||
│ ├─ SkillsLoader.build_selection_candidates()(列出候选技能摘要)
|
||||
│ ├─ embedding retrieve skill candidates(用向量召回相关技能)
|
||||
│ ├─ LLM shortlist candidate names(先用摘要粗选少量候选)
|
||||
│ │ └─ if retrieved candidates <= max_detailed_candidates -> skip shortlist
|
||||
│ ├─ SkillsLoader.load_published_skill(...)(系统侧内部读取粗选候选正文,不暴露 skill_view 给主 Agent)
|
||||
│ ├─ LLM final select activated skills(结合候选正文做最终选择)
|
||||
│ ├─ if no matching skill -> return [] and continue run without skills
|
||||
│ └─ 返回 activated skills(返回本轮被激活的技能)
|
||||
│ ├─ name(技能名称)
|
||||
│ ├─ content(技能正文)
|
||||
│ ├─ version(技能版本)
|
||||
│ ├─ content_hash(技能内容哈希,用于追踪)
|
||||
│ ├─ activation_reason(为什么激活)
|
||||
│ └─ tool_hints(技能建议使用哪些工具)
|
||||
│
|
||||
├─ ContextBuilder.build_skill_activation_messages(...)(把激活技能变成模型可读消息)
|
||||
├─ 构造 SkillActivationReceipt[](构造技能激活收据)
|
||||
@ -188,7 +209,7 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
|
||||
│ ├─ receipts(技能激活收据)
|
||||
│ └─ activation_messages(实际注入给模型的技能消息)
|
||||
│
|
||||
├─ tool_assembler.assemble(...)(选择本轮应该暴露哪些工具)
|
||||
├─ tool_assembler.assemble(...)(选择本轮应该暴露哪些工具;simple_chat 默认跳过)
|
||||
│ ├─ always tools(默认总是可用的工具)
|
||||
│ ├─ activated skill tool hints(被激活技能推荐的工具)
|
||||
│ ├─ embedding retrieve tools(用向量召回相关工具)
|
||||
@ -207,6 +228,7 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
|
||||
│ └─ append current user input(追加当前用户输入)
|
||||
│
|
||||
├─ session_manager.update_system_prompt(...)(把本轮 system prompt 快照写回会话)
|
||||
├─ session_manager.append_message(event_type="skill_selection_context_snapshotted", hidden)(完整记录 skill query)
|
||||
├─ session_manager.append_message(event_type="system_prompt_snapshotted", hidden)(记录隐藏事件:system prompt 快照)
|
||||
├─ session_manager.append_message(event_type="user_message_added")(记录可见事件:用户消息)
|
||||
│
|
||||
@ -214,12 +236,12 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
|
||||
│
|
||||
├─ 成功时(模型正常结束)
|
||||
│ ├─ session_manager.append_message(event_type="run_completed", hidden)(记录隐藏事件:运行完成)
|
||||
│ └─ _record_skill_learning(...)(记录技能使用效果,进入学习闭环)
|
||||
│ └─ _record_run_receipts(...)(记录运行证据,不生成学习候选)
|
||||
│
|
||||
├─ 失败时(运行中出现异常)
|
||||
│ ├─ append assistant error message(写入 assistant 错误消息)
|
||||
│ ├─ session_manager.append_message(event_type="run_failed", hidden)(记录隐藏事件:运行失败)
|
||||
│ └─ _record_skill_learning(...)(即使失败也记录技能效果)
|
||||
│ └─ _record_run_receipts(...)(即使失败也记录运行证据)
|
||||
│
|
||||
└─ return AgentRunResult(返回本轮结果)
|
||||
├─ session_id(会话编号)
|
||||
@ -242,6 +264,7 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
|
||||
```text
|
||||
tool loop(工具调用循环)
|
||||
│
|
||||
├─ session_manager.append_message(event_type="llm_request_snapshotted", hidden)(完整记录本次 provider messages / tools)
|
||||
├─ provider.chat(messages, tools=schemas)(把消息和工具 schema 发给模型)
|
||||
├─ session_manager.update_usage(...)(累计 token 用量)
|
||||
├─ session_manager.append_message(event_type="assistant_message_added")(记录 assistant 回复)
|
||||
@ -259,10 +282,10 @@ tool loop(工具调用循环)
|
||||
|
||||
---
|
||||
|
||||
## 6. Skills Learning Baseline
|
||||
## 6. Run Evidence / Skill Effect Recording
|
||||
|
||||
```text
|
||||
AgentLoop._record_skill_learning(...)(记录本轮技能效果)
|
||||
AgentLoop._record_run_receipts(...)(记录本轮运行证据;不直接学习)
|
||||
│
|
||||
├─ 构造 RunRecord(构造本轮运行记录)
|
||||
│ ├─ run_id(运行编号)
|
||||
@ -286,11 +309,7 @@ AgentLoop._record_skill_learning(...)(记录本轮技能效果)
|
||||
│ ├─ RunMemoryStore.append_skill_effect(...)(把 SkillEffectRecord 写入 memory/runs/skill-effects.jsonl)
|
||||
│ ├─ SkillLearningService.rescore_skill_versions()(重新统计每个技能版本表现)
|
||||
│ │ └─ SkillLearningStore.update_performance_snapshot(...)(更新表现快照)
|
||||
│ └─ optionally build learning candidates(默认不生成;只由反馈门控显式触发)
|
||||
│ ├─ revise_skill(建议修改已有技能)
|
||||
│ ├─ new_skill(建议创建新技能)
|
||||
│ ├─ merge_skills(建议合并相似技能)
|
||||
│ └─ retire_skill(建议退役长期不用的技能)
|
||||
│ └─ never build learning candidates in runtime hot path(运行完成时永不生成候选)
|
||||
│
|
||||
└─ session_manager.append_message(...)(记录隐藏事件:技能效果快照)
|
||||
├─ event_type="skill_effects_snapshotted"(技能效果已快照)
|
||||
@ -298,10 +317,23 @@ AgentLoop._record_skill_learning(...)(记录本轮技能效果)
|
||||
└─ payload(隐藏数据)
|
||||
├─ run_record(本轮运行记录)
|
||||
├─ skill_effects(技能效果记录)
|
||||
├─ learning_candidate_enabled(本轮是否允许生成候选,默认 false)
|
||||
├─ candidate_generation_allowed(本轮是否允许生成候选;runtime 固定 false)
|
||||
└─ learning_candidates(学习候选;默认空)
|
||||
```
|
||||
|
||||
```text
|
||||
runtime invariant(运行期不直接学习)
|
||||
│
|
||||
├─ run completed / run failed
|
||||
│ └─ 只写 RunRecord + SkillEffectRecord + performance snapshot
|
||||
│
|
||||
├─ simple chat
|
||||
│ └─ 不创建 Task,不触发 learning candidate
|
||||
│
|
||||
└─ Task attempt / sub-agent run
|
||||
└─ 只留下证据,等待 feedback gate 决定是否学习
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Chat Feedback / Learning Gate
|
||||
@ -328,17 +360,20 @@ POST /api/chat/feedback(聊天反馈接口,不是 Task 管理 API)
|
||||
├─ satisfied
|
||||
│ ├─ if validation accepted
|
||||
│ │ ├─ Task status -> closed
|
||||
│ │ └─ SkillLearningService.build_learning_candidates()
|
||||
│ │ └─ SkillLearningService.build_learning_candidates_for_task(task_id, trigger_run_id)
|
||||
│ └─ if validation not accepted
|
||||
│ └─ 记录人工接受但保留验证风险
|
||||
│ └─ 记录人工接受但保留验证风险;不自动生成 learning candidate
|
||||
│
|
||||
├─ revise
|
||||
│ ├─ Task status -> needs_revision
|
||||
│ └─ 下一条用户消息默认复用该 Task
|
||||
│ ├─ 更新 run / skill effect 为需修订证据
|
||||
│ └─ 下一条用户消息默认复用该 Task;不生成 learning candidate
|
||||
│
|
||||
└─ abandon
|
||||
├─ Task status -> abandoned
|
||||
└─ write Failure Memory(不生成成功 Skill draft)
|
||||
├─ 更新 run / skill effect 为失败证据
|
||||
├─ 追加 task_failure_evidence_recorded 隐藏事件
|
||||
└─ 默认不写主 memory,不生成成功 Skill draft
|
||||
```
|
||||
|
||||
---
|
||||
@ -356,15 +391,15 @@ TeamService.run_team(...)(内部 team 执行入口,不暴露产品级 Task A
|
||||
│ │ └─ reserved strategies: moa / hierarchy / heavy / group_chat / forest / maker / router
|
||||
│ ├─ provider_bundle_factory(node)(推荐:每个节点拿 fresh provider bundle)
|
||||
│ ├─ inherited_pinned_skills(主 agent 明确委派给 sub-agent 的 pinned skills)
|
||||
│ ├─ inherited_pinned_skill_contexts(missing skill draft 生成的 ephemeral skill guidance)
|
||||
│ └─ learning_candidate_enabled=False(默认只写 receipts,不绕过 Task feedback gate)
|
||||
│ ├─ inherited_pinned_skill_contexts(missing skill 生成的一次性 ephemeral guidance)
|
||||
│ └─ allow_candidate_generation=False(默认只写 receipts,不绕过 Task feedback gate)
|
||||
│
|
||||
├─ LocalAgentRunner.run(envelope)
|
||||
│ ├─ 生成 child_session_id
|
||||
│ ├─ parent_session_id -> 主 session(建立 session lineage)
|
||||
│ ├─ AgentLoop.process_direct / submit_direct(...)(复用主 AgentLoop / ContextBuilder / ToolAssembler / SkillAssembler / MemoryService)
|
||||
│ ├─ pinned_skill_names -> AgentLoop(published pinned skill 必须注入)
|
||||
│ ├─ pinned_skill_contexts -> AgentLoop(draft-only ephemeral skill 必须注入)
|
||||
│ ├─ pinned_skill_contexts -> AgentLoop(ephemeral guidance 只在本次 run 注入)
|
||||
│ └─ provider_bundle + node model/provider override 禁止混用
|
||||
│
|
||||
├─ strategy execution
|
||||
@ -522,7 +557,6 @@ SkillsLoader(技能加载器)
|
||||
├─ build_skills_summary()(构造技能摘要索引)
|
||||
├─ build_selection_candidates()(构造给 SkillAssembler 的候选摘要)
|
||||
├─ list_skill_supporting_files()(列出技能支持文件)
|
||||
├─ view_skill()(查看技能正文或支持文件)
|
||||
└─ get_always_skills()(获取 always 类型技能)
|
||||
```
|
||||
|
||||
@ -530,13 +564,17 @@ SkillsLoader(技能加载器)
|
||||
SkillAssembler(技能选择器)
|
||||
│
|
||||
├─ input(输入)
|
||||
│ ├─ task_description(用户任务描述)
|
||||
│ ├─ task_description(Task-aware query:Task 描述 / 当前用户消息 / previous skills / attempt context / validation revision context / team context)
|
||||
│ ├─ candidate skill summaries(候选技能摘要)
|
||||
│ ├─ embedding runtime(向量模型配置)
|
||||
│ └─ selector provider/model(用于选择技能的模型)
|
||||
│
|
||||
├─ embedding retrieve candidates(先用向量召回相关技能)
|
||||
├─ LLM select names(再让 LLM 选择技能名)
|
||||
├─ LLM shortlist names(用摘要粗选需要查看正文的候选)
|
||||
│ └─ skip when candidate count <= max_detailed_candidates(候选很少时直接读取正文)
|
||||
├─ internal load shortlisted SKILL.md(SkillAssembler 内部读取候选正文)
|
||||
├─ LLM final select names(结合候选正文选择最终技能名)
|
||||
├─ no match returns [](没有对应 published skill 时返回空,不阻塞任务)
|
||||
└─ return SkillContext[](返回技能上下文)
|
||||
├─ name(技能名)
|
||||
├─ content(技能正文)
|
||||
@ -586,7 +624,6 @@ ToolRegistry(工具注册表)
|
||||
│
|
||||
├─ echo(回显工具)
|
||||
├─ memory(写入/管理长期记忆)
|
||||
├─ skill_view(查看完整 skill)
|
||||
├─ session_search(搜索会话历史)
|
||||
├─ list_directory(列目录)
|
||||
├─ read_file(读文件)
|
||||
@ -599,7 +636,7 @@ ToolAssembler(工具选择器)
|
||||
├─ selected = always tools(先加入默认工具)
|
||||
├─ selected += activated skill tool hints(再加入技能推荐工具)
|
||||
├─ selected += embedding top-k tools(再用向量召回任务相关工具)
|
||||
└─ return ToolSpec[](返回本轮可用工具列表)
|
||||
└─ return ToolSpec[](返回本轮可用工具列表;不通过工具动态加载 skill)
|
||||
```
|
||||
|
||||
```text
|
||||
@ -703,11 +740,11 @@ TaskExecutionPlanner(Task 内部执行规划)
|
||||
│ ├─ 从 published skill catalog 检索候选
|
||||
│ ├─ 按 skill_query / required_capabilities / node task 选择 skill
|
||||
│ ├─ 命中 published skill 后写入 graph.nodes[].inherited_pinned_skills
|
||||
│ └─ 无命中时创建 draft-only skill,并写入 graph.nodes[].inherited_pinned_skill_contexts
|
||||
│ └─ 无命中时创建 ephemeral guidance,并写入 graph.nodes[].inherited_pinned_skill_contexts
|
||||
│
|
||||
└─ TaskExecutionPlan
|
||||
├─ graph.nodes[].agent 只是 generic runtime trace identity
|
||||
└─ to_event_payload() 写入 skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report
|
||||
└─ to_event_payload() 写入 skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report
|
||||
```
|
||||
|
||||
```text
|
||||
@ -748,8 +785,21 @@ Frontend process projection
|
||||
```text
|
||||
Learning pipeline
|
||||
│
|
||||
├─ evidence recording
|
||||
│ ├─ every run -> RunRecord
|
||||
│ ├─ activated skills -> SkillEffectRecord
|
||||
│ └─ no candidates generated here
|
||||
│
|
||||
├─ feedback gate
|
||||
│ └─ validation accepted + satisfied 才生成 learning candidate
|
||||
│ ├─ validation accepted + satisfied -> scoped learning candidate
|
||||
│ ├─ validation rejected + satisfied -> 记录人工接受风险,不生成候选
|
||||
│ ├─ revise -> 保持 Task 打开,不生成候选
|
||||
│ └─ abandon -> 失败证据,不写主 memory,不生成成功候选
|
||||
│
|
||||
├─ scoped candidate generation
|
||||
│ ├─ source = current task run_ids
|
||||
│ ├─ no published skill -> new_skill
|
||||
│ └─ published skill used -> revise_skill
|
||||
│
|
||||
├─ SkillLearningPipelineService
|
||||
│ ├─ candidate -> queued / synthesizing
|
||||
@ -799,6 +849,12 @@ Web(网页入口)
|
||||
│ ├─ agent_service.submit_direct(...)(把用户消息提交给 AgentService)
|
||||
│ └─ return WebChatResponse(返回模型回复 + run/task/validation 元数据)
|
||||
│
|
||||
├─ WS /ws/{session_id}(网页 WebSocket 适配层)
|
||||
│ ├─ ping -> pong
|
||||
│ ├─ message -> agent_service.submit_direct(...)
|
||||
│ ├─ return status / assistant message(携带 run/task/validation 元数据)
|
||||
│ └─ return session_updated(通知前端刷新 session/process)
|
||||
│
|
||||
└─ POST /api/chat/feedback(聊天反馈接口)
|
||||
├─ validate WebChatFeedbackRequest
|
||||
├─ agent_service.submit_feedback(...)
|
||||
@ -820,8 +876,10 @@ Skills learning admin API
|
||||
Gateway(消息通道入口)
|
||||
│
|
||||
├─ MessageBus(内部消息总线)
|
||||
├─ ChannelAdapter(Telegram / Slack / Email / WhatsApp 等只作为 adapter)
|
||||
├─ inbound -> AgentService.handle_inbound_message(...)(外部消息进入 AgentService)
|
||||
└─ outbound <- OutboundMessage(AgentService 返回结构化输出消息)
|
||||
├─ outbound <- OutboundMessage(AgentService 返回结构化输出消息)
|
||||
└─ ChannelManager(按 message.channel 分发 outbound)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -5,6 +5,7 @@ description = "Beaver backend skeleton"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"anthropic>=0.51.0,<1.0.0",
|
||||
"croniter>=6.0.0,<7.0.0",
|
||||
"fastmcp>=3.0.0,<4.0.0",
|
||||
"fastapi>=0.115.0,<1.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
@ -12,6 +13,7 @@ dependencies = [
|
||||
"litellm>=1.79.0,<2.0.0",
|
||||
"openai>=1.79.0,<2.0.0",
|
||||
"pydantic>=2.12.0,<3.0.0",
|
||||
"python-multipart>=0.0.20,<1.0.0",
|
||||
"typer>=0.20.0,<1.0.0",
|
||||
"uvicorn[standard]>=0.34.0,<1.0.0",
|
||||
]
|
||||
|
||||
80
app-instance/backend/tests/unit/test_active_task_api.py
Normal file
80
app-instance/backend/tests/unit/test_active_task_api.py
Normal file
@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.interfaces.web.app import create_app
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
def test_active_task_api_returns_open_task_and_hides_closed(tmp_path: Path) -> None:
|
||||
service = AgentService(workspace=tmp_path)
|
||||
loaded = service.create_loop().boot()
|
||||
task = loaded.task_service.create_task( # type: ignore[union-attr]
|
||||
session_id="web:active",
|
||||
description="实现任务连续性",
|
||||
metadata={"short_title": "任务连续性"},
|
||||
)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
active = client.get("/api/sessions/web:active/active-task")
|
||||
listed = client.get("/api/tasks")
|
||||
loaded.task_service.close_task(task.task_id, reason="done") # type: ignore[union-attr]
|
||||
inactive = client.get("/api/sessions/web:active/active-task")
|
||||
|
||||
assert active.status_code == 200
|
||||
assert active.json()["task_id"] == task.task_id
|
||||
assert active.json()["short_title"] == "任务连续性"
|
||||
assert listed.json()[0]["short_title"] == "任务连续性"
|
||||
assert inactive.status_code == 200
|
||||
assert inactive.json() is None
|
||||
|
||||
|
||||
def test_active_task_api_hides_unengaged_cron_task(tmp_path: Path) -> None:
|
||||
service = AgentService(workspace=tmp_path)
|
||||
loaded = service.create_loop().boot()
|
||||
hidden = loaded.task_service.create_task( # type: ignore[union-attr]
|
||||
session_id="web:cron",
|
||||
description="提醒用户喝水",
|
||||
creator="cron",
|
||||
metadata={"source": "scheduled_cron", "user_engaged": False},
|
||||
)
|
||||
visible = loaded.task_service.create_task( # type: ignore[union-attr]
|
||||
session_id="web:engaged",
|
||||
description="修改新闻总结",
|
||||
creator="cron",
|
||||
metadata={"source": "scheduled_run", "user_engaged": True},
|
||||
)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
hidden_response = client.get("/api/sessions/web:cron/active-task")
|
||||
visible_response = client.get("/api/sessions/web:engaged/active-task")
|
||||
|
||||
assert hidden_response.status_code == 200
|
||||
assert hidden_response.json() is None
|
||||
assert visible_response.status_code == 200
|
||||
assert visible_response.json()["task_id"] == visible.task_id
|
||||
assert hidden.task_id != visible.task_id
|
||||
|
||||
|
||||
def test_task_delete_api_removes_backend_task(tmp_path: Path) -> None:
|
||||
service = AgentService(workspace=tmp_path)
|
||||
loaded = service.create_loop().boot()
|
||||
task = loaded.task_service.create_task( # type: ignore[union-attr]
|
||||
session_id="web:delete",
|
||||
description="删除这个任务",
|
||||
)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
deleted = client.delete(f"/api/tasks/{task.task_id}")
|
||||
listed = client.get("/api/tasks")
|
||||
missing = client.get(f"/api/tasks/{task.task_id}")
|
||||
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json()["task_id"] == task.task_id
|
||||
assert all(item["task_id"] != task.task_id for item in listed.json())
|
||||
assert missing.status_code == 404
|
||||
@ -59,7 +59,7 @@ class BlockingSkillAssembler:
|
||||
self.release_first = asyncio.Event()
|
||||
|
||||
async def assemble(self, **kwargs) -> SkillAssemblyResult:
|
||||
if kwargs["task_description"] == "task first":
|
||||
if "task first" in kwargs["task_description"]:
|
||||
self.first_started.set()
|
||||
await self.release_first.wait()
|
||||
return SkillAssemblyResult()
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from beaver.engine import AgentLoop, EngineLoader
|
||||
from beaver.engine.providers import make_provider_bundle
|
||||
@ -42,6 +43,37 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None:
|
||||
assert target["extra_headers"] == {"X-Test": "1"}
|
||||
|
||||
|
||||
def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": str(tmp_path / "workspace"),
|
||||
"model": "qwen-plus",
|
||||
"provider": "custom",
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"custom": {},
|
||||
"openai": {
|
||||
"apiKey": "sk-test",
|
||||
"apiBase": "https://oai.example.com/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_config(config_path=config_path)
|
||||
|
||||
assert config.resolve_provider_target()["provider_name"] == "openai"
|
||||
assert config.resolve_provider_target(provider_name="custom")["provider_name"] == "openai"
|
||||
assert config.resolve_provider_target(provider_name="deepseek")["provider_name"] == "openai"
|
||||
|
||||
|
||||
def test_engine_loader_uses_config_workspace(tmp_path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
config_path = tmp_path / "config.json"
|
||||
@ -105,3 +137,40 @@ def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
|
||||
assert bundle.main_runtime.api_base == "https://oai.example.com/v1"
|
||||
assert isinstance(bundle.main_provider, LiteLLMProvider)
|
||||
assert bundle.main_provider._resolve_model("qwen-plus") == "openai/qwen-plus"
|
||||
|
||||
|
||||
def test_load_config_reads_stevenli_mcp_authz_identity() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[4]
|
||||
config_path = repo_root / "app-instance" / "runtime" / "instances" / "stevenli" / "nanobot-home" / "config.json"
|
||||
config = load_config(config_path=config_path)
|
||||
|
||||
server = config.tools.mcp_servers["outlook_mcp"]
|
||||
assert server.transport == "http"
|
||||
assert server.url == "http://10.6.80.29:8000/mcp"
|
||||
assert server.auth_mode == "oauth_backend_token"
|
||||
assert server.auth_audience == "mcp:outlook_mcp"
|
||||
assert "tool:mail_list_messages" in server.auth_scopes
|
||||
assert server.tool_timeout == 60
|
||||
assert server.sensitive is True
|
||||
|
||||
assert config.authz.enabled is True
|
||||
assert config.authz.base_url == "http://nano-authz-service:19090"
|
||||
assert config.backend_identity.backend_id == "stevenli"
|
||||
assert config.backend_identity.client_id == "stevenli"
|
||||
|
||||
|
||||
def test_load_config_adds_managed_local_mcp_servers(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"tools": {"mcpServers": {}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_config(config_path=config_path)
|
||||
|
||||
local = config.tools.mcp_servers["local_filesystem_mcp"]
|
||||
assert local.transport == "stdio"
|
||||
assert local.kind == "local"
|
||||
assert local.category == "filesystem"
|
||||
assert local.managed is True
|
||||
assert "beaver.interfaces.mcp.tools_server" in local.args
|
||||
|
||||
126
app-instance/backend/tests/unit/test_cron_service.py
Normal file
126
app-instance/backend/tests/unit/test_cron_service.py
Normal file
@ -0,0 +1,126 @@
|
||||
import asyncio
|
||||
|
||||
from beaver.foundation.models import CronExecutionResult, CronRunRecord, CronSchedule
|
||||
from beaver.tools.base import ToolContext
|
||||
from beaver.tools.builtins import CronTool
|
||||
from beaver.services.cron_service import CronService, compute_next_run, parse_schedule, schedule_from_api
|
||||
|
||||
|
||||
def test_parse_hermes_style_schedules() -> None:
|
||||
interval = parse_schedule("every 15m")
|
||||
assert interval.kind == "every"
|
||||
assert interval.every_ms == 15 * 60 * 1000
|
||||
|
||||
one_shot = parse_schedule("30s")
|
||||
assert one_shot.kind == "at"
|
||||
assert one_shot.at_ms is not None
|
||||
|
||||
cron = parse_schedule("0 9 * * *")
|
||||
assert cron.kind == "cron"
|
||||
assert cron.expr == "0 9 * * *"
|
||||
|
||||
|
||||
def test_schedule_from_frontend_payload() -> None:
|
||||
every = schedule_from_api({"every_seconds": 60})
|
||||
assert every.kind == "every"
|
||||
assert every.every_ms == 60_000
|
||||
|
||||
cron = schedule_from_api({"cron_expr": "0 10 * * *"})
|
||||
assert cron.kind == "cron"
|
||||
|
||||
|
||||
def test_compute_next_run_skips_missed_interval() -> None:
|
||||
schedule = CronSchedule(kind="every", every_ms=60_000)
|
||||
assert compute_next_run(schedule, now_ms=1_000_000, last_run_at_ms=0) > 1_000_000
|
||||
|
||||
|
||||
def test_manual_run_records_task_history(tmp_path) -> None:
|
||||
async def on_job(job):
|
||||
return CronExecutionResult(response="done", task_id=f"task-{job.id}", run_id="run-1")
|
||||
|
||||
service = CronService(tmp_path / "jobs.json", on_job=on_job)
|
||||
job = service.add_job(
|
||||
name="Daily check",
|
||||
message="Check the project",
|
||||
schedule=CronSchedule(kind="every", every_ms=3600_000),
|
||||
session_key="web:default",
|
||||
)
|
||||
|
||||
assert asyncio.run(service.run_job(job.id, force=True)) is True
|
||||
updated = service.get_job(job.id)
|
||||
assert updated is not None
|
||||
assert updated.last_status == "ok"
|
||||
assert updated.history[-1].task_id == f"task-{job.id}"
|
||||
assert updated.to_api_dict()["last_task_id"] == f"task-{job.id}"
|
||||
|
||||
|
||||
def test_manual_run_records_scheduled_run_output(tmp_path) -> None:
|
||||
async def on_job(job, run):
|
||||
return CronExecutionResult(
|
||||
response=f"notification for {run.scheduled_run_id}",
|
||||
run_id="run-notify",
|
||||
notification_session_id="notify:default:scheduled",
|
||||
mode="notification",
|
||||
)
|
||||
|
||||
service = CronService(tmp_path / "jobs.json", on_job=on_job)
|
||||
job = service.add_job(
|
||||
name="Daily news",
|
||||
message="Summarize news",
|
||||
schedule=CronSchedule(kind="every", every_ms=3600_000),
|
||||
)
|
||||
|
||||
assert asyncio.run(service.run_job(job.id, force=True)) is True
|
||||
updated = service.get_job(job.id)
|
||||
assert updated is not None
|
||||
run = updated.history[-1]
|
||||
assert run.scheduled_run_id
|
||||
assert run.output == f"notification for {run.scheduled_run_id}"
|
||||
assert run.notification_session_id == "notify:default:scheduled"
|
||||
assert updated.to_api_dict()["last_scheduled_run_id"] == run.scheduled_run_id
|
||||
|
||||
|
||||
def test_cron_tool_uses_runtime_service(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "jobs.json")
|
||||
tool = CronTool()
|
||||
result = asyncio.run(
|
||||
tool.invoke(
|
||||
{
|
||||
"action": "add",
|
||||
"name": "Tool-created task",
|
||||
"message": "Check the queue",
|
||||
"every_seconds": 300,
|
||||
},
|
||||
ToolContext(session_id="session-1", services={"cron_service": service}),
|
||||
)
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
jobs = service.list_jobs(include_disabled=True)
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].payload.session_key == "session-1"
|
||||
|
||||
|
||||
def test_mark_run_engaged_links_task(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "jobs.json")
|
||||
job = service.add_job(
|
||||
name="Daily news",
|
||||
message="Summarize news",
|
||||
schedule=CronSchedule(kind="every", every_ms=3600_000),
|
||||
)
|
||||
run = CronRunRecord(
|
||||
started_at_ms=1,
|
||||
status="ok",
|
||||
output="news summary",
|
||||
notification_session_id="notify:default:scheduled",
|
||||
)
|
||||
job.history.append(run)
|
||||
service._save_jobs()
|
||||
|
||||
linked = service.mark_run_engaged(run.scheduled_run_id, task_id="task-1", intent="revise_once")
|
||||
|
||||
assert linked is not None
|
||||
updated = service.get_run(run.scheduled_run_id)
|
||||
assert updated is not None
|
||||
assert updated[1].engaged is True
|
||||
assert updated[1].task_id == "task-1"
|
||||
67
app-instance/backend/tests/unit/test_debug_chat_logs_api.py
Normal file
67
app-instance/backend/tests/unit/test_debug_chat_logs_api.py
Normal file
@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.interfaces.web.app import create_app
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
def test_debug_chat_logs_group_events_by_run(tmp_path: Path) -> None:
|
||||
service = AgentService(workspace=tmp_path)
|
||||
loaded = service.create_loop().boot()
|
||||
manager = loaded.session_manager
|
||||
session_id = "web:debug"
|
||||
run_id = "run-debug"
|
||||
manager.ensure_session(session_id, source="web", title="Debug")
|
||||
manager.append_message(
|
||||
session_id,
|
||||
run_id=run_id,
|
||||
role="system",
|
||||
event_type="run_started",
|
||||
event_payload={"source": "web", "task_id": "task-1", "attempt_index": 1},
|
||||
content="hello",
|
||||
context_visible=False,
|
||||
)
|
||||
manager.append_message(
|
||||
session_id,
|
||||
run_id=run_id,
|
||||
role="system",
|
||||
event_type="llm_request_snapshotted",
|
||||
event_payload={"messages": [{"role": "user", "content": "hello"}], "tools": []},
|
||||
content='{"messages":[{"role":"user","content":"hello"}],"tools":[]}',
|
||||
context_visible=False,
|
||||
)
|
||||
manager.append_message(
|
||||
session_id,
|
||||
run_id=run_id,
|
||||
role="user",
|
||||
event_type="user_message_added",
|
||||
content="hello",
|
||||
)
|
||||
manager.append_message(
|
||||
session_id,
|
||||
run_id=run_id,
|
||||
role="assistant",
|
||||
event_type="assistant_message_added",
|
||||
content="hi",
|
||||
finish_reason="stop",
|
||||
)
|
||||
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/debug/chat-logs")
|
||||
|
||||
assert response.status_code == 200
|
||||
sessions = response.json()["sessions"]
|
||||
run = sessions[0]["runs"][0]
|
||||
assert run["run_id"] == run_id
|
||||
assert run["user_input"] == "hello"
|
||||
assert [event["event_type"] for event in run["events"]] == [
|
||||
"run_started",
|
||||
"llm_request_snapshotted",
|
||||
"user_message_added",
|
||||
"assistant_message_added",
|
||||
]
|
||||
assert run["events"][1]["event_payload"]["messages"][0]["content"] == "hello"
|
||||
@ -17,6 +17,9 @@ class FakeResult:
|
||||
provider_name: str | None = "fake"
|
||||
model: str | None = "fake-model"
|
||||
usage: dict[str, Any] = field(default_factory=dict)
|
||||
task_id: str | None = "task-1"
|
||||
task_status: str | None = "awaiting_feedback"
|
||||
validation_result: dict[str, Any] | None = field(default_factory=lambda: {"accepted": True})
|
||||
|
||||
|
||||
class FakeService:
|
||||
@ -75,6 +78,9 @@ def test_gateway_routes_memory_channel_roundtrip() -> None:
|
||||
assert message.content == "echo:hello"
|
||||
assert message.session_id == "s1"
|
||||
assert message.finish_reason == "stop"
|
||||
assert message.metadata["task_id"] == "task-1"
|
||||
assert message.metadata["task_status"] == "awaiting_feedback"
|
||||
assert message.metadata["validation_result"] == {"accepted": True}
|
||||
|
||||
stop_event.set()
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
@ -183,6 +189,50 @@ def test_agent_service_maps_stopped_runtime_to_stopped_outbound() -> None:
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_channel_manager_keeps_unknown_channel_outbound_undeliverable() -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
manager = ChannelManager(bus)
|
||||
stop_event = asyncio.Event()
|
||||
await bus.publish_outbound(
|
||||
AgentService.build_outbound_message(
|
||||
InboundMessage(channel="missing", content="hello", session_id="missing:1"),
|
||||
FakeResult(session_id="missing:1", output_text="ok"),
|
||||
)
|
||||
)
|
||||
stop_event.set()
|
||||
|
||||
await manager.dispatch_outbound(stop_event)
|
||||
|
||||
assert len(manager.undeliverable) == 1
|
||||
assert manager.undeliverable[0].channel == "missing"
|
||||
assert manager.undeliverable[0].session_id == "missing:1"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
channel = MemoryChannelAdapter(bus, name="telegram")
|
||||
inbound = await channel.publish_external_text(
|
||||
"hello",
|
||||
chat_id="chat-1",
|
||||
message_id="message-1",
|
||||
raw_payload={"platform": "telegram", "text": "hello"},
|
||||
)
|
||||
|
||||
queued = await bus.consume_inbound()
|
||||
assert queued is inbound
|
||||
assert queued.channel == "telegram"
|
||||
assert queued.session_id == "telegram:chat-1"
|
||||
assert queued.metadata["chat_id"] == "chat-1"
|
||||
assert queued.metadata["message_id"] == "message-1"
|
||||
assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"}
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
|
||||
class StartedChannel:
|
||||
name = "started"
|
||||
|
||||
145
app-instance/backend/tests/unit/test_litellm_thinking_mode.py
Normal file
145
app-instance/backend/tests/unit/test_litellm_thinking_mode.py
Normal file
@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from beaver.engine.providers.litellm import LiteLLMProvider
|
||||
|
||||
|
||||
def test_qwen_thinking_mode_is_sent_as_chat_template_kwargs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class Message:
|
||||
content = "可以"
|
||||
reasoning_content = ""
|
||||
tool_calls = []
|
||||
|
||||
class Choice:
|
||||
message = Message()
|
||||
finish_reason = "stop"
|
||||
|
||||
class Response:
|
||||
choices = [Choice()]
|
||||
usage = None
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
|
||||
|
||||
provider = LiteLLMProvider(
|
||||
api_key="sk-test",
|
||||
api_base="https://oai.example.com/v1",
|
||||
default_model="Qwen3.6-35B",
|
||||
provider_name="openai",
|
||||
)
|
||||
response = asyncio.run(
|
||||
provider.chat(
|
||||
[{"role": "user", "content": "只回复可以"}],
|
||||
model="Qwen3.6-35B",
|
||||
thinking_enabled=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert response.content == "可以"
|
||||
assert captured["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
|
||||
def test_non_qwen_thinking_mode_is_not_sent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class Message:
|
||||
content = "ok"
|
||||
reasoning_content = None
|
||||
tool_calls = []
|
||||
|
||||
class Choice:
|
||||
message = Message()
|
||||
finish_reason = "stop"
|
||||
|
||||
class Response:
|
||||
choices = [Choice()]
|
||||
usage = None
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
|
||||
|
||||
provider = LiteLLMProvider(
|
||||
api_key="sk-test",
|
||||
api_base="https://oai.example.com/v1",
|
||||
default_model="gpt-4.1-mini",
|
||||
provider_name="openai",
|
||||
)
|
||||
asyncio.run(
|
||||
provider.chat(
|
||||
[{"role": "user", "content": "reply ok"}],
|
||||
model="gpt-4.1-mini",
|
||||
thinking_enabled=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert "extra_body" not in captured
|
||||
|
||||
|
||||
def test_litellm_provider_sanitizes_tool_call_arguments(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class Message:
|
||||
content = "ok"
|
||||
reasoning_content = None
|
||||
tool_calls = []
|
||||
|
||||
class Choice:
|
||||
message = Message()
|
||||
finish_reason = "stop"
|
||||
|
||||
class Response:
|
||||
choices = [Choice()]
|
||||
usage = None
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
|
||||
|
||||
provider = LiteLLMProvider(
|
||||
api_key="sk-test",
|
||||
api_base="https://oai.example.com/v1",
|
||||
default_model="Qwen3.6-35B",
|
||||
provider_name="openai",
|
||||
)
|
||||
asyncio.run(
|
||||
provider.chat(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "cron",
|
||||
"arguments": {"action": "add", "mode": "notification"},
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call-1", "name": "cron", "content": "done"},
|
||||
],
|
||||
model="Qwen3.6-35B",
|
||||
thinking_enabled=False,
|
||||
)
|
||||
)
|
||||
|
||||
tool_call = captured["messages"][0]["tool_calls"][0]
|
||||
assert tool_call["function"]["arguments"] == '{"action": "add", "mode": "notification"}'
|
||||
116
app-instance/backend/tests/unit/test_main_agent_router.py
Normal file
116
app-instance/backend/tests/unit/test_main_agent_router.py
Normal file
@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
||||
from beaver.tasks import MainAgentRouter, TaskRecord
|
||||
|
||||
|
||||
class RouterProvider(LLMProvider):
|
||||
def __init__(self, response: str | Exception) -> None:
|
||||
super().__init__()
|
||||
self.response = response
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
self.calls.append(
|
||||
{
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"model": model,
|
||||
"thinking_enabled": thinking_enabled,
|
||||
}
|
||||
)
|
||||
if isinstance(self.response, Exception):
|
||||
raise self.response
|
||||
return LLMResponse(content=self.response, finish_reason="stop", provider_name="stub", model="stub-model")
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "stub-model"
|
||||
|
||||
|
||||
def _task() -> TaskRecord:
|
||||
return TaskRecord(
|
||||
task_id="task-1",
|
||||
session_id="web:task",
|
||||
description="实现任务连续性",
|
||||
goal="实现任务连续性",
|
||||
constraints=[],
|
||||
priority=0,
|
||||
status="awaiting_feedback",
|
||||
creator="test",
|
||||
created_at="now",
|
||||
updated_at="now",
|
||||
metadata={"short_title": "任务连续性"},
|
||||
)
|
||||
|
||||
|
||||
def test_router_continues_active_task_from_llm_decision() -> None:
|
||||
provider = RouterProvider('{"action":"continue_task","reason":"related","short_title":"任务连续性"}')
|
||||
decision = asyncio.run(
|
||||
MainAgentRouter().classify(
|
||||
"再把输入框标识也补上",
|
||||
active_task=_task(),
|
||||
provider=provider,
|
||||
)
|
||||
)
|
||||
|
||||
assert decision.is_task
|
||||
assert decision.starts_new_task is False
|
||||
assert decision.short_title == "任务连续性"
|
||||
assert provider.calls[0]["max_tokens"] == 256
|
||||
|
||||
|
||||
def test_router_receives_thinking_mode() -> None:
|
||||
provider = RouterProvider('{"action":"simple_chat","reason":"simple"}')
|
||||
decision = asyncio.run(
|
||||
MainAgentRouter().classify(
|
||||
"你好",
|
||||
provider=provider,
|
||||
thinking_enabled=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert not decision.is_task
|
||||
assert provider.calls[0]["thinking_enabled"] is False
|
||||
|
||||
|
||||
def test_router_closes_active_task_from_llm_decision() -> None:
|
||||
decision = asyncio.run(
|
||||
MainAgentRouter().classify(
|
||||
"这个任务结束了",
|
||||
active_task=_task(),
|
||||
provider=RouterProvider('{"action":"close_task","reason":"user said done"}'),
|
||||
)
|
||||
)
|
||||
|
||||
assert not decision.is_task
|
||||
assert decision.closes_task is True
|
||||
|
||||
|
||||
def test_router_fallback_keeps_active_task_but_not_new_task() -> None:
|
||||
active = asyncio.run(
|
||||
MainAgentRouter().classify(
|
||||
"继续",
|
||||
active_task=_task(),
|
||||
provider=RouterProvider(RuntimeError("provider down")),
|
||||
)
|
||||
)
|
||||
inactive = asyncio.run(
|
||||
MainAgentRouter().classify(
|
||||
"implement something",
|
||||
active_task=None,
|
||||
provider=RouterProvider(RuntimeError("provider down")),
|
||||
)
|
||||
)
|
||||
|
||||
assert active.is_task
|
||||
assert not inactive.is_task
|
||||
142
app-instance/backend/tests/unit/test_marketplace_and_hermes.py
Normal file
142
app-instance/backend/tests/unit/test_marketplace_and_hermes.py
Normal file
@ -0,0 +1,142 @@
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from beaver.interfaces.web.app import _create_skill_upload_draft
|
||||
from beaver.services.hermes_migration import HermesMigrationService
|
||||
from beaver.services.skillhub_service import SkillHubService
|
||||
from beaver.skills.drafts import DraftService
|
||||
from beaver.skills.specs import SkillSpecStore
|
||||
from beaver.tools.mcp.wrapper import MCPToolWrapper
|
||||
|
||||
|
||||
class FakeSkillHubService(SkillHubService):
|
||||
async def _get_json(self, path, *, params=None):
|
||||
if path == "/skills":
|
||||
return {
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"slug": "multi-search-engine",
|
||||
"displayName": "multi-search-engine",
|
||||
"summary": "search",
|
||||
"namespace": "global",
|
||||
"downloadCount": 1,
|
||||
"starCount": 0,
|
||||
"publishedVersion": {"version": "20260413.065325"},
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 0,
|
||||
"size": 12,
|
||||
}
|
||||
}
|
||||
if path == "/skills/global/multi-search-engine":
|
||||
return {
|
||||
"data": {
|
||||
"slug": "multi-search-engine",
|
||||
"displayName": "multi-search-engine",
|
||||
"summary": "search",
|
||||
"namespace": "global",
|
||||
"downloadCount": 1,
|
||||
"starCount": 0,
|
||||
"publishedVersion": {"version": "20260413.065325"},
|
||||
}
|
||||
}
|
||||
if path == "/skills/global/multi-search-engine/versions/20260413.065325":
|
||||
return {"data": {"version": "20260413.065325"}}
|
||||
if path == "/skills/global/multi-search-engine/versions/20260413.065325/files":
|
||||
return {"data": [{"filePath": "SKILL.md", "fileSize": 93}, {"filePath": "references/a.txt", "fileSize": 2}]}
|
||||
raise AssertionError(path)
|
||||
|
||||
async def _get_text(self, path, *, params):
|
||||
if params["path"] == "SKILL.md":
|
||||
return "---\nname: multi-search-engine\ndescription: Multi search\ntools:\n - web_search\n---\nUse search.\n"
|
||||
return "ok"
|
||||
|
||||
|
||||
def test_skillhub_search_detail_do_not_install_until_post_install(tmp_path):
|
||||
store = SkillSpecStore(tmp_path)
|
||||
service = FakeSkillHubService(store)
|
||||
|
||||
search = asyncio.run(service.search(q="multi-search-engine"))
|
||||
detail = asyncio.run(service.detail("global", "multi-search-engine"))
|
||||
assert search["items"][0]["installed"] is False
|
||||
assert detail["installed"] is False
|
||||
assert store.get_skill_spec("multi-search-engine") is None
|
||||
|
||||
install = asyncio.run(service.install("global", "multi-search-engine"))
|
||||
assert install["ok"] is True
|
||||
assert store.get_skill_spec("multi-search-engine") is not None
|
||||
assert (tmp_path / "skills" / "multi-search-engine" / "versions" / install["version"] / "references" / "a.txt").read_text() == "ok"
|
||||
|
||||
|
||||
def test_upload_skill_zip_rejects_path_traversal(tmp_path):
|
||||
store = SkillSpecStore(tmp_path)
|
||||
loaded = SimpleNamespace(skill_spec_store=store, draft_service=DraftService(store))
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr("skill/SKILL.md", "---\nname: skill\n---\nBody\n")
|
||||
archive.writestr("skill/../evil.txt", "x")
|
||||
|
||||
with pytest.raises(ValueError, match="Unsafe archive entry"):
|
||||
_create_skill_upload_draft(loaded, "skill.zip", buffer.getvalue())
|
||||
|
||||
|
||||
def test_upload_skill_zip_keeps_supporting_files_on_draft(tmp_path):
|
||||
store = SkillSpecStore(tmp_path)
|
||||
loaded = SimpleNamespace(skill_spec_store=store, draft_service=DraftService(store))
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr("skill/SKILL.md", "---\nname: skill\n---\nBody\n")
|
||||
archive.writestr("skill/references/a.txt", "context")
|
||||
|
||||
draft = _create_skill_upload_draft(loaded, "skill.zip", buffer.getvalue())
|
||||
upload_dir = draft["evidence_refs"][0]["supporting_upload_dir"]
|
||||
assert (tmp_path / "skills" / "skill" / "draft_uploads" / draft["draft_id"] / "references" / "a.txt").read_text() == "context"
|
||||
assert upload_dir.endswith(draft["draft_id"])
|
||||
|
||||
|
||||
def test_hermes_migration_manifest_includes_no_credential_skill_and_skips_api_skill(tmp_path):
|
||||
repo = tmp_path / "hermes"
|
||||
safe = repo / "skills" / "safe"
|
||||
unsafe = repo / "skills" / "unsafe"
|
||||
safe.mkdir(parents=True)
|
||||
unsafe.mkdir(parents=True)
|
||||
safe.joinpath("SKILL.md").write_text("---\nname: safe\n---\nUse local files only.\n", encoding="utf-8")
|
||||
unsafe.joinpath("SKILL.md").write_text("---\nname: unsafe\n---\nRequires API_KEY.\n", encoding="utf-8")
|
||||
|
||||
store = SkillSpecStore(tmp_path / "workspace")
|
||||
manifest = HermesMigrationService(store).migrate(repo)
|
||||
included = {item["skill_name"] for item in manifest["included"]}
|
||||
skipped = {item.get("skill_name"): item["reason"] for item in manifest["skipped"]}
|
||||
|
||||
assert "safe" in included
|
||||
assert skipped["unsafe"] == "requires_external_credentials"
|
||||
assert store.get_skill_spec("safe") is not None
|
||||
manifest_path = tmp_path / "workspace" / "hermes_migration_manifest.json"
|
||||
assert json.loads(manifest_path.read_text(encoding="utf-8"))["source"] == "hermes-agent"
|
||||
|
||||
|
||||
def test_mcp_wrapper_metadata_preserves_server_id_with_underscores():
|
||||
tool_def = SimpleNamespace(name="auth_status", description="Auth", inputSchema={"type": "object", "properties": {}})
|
||||
|
||||
async def call_tool(_name, _arguments):
|
||||
return SimpleNamespace(content=[], structuredContent={"ok": True})
|
||||
|
||||
wrapper = MCPToolWrapper(
|
||||
"outlook_mcp",
|
||||
tool_def,
|
||||
call_tool,
|
||||
kind="online",
|
||||
category="outlook",
|
||||
display_name="Outlook",
|
||||
)
|
||||
|
||||
assert wrapper.spec.name == "mcp_outlook_mcp_auth_status"
|
||||
assert wrapper.spec.metadata["server_id"] == "outlook_mcp"
|
||||
assert wrapper.spec.metadata["original_tool_name"] == "auth_status"
|
||||
@ -298,8 +298,29 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path:
|
||||
ended_at=recent,
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
feedback={"feedback_type": "satisfied"},
|
||||
activated_skills=[],
|
||||
task_id=f"task-new-{index}",
|
||||
attempt_index=1,
|
||||
validation_result={"accepted": True, "score": 0.9},
|
||||
)
|
||||
)
|
||||
|
||||
for index in range(2):
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id=f"simple-chat-{index}",
|
||||
session_id="session-simple",
|
||||
task_text="你是谁",
|
||||
started_at=recent,
|
||||
ended_at=recent,
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
feedback={},
|
||||
activated_skills=[],
|
||||
task_id=None,
|
||||
attempt_index=None,
|
||||
validation_result=None,
|
||||
)
|
||||
)
|
||||
|
||||
@ -329,8 +350,11 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path:
|
||||
ended_at=recent,
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
feedback={},
|
||||
feedback={"feedback_type": "satisfied"},
|
||||
activated_skills=receipts,
|
||||
task_id=f"task-merge-{index}",
|
||||
attempt_index=1,
|
||||
validation_result={"accepted": True, "score": 0.9},
|
||||
)
|
||||
)
|
||||
for receipt in receipts:
|
||||
@ -382,6 +406,9 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path:
|
||||
kinds = {candidate.kind for candidate in candidates}
|
||||
|
||||
assert {"revise_skill", "new_skill", "merge_skills", "retire_skill"} <= kinds
|
||||
new_candidates = [candidate for candidate in candidates if candidate.kind == "new_skill"]
|
||||
assert new_candidates
|
||||
assert all("simple-chat" not in run_id for candidate in new_candidates for run_id in candidate.source_run_ids)
|
||||
|
||||
retire_candidate = next(candidate for candidate in candidates if candidate.kind == "retire_skill")
|
||||
retire_draft = asyncio.run(
|
||||
@ -396,6 +423,100 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path:
|
||||
assert store.read_draft("svn-migration", retire_draft.draft_id) is not None
|
||||
|
||||
|
||||
def test_skill_learning_service_generates_task_scoped_candidates(tmp_path: Path) -> None:
|
||||
store = SkillSpecStore(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
learning_store = SkillLearningStore(tmp_path / "memory" / "skills")
|
||||
service = SkillLearningService(
|
||||
run_store=run_store,
|
||||
learning_store=learning_store,
|
||||
draft_service=DraftService(store),
|
||||
evidence_selector=EvidenceSelector(run_store),
|
||||
)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
receipt = _receipt(
|
||||
run_id="task-run-1",
|
||||
session_id="session-task",
|
||||
skill_name="api-review",
|
||||
skill_version="v0001",
|
||||
activated_at=now,
|
||||
)
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="task-run-1",
|
||||
session_id="session-task",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="Review API compatibility",
|
||||
started_at=now,
|
||||
ended_at=now,
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
feedback={"feedback_type": "satisfied"},
|
||||
activated_skills=[receipt],
|
||||
validation_result={"accepted": True, "score": 0.9},
|
||||
)
|
||||
)
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="other-task-run",
|
||||
session_id="session-other",
|
||||
task_id="task-2",
|
||||
attempt_index=1,
|
||||
task_text="Review API compatibility",
|
||||
started_at=now,
|
||||
ended_at=now,
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
feedback={"feedback_type": "satisfied"},
|
||||
activated_skills=[],
|
||||
validation_result={"accepted": True, "score": 0.9},
|
||||
)
|
||||
)
|
||||
|
||||
candidates = service.build_learning_candidates_for_task("task-1", trigger_run_id="task-run-1")
|
||||
|
||||
assert [candidate.candidate_id for candidate in candidates] == ["revise:api-review:v0001:task:task-1"]
|
||||
assert candidates[0].source_run_ids == ["task-run-1"]
|
||||
assert candidates[0].related_skill_names == ["api-review"]
|
||||
assert candidates[0].evidence["task_id"] == "task-1"
|
||||
|
||||
|
||||
def test_skill_learning_service_generates_new_skill_for_task_without_published_skills(tmp_path: Path) -> None:
|
||||
store = SkillSpecStore(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
learning_store = SkillLearningStore(tmp_path / "memory" / "skills")
|
||||
service = SkillLearningService(
|
||||
run_store=run_store,
|
||||
learning_store=learning_store,
|
||||
draft_service=DraftService(store),
|
||||
evidence_selector=EvidenceSelector(run_store),
|
||||
)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="task-run-1",
|
||||
session_id="session-task",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="Generate migration checklist",
|
||||
started_at=now,
|
||||
ended_at=now,
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
feedback={"feedback_type": "satisfied"},
|
||||
activated_skills=[],
|
||||
validation_result={"accepted": True, "score": 0.9},
|
||||
)
|
||||
)
|
||||
|
||||
candidates = service.build_learning_candidates_for_task("task-1", trigger_run_id="task-run-1")
|
||||
|
||||
assert [candidate.candidate_id for candidate in candidates] == ["new:task:task-1"]
|
||||
assert candidates[0].kind == "new_skill"
|
||||
assert candidates[0].source_run_ids == ["task-run-1"]
|
||||
|
||||
|
||||
def test_agent_loop_records_skill_receipts_and_effects(tmp_path: Path) -> None:
|
||||
skill = SkillContext(
|
||||
name="docker-debug",
|
||||
@ -446,7 +567,7 @@ def test_agent_loop_records_skill_receipts_and_effects(tmp_path: Path) -> None:
|
||||
skill_effects = next(event for event in events if event.event_type == "skill_effects_snapshotted")
|
||||
assert skill_effects.event_payload["run_record"]["activated_skills"][0]["skill_version"] == "v0007"
|
||||
assert skill_effects.event_payload["skill_effects"][0]["skill_name"] == "docker-debug"
|
||||
assert skill_effects.event_payload["learning_candidate_enabled"] is False
|
||||
assert skill_effects.event_payload["candidate_generation_allowed"] is False
|
||||
assert skill_effects.event_payload["learning_candidates"] == []
|
||||
|
||||
run_records = loaded.run_memory_store.list_runs()
|
||||
|
||||
@ -53,7 +53,8 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
||||
"node_id": "research",
|
||||
"skill_query": "research workflow",
|
||||
"selected_skill_names": ["research-workflow"],
|
||||
"generated_skill_draft_id": None,
|
||||
"ephemeral_guidance_id": None,
|
||||
"ephemeral_guidance_name": None,
|
||||
"ephemeral_used": False,
|
||||
"reason": "matched published skill",
|
||||
}
|
||||
@ -80,7 +81,8 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
||||
"skill_query": "research workflow",
|
||||
"selected_skill_names": ["research-workflow"],
|
||||
"ephemeral_skill_names": [],
|
||||
"generated_skill_draft_id": None,
|
||||
"ephemeral_guidance_id": None,
|
||||
"ephemeral_guidance_name": None,
|
||||
"ephemeral_used": False,
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
@ -118,5 +120,83 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
||||
sub_run = next(run for run in projection["runs"] if run["run_id"] == "sub-run")
|
||||
assert sub_run["metadata"]["selected_skill_names"] == ["research-workflow"]
|
||||
assert sub_run["metadata"]["skill_query"] == "research workflow"
|
||||
assert sub_run["metadata"]["ephemeral_guidance_id"] is None
|
||||
assert any(event["actor_name"] == "Validator" for event in projection["events"])
|
||||
assert any(run["session_id"] == "web:test" for run in projection["runs"])
|
||||
|
||||
|
||||
def test_process_projection_exposes_ephemeral_guidance_artifacts(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="sub-run",
|
||||
session_id="sub-session",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="sub task",
|
||||
started_at="2026-01-01T00:00:01+00:00",
|
||||
ended_at="2026-01-01T00:00:02+00:00",
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
)
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_execution_planned",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"plan_mode": "team",
|
||||
"strategy": "sequence",
|
||||
"node_ids": ["research"],
|
||||
"ephemeral_guidance_ids": ["eg_123"],
|
||||
"skill_resolution_report": [
|
||||
{
|
||||
"node_id": "research",
|
||||
"skill_query": "research workflow",
|
||||
"selected_skill_names": [],
|
||||
"ephemeral_guidance_id": "eg_123",
|
||||
"ephemeral_guidance_name": "research-workflow",
|
||||
"ephemeral_used": True,
|
||||
"reason": "generated ephemeral guidance",
|
||||
}
|
||||
],
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_team_run_completed",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"team_success": True,
|
||||
"team_run_ids": ["sub-run"],
|
||||
"node_results": [
|
||||
{
|
||||
"node_id": "research",
|
||||
"success": True,
|
||||
"output_text": "evidence",
|
||||
"run_id": "sub-run",
|
||||
"skill_query": "research workflow",
|
||||
"selected_skill_names": [],
|
||||
"ephemeral_skill_names": ["ephemeral:research-workflow"],
|
||||
"ephemeral_guidance_id": "eg_123",
|
||||
"ephemeral_guidance_name": "research-workflow",
|
||||
"ephemeral_used": True,
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
sub_run = next(run for run in projection["runs"] if run["run_id"] == "sub-run")
|
||||
assert sub_run["metadata"]["ephemeral_guidance_id"] == "eg_123"
|
||||
assert projection["artifacts"][0]["artifact_id"] == "sub-run:ephemeral-guidance:eg_123"
|
||||
assert projection["artifacts"][0]["metadata"]["ephemeral_guidance_name"] == "research-workflow"
|
||||
|
||||
107
app-instance/backend/tests/unit/test_session_archive.py
Normal file
107
app-instance/backend/tests/unit/test_session_archive.py
Normal file
@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.engine.session import SessionManager
|
||||
from beaver.interfaces.web.app import create_app
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
def test_archived_sessions_can_be_hidden_from_default_web_list(tmp_path: Path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
manager.ensure_session("web:keep", source="web")
|
||||
manager.ensure_session("web:archived", source="web")
|
||||
manager.end_session("web:archived", "archived")
|
||||
|
||||
visible = manager.list_sessions_rich(exclude_end_reasons=["archived"])
|
||||
visible_ids = {row["id"] for row in visible}
|
||||
|
||||
assert "web:keep" in visible_ids
|
||||
assert "web:archived" not in visible_ids
|
||||
assert manager.get_session("web:archived")["end_reason"] == "archived"
|
||||
|
||||
|
||||
def test_archived_sessions_remain_available_to_history_search(tmp_path: Path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
manager.ensure_session("web:archived", source="web")
|
||||
manager.end_session("web:archived", "archived")
|
||||
|
||||
all_sessions = manager.list_sessions_rich()
|
||||
|
||||
assert {row["id"] for row in all_sessions} == {"web:archived"}
|
||||
|
||||
|
||||
def test_visible_history_excludes_error_and_incomplete_runs(tmp_path: Path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
manager.ensure_session("web:history", source="web")
|
||||
manager.append_message("web:history", run_id="ok-run", role="user", content="hello")
|
||||
manager.append_message("web:history", run_id="ok-run", role="assistant", content="hi", finish_reason="stop")
|
||||
manager.append_message(
|
||||
"web:history",
|
||||
run_id="ok-run",
|
||||
role="assistant",
|
||||
content=None,
|
||||
tool_calls=[{"id": "call-1", "type": "function", "function": {"name": "echo", "arguments": "{}"}}],
|
||||
)
|
||||
manager.append_message(
|
||||
"web:history",
|
||||
run_id="ok-run",
|
||||
role="tool",
|
||||
content="tool result",
|
||||
tool_call_id="call-1",
|
||||
)
|
||||
manager.append_message(
|
||||
"web:history",
|
||||
run_id="ok-run",
|
||||
role="system",
|
||||
event_type="run_completed",
|
||||
content="hi",
|
||||
context_visible=False,
|
||||
)
|
||||
manager.append_message("web:history", run_id="error-run", role="user", content="bad")
|
||||
manager.append_message(
|
||||
"web:history",
|
||||
run_id="error-run",
|
||||
role="assistant",
|
||||
content="Error: provider failed",
|
||||
finish_reason="error",
|
||||
)
|
||||
manager.append_message(
|
||||
"web:history",
|
||||
run_id="error-run",
|
||||
role="system",
|
||||
event_type="run_completed",
|
||||
content="Error: provider failed",
|
||||
finish_reason="error",
|
||||
context_visible=False,
|
||||
)
|
||||
manager.append_message("web:history", run_id="pending-run", role="user", content="pending")
|
||||
|
||||
history = manager.get_visible_history("web:history")
|
||||
|
||||
assert [(message["role"], message["content"]) for message in history] == [
|
||||
("user", "hello"),
|
||||
("assistant", "hi"),
|
||||
]
|
||||
|
||||
|
||||
def test_web_archive_route_does_not_create_archive_suffix_session(tmp_path: Path) -> None:
|
||||
service = AgentService(workspace=tmp_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
create_response = client.post("/api/sessions/web:alpha")
|
||||
archive_response = client.post("/api/sessions/web:alpha/archive")
|
||||
sessions_response = client.get("/api/sessions")
|
||||
|
||||
assert create_response.status_code == 200
|
||||
assert archive_response.status_code == 200
|
||||
assert archive_response.json() == {"ok": True, "archived": True}
|
||||
assert sessions_response.status_code == 200
|
||||
|
||||
loaded = service.create_loop().boot()
|
||||
assert loaded.session_manager.get_session("web:alpha")["end_reason"] == "archived" # type: ignore[union-attr]
|
||||
assert loaded.session_manager.get_session("web:alpha/archive") is None # type: ignore[union-attr]
|
||||
assert sessions_response.json() == []
|
||||
157
app-instance/backend/tests/unit/test_skill_assembler.py
Normal file
157
app-instance/backend/tests/unit/test_skill_assembler.py
Normal file
@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
||||
from beaver.skills.assembler.task_assembler import SkillAssembler
|
||||
|
||||
|
||||
class RecordingProvider(LLMProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.thinking_enabled: bool | None = None
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
self.thinking_enabled = thinking_enabled
|
||||
return LLMResponse(content='["daily-news"]', provider_name="stub", model="stub-model")
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "stub-model"
|
||||
|
||||
|
||||
class SequencedProvider(LLMProvider):
|
||||
def __init__(self, responses: list[str]) -> None:
|
||||
super().__init__()
|
||||
self.responses = list(responses)
|
||||
self.messages: list[list[dict]] = []
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
self.messages.append(messages)
|
||||
content = self.responses.pop(0)
|
||||
return LLMResponse(content=content, provider_name="stub", model="stub-model")
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "stub-model"
|
||||
|
||||
|
||||
class StaticRetriever:
|
||||
async def retrieve(self, **kwargs):
|
||||
return kwargs["candidates"][: kwargs["top_k"]]
|
||||
|
||||
|
||||
class LoaderWithFullSkill:
|
||||
def build_selection_candidates(self) -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"name": "docker-debug",
|
||||
"description": "General container tips.",
|
||||
"version": "v1",
|
||||
"content_hash": "abc",
|
||||
}
|
||||
]
|
||||
|
||||
def load_published_skill(self, name: str) -> str | None:
|
||||
if name != "docker-debug":
|
||||
return None
|
||||
return """---
|
||||
description: General container tips.
|
||||
tools:
|
||||
- search_files
|
||||
---
|
||||
|
||||
# Docker Debug
|
||||
|
||||
Use this skill when doing Docker log triage and container failure analysis.
|
||||
"""
|
||||
|
||||
def get_skill_record(self, name: str):
|
||||
return SimpleNamespace(version="v1", content_hash="abc", tool_hints=["search_files"])
|
||||
|
||||
|
||||
def test_skill_selection_receives_thinking_mode() -> None:
|
||||
provider = RecordingProvider()
|
||||
assembler = SkillAssembler(loader=SimpleNamespace())
|
||||
|
||||
selected = asyncio.run(
|
||||
assembler._select_skill_names(
|
||||
task_description="summarize daily news",
|
||||
candidates=[{"name": "daily-news", "description": "Summarize news"}],
|
||||
provider=provider,
|
||||
model="Qwen3.6-35B",
|
||||
thinking_enabled=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert selected == ["daily-news"]
|
||||
assert provider.thinking_enabled is False
|
||||
|
||||
|
||||
def test_skill_assembler_loads_detail_directly_for_small_candidate_sets() -> None:
|
||||
provider = SequencedProvider(['["docker-debug"]'])
|
||||
assembler = SkillAssembler(loader=LoaderWithFullSkill(), retriever=StaticRetriever())
|
||||
|
||||
result = asyncio.run(
|
||||
assembler.assemble(
|
||||
task_description="debug a failing Docker container",
|
||||
provider=provider,
|
||||
model="stub-model",
|
||||
)
|
||||
)
|
||||
|
||||
assert [skill.name for skill in result.activated_skills] == ["docker-debug"]
|
||||
assert result.activated_skills[0].tool_hints == ["search_files"]
|
||||
assert [item["stage"] for item in result.llm_interactions] == ["final"]
|
||||
assert len(provider.messages) == 1
|
||||
first_user_prompt = provider.messages[0][1]["content"]
|
||||
assert "Use this skill when doing Docker log triage" in first_user_prompt
|
||||
|
||||
|
||||
def test_skill_assembler_shortlists_before_loading_detail_for_large_candidate_sets() -> None:
|
||||
provider = SequencedProvider(['["docker-debug"]', '["docker-debug"]'])
|
||||
loader = LoaderWithFullSkill()
|
||||
original_candidates = loader.build_selection_candidates
|
||||
loader.build_selection_candidates = lambda: [
|
||||
*original_candidates(),
|
||||
{
|
||||
"name": "other-skill",
|
||||
"description": "Other workflow.",
|
||||
"version": "v1",
|
||||
"content_hash": "def",
|
||||
},
|
||||
]
|
||||
assembler = SkillAssembler(
|
||||
loader=loader,
|
||||
retriever=StaticRetriever(),
|
||||
max_detailed_candidates=1,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
assembler.assemble(
|
||||
task_description="debug a failing Docker container",
|
||||
provider=provider,
|
||||
model="stub-model",
|
||||
)
|
||||
)
|
||||
|
||||
assert [skill.name for skill in result.activated_skills] == ["docker-debug"]
|
||||
assert [item["stage"] for item in result.llm_interactions] == ["shortlist", "final"]
|
||||
assert len(provider.messages) == 2
|
||||
assert "Use this skill when doing Docker log triage" not in provider.messages[0][1]["content"]
|
||||
assert "Use this skill when doing Docker log triage" in provider.messages[1][1]["content"]
|
||||
@ -90,6 +90,7 @@ def test_eval_pass_allows_publish_after_safety_and_review(tmp_path: Path) -> Non
|
||||
|
||||
report = asyncio.run(pipeline.evaluate_draft("candidate-1", draft.skill_name, draft.draft_id, provider_bundle=_bundle()))
|
||||
safety = pipeline.check_safety(draft.skill_name, draft.draft_id)
|
||||
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||
published = pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
|
||||
|
||||
@ -111,6 +112,7 @@ def test_eval_regression_blocks_publish(tmp_path: Path) -> None:
|
||||
|
||||
report = asyncio.run(pipeline.evaluate_draft("candidate-1", draft.skill_name, draft.draft_id, provider_bundle=_bundle()))
|
||||
pipeline.check_safety(draft.skill_name, draft.draft_id)
|
||||
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||
|
||||
assert report.passed is False
|
||||
|
||||
@ -68,6 +68,39 @@ def test_pipeline_lists_candidates_and_moves_draft_through_review(tmp_path: Path
|
||||
assert pipeline.get_draft(draft.skill_name, draft.draft_id).status == SkillReviewState.PUBLISHED.value
|
||||
|
||||
|
||||
def test_pipeline_approve_requires_submitted_review(tmp_path: Path) -> None:
|
||||
pipeline = _pipeline(tmp_path)
|
||||
draft = pipeline.draft_service.create_new_skill_draft(
|
||||
skill_name="needs-review",
|
||||
proposed_content="# Needs Review\n\nDo the thing.",
|
||||
proposed_frontmatter={"description": "needs review"},
|
||||
created_by="test",
|
||||
reason="test",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="in review before approval"):
|
||||
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||
|
||||
|
||||
def test_pipeline_does_not_resubmit_terminal_draft(tmp_path: Path) -> None:
|
||||
pipeline = _pipeline(tmp_path)
|
||||
draft = pipeline.draft_service.create_new_skill_draft(
|
||||
skill_name="already-published",
|
||||
proposed_content="# Already Published\n\nDo the thing.",
|
||||
proposed_frontmatter={"description": "already published"},
|
||||
created_by="test",
|
||||
reason="test",
|
||||
)
|
||||
|
||||
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||
pipeline.check_safety(draft.skill_name, draft.draft_id)
|
||||
pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
|
||||
|
||||
with pytest.raises(ValueError, match="draft status before review submission"):
|
||||
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||
|
||||
|
||||
def test_pipeline_reject_blocks_publish(tmp_path: Path) -> None:
|
||||
pipeline = _pipeline(tmp_path)
|
||||
draft = pipeline.draft_service.create_new_skill_draft(
|
||||
@ -80,5 +113,22 @@ def test_pipeline_reject_blocks_publish(tmp_path: Path) -> None:
|
||||
|
||||
pipeline.reject(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||
|
||||
with pytest.raises(ValueError, match="approved"):
|
||||
with pytest.raises(ValueError, match="Draft not found"):
|
||||
pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
|
||||
assert pipeline.draft_service.get_draft(draft.skill_name, draft.draft_id) is None
|
||||
|
||||
|
||||
def test_pipeline_reject_removes_draft_from_review_list(tmp_path: Path) -> None:
|
||||
pipeline = _pipeline(tmp_path)
|
||||
draft = pipeline.draft_service.create_new_skill_draft(
|
||||
skill_name="remove-skill",
|
||||
proposed_content="# Remove\n\nNo longer needed.",
|
||||
proposed_frontmatter={"description": "remove"},
|
||||
created_by="test",
|
||||
reason="test",
|
||||
)
|
||||
|
||||
review = pipeline.reject(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||
|
||||
assert review.status == SkillReviewState.REJECTED.value
|
||||
assert pipeline.list_drafts() == []
|
||||
|
||||
@ -65,6 +65,7 @@ def test_safety_marks_dangerous_tools_high_and_requires_confirm(tmp_path: Path)
|
||||
)
|
||||
|
||||
report = pipeline.check_safety(draft.skill_name, draft.draft_id)
|
||||
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||
|
||||
assert report.passed is True
|
||||
@ -84,6 +85,7 @@ def test_publish_requires_safety_report(tmp_path: Path) -> None:
|
||||
created_by="test",
|
||||
reason="test",
|
||||
)
|
||||
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||
|
||||
with pytest.raises(ValueError, match="safety report"):
|
||||
|
||||
@ -12,6 +12,7 @@ from beaver.engine.context.builder import ContextBuilder, ContextBuildInput
|
||||
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
||||
from beaver.engine.providers.factory import ProviderBundle
|
||||
from beaver.services.agent_service import AgentService
|
||||
from beaver.skills.assembler import SkillAssemblyResult
|
||||
from beaver.tasks import TaskExecutionPlan, TaskService, ValidationResult, ValidationService
|
||||
|
||||
|
||||
@ -67,7 +68,25 @@ class FakeLearningCandidate:
|
||||
return {"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
|
||||
|
||||
|
||||
def _bundle(*responses: str) -> ProviderBundle:
|
||||
class RecordingSkillAssembler:
|
||||
def __init__(self) -> None:
|
||||
self.task_descriptions: list[str] = []
|
||||
|
||||
async def assemble(self, **kwargs) -> SkillAssemblyResult:
|
||||
self.task_descriptions.append(kwargs["task_description"])
|
||||
return SkillAssemblyResult()
|
||||
|
||||
|
||||
def _route_response(action: str = "new_task", short_title: str = "Test task") -> LLMResponse:
|
||||
return LLMResponse(
|
||||
content=f'{{"action":"{action}","reason":"test route","short_title":"{short_title}"}}',
|
||||
finish_reason="stop",
|
||||
provider_name="stub",
|
||||
model="stub-model",
|
||||
)
|
||||
|
||||
|
||||
def _bundle(*responses: str, route_action: str = "new_task") -> ProviderBundle:
|
||||
return ProviderBundle(
|
||||
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
main_provider=StubProvider(
|
||||
@ -81,6 +100,8 @@ def _bundle(*responses: str) -> ProviderBundle:
|
||||
for response in responses
|
||||
]
|
||||
),
|
||||
auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
auxiliary_provider=StubProvider([_route_response(route_action)]),
|
||||
)
|
||||
|
||||
|
||||
@ -110,6 +131,25 @@ def _provider_bundle(provider: StubProvider) -> ProviderBundle:
|
||||
return ProviderBundle(
|
||||
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
main_provider=provider,
|
||||
auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
auxiliary_provider=StubProvider([_route_response("new_task")]),
|
||||
)
|
||||
|
||||
|
||||
def _main_only_bundle(*responses: str) -> ProviderBundle:
|
||||
return ProviderBundle(
|
||||
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
main_provider=StubProvider(
|
||||
[
|
||||
LLMResponse(
|
||||
content=response,
|
||||
finish_reason="stop",
|
||||
provider_name="stub",
|
||||
model="stub-model",
|
||||
)
|
||||
for response in responses
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -126,7 +166,7 @@ def test_simple_question_does_not_create_task(tmp_path: Path) -> None:
|
||||
service.process_direct(
|
||||
"hello?",
|
||||
session_id="web:simple",
|
||||
provider_bundle=_bundle("hi"),
|
||||
provider_bundle=_bundle("hi", route_action="simple_chat"),
|
||||
)
|
||||
)
|
||||
loaded = service.create_loop().boot()
|
||||
@ -165,8 +205,89 @@ def test_complex_request_creates_task_and_records_validation(tmp_path: Path) ->
|
||||
assert any(event.event_type == "task_validation_snapshotted" for event in events)
|
||||
assert run_record.task_id == result.task_id
|
||||
assert run_record.validation_result["accepted"] is True
|
||||
assert skill_effects.event_payload["learning_candidate_enabled"] is False
|
||||
assert skill_effects.event_payload["candidate_generation_allowed"] is False
|
||||
assert skill_effects.event_payload["learning_candidates"] == []
|
||||
assert task.metadata["short_title"] == "Test task"
|
||||
|
||||
|
||||
def test_task_mode_uses_task_aware_skill_selection_context(tmp_path: Path) -> None:
|
||||
skill_assembler = RecordingSkillAssembler()
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=_single_planner(),
|
||||
validation_service=StubValidationService(
|
||||
[ValidationResult(passed=True, score=1.0, validator="test")]
|
||||
),
|
||||
skill_assembler=skill_assembler,
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"继续按刚才的方案改",
|
||||
session_id="web:task-skill-query",
|
||||
provider_bundle=_bundle("done", route_action="new_task"),
|
||||
)
|
||||
)
|
||||
|
||||
assert result.task_id
|
||||
assert skill_assembler.task_descriptions
|
||||
query = skill_assembler.task_descriptions[0]
|
||||
assert "Task goal:" in query
|
||||
assert "Current user request:" in query
|
||||
assert "Previously activated skills:" in query
|
||||
assert "If no published skill matches, return []" in query
|
||||
|
||||
|
||||
def test_active_task_continues_until_llm_closes_it(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=_single_planner(),
|
||||
validation_service=StubValidationService(
|
||||
[
|
||||
ValidationResult(passed=True, score=0.9, validator="test"),
|
||||
ValidationResult(passed=True, score=0.9, validator="test"),
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"implement the search workflow",
|
||||
session_id="web:continue",
|
||||
provider_bundle=_bundle("first done", route_action="new_task"),
|
||||
)
|
||||
)
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"also add tests for it",
|
||||
session_id="web:continue",
|
||||
provider_bundle=_bundle("tests added", route_action="continue_task"),
|
||||
)
|
||||
)
|
||||
loaded = service.create_loop().boot()
|
||||
task = loaded.task_service.get_task(first.task_id)
|
||||
|
||||
assert task is not None
|
||||
assert second.task_id == first.task_id
|
||||
assert len(task.run_ids) == 2
|
||||
|
||||
closed = asyncio.run(
|
||||
service.process_direct(
|
||||
"这个任务结束了",
|
||||
session_id="web:continue",
|
||||
provider_bundle=_bundle("好的,已结束。", route_action="close_task"),
|
||||
)
|
||||
)
|
||||
task = loaded.task_service.get_task(first.task_id)
|
||||
|
||||
assert closed.task_id is None
|
||||
assert task is not None
|
||||
assert task.status == "closed"
|
||||
assert loaded.task_service.active_task_view("web:continue") is None
|
||||
|
||||
|
||||
def test_validation_failure_retries_once(tmp_path: Path) -> None:
|
||||
@ -229,11 +350,11 @@ def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None:
|
||||
loaded = service.create_loop().boot()
|
||||
learning_calls = []
|
||||
|
||||
def build_learning_candidates() -> list[FakeLearningCandidate]:
|
||||
learning_calls.append("called")
|
||||
def build_learning_candidates_for_task(task_id: str, *, trigger_run_id: str) -> list[FakeLearningCandidate]:
|
||||
learning_calls.append((task_id, trigger_run_id))
|
||||
return [FakeLearningCandidate()]
|
||||
|
||||
loaded.skill_learning_service.build_learning_candidates = build_learning_candidates
|
||||
loaded.skill_learning_service.build_learning_candidates_for_task = build_learning_candidates_for_task
|
||||
|
||||
feedback = asyncio.run(
|
||||
service.submit_feedback(
|
||||
@ -247,7 +368,7 @@ def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None:
|
||||
assert feedback["learning_candidates"] == [
|
||||
{"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
|
||||
]
|
||||
assert learning_calls == ["called"]
|
||||
assert learning_calls == [(result.task_id, result.run_id)]
|
||||
|
||||
service2 = AgentService(
|
||||
loader=EngineLoader(
|
||||
@ -279,6 +400,14 @@ def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None:
|
||||
|
||||
assert abandon_feedback["task_status"] == "abandoned"
|
||||
assert abandon_feedback["learning_candidates"] == []
|
||||
loaded2 = service2.create_loop().boot()
|
||||
failure_events = [
|
||||
event
|
||||
for event in loaded2.session_manager.get_run_event_records(abandoned.session_id, abandoned.run_id)
|
||||
if event.event_type == "task_failure_evidence_recorded"
|
||||
]
|
||||
assert len(failure_events) == 1
|
||||
assert loaded2.memory_service.get_store().memory_entries == []
|
||||
|
||||
|
||||
def test_feedback_is_idempotent_and_projected_to_assistant_message(tmp_path: Path) -> None:
|
||||
@ -466,7 +595,7 @@ def test_task_mode_team_retry_hides_first_synthesis_run(tmp_path: Path) -> None:
|
||||
events = loaded.session_manager.get_run_event_records(record.session_id, run_id)
|
||||
skill_effects = [event for event in events if event.event_type == "skill_effects_snapshotted"]
|
||||
assert skill_effects
|
||||
assert skill_effects[-1].event_payload["learning_candidate_enabled"] is False
|
||||
assert skill_effects[-1].event_payload["candidate_generation_allowed"] is False
|
||||
|
||||
|
||||
def test_context_builder_strips_ui_projection_fields_from_provider_history() -> None:
|
||||
@ -490,17 +619,43 @@ def test_context_builder_strips_ui_projection_fields_from_provider_history() ->
|
||||
assert assistant == {"role": "assistant", "content": "done"}
|
||||
|
||||
|
||||
def test_context_builder_normalizes_persisted_tool_arguments() -> None:
|
||||
result = ContextBuilder().build_messages(
|
||||
ContextBuildInput(
|
||||
history=[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "cron",
|
||||
"arguments": {"action": "add", "mode": "notification"},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
tool_call = result.messages[-1]["tool_calls"][0]
|
||||
assert tool_call["function"]["arguments"] == '{"action": "add", "mode": "notification"}'
|
||||
|
||||
|
||||
def test_llm_validator_parse_failure_is_not_accepted(tmp_path: Path) -> None:
|
||||
task_service = TaskService(tmp_path / "tasks")
|
||||
task = task_service.create_task(session_id="web:validator", description="implement validator handling")
|
||||
validation = asyncio.run(
|
||||
ValidationService().validate_task_result(
|
||||
task=task,
|
||||
user_message="implement validator handling",
|
||||
final_output="done",
|
||||
provider_bundle=_bundle("not json"),
|
||||
task=task,
|
||||
user_message="implement validator handling",
|
||||
final_output="done",
|
||||
provider_bundle=_main_only_bundle("not json"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert validation.accepted is False
|
||||
assert validation.validator == "llm_error"
|
||||
|
||||
@ -9,7 +9,7 @@ from beaver.engine.context import SkillContext
|
||||
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
||||
from beaver.engine.providers.factory import ProviderBundle
|
||||
from beaver.skills.drafts import DraftService
|
||||
from beaver.skills.learning import MissingSkillSynthesizer
|
||||
from beaver.skills.learning import EphemeralGuidanceSynthesizer
|
||||
from beaver.skills.publisher import SkillPublisher
|
||||
from beaver.skills.reviews import ReviewService
|
||||
from beaver.skills.specs import SkillSpecStore
|
||||
@ -116,12 +116,12 @@ def test_task_skill_resolver_pins_matching_published_skill(tmp_path: Path) -> No
|
||||
assert reports[0].ephemeral_used is False
|
||||
|
||||
|
||||
def test_task_skill_resolver_generates_draft_only_ephemeral_skill_when_missing(tmp_path: Path) -> None:
|
||||
def test_task_skill_resolver_generates_ephemeral_guidance_when_missing(tmp_path: Path) -> None:
|
||||
provider = RecordingProvider(
|
||||
[
|
||||
"""
|
||||
{
|
||||
"skill_name": "api-compatibility-review",
|
||||
"guidance_name": "api-compatibility-review",
|
||||
"description": "Review API compatibility",
|
||||
"content": "# API Compatibility Review\\n\\nCheck schema compatibility.",
|
||||
"tags": ["api", "review"]
|
||||
@ -133,7 +133,7 @@ def test_task_skill_resolver_generates_draft_only_ephemeral_skill_when_missing(t
|
||||
resolver = TaskSkillResolver(
|
||||
skills_loader=SkillsLoader(tmp_path),
|
||||
draft_service=DraftService(store),
|
||||
missing_skill_synthesizer=MissingSkillSynthesizer(),
|
||||
missing_skill_synthesizer=EphemeralGuidanceSynthesizer(),
|
||||
)
|
||||
graph = ExecutionGraph(
|
||||
strategy="sequence",
|
||||
@ -163,13 +163,14 @@ def test_task_skill_resolver_generates_draft_only_ephemeral_skill_when_missing(t
|
||||
)
|
||||
|
||||
drafts = store.list_drafts("api-compatibility-review")
|
||||
assert len(drafts) == 1
|
||||
assert drafts == []
|
||||
assert store.list_published_skill_names() == []
|
||||
assert resolved.nodes[0].inherited_pinned_skills == []
|
||||
assert len(resolved.nodes[0].inherited_pinned_skill_contexts) == 1
|
||||
context: SkillContext = resolved.nodes[0].inherited_pinned_skill_contexts[0]
|
||||
assert context.name == "draft:api-compatibility-review"
|
||||
assert context.version == f"draft:{drafts[0].draft_id}"
|
||||
assert context.activation_reason == "generated_missing_skill"
|
||||
assert reports[0].generated_skill_draft_id == drafts[0].draft_id
|
||||
assert context.name == "ephemeral:api-compatibility-review"
|
||||
assert context.version.startswith("ephemeral:eg_")
|
||||
assert context.activation_reason == "ephemeral_guidance"
|
||||
assert reports[0].ephemeral_guidance_id is not None
|
||||
assert reports[0].ephemeral_guidance_name == "api-compatibility-review"
|
||||
assert reports[0].ephemeral_used is True
|
||||
|
||||
@ -83,7 +83,6 @@ tools:
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(DummyTool("memory", toolset="memory", always_available=True))
|
||||
registry.register(DummyTool("skill_view", toolset="skills", always_available=True))
|
||||
registry.register(DummyTool("terminal", toolset="shell"))
|
||||
registry.register(DummyTool("search_files", toolset="file"))
|
||||
registry.register(DummyTool("echo", toolset="debug"))
|
||||
@ -100,7 +99,7 @@ tools:
|
||||
)
|
||||
)
|
||||
|
||||
assert [spec.name for spec in selected] == ["memory", "skill_view", "terminal", "search_files"]
|
||||
assert [spec.name for spec in selected] == ["memory", "terminal", "search_files"]
|
||||
|
||||
|
||||
def test_embedding_fallback_can_return_all_or_top_k() -> None:
|
||||
|
||||
132
app-instance/backend/tests/unit/test_websocket_chat.py
Normal file
132
app-instance/backend/tests/unit/test_websocket_chat.py
Normal file
@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.interfaces.web.app import create_app
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StubRunResult:
|
||||
session_id: str
|
||||
run_id: str = "run-1"
|
||||
output_text: str = "ok"
|
||||
finish_reason: str = "stop"
|
||||
tool_iterations: int = 0
|
||||
provider_name: str | None = "stub"
|
||||
model: str | None = "stub-model"
|
||||
usage: dict[str, Any] = field(default_factory=lambda: {"total_tokens": 3})
|
||||
task_id: str | None = "task-1"
|
||||
task_status: str | None = "awaiting_feedback"
|
||||
validation_result: dict[str, Any] | None = field(default_factory=lambda: {"accepted": True})
|
||||
|
||||
|
||||
class StubAgentService(AgentService):
|
||||
def __init__(self, *, fail: bool = False) -> None:
|
||||
super().__init__()
|
||||
self.fail = fail
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
async def submit_direct(self, message: str, **kwargs: Any) -> StubRunResult: # type: ignore[override]
|
||||
self.calls.append({"message": message, **kwargs})
|
||||
if self.fail:
|
||||
raise RuntimeError("boom")
|
||||
return StubRunResult(
|
||||
session_id=kwargs.get("session_id") or "web:default",
|
||||
output_text=f"echo:{message}",
|
||||
)
|
||||
|
||||
|
||||
def test_websocket_ping_pong() -> None:
|
||||
app = create_app(service=StubAgentService(), manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/ws/web:alpha") as websocket:
|
||||
websocket.send_json({"type": "ping"})
|
||||
assert websocket.receive_json() == {"type": "pong"}
|
||||
|
||||
|
||||
def test_websocket_message_returns_chat_metadata_and_session_updated() -> None:
|
||||
service = StubAgentService()
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/ws/web:alpha") as websocket:
|
||||
websocket.send_json(
|
||||
{
|
||||
"type": "message",
|
||||
"content": "hello",
|
||||
"metadata": {"source": "test"},
|
||||
"attachments": [{"file_id": "file-1", "name": "a.txt"}],
|
||||
}
|
||||
)
|
||||
assert websocket.receive_json() == {"type": "status", "status": "thinking"}
|
||||
message = websocket.receive_json()
|
||||
session_updated = websocket.receive_json()
|
||||
|
||||
assert service.calls == [
|
||||
{
|
||||
"message": "hello",
|
||||
"session_id": "web:alpha",
|
||||
"source": "websocket",
|
||||
"user_id": None,
|
||||
"title": None,
|
||||
"execution_context": None,
|
||||
"model": None,
|
||||
"provider_name": None,
|
||||
"embedding_model": None,
|
||||
}
|
||||
]
|
||||
assert message["type"] == "message"
|
||||
assert message["role"] == "assistant"
|
||||
assert message["content"] == "echo:hello"
|
||||
assert message["session_id"] == "web:alpha"
|
||||
assert message["run_id"] == "run-1"
|
||||
assert message["task_id"] == "task-1"
|
||||
assert message["task_status"] == "awaiting_feedback"
|
||||
assert message["validation_result"] == {"accepted": True}
|
||||
assert message["validation_status"] == "passed"
|
||||
assert message["metadata"]["input_metadata"] == {
|
||||
"source": "test",
|
||||
"attachments": [{"file_id": "file-1", "name": "a.txt"}],
|
||||
}
|
||||
assert session_updated == {
|
||||
"type": "session_updated",
|
||||
"session_id": "web:alpha",
|
||||
"source": "websocket",
|
||||
}
|
||||
|
||||
|
||||
def test_websocket_empty_content_returns_error_without_runtime_call() -> None:
|
||||
service = StubAgentService()
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/ws/web:alpha") as websocket:
|
||||
websocket.send_json({"type": "message", "content": " "})
|
||||
assert websocket.receive_json() == {"type": "error", "error": "'content' is required"}
|
||||
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_websocket_runtime_error_returns_assistant_error_message() -> None:
|
||||
service = StubAgentService(fail=True)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/ws/web:alpha") as websocket:
|
||||
websocket.send_json({"type": "message", "content": "hello"})
|
||||
assert websocket.receive_json() == {"type": "status", "status": "thinking"}
|
||||
message = websocket.receive_json()
|
||||
websocket.send_json({"type": "ping"})
|
||||
pong = websocket.receive_json()
|
||||
|
||||
assert message["type"] == "message"
|
||||
assert message["role"] == "assistant"
|
||||
assert message["session_id"] == "web:alpha"
|
||||
assert message["finish_reason"] == "error"
|
||||
assert "boom" in message["content"]
|
||||
assert pong == {"type": "pong"}
|
||||
37
app-instance/backend/uv.lock
generated
37
app-instance/backend/uv.lock
generated
@ -238,6 +238,7 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "anthropic" },
|
||||
{ name = "croniter" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastmcp" },
|
||||
{ name = "httpx" },
|
||||
@ -245,6 +246,7 @@ dependencies = [
|
||||
{ name = "litellm" },
|
||||
{ name = "openai" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
@ -257,6 +259,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "anthropic", specifier = ">=0.51.0,<1.0.0" },
|
||||
{ name = "croniter", specifier = ">=6.0.0,<7.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
|
||||
{ name = "fastmcp", specifier = ">=3.0.0,<4.0.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" },
|
||||
@ -265,6 +268,7 @@ requires-dist = [
|
||||
{ name = "openai", specifier = ">=1.79.0,<2.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20,<1.0.0" },
|
||||
{ name = "typer", specifier = ">=0.20.0,<1.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" },
|
||||
]
|
||||
@ -493,6 +497,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "croniter"
|
||||
version = "6.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "48.0.0"
|
||||
@ -1927,6 +1943,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
@ -2317,6 +2345,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
- `task_id`
|
||||
- `task_mode`
|
||||
- `attempt_index`
|
||||
- `learning_candidate_enabled`
|
||||
- `allow_candidate_generation`
|
||||
4. `RunRecord` 已记录:
|
||||
- `task_id`
|
||||
- `attempt_index`
|
||||
@ -55,7 +55,7 @@
|
||||
8. 学习触发已经收紧。
|
||||
- Task 模式 run 不再直接生成成功学习候选
|
||||
- 只有“自动验证通过 + 用户点击满意”才触发成功学习候选
|
||||
- “放弃”写 Failure Memory,不生成成功 Skill draft
|
||||
- “放弃”只写失败证据,不默认写主 memory,不生成成功 Skill draft
|
||||
9. Agent Team v1 已落地为 Beaver 自有轻量 coordinator。
|
||||
- 新增 `AgentDescriptor / DelegationEnvelope / ExecutionNode / ExecutionGraph / TeamRunResult`
|
||||
- 新增 `TeamService.run_team(...)` 作为内部服务入口
|
||||
@ -72,7 +72,7 @@
|
||||
- `TaskExecutionPlanner` 使用 LLM JSON 规划 `single / team`
|
||||
- team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设
|
||||
- 新增 `beaver/tasks/skill_resolver.py`
|
||||
- `TaskSkillResolver` 为 generic sub-agent 选择 published skill;未命中时生成 draft-only skill,并作为本次 run 的 ephemeral pinned instruction 使用
|
||||
- `TaskSkillResolver` 为 generic sub-agent 选择 published skill;未命中时生成 ephemeral guidance,并作为本次 run 的 pinned guidance 使用
|
||||
- 只允许 v1 已实现的 `sequence / parallel / dag`
|
||||
- planner 失败或 graph 非法时降级为 `single`
|
||||
- team run 先作为 sub-agent 内部执行,输出注入主 Agent synthesis run
|
||||
@ -1407,7 +1407,7 @@ Hermes 官方公开说明里,明确把这些能力作为它的核心区别:
|
||||
│ ├─ provider/chat/tool loop
|
||||
│ ├─ sessions.append_message(event_type="run_completed" 或 "run_failed", hidden)
|
||||
│ │
|
||||
│ └─ AgentLoop._record_skill_learning(...)
|
||||
│ └─ AgentLoop._record_run_receipts(...)
|
||||
│ ├─ 构造 `RunRecord`
|
||||
│ ├─ 构造 `SkillEffectRecord[]`
|
||||
│ ├─ 默认只记录 receipts/effects,不生成学习候选
|
||||
@ -1750,7 +1750,10 @@ app-instance 镜像也已经切到新 Beaver 后端:
|
||||
- 当前 channel 职责很窄:
|
||||
- 把外部输入发布成 `InboundMessage`
|
||||
- 接收并投递 `OutboundMessage`
|
||||
- old-style 平台字段(如 `chat_id/message_id/thread_id/raw_channel_payload`)只能在 adapter 层映射和保留
|
||||
- adapter 负责生成稳定 `session_id`,例如 `telegram:{chat_id}` / `slack:{channel_id}:{thread_ts}`
|
||||
- `MemoryChannelAdapter` 只用于本地测试和内嵌接入,不是正式消息 broker
|
||||
- WebSocket 是 Web 入口适配层,不是 Gateway channel;真实多渠道仍统一走 `ChannelAdapter -> MessageBus -> AgentService.handle_inbound_message(...)`
|
||||
|
||||
所以现在已经明确:
|
||||
|
||||
@ -2191,13 +2194,13 @@ app-instance 镜像也已经切到新 Beaver 后端:
|
||||
1. planner team JSON 支持 `skill_query / required_capabilities`,不要求 agent role。
|
||||
2. `TaskSkillResolver` 命中 published skill 时,写入 `ExecutionNode.inherited_pinned_skills`。
|
||||
3. sub-agent run 的 published pinned skill receipt 记录 `activation_reason=pinned_delegation`。
|
||||
4. 未命中 skill 时创建 draft-only skill,并写入 `ExecutionNode.inherited_pinned_skill_contexts`。
|
||||
5. draft-only skill receipt 记录 `activation_reason=generated_missing_skill`。
|
||||
6. missing skill draft 不自动 approve/publish,不进入 runtime skill catalog。
|
||||
7. plan event 写入 `skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report`。
|
||||
4. 未命中 skill 时创建 ephemeral guidance,并写入 `ExecutionNode.inherited_pinned_skill_contexts`。
|
||||
5. ephemeral guidance receipt 记录 `activation_reason=ephemeral_guidance`。
|
||||
6. ephemeral guidance 不写入 draft store,不自动 approve/publish,不进入 runtime skill catalog。
|
||||
7. plan event 写入 `skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report`。
|
||||
8. `/api/sessions/{session_id}/process` 能把隐藏 Task/team/validation 事件投影成 `processRuns / processEvents`。
|
||||
9. ChatWorkbench 桌面端有 `ProcessLane`,移动端有 `Process` tab。
|
||||
10. process view 展示 selected skills、generated draft id、ephemeral skill used,不展示 specialist agent selection。
|
||||
10. process view 展示 selected skills、ephemeral guidance id、ephemeral skill used,不展示 specialist agent selection。
|
||||
11. team 部分失败时,process view 显示失败节点,但最终回答仍来自主 Agent。
|
||||
12. `SkillLearningPipelineService` 能串起 candidate -> draft -> safety/eval -> review -> approve/reject -> publish。
|
||||
13. rejected draft 不能 publish。
|
||||
|
||||
@ -1,419 +1,5 @@
|
||||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Clock,
|
||||
Plus,
|
||||
Trash2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
listCronJobs,
|
||||
addCronJob,
|
||||
removeCronJob,
|
||||
toggleCronJob,
|
||||
runCronJob,
|
||||
} from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { CronJob } from '@/types';
|
||||
|
||||
export default function CronPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const sessionId = useChatStore((s) => s.sessionId);
|
||||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const targetSessionKey = sessionId.startsWith('web:') ? sessionId : 'web:default';
|
||||
|
||||
const loadJobs = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listCronJobs(true);
|
||||
setJobs(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '加载任务失败', 'Failed to load jobs'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
}, []);
|
||||
|
||||
const handleToggle = async (jobId: string, enabled: boolean) => {
|
||||
try {
|
||||
await toggleCronJob(jobId, enabled);
|
||||
loadJobs();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (jobId: string) => {
|
||||
try {
|
||||
await removeCronJob(jobId);
|
||||
loadJobs();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleRun = async (jobId: string) => {
|
||||
try {
|
||||
await runCronJob(jobId);
|
||||
loadJobs();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (params: {
|
||||
name: string;
|
||||
message: string;
|
||||
every_seconds?: number;
|
||||
cron_expr?: string;
|
||||
}) => {
|
||||
try {
|
||||
await addCronJob({
|
||||
...params,
|
||||
session_key: targetSessionKey,
|
||||
});
|
||||
setShowAdd(false);
|
||||
loadJobs();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (ms: number | null) => {
|
||||
if (!ms) return '-';
|
||||
return new Date(ms).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||
<TaskManagementTabs />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Clock className="w-6 h-6" />
|
||||
{pickAppText(locale, '定时任务', 'Scheduled tasks')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={loadJobs} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowAdd(true)} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '新建任务', 'New job')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-destructive text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Add Job Form */}
|
||||
{showAdd && (
|
||||
<AddJobForm
|
||||
targetSessionKey={targetSessionKey}
|
||||
onAdd={handleAdd}
|
||||
onCancel={() => setShowAdd(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Jobs Table */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{jobs.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
<Clock className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">{pickAppText(locale, '暂无定时任务', 'No scheduled tasks yet')}</p>
|
||||
<p className="text-sm mt-1">{pickAppText(locale, '新建一个任务,让智能体按计划自动执行。', 'Create a job to let the agent run on a schedule.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">{pickAppText(locale, '启用', 'Enabled')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '计划', 'Schedule')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '消息', 'Message')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '上次运行', 'Last run')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '下次运行', 'Next run')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
|
||||
<TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={job.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(job.id, checked)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<div>
|
||||
<span>{job.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{job.id}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{job.schedule_display}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm truncate max-w-[200px] block">
|
||||
{job.message}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatTime(job.last_run_at_ms)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatTime(job.next_run_at_ms)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{job.last_status === 'ok' && (
|
||||
<Badge variant="default" className="text-xs bg-green-600">
|
||||
OK
|
||||
</Badge>
|
||||
)}
|
||||
{job.last_status === 'error' && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{pickAppText(locale, '错误', 'Error')}
|
||||
</Badge>
|
||||
)}
|
||||
{!job.last_status && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleRun(job.id)}
|
||||
title={pickAppText(locale, '立即执行', 'Run now')}
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(job.id)}
|
||||
title={pickAppText(locale, '删除', 'Delete')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddJobForm({
|
||||
targetSessionKey,
|
||||
onAdd,
|
||||
onCancel,
|
||||
}: {
|
||||
targetSessionKey: string;
|
||||
onAdd: (params: {
|
||||
name: string;
|
||||
message: string;
|
||||
every_seconds?: number;
|
||||
cron_expr?: string;
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [scheduleType, setScheduleType] = useState<'every' | 'cron'>('every');
|
||||
const [everySeconds, setEverySeconds] = useState('3600');
|
||||
const [cronExpr, setCronExpr] = useState('0 9 * * *');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !message.trim()) return;
|
||||
|
||||
const params: any = { name: name.trim(), message: message.trim() };
|
||||
if (scheduleType === 'every') {
|
||||
params.every_seconds = parseInt(everySeconds, 10) || 3600;
|
||||
} else {
|
||||
params.cron_expr = cronExpr.trim();
|
||||
}
|
||||
onAdd(params);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">{pickAppText(locale, '任务名称', 'Job name')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={pickAppText(locale, '例如:日报汇总', 'Example: daily summary')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="schedule-type">{pickAppText(locale, '调度类型', 'Schedule type')}</Label>
|
||||
<Select
|
||||
value={scheduleType}
|
||||
onValueChange={(v) => setScheduleType(v as 'every' | 'cron')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="every">{pickAppText(locale, '固定间隔(每 N 秒)', 'Fixed interval (every N seconds)')}</SelectItem>
|
||||
<SelectItem value="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scheduleType === 'every' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="every">{pickAppText(locale, '间隔(秒)', 'Interval (seconds)')}</Label>
|
||||
<Input
|
||||
id="every"
|
||||
type="number"
|
||||
value={everySeconds}
|
||||
onChange={(e) => setEverySeconds(e.target.value)}
|
||||
min="10"
|
||||
placeholder="3600"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{parseInt(everySeconds, 10) >= 3600
|
||||
? pickAppText(locale, `约 ${Math.floor(parseInt(everySeconds, 10) / 3600)} 小时 ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)} 分`, `About ${Math.floor(parseInt(everySeconds, 10) / 3600)}h ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}m`)
|
||||
: parseInt(everySeconds, 10) >= 60
|
||||
? pickAppText(locale, `约 ${Math.floor(parseInt(everySeconds, 10) / 60)} 分 ${parseInt(everySeconds, 10) % 60} 秒`, `About ${Math.floor(parseInt(everySeconds, 10) / 60)}m ${parseInt(everySeconds, 10) % 60}s`)
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</Label>
|
||||
<Input
|
||||
id="cron"
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
placeholder="0 9 * * *"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '格式:分钟 小时 日 月 周', 'Format: minute hour day month weekday')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">{pickAppText(locale, '发送给智能体的消息', 'Message for the agent')}</Label>
|
||||
<Input
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={pickAppText(locale, '例如:检查我的邮件并生成摘要', 'Example: check my email and generate a summary')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '任务结果会自动回写到当前 Web 会话:', 'Results are written back to the current web session:')} <code className="bg-muted px-1 py-0.5 rounded">{targetSessionKey}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || !message.trim()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '创建任务', 'Create job')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
export default function CronRedirectPage() {
|
||||
redirect('/tasks?tab=scheduled');
|
||||
}
|
||||
|
||||
@ -1,21 +1,9 @@
|
||||
import Header from '@/components/Header';
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
import { AppRuntimeBridge } from '@/components/AppRuntimeBridge';
|
||||
import { AppShell } from '@/components/AppShell';
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<Header />
|
||||
<main className="pt-16">
|
||||
<AuthGuard>
|
||||
<AppRuntimeBridge />
|
||||
{children}
|
||||
</AuthGuard>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
|
||||
205
app-instance/frontend/app/(app)/logs/page.tsx
Normal file
205
app-instance/frontend/app/(app)/logs/page.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertCircle, Bot, Braces, ChevronDown, Loader2, MessageSquare, RefreshCw, TerminalSquare } from 'lucide-react';
|
||||
|
||||
import { getChatLogs } from '@/lib/api';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { ChatLogEvent, ChatLogSession } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
function eventLabel(event: ChatLogEvent): string {
|
||||
return event.event_type || event.role || 'event';
|
||||
}
|
||||
|
||||
function eventIcon(event: ChatLogEvent) {
|
||||
if (event.event_type === 'llm_request_snapshotted') return Braces;
|
||||
if (event.role === 'assistant') return Bot;
|
||||
if (event.role === 'tool') return TerminalSquare;
|
||||
return MessageSquare;
|
||||
}
|
||||
|
||||
function formatPayload(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '';
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function eventBody(event: ChatLogEvent): string {
|
||||
const content = event.content?.trim();
|
||||
if (content) return content;
|
||||
return formatPayload(event.event_payload);
|
||||
}
|
||||
|
||||
function timestampLabel(value?: string | null): string {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [sessions, setSessions] = useState<ChatLogSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedRuns, setExpandedRuns] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const runs = useMemo(
|
||||
() =>
|
||||
sessions.flatMap((session) =>
|
||||
session.runs.map((run) => ({
|
||||
...run,
|
||||
sessionTitle: session.title || session.session_id,
|
||||
}))
|
||||
),
|
||||
[sessions]
|
||||
);
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getChatLogs(80);
|
||||
setSessions(data.sessions || []);
|
||||
const firstRun = data.sessions?.[0]?.runs?.[0]?.run_id;
|
||||
if (firstRun) {
|
||||
setExpandedRuns((current) => (current.size ? current : new Set([firstRun])));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '读取日志失败', 'Failed to load logs'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, []);
|
||||
|
||||
const toggleRun = (runId: string) => {
|
||||
setExpandedRuns((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(runId)) next.delete(runId);
|
||||
else next.add(runId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl p-6 space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{pickAppText(locale, '运行日志', 'Runtime Logs')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{pickAppText(
|
||||
locale,
|
||||
'按每一次用户输入分组,查看 LLM 请求、回复、工具结果和隐藏运行快照。',
|
||||
'Grouped by each user input, with LLM requests, responses, tool results, and hidden runtime snapshots.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={loadLogs} variant="outline" size="sm" disabled={loading}>
|
||||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-start gap-3 pt-6 text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{pickAppText(locale, '无法读取日志', 'Unable to load logs')}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{loading && !runs.length ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !runs.length && !error ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '还没有可展示的运行日志。', 'No runtime logs are available yet.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{runs.map((run) => {
|
||||
const expanded = expandedRuns.has(run.run_id);
|
||||
return (
|
||||
<Card key={`${run.session_id}:${run.run_id}`} className="overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRun(run.run_id)}
|
||||
className="flex w-full items-start justify-between gap-4 border-b px-5 py-4 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<span className="min-w-0 space-y-2">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={run.task_mode ? 'default' : 'secondary'}>
|
||||
{run.task_mode ? 'Task' : 'Chat'}
|
||||
</Badge>
|
||||
{run.source ? <Badge variant="outline">{run.source}</Badge> : null}
|
||||
{run.attempt_index ? <Badge variant="outline">attempt {run.attempt_index}</Badge> : null}
|
||||
<span className="text-xs text-muted-foreground">{timestampLabel(run.started_at)}</span>
|
||||
</span>
|
||||
<span className="block truncate text-sm font-semibold text-foreground">
|
||||
{run.user_input || run.title || run.run_id}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{run.sessionTitle} · {run.run_id}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className={`mt-1 h-4 w-4 shrink-0 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{expanded ? (
|
||||
<CardContent className="space-y-3 p-5">
|
||||
{run.events.map((event, index) => {
|
||||
const Icon = eventIcon(event);
|
||||
const body = eventBody(event);
|
||||
return (
|
||||
<div
|
||||
key={`${event.message_id ?? index}:${event.event_type}`}
|
||||
className="rounded-lg border border-border bg-background"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">{eventLabel(event)}</span>
|
||||
<Badge variant={event.context_visible ? 'secondary' : 'outline'}>
|
||||
{event.context_visible ? 'visible' : 'hidden'}
|
||||
</Badge>
|
||||
{event.tool_name ? <Badge variant="outline">{event.tool_name}</Badge> : null}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span>
|
||||
</div>
|
||||
<pre className="max-h-[520px] overflow-auto whitespace-pre-wrap break-words p-3 text-xs leading-5 text-foreground">
|
||||
{body || formatPayload(event)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,439 +1,256 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertCircle, ArrowLeft, Check, Download, Loader2, Search, Star } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Store,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
Download,
|
||||
Check,
|
||||
X,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
listMarketplaces,
|
||||
addMarketplace,
|
||||
removeMarketplace,
|
||||
updateMarketplace,
|
||||
listMarketplacePlugins,
|
||||
installMarketplacePlugin,
|
||||
uninstallPlugin,
|
||||
getSkillHubDetail,
|
||||
getSkillHubVersion,
|
||||
installSkillHubSkill,
|
||||
searchSkillHubSkills,
|
||||
} from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Marketplace, MarketplacePlugin } from '@/types';
|
||||
import type { SkillHubSearchItem, SkillHubVersionResponse } from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
type SortMode = 'relevance' | 'downloads' | 'newest';
|
||||
|
||||
function publishedVersion(skill: SkillHubSearchItem | null): string {
|
||||
return skill?.publishedVersion?.version || skill?.headlineVersion?.version || '';
|
||||
}
|
||||
|
||||
export default function MarketplacePage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [marketplaces, setMarketplaces] = useState<Marketplace[]>([]);
|
||||
const [selectedMarketplace, setSelectedMarketplace] = useState<string | null>(null);
|
||||
const [plugins, setPlugins] = useState<MarketplacePlugin[]>([]);
|
||||
const t = useCallback((zh: string, en: string) => pickAppText(locale, zh, en), [locale]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [sort, setSort] = useState<SortMode>('newest');
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const [items, setItems] = useState<SkillHubSearchItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pluginsLoading, setPluginsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addSource, setAddSource] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [actionPlugin, setActionPlugin] = useState<string | null>(null);
|
||||
const [updatingMarketplace, setUpdatingMarketplace] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<SkillHubSearchItem | null>(null);
|
||||
const [versionDetail, setVersionDetail] = useState<SkillHubVersionResponse | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
|
||||
const loadMarketplaces = useCallback(async () => {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listMarketplaces();
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
setMarketplaces(list);
|
||||
// Auto-select first marketplace if none selected or selected was removed
|
||||
if (list.length > 0) {
|
||||
setSelectedMarketplace((prev) => {
|
||||
if (prev && list.some((m) => m.name === prev)) return prev;
|
||||
return list[0].name;
|
||||
});
|
||||
} else {
|
||||
setSelectedMarketplace(null);
|
||||
setPlugins([]);
|
||||
}
|
||||
const result = await searchSkillHubSkills({ q: query, sort, page, size: 12 });
|
||||
const nextItems = Array.isArray(result.items) ? result.items : [];
|
||||
setItems(starredOnly ? nextItems.filter((item) => (item.starCount || 0) > 0) : nextItems);
|
||||
setTotal(result.total || 0);
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '加载市场失败', 'Failed to load marketplaces'));
|
||||
setError(err.message || t('加载 SkillHub 失败', 'Failed to load SkillHub'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadPlugins = useCallback(async (marketplaceName: string) => {
|
||||
setPluginsLoading(true);
|
||||
try {
|
||||
const data = await listMarketplacePlugins(marketplaceName);
|
||||
setPlugins(Array.isArray(data) ? data : []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '加载插件失败', 'Failed to load plugins'));
|
||||
} finally {
|
||||
setPluginsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [page, query, sort, starredOnly, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMarketplaces();
|
||||
}, [loadMarketplaces]);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedMarketplace) {
|
||||
loadPlugins(selectedMarketplace);
|
||||
}
|
||||
}, [selectedMarketplace, loadPlugins]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!addSource.trim()) return;
|
||||
setAdding(true);
|
||||
const openDetail = async (item: SkillHubSearchItem) => {
|
||||
setSelected(item);
|
||||
setVersionDetail(null);
|
||||
setDetailLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const marketplace = await addMarketplace(addSource.trim());
|
||||
setAddSource('');
|
||||
setShowAddForm(false);
|
||||
await loadMarketplaces();
|
||||
setSelectedMarketplace(marketplace.name);
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '添加市场失败', 'Failed to add the marketplace'));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (name: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await removeMarketplace(name);
|
||||
if (selectedMarketplace === name) {
|
||||
setSelectedMarketplace(null);
|
||||
setPlugins([]);
|
||||
}
|
||||
await loadMarketplaces();
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '移除市场失败', 'Failed to remove the marketplace'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateMarketplace = async (name: string) => {
|
||||
setUpdatingMarketplace(name);
|
||||
setError(null);
|
||||
try {
|
||||
await updateMarketplace(name);
|
||||
await loadPlugins(name);
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '更新市场失败', 'Failed to update the marketplace'));
|
||||
} finally {
|
||||
setUpdatingMarketplace(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePlugin = async (marketplaceName: string, pluginName: string) => {
|
||||
setActionPlugin(pluginName);
|
||||
setError(null);
|
||||
try {
|
||||
await installMarketplacePlugin(marketplaceName, pluginName);
|
||||
await loadPlugins(marketplaceName);
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '更新插件失败', 'Failed to update the plugin'));
|
||||
} finally {
|
||||
setActionPlugin(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async (marketplaceName: string, pluginName: string) => {
|
||||
setActionPlugin(pluginName);
|
||||
setError(null);
|
||||
try {
|
||||
await installMarketplacePlugin(marketplaceName, pluginName);
|
||||
await loadPlugins(marketplaceName);
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '安装插件失败', 'Failed to install the plugin'));
|
||||
} finally {
|
||||
setActionPlugin(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async (pluginName: string) => {
|
||||
setActionPlugin(pluginName);
|
||||
setError(null);
|
||||
try {
|
||||
await uninstallPlugin(pluginName);
|
||||
if (selectedMarketplace) {
|
||||
await loadPlugins(selectedMarketplace);
|
||||
const detail = await getSkillHubDetail(item.namespace, item.slug);
|
||||
setSelected(detail);
|
||||
const version = publishedVersion(detail);
|
||||
if (version) {
|
||||
setVersionDetail(await getSkillHubVersion(detail.namespace, detail.slug, version));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '卸载插件失败', 'Failed to uninstall the plugin'));
|
||||
setError(err.message || t('加载技能详情失败', 'Failed to load skill details'));
|
||||
} finally {
|
||||
setActionPlugin(null);
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await loadMarketplaces();
|
||||
if (selectedMarketplace) {
|
||||
await loadPlugins(selectedMarketplace);
|
||||
const installSelected = async () => {
|
||||
if (!selected) return;
|
||||
setInstalling(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await installSkillHubSkill(selected.namespace, selected.slug, publishedVersion(selected));
|
||||
setSelected({ ...selected, installed: true, installed_version: result.version });
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('安装技能失败', 'Failed to install skill'));
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / 12)), [total]);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Store className="w-6 h-6" />
|
||||
{pickAppText(locale, '插件市场', 'Plugin marketplace')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{pickAppText(locale, '浏览并安装已注册市场中的插件', 'Browse and install plugins from registered marketplaces')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setShowAddForm((v) => !v)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '添加市场', 'Add marketplace')}
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<div className="mx-auto mb-10 max-w-4xl">
|
||||
<form
|
||||
className="flex gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setPage(0);
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('搜索技能...', 'Search skills...')}
|
||||
className="h-14 rounded-2xl pl-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="h-14 rounded-2xl px-10 text-base">
|
||||
{t('搜索', 'Search')}
|
||||
</Button>
|
||||
<Button onClick={handleRefresh} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between gap-2 text-destructive text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selected ? (
|
||||
<div className="space-y-5">
|
||||
<Button variant="ghost" onClick={() => setSelected(null)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t('返回搜索', 'Back to search')}
|
||||
</Button>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Badge variant="outline">@{selected.namespace}</Badge>
|
||||
{selected.installed && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Check className="h-3 w-3" />
|
||||
{t('已安装', 'Installed')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{selected.displayName || selected.slug}</CardTitle>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">{selected.summary}</p>
|
||||
</div>
|
||||
<Button onClick={installSelected} disabled={installing || detailLoading}>
|
||||
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||
{selected.installed ? t('重新安装/更新', 'Reinstall/update') : t('安装', 'Install')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 h-6 w-6 p-0"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Add marketplace form */}
|
||||
{showAddForm && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder={pickAppText(locale, '本地路径或 Git 地址(例如 /path/to/marketplace 或 https://github.com/...)', 'Local path or Git URL (for example /path/to/marketplace or https://github.com/...)')}
|
||||
value={addSource}
|
||||
onChange={(e) => setAddSource(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleAdd();
|
||||
}}
|
||||
disabled={adding}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleAdd} disabled={adding || !addSource.trim()} size="sm">
|
||||
{adding ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{pickAppText(locale, '添加', 'Add')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowAddForm(false);
|
||||
setAddSource('');
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Marketplace tabs */}
|
||||
{marketplaces.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{marketplaces.map((marketplace) => (
|
||||
<div key={marketplace.name} className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant={selectedMarketplace === marketplace.name ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedMarketplace(marketplace.name)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{marketplace.type === 'git' ? (
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{marketplace.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-primary"
|
||||
disabled={updatingMarketplace === marketplace.name}
|
||||
onClick={() => handleUpdateMarketplace(marketplace.name)}
|
||||
title={pickAppText(locale, '更新市场', 'Update marketplace')}
|
||||
>
|
||||
{updatingMarketplace === marketplace.name ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemove(marketplace.name)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{marketplaces.length === 0 && !error && (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center text-muted-foreground">
|
||||
<Store className="w-12 h-12 mx-auto mb-4 opacity-30" />
|
||||
<p className="font-medium">{pickAppText(locale, '还没有注册任何市场', 'No marketplaces are registered yet')}</p>
|
||||
<p className="text-sm mt-2 max-w-sm mx-auto">
|
||||
{pickAppText(locale, '点击上方的', 'Use the')}<strong>{pickAppText(locale, '添加市场', 'Add marketplace')}</strong>{pickAppText(locale, ',填入本地路径或 Git 地址即可开始使用。', ' action above and provide a local path or Git URL to get started.')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Plugin list */}
|
||||
{selectedMarketplace && (
|
||||
<>
|
||||
{pluginsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Store className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">{pickAppText(locale, '暂无可用插件', 'No plugins available')}</p>
|
||||
<p className="text-sm mt-1">{pickAppText(locale, '这个市场里暂时还没有插件。', 'There are no plugins in this marketplace yet.')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{plugins.map((plugin) => (
|
||||
<Card key={plugin.name}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{plugin.name}
|
||||
</CardTitle>
|
||||
{plugin.installed && (
|
||||
<Badge variant="secondary" className="text-xs gap-1">
|
||||
<Check className="w-3 h-3" />
|
||||
{pickAppText(locale, '已安装', 'Installed')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{plugin.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{plugin.installed ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actionPlugin === plugin.name}
|
||||
onClick={() =>
|
||||
handleUpdatePlugin(plugin.marketplace_name, plugin.name)
|
||||
}
|
||||
>
|
||||
{actionPlugin === plugin.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{pickAppText(locale, '更新', 'Update')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actionPlugin === plugin.name}
|
||||
onClick={() => handleUninstall(plugin.name)}
|
||||
>
|
||||
{actionPlugin === plugin.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{pickAppText(locale, '卸载', 'Uninstall')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={actionPlugin === plugin.name}
|
||||
onClick={() =>
|
||||
handleInstall(plugin.marketplace_name, plugin.name)
|
||||
}
|
||||
>
|
||||
{actionPlugin === plugin.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{pickAppText(locale, '安装', 'Install')}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{detailLoading ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||
<Badge variant="outline">v{publishedVersion(selected) || '-'}</Badge>
|
||||
<span>{t('下载', 'Downloads')}: {selected.downloadCount || 0}</span>
|
||||
<span>{t('收藏', 'Stars')}: {selected.starCount || 0}</span>
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="mb-2 text-sm font-medium">SKILL.md</div>
|
||||
<pre className="max-h-[520px] overflow-auto whitespace-pre-wrap text-xs">
|
||||
{versionDetail?.detail?.parsedMetadataJson || t('暂无预览', 'No preview available')}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="mb-3 text-sm font-medium">{t('版本文件', 'Version files')}</div>
|
||||
<div className="space-y-2">
|
||||
{(versionDetail?.files || []).map((file) => (
|
||||
<div key={file.filePath} className="flex items-center justify-between gap-3 rounded-md bg-background px-3 py-2 text-xs">
|
||||
<span className="break-all font-mono">{file.filePath}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{file.fileSize} B</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">{t('排序:', 'Sort:')}</span>
|
||||
{([
|
||||
['relevance', t('相关性', 'Relevance')],
|
||||
['downloads', t('下载量', 'Downloads')],
|
||||
['newest', t('最新', 'Newest')],
|
||||
] as Array<[SortMode, string]>).map(([value, label]) => (
|
||||
<Button key={value} size="sm" variant={sort === value ? 'default' : 'outline'} onClick={() => { setSort(value); setPage(0); }}>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
<span className="ml-4 text-sm font-medium text-muted-foreground">{t('筛选:', 'Filter:')}</span>
|
||||
<Button size="sm" variant={starredOnly ? 'default' : 'outline'} onClick={() => setStarredOnly((value) => !value)}>
|
||||
<Star className="mr-2 h-4 w-4" />
|
||||
{t('只看已收藏', 'Starred only')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<Card key={`${item.namespace}/${item.slug}`} className="cursor-pointer transition hover:border-primary" onClick={() => void openDetail(item)}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<CardTitle className="text-xl">{item.displayName || item.slug}</CardTitle>
|
||||
<Badge variant="outline">@{item.namespace}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<p className="line-clamp-3 min-h-[4.5rem] text-sm leading-6 text-muted-foreground">{item.summary}</p>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<Badge variant="secondary">v{publishedVersion(item) || '-'}</Badge>
|
||||
<span>{item.downloadCount || 0}</span>
|
||||
<span>{item.starCount || 0}</span>
|
||||
{item.installed && <Badge variant="outline">{t('已安装', 'Installed')}</Badge>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button variant="outline" disabled={page <= 0} onClick={() => setPage((value) => Math.max(0, value - 1))}>
|
||||
{t('上一页', 'Previous')}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">{page + 1} / {totalPages}</span>
|
||||
<Button variant="outline" disabled={page + 1 >= totalPages} onClick={() => setPage((value) => value + 1)}>
|
||||
{t('下一页', 'Next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user