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 化、验证、反馈门控。
|
2. 聊天入口支持 Main Agent 自动 Task 化、验证、反馈门控。
|
||||||
3. skills 已有版本化、receipt/effect 记录、学习候选门控,以及后台 assisted learning pipeline。
|
3. skills 已有版本化、receipt/effect 记录、学习候选门控,以及后台 assisted learning pipeline。
|
||||||
4. Agent Team v1 已支持内部 `sequence / parallel / dag` coordinator。
|
4. Agent Team v1 已支持内部 `sequence / parallel / dag` coordinator。
|
||||||
5. Task mode 已能通过 `TaskExecutionPlanner` 按需调用 sub-agent/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。
|
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,
|
provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None = None,
|
||||||
inherited_pinned_skills: list[str] | None = None,
|
inherited_pinned_skills: list[str] | None = None,
|
||||||
inherited_pinned_skill_contexts: list["SkillContext"] | None = None,
|
inherited_pinned_skill_contexts: list["SkillContext"] | None = None,
|
||||||
learning_candidate_enabled: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
) -> TeamRunResult:
|
) -> TeamRunResult:
|
||||||
graph.validate()
|
graph.validate()
|
||||||
if provider_bundle is not None and len(graph.nodes) > 1:
|
if provider_bundle is not None and len(graph.nodes) > 1:
|
||||||
@ -49,7 +49,7 @@ class TeamGraphScheduler:
|
|||||||
provider_bundle_factory=provider_bundle_factory,
|
provider_bundle_factory=provider_bundle_factory,
|
||||||
inherited_pinned_skills=inherited,
|
inherited_pinned_skills=inherited,
|
||||||
inherited_pinned_skill_contexts=inherited_contexts,
|
inherited_pinned_skill_contexts=inherited_contexts,
|
||||||
learning_candidate_enabled=learning_candidate_enabled,
|
allow_candidate_generation=allow_candidate_generation,
|
||||||
)
|
)
|
||||||
elif graph.strategy == "parallel":
|
elif graph.strategy == "parallel":
|
||||||
results = await self._run_parallel(
|
results = await self._run_parallel(
|
||||||
@ -61,7 +61,7 @@ class TeamGraphScheduler:
|
|||||||
provider_bundle_factory=provider_bundle_factory,
|
provider_bundle_factory=provider_bundle_factory,
|
||||||
inherited_pinned_skills=inherited,
|
inherited_pinned_skills=inherited,
|
||||||
inherited_pinned_skill_contexts=inherited_contexts,
|
inherited_pinned_skill_contexts=inherited_contexts,
|
||||||
learning_candidate_enabled=learning_candidate_enabled,
|
allow_candidate_generation=allow_candidate_generation,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
results = await self._run_dag(
|
results = await self._run_dag(
|
||||||
@ -73,7 +73,7 @@ class TeamGraphScheduler:
|
|||||||
provider_bundle_factory=provider_bundle_factory,
|
provider_bundle_factory=provider_bundle_factory,
|
||||||
inherited_pinned_skills=inherited,
|
inherited_pinned_skills=inherited,
|
||||||
inherited_pinned_skill_contexts=inherited_contexts,
|
inherited_pinned_skill_contexts=inherited_contexts,
|
||||||
learning_candidate_enabled=learning_candidate_enabled,
|
allow_candidate_generation=allow_candidate_generation,
|
||||||
)
|
)
|
||||||
return self._summarize(results, task_id=parent_task_id)
|
return self._summarize(results, task_id=parent_task_id)
|
||||||
|
|
||||||
@ -162,7 +162,7 @@ class TeamGraphScheduler:
|
|||||||
provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None,
|
provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None,
|
||||||
inherited_pinned_skills: list[str],
|
inherited_pinned_skills: list[str],
|
||||||
inherited_pinned_skill_contexts: list["SkillContext"],
|
inherited_pinned_skill_contexts: list["SkillContext"],
|
||||||
learning_candidate_enabled: bool,
|
allow_candidate_generation: bool,
|
||||||
dependency_outputs: dict[str, str],
|
dependency_outputs: dict[str, str],
|
||||||
) -> NodeRunResult:
|
) -> NodeRunResult:
|
||||||
try:
|
try:
|
||||||
@ -188,7 +188,7 @@ class TeamGraphScheduler:
|
|||||||
return await self.runner.run(
|
return await self.runner.run(
|
||||||
envelope,
|
envelope,
|
||||||
provider_bundle=node_provider_bundle,
|
provider_bundle=node_provider_bundle,
|
||||||
learning_candidate_enabled=learning_candidate_enabled,
|
allow_candidate_generation=allow_candidate_generation,
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@ -21,7 +21,7 @@ class LocalAgentRunner:
|
|||||||
envelope: DelegationEnvelope,
|
envelope: DelegationEnvelope,
|
||||||
*,
|
*,
|
||||||
provider_bundle: ProviderBundle | None = None,
|
provider_bundle: ProviderBundle | None = None,
|
||||||
learning_candidate_enabled: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
) -> NodeRunResult:
|
) -> NodeRunResult:
|
||||||
if provider_bundle is not None and (envelope.agent.model or envelope.agent.provider_name):
|
if provider_bundle is not None and (envelope.agent.model or envelope.agent.provider_name):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@ -37,6 +37,7 @@ class LocalAgentRunner:
|
|||||||
source=f"team:{envelope.agent.name}",
|
source=f"team:{envelope.agent.name}",
|
||||||
title=envelope.agent.role or envelope.agent.name,
|
title=envelope.agent.role or envelope.agent.name,
|
||||||
execution_context=self._execution_context(envelope),
|
execution_context=self._execution_context(envelope),
|
||||||
|
skill_selection_context=self._skill_selection_context(envelope),
|
||||||
model=envelope.agent.model,
|
model=envelope.agent.model,
|
||||||
provider_name=envelope.agent.provider_name,
|
provider_name=envelope.agent.provider_name,
|
||||||
provider_bundle=provider_bundle,
|
provider_bundle=provider_bundle,
|
||||||
@ -44,7 +45,7 @@ class LocalAgentRunner:
|
|||||||
task_mode=bool(envelope.parent_task_id),
|
task_mode=bool(envelope.parent_task_id),
|
||||||
pinned_skill_names=envelope.inherited_pinned_skills,
|
pinned_skill_names=envelope.inherited_pinned_skills,
|
||||||
pinned_skill_contexts=envelope.inherited_pinned_skill_contexts,
|
pinned_skill_contexts=envelope.inherited_pinned_skill_contexts,
|
||||||
learning_candidate_enabled=learning_candidate_enabled,
|
allow_candidate_generation=allow_candidate_generation,
|
||||||
)
|
)
|
||||||
success = result.finish_reason == "stop"
|
success = result.finish_reason == "stop"
|
||||||
return NodeRunResult(
|
return NodeRunResult(
|
||||||
@ -86,7 +87,48 @@ class LocalAgentRunner:
|
|||||||
sections.append("Pinned inherited skills:\n" + "\n".join(f"- {item}" for item in envelope.inherited_pinned_skills))
|
sections.append("Pinned inherited skills:\n" + "\n".join(f"- {item}" for item in envelope.inherited_pinned_skills))
|
||||||
if envelope.inherited_pinned_skill_contexts:
|
if envelope.inherited_pinned_skill_contexts:
|
||||||
sections.append(
|
sections.append(
|
||||||
"Ephemeral pinned skill drafts:\n"
|
"Ephemeral pinned guidance:\n"
|
||||||
+ "\n".join(f"- {item.name} ({item.version})" for item in envelope.inherited_pinned_skill_contexts)
|
+ "\n".join(f"- {item.name} ({item.version})" for item in envelope.inherited_pinned_skill_contexts)
|
||||||
)
|
)
|
||||||
return "\n\n".join(sections)
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _skill_selection_context(envelope: DelegationEnvelope) -> str:
|
||||||
|
sections: list[str] = []
|
||||||
|
if envelope.parent_task_id:
|
||||||
|
sections.append(f"Parent task ID:\n{envelope.parent_task_id}")
|
||||||
|
sections.append(f"Node task:\n{envelope.task}")
|
||||||
|
sections.append("Execution phase:\nteam_node")
|
||||||
|
if envelope.agent.role:
|
||||||
|
sections.append(f"Agent role:\n{envelope.agent.role}")
|
||||||
|
skill_query = envelope.agent.metadata.get("skill_query")
|
||||||
|
if skill_query:
|
||||||
|
sections.append(f"Skill query:\n{skill_query}")
|
||||||
|
required_capabilities = envelope.agent.metadata.get("required_capabilities")
|
||||||
|
if required_capabilities:
|
||||||
|
if isinstance(required_capabilities, list):
|
||||||
|
rendered = "\n".join(f"- {item}" for item in required_capabilities)
|
||||||
|
else:
|
||||||
|
rendered = str(required_capabilities)
|
||||||
|
sections.append(f"Required capabilities:\n{rendered}")
|
||||||
|
if envelope.constraints:
|
||||||
|
sections.append("Constraints:\n" + "\n".join(f"- {item}" for item in envelope.constraints))
|
||||||
|
if envelope.expected_output:
|
||||||
|
sections.append(f"Expected output:\n{envelope.expected_output}")
|
||||||
|
if envelope.inherited_pinned_skills:
|
||||||
|
sections.append(
|
||||||
|
"Pinned inherited skills (must be injected separately; use as strong context):\n"
|
||||||
|
+ "\n".join(f"- {item}" for item in envelope.inherited_pinned_skills)
|
||||||
|
)
|
||||||
|
if envelope.dependency_outputs:
|
||||||
|
rendered = "\n\n".join(
|
||||||
|
f"Dependency {node_id} output:\n{output[:800]}"
|
||||||
|
for node_id, output in envelope.dependency_outputs.items()
|
||||||
|
)
|
||||||
|
sections.append("Dependency outputs:\n" + rendered)
|
||||||
|
sections.append(
|
||||||
|
"Skill selection instruction:\n"
|
||||||
|
"Select published skills for this delegated node. "
|
||||||
|
"If no published skill matches, return [] and let the node continue without skills."
|
||||||
|
)
|
||||||
|
return "\n\n".join(sections)
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -224,8 +225,29 @@ class ContextBuilder:
|
|||||||
clean = {key: value for key, value in message.items() if key in allowed}
|
clean = {key: value for key, value in message.items() if key in allowed}
|
||||||
if "name" not in clean and message.get("tool_name"):
|
if "name" not in clean and message.get("tool_name"):
|
||||||
clean["name"] = message.get("tool_name")
|
clean["name"] = message.get("tool_name")
|
||||||
|
if isinstance(clean.get("tool_calls"), list):
|
||||||
|
clean["tool_calls"] = ContextBuilder._provider_tool_calls(clean["tool_calls"])
|
||||||
return clean
|
return clean
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _provider_tool_calls(tool_calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Normalize persisted tool calls to OpenAI-compatible provider payloads."""
|
||||||
|
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
for tool_call in tool_calls:
|
||||||
|
if not isinstance(tool_call, dict):
|
||||||
|
continue
|
||||||
|
clean = dict(tool_call)
|
||||||
|
function = clean.get("function")
|
||||||
|
if isinstance(function, dict):
|
||||||
|
clean_function = dict(function)
|
||||||
|
arguments = clean_function.get("arguments")
|
||||||
|
if not isinstance(arguments, str):
|
||||||
|
clean_function["arguments"] = json.dumps(arguments or {}, ensure_ascii=False, default=str)
|
||||||
|
clean["function"] = clean_function
|
||||||
|
normalized.append(clean)
|
||||||
|
return normalized
|
||||||
|
|
||||||
def add_tool_result(
|
def add_tool_result(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
@ -278,7 +300,7 @@ class ContextBuilder:
|
|||||||
"content": content,
|
"content": content,
|
||||||
}
|
}
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
message["tool_calls"] = tool_calls
|
message["tool_calls"] = self._provider_tool_calls(tool_calls)
|
||||||
if reasoning_content is not None:
|
if reasoning_content is not None:
|
||||||
message["reasoning_content"] = reasoning_content
|
message["reasoning_content"] = reasoning_content
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -11,6 +12,7 @@ from beaver.coordinator.registry import AgentRegistry
|
|||||||
from beaver.engine.context import ContextBuilder
|
from beaver.engine.context import ContextBuilder
|
||||||
from beaver.engine.session import SessionManager
|
from beaver.engine.session import SessionManager
|
||||||
from beaver.foundation.config import BeaverConfig, load_config
|
from beaver.foundation.config import BeaverConfig, load_config
|
||||||
|
from beaver.integrations.mcp import MCPConnectionManager
|
||||||
from beaver.memory.curated.store import MemoryStore
|
from beaver.memory.curated.store import MemoryStore
|
||||||
from beaver.memory.runs import RunMemoryStore
|
from beaver.memory.runs import RunMemoryStore
|
||||||
from beaver.memory.skills import SkillLearningStore
|
from beaver.memory.skills import SkillLearningStore
|
||||||
@ -27,13 +29,27 @@ from beaver.tasks.skill_resolver import TaskSkillResolver
|
|||||||
from beaver.skills import SkillAssembler, SkillsLoader
|
from beaver.skills import SkillAssembler, SkillsLoader
|
||||||
from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry
|
from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry
|
||||||
from beaver.tools.builtins import (
|
from beaver.tools.builtins import (
|
||||||
|
ClarifyTool,
|
||||||
|
CronTool,
|
||||||
|
DelegateTool,
|
||||||
EchoTool,
|
EchoTool,
|
||||||
|
ExecuteCodeTool,
|
||||||
ListDirectoryTool,
|
ListDirectoryTool,
|
||||||
MemoryTool,
|
MemoryTool,
|
||||||
|
PatchFileTool,
|
||||||
|
ProcessTool,
|
||||||
ReadFileTool,
|
ReadFileTool,
|
||||||
SearchFilesTool,
|
SearchFilesTool,
|
||||||
|
SendMessageTool,
|
||||||
|
SpawnTool,
|
||||||
SessionSearchTool,
|
SessionSearchTool,
|
||||||
SkillViewTool,
|
SkillManageTool,
|
||||||
|
SkillsListTool,
|
||||||
|
TerminalTool,
|
||||||
|
TodoTool,
|
||||||
|
WebFetchTool,
|
||||||
|
WebSearchTool,
|
||||||
|
WriteFileTool,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -76,6 +92,8 @@ class EngineLoadResult:
|
|||||||
task_service: TaskService | None = None
|
task_service: TaskService | None = None
|
||||||
task_execution_planner: TaskExecutionPlanner | None = None
|
task_execution_planner: TaskExecutionPlanner | None = None
|
||||||
validation_service: ValidationService | None = None
|
validation_service: ValidationService | None = None
|
||||||
|
mcp_manager: MCPConnectionManager | None = None
|
||||||
|
mcp_report: dict[str, dict] = field(default_factory=dict)
|
||||||
closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False)
|
closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False)
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
|
|
||||||
@ -198,11 +216,25 @@ class EngineLoader:
|
|||||||
[
|
[
|
||||||
ObjectBackedTool(EchoTool()),
|
ObjectBackedTool(EchoTool()),
|
||||||
ObjectBackedTool(MemoryTool(store=memory_service.get_store())),
|
ObjectBackedTool(MemoryTool(store=memory_service.get_store())),
|
||||||
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
|
|
||||||
ObjectBackedTool(SessionSearchTool(db=session_manager)),
|
ObjectBackedTool(SessionSearchTool(db=session_manager)),
|
||||||
ObjectBackedTool(ListDirectoryTool()),
|
ObjectBackedTool(ListDirectoryTool()),
|
||||||
ObjectBackedTool(ReadFileTool()),
|
ObjectBackedTool(ReadFileTool()),
|
||||||
ObjectBackedTool(SearchFilesTool()),
|
ObjectBackedTool(SearchFilesTool()),
|
||||||
|
ObjectBackedTool(WriteFileTool()),
|
||||||
|
ObjectBackedTool(PatchFileTool()),
|
||||||
|
ObjectBackedTool(WebFetchTool()),
|
||||||
|
ObjectBackedTool(WebSearchTool()),
|
||||||
|
ObjectBackedTool(TerminalTool()),
|
||||||
|
ObjectBackedTool(ProcessTool()),
|
||||||
|
ObjectBackedTool(ExecuteCodeTool()),
|
||||||
|
ObjectBackedTool(TodoTool()),
|
||||||
|
ObjectBackedTool(ClarifyTool()),
|
||||||
|
ObjectBackedTool(SendMessageTool()),
|
||||||
|
ObjectBackedTool(DelegateTool()),
|
||||||
|
ObjectBackedTool(SpawnTool()),
|
||||||
|
SkillsListTool(),
|
||||||
|
SkillManageTool(),
|
||||||
|
CronTool(),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -240,6 +272,11 @@ class EngineLoader:
|
|||||||
task_service = self._task_service or TaskService(workspace / "tasks")
|
task_service = self._task_service or TaskService(workspace / "tasks")
|
||||||
task_execution_planner = self._task_execution_planner or TaskExecutionPlanner(task_skill_resolver=task_skill_resolver)
|
task_execution_planner = self._task_execution_planner or TaskExecutionPlanner(task_skill_resolver=task_skill_resolver)
|
||||||
validation_service = self._validation_service or ValidationService()
|
validation_service = self._validation_service or ValidationService()
|
||||||
|
mcp_manager = MCPConnectionManager(
|
||||||
|
self.config.tools.mcp_servers,
|
||||||
|
authz_config=self.config.authz,
|
||||||
|
backend_identity=self.config.backend_identity,
|
||||||
|
)
|
||||||
|
|
||||||
result = EngineLoadResult(
|
result = EngineLoadResult(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
@ -270,7 +307,18 @@ class EngineLoader:
|
|||||||
task_service=task_service,
|
task_service=task_service,
|
||||||
task_execution_planner=task_execution_planner,
|
task_execution_planner=task_execution_planner,
|
||||||
validation_service=validation_service,
|
validation_service=validation_service,
|
||||||
|
mcp_manager=mcp_manager,
|
||||||
)
|
)
|
||||||
if self._session_manager is None:
|
if self._session_manager is None:
|
||||||
result.register_closeable("session_manager", session_manager.close)
|
result.register_closeable("session_manager", session_manager.close)
|
||||||
|
result.register_closeable("mcp_manager", lambda: _close_mcp_manager(mcp_manager))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _close_mcp_manager(manager: MCPConnectionManager) -> None:
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
asyncio.run(manager.close())
|
||||||
|
return
|
||||||
|
loop.create_task(manager.close())
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -64,6 +65,7 @@ class AgentLoop:
|
|||||||
self.profile = profile or AgentProfile()
|
self.profile = profile or AgentProfile()
|
||||||
self.loader = loader or EngineLoader()
|
self.loader = loader or EngineLoader()
|
||||||
self.loaded: EngineLoadResult | None = None
|
self.loaded: EngineLoadResult | None = None
|
||||||
|
self.runtime_services: dict[str, Any] = {}
|
||||||
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
|
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._stop_requested = False
|
self._stop_requested = False
|
||||||
@ -190,6 +192,7 @@ class AgentLoop:
|
|||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
execution_context: str | None = None,
|
execution_context: str | None = None,
|
||||||
|
skill_selection_context: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
provider_name: str | None = None,
|
provider_name: str | None = None,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
@ -202,6 +205,9 @@ class AgentLoop:
|
|||||||
embedding_model: str | None = None,
|
embedding_model: str | None = None,
|
||||||
max_tokens: int | None = None,
|
max_tokens: int | None = None,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
|
include_skill_assembly: bool = True,
|
||||||
|
include_tools: bool = True,
|
||||||
max_tool_iterations: int | None = None,
|
max_tool_iterations: int | None = None,
|
||||||
provider_bundle: ProviderBundle | None = None,
|
provider_bundle: ProviderBundle | None = None,
|
||||||
parent_session_id: str | None = None,
|
parent_session_id: str | None = None,
|
||||||
@ -210,7 +216,7 @@ class AgentLoop:
|
|||||||
attempt_index: int | None = None,
|
attempt_index: int | None = None,
|
||||||
pinned_skill_names: list[str] | None = None,
|
pinned_skill_names: list[str] | None = None,
|
||||||
pinned_skill_contexts: list[SkillContext] | None = None,
|
pinned_skill_contexts: list[SkillContext] | None = None,
|
||||||
learning_candidate_enabled: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
"""跑通最小 direct run 主链。
|
"""跑通最小 direct run 主链。
|
||||||
|
|
||||||
@ -234,6 +240,7 @@ class AgentLoop:
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
title=title,
|
title=title,
|
||||||
execution_context=execution_context,
|
execution_context=execution_context,
|
||||||
|
skill_selection_context=skill_selection_context,
|
||||||
model=model,
|
model=model,
|
||||||
provider_name=provider_name,
|
provider_name=provider_name,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
@ -246,6 +253,9 @@ class AgentLoop:
|
|||||||
embedding_model=embedding_model,
|
embedding_model=embedding_model,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
|
thinking_enabled=thinking_enabled,
|
||||||
|
include_skill_assembly=include_skill_assembly,
|
||||||
|
include_tools=include_tools,
|
||||||
max_tool_iterations=max_tool_iterations,
|
max_tool_iterations=max_tool_iterations,
|
||||||
provider_bundle=provider_bundle,
|
provider_bundle=provider_bundle,
|
||||||
parent_session_id=parent_session_id,
|
parent_session_id=parent_session_id,
|
||||||
@ -254,7 +264,7 @@ class AgentLoop:
|
|||||||
attempt_index=attempt_index,
|
attempt_index=attempt_index,
|
||||||
pinned_skill_names=pinned_skill_names,
|
pinned_skill_names=pinned_skill_names,
|
||||||
pinned_skill_contexts=pinned_skill_contexts,
|
pinned_skill_contexts=pinned_skill_contexts,
|
||||||
learning_candidate_enabled=learning_candidate_enabled,
|
allow_candidate_generation=allow_candidate_generation,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _process_direct_impl(
|
async def _process_direct_impl(
|
||||||
@ -266,6 +276,7 @@ class AgentLoop:
|
|||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
execution_context: str | None = None,
|
execution_context: str | None = None,
|
||||||
|
skill_selection_context: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
provider_name: str | None = None,
|
provider_name: str | None = None,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
@ -278,6 +289,9 @@ class AgentLoop:
|
|||||||
embedding_model: str | None = None,
|
embedding_model: str | None = None,
|
||||||
max_tokens: int | None = None,
|
max_tokens: int | None = None,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
|
include_skill_assembly: bool = True,
|
||||||
|
include_tools: bool = True,
|
||||||
max_tool_iterations: int | None = None,
|
max_tool_iterations: int | None = None,
|
||||||
provider_bundle: ProviderBundle | None = None,
|
provider_bundle: ProviderBundle | None = None,
|
||||||
parent_session_id: str | None = None,
|
parent_session_id: str | None = None,
|
||||||
@ -286,7 +300,7 @@ class AgentLoop:
|
|||||||
attempt_index: int | None = None,
|
attempt_index: int | None = None,
|
||||||
pinned_skill_names: list[str] | None = None,
|
pinned_skill_names: list[str] | None = None,
|
||||||
pinned_skill_contexts: list[SkillContext] | None = None,
|
pinned_skill_contexts: list[SkillContext] | None = None,
|
||||||
learning_candidate_enabled: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
"""真正执行一轮 direct run 的内部实现。
|
"""真正执行一轮 direct run 的内部实现。
|
||||||
|
|
||||||
@ -306,6 +320,10 @@ class AgentLoop:
|
|||||||
skills_loader = self._require_loaded("skills_loader")
|
skills_loader = self._require_loaded("skills_loader")
|
||||||
skill_assembler = self._require_loaded("skill_assembler")
|
skill_assembler = self._require_loaded("skill_assembler")
|
||||||
skill_learning_service = self._require_loaded("skill_learning_service")
|
skill_learning_service = self._require_loaded("skill_learning_service")
|
||||||
|
mcp_manager = getattr(loaded, "mcp_manager", None)
|
||||||
|
if mcp_manager is not None:
|
||||||
|
loaded.mcp_report = await mcp_manager.connect_all(tool_registry)
|
||||||
|
loaded.tools = [spec.name for spec in tool_registry.list_specs()]
|
||||||
|
|
||||||
config = loaded.config
|
config = loaded.config
|
||||||
configured_provider = config.resolve_provider_target(model=model, provider_name=provider_name)
|
configured_provider = config.resolve_provider_target(model=model, provider_name=provider_name)
|
||||||
@ -357,6 +375,9 @@ class AgentLoop:
|
|||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"task_mode": task_mode,
|
"task_mode": task_mode,
|
||||||
"attempt_index": attempt_index,
|
"attempt_index": attempt_index,
|
||||||
|
"thinking_enabled": thinking_enabled,
|
||||||
|
"include_skill_assembly": include_skill_assembly,
|
||||||
|
"skill_selection_context_present": bool(skill_selection_context),
|
||||||
"parent_session_id": parent_session_id,
|
"parent_session_id": parent_session_id,
|
||||||
"pinned_skill_names": list(pinned_skill_names or []),
|
"pinned_skill_names": list(pinned_skill_names or []),
|
||||||
"pinned_skill_context_names": [skill.name for skill in pinned_skill_contexts or []],
|
"pinned_skill_context_names": [skill.name for skill in pinned_skill_contexts or []],
|
||||||
@ -396,19 +417,39 @@ class AgentLoop:
|
|||||||
if bundle.auxiliary_runtime is not None
|
if bundle.auxiliary_runtime is not None
|
||||||
else bundle.main_runtime.model
|
else bundle.main_runtime.model
|
||||||
)
|
)
|
||||||
assembled_skills = await skill_assembler.assemble(
|
pinned_skills = [
|
||||||
task_description=task,
|
*(pinned_skill_contexts or []),
|
||||||
provider=skill_selector_provider,
|
*self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []),
|
||||||
model=skill_selector_model,
|
]
|
||||||
embedding_runtime=bundle.embedding_runtime,
|
if not include_skill_assembly or thinking_enabled is False:
|
||||||
)
|
activated_skills = self._merge_skill_contexts(pinned_skills, [])
|
||||||
activated_skills = self._merge_skill_contexts(
|
else:
|
||||||
[
|
skill_query = skill_selection_context or task
|
||||||
*(pinned_skill_contexts or []),
|
assembled_skills = await skill_assembler.assemble(
|
||||||
*self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []),
|
task_description=skill_query,
|
||||||
],
|
provider=skill_selector_provider,
|
||||||
assembled_skills.activated_skills,
|
model=skill_selector_model,
|
||||||
)
|
embedding_runtime=bundle.embedding_runtime,
|
||||||
|
thinking_enabled=thinking_enabled,
|
||||||
|
)
|
||||||
|
for interaction in getattr(assembled_skills, "llm_interactions", []) or []:
|
||||||
|
session_manager.append_message(
|
||||||
|
resolved_session_id,
|
||||||
|
run_id=resolved_run_id,
|
||||||
|
role="system",
|
||||||
|
event_type="skill_assembler_llm_interaction_snapshotted",
|
||||||
|
event_payload=interaction,
|
||||||
|
content=json.dumps(interaction, ensure_ascii=False, default=str),
|
||||||
|
context_visible=False,
|
||||||
|
source=source,
|
||||||
|
title=title,
|
||||||
|
model=skill_selector_model,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
activated_skills = self._merge_skill_contexts(
|
||||||
|
pinned_skills,
|
||||||
|
assembled_skills.activated_skills,
|
||||||
|
)
|
||||||
skill_activation_messages = context_builder.build_skill_activation_messages(
|
skill_activation_messages = context_builder.build_skill_activation_messages(
|
||||||
activated_skills
|
activated_skills
|
||||||
)
|
)
|
||||||
@ -444,14 +485,19 @@ class AgentLoop:
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
selected_tool_specs = await tool_assembler.assemble(
|
if not include_tools:
|
||||||
task_description=task,
|
selected_tool_specs = []
|
||||||
registry=tool_registry,
|
elif thinking_enabled is False:
|
||||||
skills_loader=skills_loader,
|
selected_tool_specs = tool_registry.list_specs()
|
||||||
activated_skills=activated_skills,
|
else:
|
||||||
embedding_runtime=bundle.embedding_runtime,
|
selected_tool_specs = await tool_assembler.assemble(
|
||||||
top_k=10,
|
task_description=task,
|
||||||
)
|
registry=tool_registry,
|
||||||
|
skills_loader=skills_loader,
|
||||||
|
activated_skills=activated_skills,
|
||||||
|
embedding_runtime=bundle.embedding_runtime,
|
||||||
|
top_k=10,
|
||||||
|
)
|
||||||
tool_schemas = tool_registry.export_selected_provider_schemas(selected_tool_specs)
|
tool_schemas = tool_registry.export_selected_provider_schemas(selected_tool_specs)
|
||||||
session_manager.append_message(
|
session_manager.append_message(
|
||||||
resolved_session_id,
|
resolved_session_id,
|
||||||
@ -486,6 +532,25 @@ class AgentLoop:
|
|||||||
execution_context=execution_context,
|
execution_context=execution_context,
|
||||||
)
|
)
|
||||||
context_result = context_builder.build_messages(build_input)
|
context_result = context_builder.build_messages(build_input)
|
||||||
|
if skill_selection_context:
|
||||||
|
session_manager.append_message(
|
||||||
|
resolved_session_id,
|
||||||
|
run_id=resolved_run_id,
|
||||||
|
role="system",
|
||||||
|
event_type="skill_selection_context_snapshotted",
|
||||||
|
event_payload={
|
||||||
|
"skill_selection_context": skill_selection_context,
|
||||||
|
"task_id": task_id,
|
||||||
|
"task_mode": task_mode,
|
||||||
|
"attempt_index": attempt_index,
|
||||||
|
},
|
||||||
|
content=skill_selection_context,
|
||||||
|
context_visible=False,
|
||||||
|
source=source,
|
||||||
|
title=title,
|
||||||
|
model=resolved_model,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
session_manager.update_system_prompt(resolved_session_id, context_result.system_prompt)
|
session_manager.update_system_prompt(resolved_session_id, context_result.system_prompt)
|
||||||
session_manager.append_message(
|
session_manager.append_message(
|
||||||
resolved_session_id,
|
resolved_session_id,
|
||||||
@ -528,6 +593,9 @@ class AgentLoop:
|
|||||||
"memory_service": memory_service,
|
"memory_service": memory_service,
|
||||||
"memory_store": memory_service.get_store(),
|
"memory_store": memory_service.get_store(),
|
||||||
"tool_registry": tool_registry,
|
"tool_registry": tool_registry,
|
||||||
|
"skills_loader": skills_loader,
|
||||||
|
"draft_service": getattr(loaded, "draft_service", None),
|
||||||
|
**self.runtime_services,
|
||||||
},
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"source": source,
|
"source": source,
|
||||||
@ -541,13 +609,45 @@ class AgentLoop:
|
|||||||
final_model = bundle.main_runtime.model
|
final_model = bundle.main_runtime.model
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
response = await provider.chat(
|
chat_kwargs: dict[str, Any] = {
|
||||||
messages=messages,
|
"messages": messages,
|
||||||
tools=tool_schemas,
|
"tools": tool_schemas,
|
||||||
|
"model": final_model,
|
||||||
|
"max_tokens": resolved_max_tokens,
|
||||||
|
"temperature": resolved_temperature,
|
||||||
|
}
|
||||||
|
if thinking_enabled is not None:
|
||||||
|
chat_kwargs["thinking_enabled"] = thinking_enabled
|
||||||
|
session_manager.append_message(
|
||||||
|
resolved_session_id,
|
||||||
|
run_id=resolved_run_id,
|
||||||
|
role="system",
|
||||||
|
event_type="llm_request_snapshotted",
|
||||||
|
event_payload={
|
||||||
|
"iteration": iterations,
|
||||||
|
"provider_name": final_provider_name,
|
||||||
|
"model": final_model,
|
||||||
|
"messages": messages,
|
||||||
|
"tools": tool_schemas,
|
||||||
|
"max_tokens": resolved_max_tokens,
|
||||||
|
"temperature": resolved_temperature,
|
||||||
|
"thinking_enabled": thinking_enabled,
|
||||||
|
},
|
||||||
|
content=json.dumps(
|
||||||
|
{
|
||||||
|
"messages": messages,
|
||||||
|
"tools": tool_schemas,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=str,
|
||||||
|
),
|
||||||
|
context_visible=False,
|
||||||
|
source=source,
|
||||||
|
title=title,
|
||||||
model=final_model,
|
model=final_model,
|
||||||
max_tokens=resolved_max_tokens,
|
user_id=user_id,
|
||||||
temperature=resolved_temperature,
|
|
||||||
)
|
)
|
||||||
|
response = await provider.chat(**chat_kwargs)
|
||||||
final_provider_name = response.provider_name or final_provider_name
|
final_provider_name = response.provider_name or final_provider_name
|
||||||
final_model = response.model or final_model
|
final_model = response.model or final_model
|
||||||
final_usage = self._merge_usage(final_usage, response.usage or {})
|
final_usage = self._merge_usage(final_usage, response.usage or {})
|
||||||
@ -650,7 +750,7 @@ class AgentLoop:
|
|||||||
model=final_model,
|
model=final_model,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
self._record_skill_learning(
|
self._record_run_receipts(
|
||||||
skill_learning_service=skill_learning_service,
|
skill_learning_service=skill_learning_service,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
session_id=resolved_session_id,
|
session_id=resolved_session_id,
|
||||||
@ -663,7 +763,7 @@ class AgentLoop:
|
|||||||
success=(final_finish_reason == "stop"),
|
success=(final_finish_reason == "stop"),
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
attempt_index=attempt_index,
|
attempt_index=attempt_index,
|
||||||
generate_candidates=learning_candidate_enabled,
|
allow_candidate_generation=False,
|
||||||
)
|
)
|
||||||
return AgentRunResult(
|
return AgentRunResult(
|
||||||
session_id=resolved_session_id,
|
session_id=resolved_session_id,
|
||||||
@ -703,7 +803,7 @@ class AgentLoop:
|
|||||||
usage=final_usage,
|
usage=final_usage,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
)
|
)
|
||||||
self._record_skill_learning(
|
self._record_run_receipts(
|
||||||
skill_learning_service=skill_learning_service,
|
skill_learning_service=skill_learning_service,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
session_id=resolved_session_id,
|
session_id=resolved_session_id,
|
||||||
@ -716,7 +816,7 @@ class AgentLoop:
|
|||||||
success=False,
|
success=False,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
attempt_index=attempt_index,
|
attempt_index=attempt_index,
|
||||||
generate_candidates=learning_candidate_enabled,
|
allow_candidate_generation=False,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -771,13 +871,16 @@ class AgentLoop:
|
|||||||
def _serialize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]:
|
def _serialize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]:
|
||||||
payload: list[dict[str, Any]] = []
|
payload: list[dict[str, Any]] = []
|
||||||
for tool_call in tool_calls:
|
for tool_call in tool_calls:
|
||||||
|
arguments = tool_call.arguments
|
||||||
|
if not isinstance(arguments, str):
|
||||||
|
arguments = json.dumps(arguments or {}, ensure_ascii=False, default=str)
|
||||||
payload.append(
|
payload.append(
|
||||||
{
|
{
|
||||||
"id": tool_call.id,
|
"id": tool_call.id,
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
"arguments": tool_call.arguments,
|
"arguments": arguments,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -877,7 +980,7 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _record_skill_learning(
|
def _record_run_receipts(
|
||||||
*,
|
*,
|
||||||
skill_learning_service: Any,
|
skill_learning_service: Any,
|
||||||
session_manager: Any,
|
session_manager: Any,
|
||||||
@ -891,7 +994,7 @@ class AgentLoop:
|
|||||||
success: bool,
|
success: bool,
|
||||||
task_id: str | None = None,
|
task_id: str | None = None,
|
||||||
attempt_index: int | None = None,
|
attempt_index: int | None = None,
|
||||||
generate_candidates: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
run_record = RunRecord(
|
run_record = RunRecord(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
@ -921,7 +1024,7 @@ class AgentLoop:
|
|||||||
try:
|
try:
|
||||||
candidates = skill_learning_service.collect_run_receipts(
|
candidates = skill_learning_service.collect_run_receipts(
|
||||||
RunReceiptContext(run_record=run_record, effect_records=effect_records),
|
RunReceiptContext(run_record=run_record, effect_records=effect_records),
|
||||||
generate_candidates=generate_candidates,
|
generate_candidates=allow_candidate_generation,
|
||||||
)
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive hot-path guard
|
except Exception as exc: # pragma: no cover - defensive hot-path guard
|
||||||
session_manager.append_message(
|
session_manager.append_message(
|
||||||
@ -948,7 +1051,7 @@ class AgentLoop:
|
|||||||
"run_record": run_record.to_dict(),
|
"run_record": run_record.to_dict(),
|
||||||
"skill_effects": [item.to_dict() for item in effect_records],
|
"skill_effects": [item.to_dict() for item in effect_records],
|
||||||
"learning_candidates": [candidate.to_dict() for candidate in candidates],
|
"learning_candidates": [candidate.to_dict() for candidate in candidates],
|
||||||
"learning_candidate_enabled": generate_candidates,
|
"candidate_generation_allowed": allow_candidate_generation,
|
||||||
},
|
},
|
||||||
content=f"Recorded {len(effect_records)} skill effect record(s).",
|
content=f"Recorded {len(effect_records)} skill effect record(s).",
|
||||||
context_visible=False,
|
context_visible=False,
|
||||||
|
|||||||
@ -45,6 +45,7 @@ class AnthropicProvider(LLMProvider):
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
try:
|
try:
|
||||||
client = self._client_or_raise()
|
client = self._client_or_raise()
|
||||||
|
|||||||
@ -90,6 +90,7 @@ class LLMProvider(ABC):
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""统一聊天接口。"""
|
"""统一聊天接口。"""
|
||||||
|
|
||||||
|
|||||||
@ -58,6 +58,7 @@ class FallbackProviderChain(LLMProvider):
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
self._last_provider = self.primary_provider
|
self._last_provider = self.primary_provider
|
||||||
self._last_runtime = self.primary_runtime
|
self._last_runtime = self.primary_runtime
|
||||||
@ -71,6 +72,7 @@ class FallbackProviderChain(LLMProvider):
|
|||||||
model=model or self.primary_runtime.model,
|
model=model or self.primary_runtime.model,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
|
thinking_enabled=thinking_enabled,
|
||||||
)
|
)
|
||||||
response = self._decorate_response(response, self.primary_runtime)
|
response = self._decorate_response(response, self.primary_runtime)
|
||||||
if not self._should_activate_fallback(response):
|
if not self._should_activate_fallback(response):
|
||||||
@ -91,6 +93,7 @@ class FallbackProviderChain(LLMProvider):
|
|||||||
model=self.fallback_runtime.model,
|
model=self.fallback_runtime.model,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
|
thinking_enabled=thinking_enabled,
|
||||||
)
|
)
|
||||||
return self._decorate_response(response, self.fallback_runtime)
|
return self._decorate_response(response, self.fallback_runtime)
|
||||||
|
|
||||||
@ -114,6 +117,7 @@ class FallbackProviderChain(LLMProvider):
|
|||||||
model: str,
|
model: str,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
|
thinking_enabled: bool | None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""把 provider 抛出的异常也收敛成统一 error response。
|
"""把 provider 抛出的异常也收敛成统一 error response。
|
||||||
|
|
||||||
@ -121,13 +125,16 @@ class FallbackProviderChain(LLMProvider):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await provider.chat(
|
kwargs = {
|
||||||
messages=messages,
|
"messages": messages,
|
||||||
tools=tools,
|
"tools": tools,
|
||||||
model=model,
|
"model": model,
|
||||||
max_tokens=max_tokens,
|
"max_tokens": max_tokens,
|
||||||
temperature=temperature,
|
"temperature": temperature,
|
||||||
)
|
}
|
||||||
|
if thinking_enabled is not None:
|
||||||
|
kwargs["thinking_enabled"] = thinking_enabled
|
||||||
|
return await provider.chat(**kwargs)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=f"Error: {exc}",
|
content=f"Error: {exc}",
|
||||||
|
|||||||
@ -41,6 +41,7 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
if httpx is None or get_codex_token is None:
|
if httpx is None or get_codex_token is None:
|
||||||
return LLMResponse(content="Error: codex dependencies are not installed", finish_reason="error", provider_name="openai_codex")
|
return LLMResponse(content="Error: codex dependencies are not installed", finish_reason="error", provider_name="openai_codex")
|
||||||
|
|||||||
@ -49,6 +49,7 @@ class CustomProvider(LLMProvider):
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
client = self._client_or_raise()
|
client = self._client_or_raise()
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
|
|||||||
@ -123,6 +123,25 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
clean = {key: value for key, value in message.items() if key in _ALLOWED_MSG_KEYS}
|
clean = {key: value for key, value in message.items() if key in _ALLOWED_MSG_KEYS}
|
||||||
if clean.get("role") == "assistant" and "content" not in clean:
|
if clean.get("role") == "assistant" and "content" not in clean:
|
||||||
clean["content"] = None
|
clean["content"] = None
|
||||||
|
if isinstance(clean.get("tool_calls"), list):
|
||||||
|
clean["tool_calls"] = LiteLLMProvider._sanitize_tool_calls(clean["tool_calls"])
|
||||||
|
sanitized.append(clean)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]:
|
||||||
|
sanitized: list[dict[str, Any]] = []
|
||||||
|
for tool_call in tool_calls:
|
||||||
|
if not isinstance(tool_call, dict):
|
||||||
|
continue
|
||||||
|
clean = dict(tool_call)
|
||||||
|
function = clean.get("function")
|
||||||
|
if isinstance(function, dict):
|
||||||
|
clean_function = dict(function)
|
||||||
|
arguments = clean_function.get("arguments")
|
||||||
|
if not isinstance(arguments, str):
|
||||||
|
clean_function["arguments"] = json.dumps(arguments or {}, ensure_ascii=False, default=str)
|
||||||
|
clean["function"] = clean_function
|
||||||
sanitized.append(clean)
|
sanitized.append(clean)
|
||||||
return sanitized
|
return sanitized
|
||||||
|
|
||||||
@ -155,6 +174,18 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
if provider_payload:
|
if provider_payload:
|
||||||
kwargs["provider"] = provider_payload
|
kwargs["provider"] = provider_payload
|
||||||
|
|
||||||
|
def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None:
|
||||||
|
if enabled is None:
|
||||||
|
return
|
||||||
|
model_key = f"{original_model} {resolved_model}".lower()
|
||||||
|
if "qwen" not in model_key:
|
||||||
|
return
|
||||||
|
extra_body = dict(kwargs.get("extra_body") or {})
|
||||||
|
chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {})
|
||||||
|
chat_template_kwargs["enable_thinking"] = bool(enabled)
|
||||||
|
extra_body["chat_template_kwargs"] = chat_template_kwargs
|
||||||
|
kwargs["extra_body"] = extra_body
|
||||||
|
|
||||||
async def chat(
|
async def chat(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
@ -162,6 +193,7 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
if acompletion is None:
|
if acompletion is None:
|
||||||
return LLMResponse(content="Error: litellm is not installed", finish_reason="error", provider_name=self.provider_name)
|
return LLMResponse(content="Error: litellm is not installed", finish_reason="error", provider_name=self.provider_name)
|
||||||
@ -174,6 +206,7 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
"messages": sanitized_messages,
|
"messages": sanitized_messages,
|
||||||
"max_tokens": max(1, max_tokens),
|
"max_tokens": max(1, max_tokens),
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
|
"timeout": self.request_timeout_seconds or 45.0,
|
||||||
}
|
}
|
||||||
if self.api_key:
|
if self.api_key:
|
||||||
kwargs["api_key"] = self.api_key
|
kwargs["api_key"] = self.api_key
|
||||||
@ -186,6 +219,7 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
kwargs["tool_choice"] = "auto"
|
kwargs["tool_choice"] = "auto"
|
||||||
self._apply_model_overrides(original_model, kwargs)
|
self._apply_model_overrides(original_model, kwargs)
|
||||||
self._apply_openrouter_routing(kwargs)
|
self._apply_openrouter_routing(kwargs)
|
||||||
|
self._apply_thinking_mode(original_model, resolved_model, kwargs, thinking_enabled)
|
||||||
env_overrides = self._build_env_overrides(self.api_key, self.api_base, original_model)
|
env_overrides = self._build_env_overrides(self.api_key, self.api_base, original_model)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -121,7 +121,37 @@ class SessionManager:
|
|||||||
3. 让 `ContextBuilder` 明确消费的是“上游裁剪后的可见片段”
|
3. 让 `ContextBuilder` 明确消费的是“上游裁剪后的可见片段”
|
||||||
"""
|
"""
|
||||||
|
|
||||||
history = self.get_messages_as_conversation(session_id)
|
records = self.get_event_records(session_id)
|
||||||
|
completed_run_ids = {
|
||||||
|
record.run_id
|
||||||
|
for record in records
|
||||||
|
if record.run_id and record.event_type == "run_completed"
|
||||||
|
}
|
||||||
|
failed_run_ids = {
|
||||||
|
record.run_id
|
||||||
|
for record in records
|
||||||
|
if record.run_id
|
||||||
|
and record.event_type == "run_completed"
|
||||||
|
and (
|
||||||
|
record.finish_reason == "error"
|
||||||
|
or (record.event_payload or {}).get("finish_reason") == "error"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
history = []
|
||||||
|
for record in records:
|
||||||
|
if not record.context_visible or record.role == "system":
|
||||||
|
continue
|
||||||
|
if record.role == "tool":
|
||||||
|
continue
|
||||||
|
if record.role == "assistant" and record.tool_calls:
|
||||||
|
continue
|
||||||
|
if record.run_id and record.run_id not in completed_run_ids:
|
||||||
|
continue
|
||||||
|
if record.run_id and record.run_id in failed_run_ids:
|
||||||
|
continue
|
||||||
|
if record.role == "assistant" and record.finish_reason == "error":
|
||||||
|
continue
|
||||||
|
history.append(record.to_conversation_message())
|
||||||
sliced = history[-max_messages:]
|
sliced = history[-max_messages:]
|
||||||
for index, message in enumerate(sliced):
|
for index, message in enumerate(sliced):
|
||||||
if message.get("role") == "user":
|
if message.get("role") == "user":
|
||||||
|
|||||||
@ -88,6 +88,15 @@ class MessageRecord:
|
|||||||
payload["feedback_state"] = self.event_payload.get("feedback_state")
|
payload["feedback_state"] = self.event_payload.get("feedback_state")
|
||||||
if self.event_payload.get("feedback_error"):
|
if self.event_payload.get("feedback_error"):
|
||||||
payload["feedback_error"] = self.event_payload.get("feedback_error")
|
payload["feedback_error"] = self.event_payload.get("feedback_error")
|
||||||
|
for key in (
|
||||||
|
"message_type",
|
||||||
|
"scheduled_job_id",
|
||||||
|
"scheduled_run_id",
|
||||||
|
"cron_job_name",
|
||||||
|
"mode",
|
||||||
|
):
|
||||||
|
if self.event_payload.get(key):
|
||||||
|
payload[key] = self.event_payload.get(key)
|
||||||
if self.tool_name:
|
if self.tool_name:
|
||||||
payload["tool_name"] = self.tool_name
|
payload["tool_name"] = self.tool_name
|
||||||
if self.tool_calls:
|
if self.tool_calls:
|
||||||
|
|||||||
@ -70,6 +70,7 @@ class SessionSearchService:
|
|||||||
include_children: bool = False,
|
include_children: bool = False,
|
||||||
source: str | None = None,
|
source: str | None = None,
|
||||||
exclude_sources: list[str] | None = None,
|
exclude_sources: list[str] | None = None,
|
||||||
|
exclude_end_reasons: list[str] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""列出最近活跃的 session 及其摘要元数据。"""
|
"""列出最近活跃的 session 及其摘要元数据。"""
|
||||||
|
|
||||||
@ -85,6 +86,10 @@ class SessionSearchService:
|
|||||||
placeholders = ",".join("?" for _ in exclude_sources)
|
placeholders = ",".join("?" for _ in exclude_sources)
|
||||||
clauses.append(f"source NOT IN ({placeholders})")
|
clauses.append(f"source NOT IN ({placeholders})")
|
||||||
params.extend(exclude_sources)
|
params.extend(exclude_sources)
|
||||||
|
if exclude_end_reasons:
|
||||||
|
placeholders = ",".join("?" for _ in exclude_end_reasons)
|
||||||
|
clauses.append(f"(end_reason IS NULL OR end_reason NOT IN ({placeholders}))")
|
||||||
|
params.extend(exclude_end_reasons)
|
||||||
|
|
||||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|||||||
@ -128,19 +128,46 @@ class SessionStore:
|
|||||||
self._conn.executescript(SCHEMA_SQL)
|
self._conn.executescript(SCHEMA_SQL)
|
||||||
try:
|
try:
|
||||||
self._conn.execute("SELECT * FROM messages_fts LIMIT 0")
|
self._conn.execute("SELECT * FROM messages_fts LIMIT 0")
|
||||||
except sqlite3.OperationalError:
|
self._conn.executescript(FTS_TRIGGER_SQL)
|
||||||
self._conn.executescript(FTS_TABLE_SQL)
|
except sqlite3.Error:
|
||||||
self._conn.executescript(FTS_TRIGGER_SQL)
|
self._rebuild_fts_index()
|
||||||
|
return
|
||||||
# 旧版本可能把 hidden 事件也写进了 FTS;初始化时顺手清掉这些噪声项。
|
# 旧版本可能把 hidden 事件也写进了 FTS;初始化时顺手清掉这些噪声项。
|
||||||
self._conn.execute(
|
try:
|
||||||
"""
|
self._conn.execute(
|
||||||
INSERT INTO messages_fts(messages_fts, rowid, content)
|
"""
|
||||||
SELECT 'delete', id, content
|
INSERT INTO messages_fts(messages_fts, rowid, content)
|
||||||
FROM messages
|
SELECT 'delete', id, content
|
||||||
WHERE context_visible = 0 AND content IS NOT NULL
|
FROM messages
|
||||||
"""
|
WHERE context_visible = 0 AND content IS NOT NULL
|
||||||
)
|
"""
|
||||||
self._conn.commit()
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
except sqlite3.Error:
|
||||||
|
self._rebuild_fts_index()
|
||||||
|
|
||||||
|
def _rebuild_fts_index(self) -> None:
|
||||||
|
"""Recreate the derived FTS index without touching canonical session rows."""
|
||||||
|
|
||||||
|
self._conn.executescript(
|
||||||
|
"""
|
||||||
|
DROP TRIGGER IF EXISTS messages_fts_insert;
|
||||||
|
DROP TRIGGER IF EXISTS messages_fts_delete;
|
||||||
|
DROP TRIGGER IF EXISTS messages_fts_update;
|
||||||
|
DROP TABLE IF EXISTS messages_fts;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._conn.executescript(FTS_TABLE_SQL)
|
||||||
|
self._conn.executescript(FTS_TRIGGER_SQL)
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO messages_fts(rowid, content)
|
||||||
|
SELECT id, content
|
||||||
|
FROM messages
|
||||||
|
WHERE context_visible = 1 AND content IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@ -1,13 +1,26 @@
|
|||||||
"""Configuration models and loaders."""
|
"""Configuration models and loaders."""
|
||||||
|
|
||||||
from .loader import default_config_path, load_config
|
from .loader import default_config_path, load_config
|
||||||
from .schema import AgentDefaultsConfig, BeaverConfig, EmbeddingConfig, ProviderConfig
|
from .schema import (
|
||||||
|
AgentDefaultsConfig,
|
||||||
|
AuthzConfig,
|
||||||
|
BackendIdentityConfig,
|
||||||
|
BeaverConfig,
|
||||||
|
EmbeddingConfig,
|
||||||
|
MCPServerConfig,
|
||||||
|
ProviderConfig,
|
||||||
|
ToolsConfig,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AgentDefaultsConfig",
|
"AgentDefaultsConfig",
|
||||||
|
"AuthzConfig",
|
||||||
|
"BackendIdentityConfig",
|
||||||
"BeaverConfig",
|
"BeaverConfig",
|
||||||
"EmbeddingConfig",
|
"EmbeddingConfig",
|
||||||
|
"MCPServerConfig",
|
||||||
"ProviderConfig",
|
"ProviderConfig",
|
||||||
|
"ToolsConfig",
|
||||||
"default_config_path",
|
"default_config_path",
|
||||||
"load_config",
|
"load_config",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -4,10 +4,30 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .schema import AgentDefaultsConfig, BeaverConfig, EmbeddingConfig, ProviderConfig
|
from .schema import (
|
||||||
|
AgentDefaultsConfig,
|
||||||
|
AuthzConfig,
|
||||||
|
BackendIdentityConfig,
|
||||||
|
BeaverConfig,
|
||||||
|
EmbeddingConfig,
|
||||||
|
MCPServerConfig,
|
||||||
|
ProviderConfig,
|
||||||
|
ToolsConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
LOCAL_MCP_CATEGORIES: dict[str, dict[str, str]] = {
|
||||||
|
"local_filesystem_mcp": {"category": "filesystem", "display_name": "本地文件工具"},
|
||||||
|
"local_runtime_mcp": {"category": "runtime", "display_name": "本地运行工具"},
|
||||||
|
"local_memory_mcp": {"category": "memory", "display_name": "本地记忆工具"},
|
||||||
|
"local_skills_mcp": {"category": "skills", "display_name": "本地技能工具"},
|
||||||
|
"local_coordination_mcp": {"category": "coordination", "display_name": "本地协作工具"},
|
||||||
|
"local_scheduler_mcp": {"category": "scheduler", "display_name": "本地定时工具"},
|
||||||
|
"local_web_mcp": {"category": "web", "display_name": "本地联网工具"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def default_config_path(*, workspace: str | Path | None = None) -> Path:
|
def default_config_path(*, workspace: str | Path | None = None) -> Path:
|
||||||
@ -57,6 +77,9 @@ def load_config(
|
|||||||
agents_defaults=_parse_agent_defaults(data),
|
agents_defaults=_parse_agent_defaults(data),
|
||||||
providers=_parse_providers(data.get("providers")),
|
providers=_parse_providers(data.get("providers")),
|
||||||
embedding=_parse_embedding(data),
|
embedding=_parse_embedding(data),
|
||||||
|
tools=_parse_tools(data.get("tools")),
|
||||||
|
authz=_parse_authz(data.get("authz")),
|
||||||
|
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
|
||||||
config_path=path,
|
config_path=path,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -104,6 +127,73 @@ def _parse_embedding(data: dict[str, Any]) -> EmbeddingConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tools(raw: Any) -> ToolsConfig:
|
||||||
|
data = _as_dict(raw)
|
||||||
|
mcp_servers: dict[str, MCPServerConfig] = {}
|
||||||
|
for server_id, payload in _as_dict(data.get("mcpServers") or data.get("mcp_servers")).items():
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
mcp_servers[str(server_id)] = MCPServerConfig(
|
||||||
|
command=_string(payload.get("command")) or "",
|
||||||
|
args=_string_list(payload.get("args")),
|
||||||
|
env=_string_dict(payload.get("env")),
|
||||||
|
url=_string(payload.get("url")) or "",
|
||||||
|
headers=_string_dict(payload.get("headers")),
|
||||||
|
auth_mode=(_string(payload.get("authMode") or payload.get("auth_mode")) or "none").lower(),
|
||||||
|
auth_audience=_string(payload.get("authAudience") or payload.get("auth_audience")) or "",
|
||||||
|
auth_scopes=_string_list(payload.get("authScopes") or payload.get("auth_scopes")),
|
||||||
|
tool_timeout=int(_float(payload.get("toolTimeout") or payload.get("tool_timeout")) or 30),
|
||||||
|
sensitive=_bool(payload.get("sensitive"), default=False),
|
||||||
|
kind=(_string(payload.get("kind")) or ("local" if payload.get("command") else "online")).lower(),
|
||||||
|
category=_string(payload.get("category")) or ("local" if payload.get("command") else "online"),
|
||||||
|
managed=_bool(payload.get("managed"), default=False),
|
||||||
|
display_name=_string(payload.get("displayName") or payload.get("display_name")) or "",
|
||||||
|
source=_string(payload.get("source")) or "config",
|
||||||
|
)
|
||||||
|
for server_id, meta in LOCAL_MCP_CATEGORIES.items():
|
||||||
|
if server_id in mcp_servers:
|
||||||
|
continue
|
||||||
|
mcp_servers[server_id] = MCPServerConfig(
|
||||||
|
command=sys.executable or "python",
|
||||||
|
args=["-m", "beaver.interfaces.mcp.tools_server", "--category", meta["category"]],
|
||||||
|
env={},
|
||||||
|
kind="local",
|
||||||
|
category=meta["category"],
|
||||||
|
managed=True,
|
||||||
|
display_name=meta["display_name"],
|
||||||
|
source="beaver-default",
|
||||||
|
tool_timeout=60,
|
||||||
|
)
|
||||||
|
return ToolsConfig(
|
||||||
|
restrict_to_workspace=_bool(
|
||||||
|
data.get("restrictToWorkspace") if "restrictToWorkspace" in data else data.get("restrict_to_workspace"),
|
||||||
|
default=True,
|
||||||
|
),
|
||||||
|
mcp_servers=mcp_servers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_authz(raw: Any) -> AuthzConfig:
|
||||||
|
data = _as_dict(raw)
|
||||||
|
return AuthzConfig(
|
||||||
|
enabled=_bool(data.get("enabled"), default=False),
|
||||||
|
base_url=_string(data.get("baseUrl") or data.get("base_url")) or "",
|
||||||
|
request_timeout_seconds=int(_float(data.get("requestTimeoutSeconds") or data.get("request_timeout_seconds")) or 10),
|
||||||
|
outlook_mcp_url=_string(data.get("outlookMcpUrl") or data.get("outlook_mcp_url")) or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
|
||||||
|
data = _as_dict(raw)
|
||||||
|
return BackendIdentityConfig(
|
||||||
|
backend_id=_string(data.get("backendId") or data.get("backend_id")) or "",
|
||||||
|
client_id=_string(data.get("clientId") or data.get("client_id")) or "",
|
||||||
|
client_secret=_string(data.get("clientSecret") or data.get("client_secret")) or "",
|
||||||
|
name=_string(data.get("name")) or "",
|
||||||
|
public_base_url=_string(data.get("publicBaseUrl") or data.get("public_base_url")) or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _as_dict(value: Any) -> dict[str, Any]:
|
def _as_dict(value: Any) -> dict[str, Any]:
|
||||||
return value if isinstance(value, dict) else {}
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
@ -121,7 +211,23 @@ def _string_dict(value: Any) -> dict[str, str]:
|
|||||||
return {str(key): str(item) for key, item in value.items() if item is not None}
|
return {str(key): str(item) for key, item in value.items() if item is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: Any) -> list[str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [str(item) for item in value if str(item).strip()]
|
||||||
|
|
||||||
|
|
||||||
def _float(value: Any) -> float | None:
|
def _float(value: Any) -> float | None:
|
||||||
if value in (None, ""):
|
if value in (None, ""):
|
||||||
return None
|
return None
|
||||||
return float(value)
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(value: Any, *, default: bool) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if value in (None, ""):
|
||||||
|
return default
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
return bool(value)
|
||||||
|
|||||||
@ -39,6 +39,65 @@ class EmbeddingConfig:
|
|||||||
request_timeout_seconds: float | None = None
|
request_timeout_seconds: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class MCPServerConfig:
|
||||||
|
"""One configured MCP server.
|
||||||
|
|
||||||
|
Transport is inferred from fields:
|
||||||
|
- command => local stdio MCP server
|
||||||
|
- url => remote streamable HTTP MCP server
|
||||||
|
"""
|
||||||
|
|
||||||
|
command: str = ""
|
||||||
|
args: list[str] = field(default_factory=list)
|
||||||
|
env: dict[str, str] = field(default_factory=dict)
|
||||||
|
url: str = ""
|
||||||
|
headers: dict[str, str] = field(default_factory=dict)
|
||||||
|
auth_mode: str = "none"
|
||||||
|
auth_audience: str = ""
|
||||||
|
auth_scopes: list[str] = field(default_factory=list)
|
||||||
|
tool_timeout: int = 30
|
||||||
|
sensitive: bool = False
|
||||||
|
kind: str = "online"
|
||||||
|
category: str = "online"
|
||||||
|
managed: bool = False
|
||||||
|
display_name: str = ""
|
||||||
|
source: str = "config"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def transport(self) -> str:
|
||||||
|
return "stdio" if _clean(self.command) else "http"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ToolsConfig:
|
||||||
|
"""Runtime tool configuration."""
|
||||||
|
|
||||||
|
restrict_to_workspace: bool = True
|
||||||
|
mcp_servers: dict[str, MCPServerConfig] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuthzConfig:
|
||||||
|
"""External AuthZ service configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
base_url: str = ""
|
||||||
|
request_timeout_seconds: int = 10
|
||||||
|
outlook_mcp_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class BackendIdentityConfig:
|
||||||
|
"""This backend's AuthZ client identity."""
|
||||||
|
|
||||||
|
backend_id: str = ""
|
||||||
|
client_id: str = ""
|
||||||
|
client_secret: str = ""
|
||||||
|
name: str = ""
|
||||||
|
public_base_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class BeaverConfig:
|
class BeaverConfig:
|
||||||
"""Config loaded once per backend sandbox instance."""
|
"""Config loaded once per backend sandbox instance."""
|
||||||
@ -46,6 +105,9 @@ class BeaverConfig:
|
|||||||
agents_defaults: AgentDefaultsConfig = field(default_factory=AgentDefaultsConfig)
|
agents_defaults: AgentDefaultsConfig = field(default_factory=AgentDefaultsConfig)
|
||||||
providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
||||||
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
||||||
|
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
||||||
|
authz: AuthzConfig = field(default_factory=AuthzConfig)
|
||||||
|
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
|
||||||
config_path: Path | None = None
|
config_path: Path | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -69,7 +131,13 @@ class BeaverConfig:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
resolved_model = _clean(model) or self.default_model
|
resolved_model = _clean(model) or self.default_model
|
||||||
resolved_provider = _clean(provider_name) or self._infer_provider(resolved_model)
|
requested_provider = _clean(provider_name)
|
||||||
|
enabled_providers = self._enabled_provider_names()
|
||||||
|
resolved_provider = (
|
||||||
|
requested_provider
|
||||||
|
if requested_provider and requested_provider in enabled_providers
|
||||||
|
else self._infer_provider(resolved_model)
|
||||||
|
)
|
||||||
provider_cfg = self.providers.get(resolved_provider or "") if resolved_provider else None
|
provider_cfg = self.providers.get(resolved_provider or "") if resolved_provider else None
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"model": resolved_model,
|
"model": resolved_model,
|
||||||
@ -115,22 +183,36 @@ class BeaverConfig:
|
|||||||
|
|
||||||
def _infer_provider(self, model: str | None) -> str | None:
|
def _infer_provider(self, model: str | None) -> str | None:
|
||||||
configured_provider = _clean(self.agents_defaults.provider)
|
configured_provider = _clean(self.agents_defaults.provider)
|
||||||
if configured_provider:
|
if configured_provider and configured_provider != "custom":
|
||||||
return configured_provider
|
return configured_provider
|
||||||
|
|
||||||
if model and "/" in model:
|
if model and "/" in model:
|
||||||
prefix = model.split("/", 1)[0]
|
prefix = model.split("/", 1)[0]
|
||||||
if prefix in self.providers:
|
if prefix in self._enabled_provider_names():
|
||||||
return prefix
|
return prefix
|
||||||
|
|
||||||
if len(self.providers) == 1:
|
enabled_providers = self._enabled_provider_names()
|
||||||
return next(iter(self.providers))
|
if len(enabled_providers) == 1:
|
||||||
|
return enabled_providers[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _enabled_provider_names(self) -> list[str]:
|
||||||
|
return [
|
||||||
|
name
|
||||||
|
for name, provider in self.providers.items()
|
||||||
|
if name != "custom"
|
||||||
|
and any(
|
||||||
|
[
|
||||||
|
_clean(provider.api_key),
|
||||||
|
_clean(provider.api_base),
|
||||||
|
provider.extra_headers,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _clean(value: str | None) -> str | None:
|
def _clean(value: str | None) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
value = str(value).strip()
|
value = str(value).strip()
|
||||||
return value or None
|
return value or None
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@ class EmbeddingRetriever:
|
|||||||
api_key_env: str = "OPENAI_API_KEY",
|
api_key_env: str = "OPENAI_API_KEY",
|
||||||
api_base_env: str = "OPENAI_API_BASE",
|
api_base_env: str = "OPENAI_API_BASE",
|
||||||
model: str = "text-embedding-v4",
|
model: str = "text-embedding-v4",
|
||||||
timeout_seconds: float = 20.0,
|
timeout_seconds: float = 3.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.api_key_env = api_key_env
|
self.api_key_env = api_key_env
|
||||||
self.api_base_env = api_base_env
|
self.api_base_env = api_base_env
|
||||||
|
|||||||
@ -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."""
|
"""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)
|
await self.bus.publish_inbound(message)
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
async def publish_external_text(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
chat_id: str,
|
||||||
|
message_id: str | None = None,
|
||||||
|
thread_id: str | None = None,
|
||||||
|
raw_payload: dict[str, Any] | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
|
) -> InboundMessage:
|
||||||
|
"""Publish an old-style channel payload through the new adapter contract.
|
||||||
|
|
||||||
|
Real platform adapters should keep platform-specific fields here, build
|
||||||
|
a stable Beaver session_id, and pass the normalized InboundMessage to
|
||||||
|
the shared gateway bus.
|
||||||
|
"""
|
||||||
|
|
||||||
|
session_parts = [self.name, chat_id]
|
||||||
|
if thread_id:
|
||||||
|
session_parts.append(thread_id)
|
||||||
|
metadata = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"raw_channel_payload": raw_payload or {},
|
||||||
|
}
|
||||||
|
return await self.publish_text(
|
||||||
|
content,
|
||||||
|
session_id=":".join(str(part) for part in session_parts if str(part)),
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""CLI entry for Beaver."""
|
"""CLI entry for Beaver."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import typer
|
import typer
|
||||||
except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments
|
except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments
|
||||||
@ -27,6 +29,8 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
|
|||||||
typer = _FallbackTyper() # type: ignore[assignment]
|
typer = _FallbackTyper() # type: ignore[assignment]
|
||||||
|
|
||||||
from beaver.services.agent_service import AgentService
|
from beaver.services.agent_service import AgentService
|
||||||
|
from beaver.services.hermes_migration import HermesMigrationService
|
||||||
|
from beaver.skills.specs import SkillSpecStore
|
||||||
|
|
||||||
app = typer.Typer(help="Beaver backend CLI") if hasattr(typer, "Typer") else typer
|
app = typer.Typer(help="Beaver backend CLI") if hasattr(typer, "Typer") else typer
|
||||||
|
|
||||||
@ -55,6 +59,26 @@ def run(
|
|||||||
typer.echo(result.output_text)
|
typer.echo(result.output_text)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("migrate-hermes")
|
||||||
|
def migrate_hermes(
|
||||||
|
repo: str = typer.Option(..., "--repo", help="Local checkout of https://github.com/NousResearch/hermes-agent."),
|
||||||
|
workspace: str | None = typer.Option(None, "--workspace", help="Workspace root to import skills into."),
|
||||||
|
manifest: str | None = typer.Option(None, "--manifest", help="Path for hermes_migration_manifest.json."),
|
||||||
|
dry_run: bool = typer.Option(False, "--dry-run", help="Only write the manifest without importing skills."),
|
||||||
|
) -> None:
|
||||||
|
"""Import no-credential Hermes Agent skills and write a manifest."""
|
||||||
|
|
||||||
|
service = AgentService(workspace=workspace)
|
||||||
|
loaded = service.create_loop().boot()
|
||||||
|
store = loaded.skill_spec_store or SkillSpecStore(loaded.workspace)
|
||||||
|
migration = HermesMigrationService(store, manifest_path=Path(manifest) if manifest else None)
|
||||||
|
result = migration.migrate(repo, dry_run=dry_run)
|
||||||
|
typer.echo(
|
||||||
|
f"Hermes migration complete: {len(result['included'])} included, "
|
||||||
|
f"{len(result['skipped'])} skipped."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Project script entrypoint."""
|
"""Project script entrypoint."""
|
||||||
app()
|
app()
|
||||||
|
|||||||
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
|
embedding_model: str | None = None
|
||||||
temperature: float | None = None
|
temperature: float | None = None
|
||||||
max_tokens: int | None = None
|
max_tokens: int | None = None
|
||||||
|
thinking_enabled: bool | None = None
|
||||||
max_tool_iterations: int | None = None
|
max_tool_iterations: int | None = None
|
||||||
fallback_target: WebProviderTarget | None = None
|
fallback_target: WebProviderTarget | None = None
|
||||||
auxiliary_target: WebProviderTarget | None = None
|
auxiliary_target: WebProviderTarget | None = None
|
||||||
embedding_target: WebProviderTarget | None = None
|
embedding_target: WebProviderTarget | None = None
|
||||||
|
reply_to_scheduled_run_id: str | None = None
|
||||||
|
scheduled_reply_intent: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class WebChatResponse(BaseModel):
|
class WebChatResponse(BaseModel):
|
||||||
|
|||||||
@ -44,6 +44,29 @@ class RunMemoryStore:
|
|||||||
def append_skill_effect(self, effect: SkillEffectRecord) -> None:
|
def append_skill_effect(self, effect: SkillEffectRecord) -> None:
|
||||||
self._append_jsonl(self.effects_path, effect.to_dict())
|
self._append_jsonl(self.effects_path, effect.to_dict())
|
||||||
|
|
||||||
|
def update_skill_effects_for_run(self, run_id: str, **updates: object) -> list[SkillEffectRecord]:
|
||||||
|
effects = [SkillEffectRecord.from_dict(item) for item in self._read_jsonl(self.effects_path)]
|
||||||
|
updated: list[SkillEffectRecord] = []
|
||||||
|
for index, effect in enumerate(effects):
|
||||||
|
if effect.run_id != run_id:
|
||||||
|
continue
|
||||||
|
payload = effect.to_dict()
|
||||||
|
payload.update(updates)
|
||||||
|
next_effect = SkillEffectRecord.from_dict(payload)
|
||||||
|
effects[index] = next_effect
|
||||||
|
updated.append(next_effect)
|
||||||
|
if not updated:
|
||||||
|
return []
|
||||||
|
self.effects_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.effects_path.write_text(
|
||||||
|
"".join(
|
||||||
|
json.dumps(effect.to_dict(), ensure_ascii=False, sort_keys=True) + "\n"
|
||||||
|
for effect in effects
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
def list_runs(self) -> list[RunRecord]:
|
def list_runs(self) -> list[RunRecord]:
|
||||||
return [RunRecord.from_dict(item) for item in self._read_jsonl(self.runs_path)]
|
return [RunRecord.from_dict(item) for item in self._read_jsonl(self.runs_path)]
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"""Application services for Beaver."""
|
"""Application services for Beaver."""
|
||||||
|
|
||||||
__all__ = ["AgentService", "MemoryService"]
|
__all__ = ["AgentService", "CronService", "MemoryService"]
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str):
|
def __getattr__(name: str):
|
||||||
@ -12,4 +12,8 @@ def __getattr__(name: str):
|
|||||||
from .memory_service import MemoryService
|
from .memory_service import MemoryService
|
||||||
|
|
||||||
return MemoryService
|
return MemoryService
|
||||||
|
if name == "CronService":
|
||||||
|
from .cron_service import CronService
|
||||||
|
|
||||||
|
return CronService
|
||||||
raise AttributeError(name)
|
raise AttributeError(name)
|
||||||
|
|||||||
@ -21,9 +21,13 @@ from beaver.coordinator.models import ExecutionNode, TeamRunResult
|
|||||||
from beaver.engine import AgentLoop, AgentProfile, AgentRunResult, EngineLoader
|
from beaver.engine import AgentLoop, AgentProfile, AgentRunResult, EngineLoader
|
||||||
from beaver.engine.providers import make_provider_bundle
|
from beaver.engine.providers import make_provider_bundle
|
||||||
from beaver.foundation.events import InboundMessage, OutboundMessage
|
from beaver.foundation.events import InboundMessage, OutboundMessage
|
||||||
|
from beaver.foundation.models import CronJob, CronRunRecord
|
||||||
from beaver.tasks import MainAgentRouter, TaskExecutionPlan, TaskRecord, ValidationResult
|
from beaver.tasks import MainAgentRouter, TaskExecutionPlan, TaskRecord, ValidationResult
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFICATION_SESSION_ID = "notify:default:scheduled"
|
||||||
|
|
||||||
|
|
||||||
class AgentService:
|
class AgentService:
|
||||||
"""面向 interfaces 的统一 agent 运行入口。
|
"""面向 interfaces 的统一 agent 运行入口。
|
||||||
|
|
||||||
@ -50,15 +54,24 @@ class AgentService:
|
|||||||
self._loop: AgentLoop | None = None
|
self._loop: AgentLoop | None = None
|
||||||
self._run_task: asyncio.Task[None] | None = None
|
self._run_task: asyncio.Task[None] | None = None
|
||||||
self._main_agent_router = MainAgentRouter()
|
self._main_agent_router = MainAgentRouter()
|
||||||
|
self._runtime_services: dict[str, Any] = {}
|
||||||
|
|
||||||
def create_loop(self) -> AgentLoop:
|
def create_loop(self) -> AgentLoop:
|
||||||
"""创建并缓存当前 service 使用的 AgentLoop。"""
|
"""创建并缓存当前 service 使用的 AgentLoop。"""
|
||||||
|
|
||||||
if self._loop is None:
|
if self._loop is None:
|
||||||
self._loop = AgentLoop(profile=self.profile, loader=self.loader)
|
self._loop = AgentLoop(profile=self.profile, loader=self.loader)
|
||||||
|
self._loop.runtime_services.update(self._runtime_services)
|
||||||
self._loop.boot()
|
self._loop.boot()
|
||||||
return self._loop
|
return self._loop
|
||||||
|
|
||||||
|
def register_runtime_service(self, name: str, service: Any) -> None:
|
||||||
|
"""Expose process-level services to tools during agent runs."""
|
||||||
|
|
||||||
|
self._runtime_services[name] = service
|
||||||
|
if self._loop is not None:
|
||||||
|
self._loop.runtime_services[name] = service
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_loop(self) -> bool:
|
def has_loop(self) -> bool:
|
||||||
"""当前 service 是否已经创建过 loop。"""
|
"""当前 service 是否已经创建过 loop。"""
|
||||||
@ -196,6 +209,191 @@ class AgentService:
|
|||||||
loop = self.create_loop()
|
loop = self.create_loop()
|
||||||
return await self._process_with_main_agent(message, runner=loop.submit_direct, kwargs=kwargs)
|
return await self._process_with_main_agent(message, runner=loop.submit_direct, kwargs=kwargs)
|
||||||
|
|
||||||
|
async def run_scheduled_task(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
cron_job_id: str,
|
||||||
|
cron_job_name: str,
|
||||||
|
scheduled_run_id: str | None = None,
|
||||||
|
requires_followup: bool = False,
|
||||||
|
) -> AgentRunResult:
|
||||||
|
"""Run a cron trigger as a normal internal Task.
|
||||||
|
|
||||||
|
Scheduled jobs are product-level Tasks, not hidden one-off agent turns.
|
||||||
|
This entry bypasses the main-agent classifier and forces Task mode so
|
||||||
|
every trigger produces a TaskRecord, validation, feedback state, and a
|
||||||
|
run_id that the scheduled-task history can link to.
|
||||||
|
"""
|
||||||
|
|
||||||
|
loaded = self.create_loop().boot()
|
||||||
|
task_service = self._require_loaded(loaded, "task_service")
|
||||||
|
loop = self.create_loop()
|
||||||
|
task = task_service.create_task(
|
||||||
|
session_id=session_id,
|
||||||
|
description=message,
|
||||||
|
creator="cron",
|
||||||
|
metadata={
|
||||||
|
"source": "scheduled_cron",
|
||||||
|
"cron_job_id": cron_job_id,
|
||||||
|
"cron_job_name": cron_job_name,
|
||||||
|
"scheduled_run_id": scheduled_run_id,
|
||||||
|
"user_engaged": False,
|
||||||
|
"requires_followup": requires_followup,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
execution_context = (
|
||||||
|
"This turn was triggered automatically by a scheduled task.\n\n"
|
||||||
|
f"Cron Job ID: {cron_job_id}\n"
|
||||||
|
f"Cron Job Name: {cron_job_name}\n"
|
||||||
|
f"Scheduled Run ID: {scheduled_run_id or 'unknown'}\n"
|
||||||
|
"Run it as a normal Beaver Task. Do not ask the user for confirmation; "
|
||||||
|
"execute the task and report the concrete outcome."
|
||||||
|
)
|
||||||
|
runner = loop.submit_direct if self.is_running else loop.process_direct
|
||||||
|
result = await self._run_task_mode(
|
||||||
|
message,
|
||||||
|
runner=runner,
|
||||||
|
task=task,
|
||||||
|
kwargs={
|
||||||
|
"session_id": session_id,
|
||||||
|
"source": "cron",
|
||||||
|
"user_id": "cron",
|
||||||
|
"title": cron_job_name,
|
||||||
|
"execution_context": execution_context,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
loaded = self.create_loop().boot()
|
||||||
|
session_manager = self._require_loaded(loaded, "session_manager")
|
||||||
|
session_manager.update_latest_assistant_event_payload(
|
||||||
|
result.session_id,
|
||||||
|
result.run_id,
|
||||||
|
{
|
||||||
|
"message_type": "scheduled_reply",
|
||||||
|
"scheduled_job_id": job.id,
|
||||||
|
"scheduled_run_id": run.scheduled_run_id,
|
||||||
|
"cron_job_name": job.name,
|
||||||
|
"mode": "notification",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def run_scheduled_notification(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
session_id: str = NOTIFICATION_SESSION_ID,
|
||||||
|
cron_job_id: str,
|
||||||
|
cron_job_name: str,
|
||||||
|
scheduled_run_id: str,
|
||||||
|
) -> AgentRunResult:
|
||||||
|
"""Run a cron trigger as a notification result, not as an active Task."""
|
||||||
|
|
||||||
|
loop = self.create_loop()
|
||||||
|
loaded = loop.boot()
|
||||||
|
session_manager = self._require_loaded(loaded, "session_manager")
|
||||||
|
runner = loop.submit_direct if self.is_running else loop.process_direct
|
||||||
|
execution_context = (
|
||||||
|
"This turn was triggered automatically by a scheduled notification.\n\n"
|
||||||
|
f"Cron Job ID: {cron_job_id}\n"
|
||||||
|
f"Cron Job Name: {cron_job_name}\n"
|
||||||
|
f"Scheduled Run ID: {scheduled_run_id}\n"
|
||||||
|
"Generate the notification content directly for the user. Do not ask for confirmation."
|
||||||
|
)
|
||||||
|
result = await runner(
|
||||||
|
message,
|
||||||
|
session_id=session_id,
|
||||||
|
source="notification",
|
||||||
|
user_id="cron",
|
||||||
|
title=cron_job_name,
|
||||||
|
execution_context=execution_context,
|
||||||
|
)
|
||||||
|
session_manager.update_latest_assistant_event_payload(
|
||||||
|
result.session_id,
|
||||||
|
result.run_id,
|
||||||
|
{
|
||||||
|
"message_type": "scheduled_result",
|
||||||
|
"scheduled_job_id": cron_job_id,
|
||||||
|
"scheduled_run_id": scheduled_run_id,
|
||||||
|
"cron_job_name": cron_job_name,
|
||||||
|
"mode": "notification",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def engage_scheduled_run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
job: CronJob,
|
||||||
|
run: CronRunRecord,
|
||||||
|
intent: str = "revise_once",
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
|
) -> TaskRecord:
|
||||||
|
"""Create or mark the Task that lets the user work on a scheduled result."""
|
||||||
|
|
||||||
|
loaded = self.create_loop().boot()
|
||||||
|
task_service = self._require_loaded(loaded, "task_service")
|
||||||
|
if run.task_id:
|
||||||
|
existing = task_service.get_task(run.task_id)
|
||||||
|
if existing is not None:
|
||||||
|
existing.metadata["user_engaged"] = True
|
||||||
|
existing.metadata["engage_intent"] = intent
|
||||||
|
task_service.store.upsert_task(existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
task = task_service.create_task(
|
||||||
|
session_id=run.notification_session_id or NOTIFICATION_SESSION_ID,
|
||||||
|
description=f"修改定时通知:{job.name}",
|
||||||
|
creator="cron",
|
||||||
|
metadata={
|
||||||
|
"source": "scheduled_run",
|
||||||
|
"cron_job_id": job.id,
|
||||||
|
"cron_job_name": job.name,
|
||||||
|
"scheduled_run_id": run.scheduled_run_id,
|
||||||
|
"scheduled_output": run.output,
|
||||||
|
"user_engaged": True,
|
||||||
|
"engage_intent": intent,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
async def submit_scheduled_reply(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
job: CronJob,
|
||||||
|
run: CronRunRecord,
|
||||||
|
intent: str = "revise_once",
|
||||||
|
) -> AgentRunResult:
|
||||||
|
task = self.engage_scheduled_run(job=job, run=run, intent=intent)
|
||||||
|
loop = self.create_loop()
|
||||||
|
runner = loop.submit_direct if self.is_running else loop.process_direct
|
||||||
|
execution_context = (
|
||||||
|
"The user is replying to a scheduled notification result.\n\n"
|
||||||
|
f"Cron Job ID: {job.id}\n"
|
||||||
|
f"Cron Job Name: {job.name}\n"
|
||||||
|
f"Scheduled Run ID: {run.scheduled_run_id}\n"
|
||||||
|
f"Engagement intent: {intent}\n"
|
||||||
|
f"Original scheduled instruction: {job.payload.message}\n"
|
||||||
|
f"Original notification output:\n{run.output or ''}\n\n"
|
||||||
|
"Handle this as a Task continuation. If the intent is update_future, explain the durable change "
|
||||||
|
"that should apply to future notifications."
|
||||||
|
)
|
||||||
|
return await self._run_task_mode(
|
||||||
|
message,
|
||||||
|
runner=runner,
|
||||||
|
task=task,
|
||||||
|
kwargs={
|
||||||
|
"session_id": task.session_id,
|
||||||
|
"source": "notification",
|
||||||
|
"user_id": "web",
|
||||||
|
"title": job.name,
|
||||||
|
"execution_context": execution_context,
|
||||||
|
"thinking_enabled": thinking_enabled,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def submit_feedback(
|
async def submit_feedback(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@ -269,19 +467,51 @@ class AgentService:
|
|||||||
|
|
||||||
generated_candidates = []
|
generated_candidates = []
|
||||||
validation = ValidationResult.from_dict(updated.validation_result)
|
validation = ValidationResult.from_dict(updated.validation_result)
|
||||||
|
if not already_recorded:
|
||||||
|
run_memory_store = self._require_loaded(loaded, "run_memory_store")
|
||||||
|
feedback_payload = {
|
||||||
|
"feedback_type": normalized,
|
||||||
|
"comment": comment or "",
|
||||||
|
"task_status": updated.status,
|
||||||
|
}
|
||||||
|
run_memory_store.update_run_record(
|
||||||
|
run_id,
|
||||||
|
success=normalized == "satisfied",
|
||||||
|
feedback=feedback_payload,
|
||||||
|
)
|
||||||
|
run_memory_store.update_skill_effects_for_run(
|
||||||
|
run_id,
|
||||||
|
success=normalized == "satisfied",
|
||||||
|
feedback_score=self._feedback_score_for_learning(normalized, validation),
|
||||||
|
notes=(comment or normalized).strip(),
|
||||||
|
)
|
||||||
|
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
|
||||||
|
skill_learning_service.rescore_skill_versions()
|
||||||
if already_recorded:
|
if already_recorded:
|
||||||
generated_candidates = []
|
generated_candidates = []
|
||||||
elif normalized == "satisfied" and validation is not None and validation.accepted:
|
elif normalized == "satisfied" and validation is not None and validation.accepted:
|
||||||
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
|
generated_candidates = [
|
||||||
generated_candidates = [item.to_dict() for item in skill_learning_service.build_learning_candidates()]
|
item.to_dict()
|
||||||
|
for item in skill_learning_service.build_learning_candidates_for_task(
|
||||||
|
updated.task_id,
|
||||||
|
trigger_run_id=run_id,
|
||||||
|
)
|
||||||
|
]
|
||||||
elif normalized == "abandon":
|
elif normalized == "abandon":
|
||||||
memory_service = self._require_loaded(loaded, "memory_service")
|
session_manager.append_message(
|
||||||
memory_service.get_store().add(
|
session_id,
|
||||||
"memory",
|
run_id=run_id,
|
||||||
(
|
role="system",
|
||||||
f"Failure memory: task {task.task_id} in session {session_id} was abandoned. "
|
event_type="task_failure_evidence_recorded",
|
||||||
f"Reason: {(comment or 'not specified').strip()}"
|
event_payload={
|
||||||
),
|
"task_id": updated.task_id,
|
||||||
|
"feedback_type": normalized,
|
||||||
|
"comment": comment or "",
|
||||||
|
"task_status": updated.status,
|
||||||
|
"durable_memory_written": False,
|
||||||
|
},
|
||||||
|
content=(comment or "Task abandoned; retained as run/session failure evidence."),
|
||||||
|
context_visible=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -302,20 +532,46 @@ class AgentService:
|
|||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
loaded = self.create_loop().boot()
|
loaded = self.create_loop().boot()
|
||||||
task_service = self._require_loaded(loaded, "task_service")
|
task_service = self._require_loaded(loaded, "task_service")
|
||||||
|
session_manager = self._require_loaded(loaded, "session_manager")
|
||||||
session_id = kwargs.get("session_id") or uuid4().hex
|
session_id = kwargs.get("session_id") or uuid4().hex
|
||||||
kwargs = dict(kwargs)
|
kwargs = dict(kwargs)
|
||||||
kwargs["session_id"] = session_id
|
kwargs["session_id"] = session_id
|
||||||
|
|
||||||
|
provider_bundle = kwargs.get("provider_bundle") or self._make_provider_bundle_for_task(loaded, kwargs)
|
||||||
|
kwargs["provider_bundle"] = provider_bundle
|
||||||
|
router_provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider
|
||||||
|
router_runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime
|
||||||
active_task = task_service.get_latest_open_task(session_id)
|
active_task = task_service.get_latest_open_task(session_id)
|
||||||
decision = self._main_agent_router.classify(message, active_task=active_task)
|
decision = await self._main_agent_router.classify(
|
||||||
|
message,
|
||||||
|
active_task=active_task,
|
||||||
|
provider=router_provider,
|
||||||
|
model=getattr(router_runtime, "model", None),
|
||||||
|
recent_messages=session_manager.get_messages_as_conversation(session_id),
|
||||||
|
thinking_enabled=kwargs.get("thinking_enabled"),
|
||||||
|
)
|
||||||
|
if active_task is not None and decision.short_title and not active_task.metadata.get("short_title"):
|
||||||
|
active_task.metadata["short_title"] = decision.short_title
|
||||||
|
task_service.store.upsert_task(active_task)
|
||||||
|
if active_task is not None and decision.closes_task:
|
||||||
|
task_service.close_task(active_task.task_id, reason=decision.reason)
|
||||||
|
return await runner(message, **kwargs)
|
||||||
|
if active_task is not None and decision.abandons_task:
|
||||||
|
task_service.abandon_task(active_task.task_id, reason=decision.reason)
|
||||||
|
return await runner(message, **kwargs)
|
||||||
if not decision.is_task:
|
if not decision.is_task:
|
||||||
|
kwargs["include_skill_assembly"] = False
|
||||||
|
kwargs["include_tools"] = False
|
||||||
return await runner(message, **kwargs)
|
return await runner(message, **kwargs)
|
||||||
|
|
||||||
task = (
|
task = (
|
||||||
task_service.create_task(
|
task_service.create_task(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
description=message,
|
description=message,
|
||||||
metadata={"router_reason": decision.reason},
|
metadata={
|
||||||
|
"router_reason": decision.reason,
|
||||||
|
**({"short_title": decision.short_title} if decision.short_title else {}),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if active_task is None or decision.starts_new_task
|
if active_task is None or decision.starts_new_task
|
||||||
else active_task
|
else active_task
|
||||||
@ -420,7 +676,7 @@ class AgentService:
|
|||||||
"task_id": task.task_id,
|
"task_id": task.task_id,
|
||||||
"task_mode": True,
|
"task_mode": True,
|
||||||
"attempt_index": attempt_index,
|
"attempt_index": attempt_index,
|
||||||
"learning_candidate_enabled": False,
|
"allow_candidate_generation": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if attempt_index == 2 and latest_validation is not None:
|
if attempt_index == 2 and latest_validation is not None:
|
||||||
@ -433,6 +689,14 @@ class AgentService:
|
|||||||
)
|
)
|
||||||
elif team_execution_context:
|
elif team_execution_context:
|
||||||
attempt_kwargs["execution_context"] = self._join_context(base_execution_context, team_execution_context)
|
attempt_kwargs["execution_context"] = self._join_context(base_execution_context, team_execution_context)
|
||||||
|
attempt_kwargs["skill_selection_context"] = self._build_skill_selection_context(
|
||||||
|
task=task,
|
||||||
|
user_message=message,
|
||||||
|
attempt_index=attempt_index,
|
||||||
|
latest_validation=latest_validation,
|
||||||
|
plan=plan,
|
||||||
|
team_summaries=team_summaries,
|
||||||
|
)
|
||||||
|
|
||||||
result = await runner(message, **attempt_kwargs)
|
result = await runner(message, **attempt_kwargs)
|
||||||
last_result = result
|
last_result = result
|
||||||
@ -519,7 +783,7 @@ class AgentService:
|
|||||||
parent_session_id=parent_session_id,
|
parent_session_id=parent_session_id,
|
||||||
parent_run_id=None,
|
parent_run_id=None,
|
||||||
provider_bundle_factory=provider_bundle_factory,
|
provider_bundle_factory=provider_bundle_factory,
|
||||||
learning_candidate_enabled=False,
|
allow_candidate_generation=False,
|
||||||
)
|
)
|
||||||
return result, None
|
return result, None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@ -542,6 +806,93 @@ class AgentService:
|
|||||||
return [receipt.skill_name for receipt in record.activated_skills]
|
return [receipt.skill_name for receipt in record.activated_skills]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _feedback_score_for_learning(feedback_type: str, validation: ValidationResult | None) -> float:
|
||||||
|
if feedback_type == "satisfied":
|
||||||
|
if validation is not None:
|
||||||
|
return max(0.0, min(1.0, float(validation.score)))
|
||||||
|
return 1.0
|
||||||
|
if feedback_type == "revise":
|
||||||
|
return 0.5
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_skill_selection_context(
|
||||||
|
*,
|
||||||
|
task: TaskRecord,
|
||||||
|
user_message: str,
|
||||||
|
attempt_index: int,
|
||||||
|
latest_validation: ValidationResult | None = None,
|
||||||
|
plan: TaskExecutionPlan | None = None,
|
||||||
|
team_summaries: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
phase = f"attempt_{attempt_index}"
|
||||||
|
if latest_validation is not None:
|
||||||
|
phase = f"revision_attempt_{attempt_index}"
|
||||||
|
elif plan is not None and plan.is_team:
|
||||||
|
phase = f"team_synthesis_attempt_{attempt_index}"
|
||||||
|
|
||||||
|
sections = [
|
||||||
|
f"Task goal:\n{task.goal or task.description}",
|
||||||
|
f"Task description:\n{task.description}",
|
||||||
|
f"Current user request:\n{user_message}",
|
||||||
|
f"Execution phase:\n{phase}",
|
||||||
|
f"Task status:\n{task.status}",
|
||||||
|
]
|
||||||
|
if task.constraints:
|
||||||
|
sections.append("Known constraints:\n" + "\n".join(f"- {item}" for item in task.constraints))
|
||||||
|
if task.skill_names:
|
||||||
|
sections.append(
|
||||||
|
"Previously activated skills (reuse bias, not pinned):\n"
|
||||||
|
+ "\n".join(f"- {item}" for item in task.skill_names)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sections.append("Previously activated skills:\nNone")
|
||||||
|
if latest_validation is not None:
|
||||||
|
validation_lines = [
|
||||||
|
f"accepted: {latest_validation.accepted}",
|
||||||
|
f"score: {latest_validation.score}",
|
||||||
|
]
|
||||||
|
if latest_validation.issues:
|
||||||
|
validation_lines.append("issues:\n" + "\n".join(f"- {item}" for item in latest_validation.issues))
|
||||||
|
if latest_validation.missing_requirements:
|
||||||
|
validation_lines.append(
|
||||||
|
"missing requirements:\n"
|
||||||
|
+ "\n".join(f"- {item}" for item in latest_validation.missing_requirements)
|
||||||
|
)
|
||||||
|
if latest_validation.recommended_revision_prompt:
|
||||||
|
validation_lines.append(
|
||||||
|
"recommended revision:\n"
|
||||||
|
+ latest_validation.recommended_revision_prompt
|
||||||
|
)
|
||||||
|
sections.append("Validation feedback:\n" + "\n".join(validation_lines))
|
||||||
|
if plan is not None:
|
||||||
|
plan_lines = [
|
||||||
|
f"mode: {plan.mode}",
|
||||||
|
f"reason: {plan.reason}",
|
||||||
|
]
|
||||||
|
if plan.final_synthesis_instruction:
|
||||||
|
plan_lines.append(f"final synthesis instruction: {plan.final_synthesis_instruction}")
|
||||||
|
if plan.graph is not None:
|
||||||
|
plan_lines.append(f"strategy: {plan.graph.strategy}")
|
||||||
|
plan_lines.append(
|
||||||
|
"nodes:\n"
|
||||||
|
+ "\n".join(
|
||||||
|
f"- {node.node_id}: {node.task}"
|
||||||
|
for node in plan.graph.nodes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sections.append("Execution plan:\n" + "\n".join(plan_lines))
|
||||||
|
if team_summaries:
|
||||||
|
sections.append("Team execution summaries:\n" + "\n\n".join(team_summaries)[:2400])
|
||||||
|
sections.append(
|
||||||
|
"Skill selection instruction:\n"
|
||||||
|
"Prefer reusing previously activated skills when they still match the Task. "
|
||||||
|
"Select new skills only if the current request, revision, or execution plan needs a different capability. "
|
||||||
|
"If no published skill matches, return [] and let the run continue without skills."
|
||||||
|
)
|
||||||
|
return "\n\n".join(section for section in sections if section.strip())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _run_excerpt(session_manager: Any, session_id: str, run_id: str) -> str:
|
def _run_excerpt(session_manager: Any, session_id: str, run_id: str) -> str:
|
||||||
lines = []
|
lines = []
|
||||||
@ -611,8 +962,8 @@ class AgentService:
|
|||||||
skill.name for skill in node.inherited_pinned_skill_contexts
|
skill.name for skill in node.inherited_pinned_skill_contexts
|
||||||
]
|
]
|
||||||
payload["skill_query"] = node.agent.metadata.get("skill_query")
|
payload["skill_query"] = node.agent.metadata.get("skill_query")
|
||||||
payload["generated_skill_draft_id"] = node.agent.metadata.get("generated_skill_draft_id")
|
payload["ephemeral_guidance_id"] = node.agent.metadata.get("ephemeral_guidance_id")
|
||||||
payload["generated_skill_name"] = node.agent.metadata.get("generated_skill_name")
|
payload["ephemeral_guidance_name"] = node.agent.metadata.get("ephemeral_guidance_name")
|
||||||
payload["ephemeral_used"] = bool(node.inherited_pinned_skill_contexts)
|
payload["ephemeral_used"] = bool(node.inherited_pinned_skill_contexts)
|
||||||
payloads.append(payload)
|
payloads.append(payload)
|
||||||
return payloads
|
return payloads
|
||||||
|
|||||||
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()}
|
run_records = {record.run_id: record for record in self.run_memory_store.list_runs()}
|
||||||
runs: dict[str, dict[str, Any]] = {}
|
runs: dict[str, dict[str, Any]] = {}
|
||||||
events: list[dict[str, Any]] = []
|
events: list[dict[str, Any]] = []
|
||||||
|
artifacts: list[dict[str, Any]] = []
|
||||||
|
|
||||||
def add_event(
|
def add_event(
|
||||||
*,
|
*,
|
||||||
@ -84,7 +85,7 @@ class SessionProcessProjector:
|
|||||||
"node_ids": node_ids,
|
"node_ids": node_ids,
|
||||||
"skill_queries": payload.get("skill_queries") or [],
|
"skill_queries": payload.get("skill_queries") or [],
|
||||||
"selected_skill_names": payload.get("selected_skill_names") or [],
|
"selected_skill_names": payload.get("selected_skill_names") or [],
|
||||||
"generated_skill_draft_ids": payload.get("generated_skill_draft_ids") or [],
|
"ephemeral_guidance_ids": payload.get("ephemeral_guidance_ids") or [],
|
||||||
"skill_resolution_report": payload.get("skill_resolution_report") or [],
|
"skill_resolution_report": payload.get("skill_resolution_report") or [],
|
||||||
"fallback_error": payload.get("fallback_error"),
|
"fallback_error": payload.get("fallback_error"),
|
||||||
}
|
}
|
||||||
@ -151,13 +152,42 @@ class SessionProcessProjector:
|
|||||||
"skill_query": item.get("skill_query"),
|
"skill_query": item.get("skill_query"),
|
||||||
"selected_skill_names": item.get("selected_skill_names") or [],
|
"selected_skill_names": item.get("selected_skill_names") or [],
|
||||||
"ephemeral_skill_names": item.get("ephemeral_skill_names") or [],
|
"ephemeral_skill_names": item.get("ephemeral_skill_names") or [],
|
||||||
"generated_skill_draft_id": item.get("generated_skill_draft_id"),
|
"ephemeral_guidance_id": item.get("ephemeral_guidance_id"),
|
||||||
"generated_skill_name": item.get("generated_skill_name"),
|
"ephemeral_guidance_name": item.get("ephemeral_guidance_name"),
|
||||||
"ephemeral_used": bool(item.get("ephemeral_used")),
|
"ephemeral_used": bool(item.get("ephemeral_used")),
|
||||||
"finish_reason": item.get("finish_reason"),
|
"finish_reason": item.get("finish_reason"),
|
||||||
"error": item.get("error"),
|
"error": item.get("error"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
guidance_id = item.get("ephemeral_guidance_id")
|
||||||
|
if guidance_id:
|
||||||
|
guidance_name = str(item.get("ephemeral_guidance_name") or guidance_id)
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"artifact_id": f"{node_run_id}:ephemeral-guidance:{guidance_id}",
|
||||||
|
"run_id": str(node_run_id),
|
||||||
|
"actor_type": "agent",
|
||||||
|
"actor_id": str(item.get("node_id") or "sub-agent"),
|
||||||
|
"actor_name": str(item.get("node_id") or "Sub-agent"),
|
||||||
|
"title": f"Ephemeral guidance: {guidance_name}",
|
||||||
|
"artifact_type": "markdown",
|
||||||
|
"content": (
|
||||||
|
f"# Ephemeral guidance\n\n"
|
||||||
|
f"- Guidance: {guidance_name}\n"
|
||||||
|
f"- Guidance ID: {guidance_id}\n"
|
||||||
|
f"- Scope: current delegated sub-agent run only"
|
||||||
|
),
|
||||||
|
"metadata": {
|
||||||
|
"task_id": task_id,
|
||||||
|
"attempt_index": attempt_index,
|
||||||
|
"node_id": item.get("node_id"),
|
||||||
|
"ephemeral_guidance_id": guidance_id,
|
||||||
|
"ephemeral_guidance_name": guidance_name,
|
||||||
|
"ephemeral_skill_names": item.get("ephemeral_skill_names") or [],
|
||||||
|
},
|
||||||
|
"created_at": created_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
add_event(
|
add_event(
|
||||||
event_id=f"{_event_id(record, 'node')}:{item.get('node_id')}",
|
event_id=f"{_event_id(record, 'node')}:{item.get('node_id')}",
|
||||||
run_id=str(node_run_id),
|
run_id=str(node_run_id),
|
||||||
@ -231,7 +261,7 @@ class SessionProcessProjector:
|
|||||||
return {
|
return {
|
||||||
"runs": sorted(runs.values(), key=lambda item: item.get("started_at") or ""),
|
"runs": sorted(runs.values(), key=lambda item: item.get("started_at") or ""),
|
||||||
"events": sorted(events, key=lambda item: item.get("created_at") or ""),
|
"events": sorted(events, key=lambda item: item.get("created_at") or ""),
|
||||||
"artifacts": [],
|
"artifacts": sorted(artifacts, key=lambda item: item.get("created_at") or ""),
|
||||||
"agents": [],
|
"agents": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,
|
provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None = None,
|
||||||
inherited_pinned_skills: list[str] | None = None,
|
inherited_pinned_skills: list[str] | None = None,
|
||||||
inherited_pinned_skill_contexts: list["SkillContext"] | None = None,
|
inherited_pinned_skill_contexts: list["SkillContext"] | None = None,
|
||||||
learning_candidate_enabled: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
) -> TeamRunResult:
|
) -> TeamRunResult:
|
||||||
"""Run a team graph inside the parent task context."""
|
"""Run a team graph inside the parent task context."""
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ class TeamService:
|
|||||||
provider_bundle_factory=provider_bundle_factory,
|
provider_bundle_factory=provider_bundle_factory,
|
||||||
inherited_pinned_skills=inherited_pinned_skills,
|
inherited_pinned_skills=inherited_pinned_skills,
|
||||||
inherited_pinned_skill_contexts=inherited_pinned_skill_contexts,
|
inherited_pinned_skill_contexts=inherited_pinned_skill_contexts,
|
||||||
learning_candidate_enabled=learning_candidate_enabled,
|
allow_candidate_generation=allow_candidate_generation,
|
||||||
)
|
)
|
||||||
self._attach_runs_to_parent_task(result)
|
self._attach_runs_to_parent_task(result)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@ -1,19 +1,22 @@
|
|||||||
"""LLM-driven skill assembler.
|
"""LLM-driven skill assembler.
|
||||||
|
|
||||||
这层现在不再自己做规则打分,而是直接把:
|
这层现在不再自己做规则打分,而是分两步把:
|
||||||
1. task description
|
1. task description
|
||||||
2. embedding 召回后的候选 skill 摘要
|
2. embedding 召回后的候选 skill 摘要
|
||||||
|
3. 粗选候选的完整 skill 正文
|
||||||
|
|
||||||
交给一个模型来决定本轮要激活哪些 skill。
|
交给一个模型来决定本轮要激活哪些 skill。
|
||||||
|
|
||||||
当前目标非常克制:
|
当前目标非常克制:
|
||||||
- 输入尽量简单
|
- 主 agent 不拿 skill_view,也不动态探索技能库
|
||||||
|
- SkillAssembler 可以在系统侧内部读取候选 skill 正文
|
||||||
- 输出只要 skill 名称
|
- 输出只要 skill 名称
|
||||||
- 没有命中就返回空 skills
|
- 没有命中就返回空 skills
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -31,6 +34,7 @@ class SkillAssemblyResult:
|
|||||||
"""一次装配后真正要注入当前 run 的 skills。"""
|
"""一次装配后真正要注入当前 run 的 skills。"""
|
||||||
|
|
||||||
activated_skills: list[SkillContext] = field(default_factory=list)
|
activated_skills: list[SkillContext] = field(default_factory=list)
|
||||||
|
llm_interactions: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class SkillAssembler:
|
class SkillAssembler:
|
||||||
@ -40,9 +44,14 @@ class SkillAssembler:
|
|||||||
self,
|
self,
|
||||||
loader: SkillsLoader,
|
loader: SkillsLoader,
|
||||||
retriever: SkillEmbeddingRetriever | None = None,
|
retriever: SkillEmbeddingRetriever | None = None,
|
||||||
|
*,
|
||||||
|
max_detailed_candidates: int = 5,
|
||||||
|
max_candidate_content_chars: int = 6000,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.loader = loader
|
self.loader = loader
|
||||||
self.retriever = retriever or SkillEmbeddingRetriever()
|
self.retriever = retriever or SkillEmbeddingRetriever()
|
||||||
|
self.max_detailed_candidates = max(1, max_detailed_candidates)
|
||||||
|
self.max_candidate_content_chars = max(1000, max_candidate_content_chars)
|
||||||
|
|
||||||
async def assemble(
|
async def assemble(
|
||||||
self,
|
self,
|
||||||
@ -51,6 +60,7 @@ class SkillAssembler:
|
|||||||
provider: LLMProvider,
|
provider: LLMProvider,
|
||||||
model: str,
|
model: str,
|
||||||
embedding_runtime: ProviderRuntime | None = None,
|
embedding_runtime: ProviderRuntime | None = None,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
top_k: int = 12,
|
top_k: int = 12,
|
||||||
) -> SkillAssemblyResult:
|
) -> SkillAssemblyResult:
|
||||||
candidates = self.loader.build_selection_candidates()
|
candidates = self.loader.build_selection_candidates()
|
||||||
@ -71,15 +81,39 @@ class SkillAssembler:
|
|||||||
)
|
)
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return SkillAssemblyResult()
|
return SkillAssemblyResult()
|
||||||
|
llm_interactions: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
if len(candidates) <= self.max_detailed_candidates:
|
||||||
|
shortlisted_names = [item["name"] for item in candidates]
|
||||||
|
else:
|
||||||
|
shortlisted_names = await self._select_skill_names(
|
||||||
|
task_description=task_description,
|
||||||
|
candidates=candidates,
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
thinking_enabled=thinking_enabled,
|
||||||
|
max_selected=self.max_detailed_candidates,
|
||||||
|
selection_stage="shortlist",
|
||||||
|
llm_interactions=llm_interactions,
|
||||||
|
)
|
||||||
|
if not shortlisted_names:
|
||||||
|
return SkillAssemblyResult(llm_interactions=llm_interactions)
|
||||||
|
|
||||||
|
detailed_candidates = self._build_detailed_candidates(
|
||||||
|
candidates=candidates,
|
||||||
|
selected_names=shortlisted_names,
|
||||||
|
)
|
||||||
selected_names = await self._select_skill_names(
|
selected_names = await self._select_skill_names(
|
||||||
task_description=task_description,
|
task_description=task_description,
|
||||||
candidates=candidates,
|
candidates=detailed_candidates,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
model=model,
|
model=model,
|
||||||
|
thinking_enabled=thinking_enabled,
|
||||||
|
selection_stage="final",
|
||||||
|
llm_interactions=llm_interactions,
|
||||||
)
|
)
|
||||||
if not selected_names:
|
if not selected_names:
|
||||||
return SkillAssemblyResult()
|
return SkillAssemblyResult(llm_interactions=llm_interactions)
|
||||||
|
|
||||||
activated_skills: list[SkillContext] = []
|
activated_skills: list[SkillContext] = []
|
||||||
for name in selected_names:
|
for name in selected_names:
|
||||||
@ -99,7 +133,7 @@ class SkillAssembler:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return SkillAssemblyResult(activated_skills=activated_skills)
|
return SkillAssemblyResult(activated_skills=activated_skills, llm_interactions=llm_interactions)
|
||||||
|
|
||||||
async def _select_skill_names(
|
async def _select_skill_names(
|
||||||
self,
|
self,
|
||||||
@ -108,17 +142,28 @@ class SkillAssembler:
|
|||||||
candidates: list[dict[str, str]],
|
candidates: list[dict[str, str]],
|
||||||
provider: LLMProvider,
|
provider: LLMProvider,
|
||||||
model: str,
|
model: str,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
|
max_selected: int | None = None,
|
||||||
|
selection_stage: str = "final",
|
||||||
|
llm_interactions: list[dict[str, Any]] | None = None,
|
||||||
|
timeout_seconds: float = 8.0,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
candidate_summary = self._render_candidates(candidates)
|
candidate_summary = self._render_candidates(candidates)
|
||||||
candidate_names = {item["name"] for item in candidates}
|
candidate_names = {item["name"] for item in candidates}
|
||||||
|
selection_instruction = (
|
||||||
|
f"Return at most {max_selected} names for detailed inspection. "
|
||||||
|
if max_selected is not None
|
||||||
|
else "Return the final skill names to activate. "
|
||||||
|
)
|
||||||
messages = [
|
messages = [
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": (
|
"content": (
|
||||||
"You select Beaver skills for a single run. "
|
"You select Beaver skills for a single run. "
|
||||||
"Given a task description and candidate skill summaries, "
|
"Given a task description and candidate skill information, "
|
||||||
"return only a JSON array of skill names to activate. "
|
"return only a JSON array of skill names to activate. "
|
||||||
"Do not invent names. If nothing matches, return []."
|
"Do not invent names. If nothing matches, return []. "
|
||||||
|
f"Selection stage: {selection_stage}. {selection_instruction}"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -130,13 +175,34 @@ class SkillAssembler:
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
response = await provider.chat(
|
chat_kwargs: dict[str, Any] = {
|
||||||
messages=messages,
|
"messages": messages,
|
||||||
tools=None,
|
"tools": None,
|
||||||
model=model,
|
"model": model,
|
||||||
max_tokens=512,
|
"max_tokens": 256,
|
||||||
temperature=0,
|
"temperature": 0,
|
||||||
)
|
}
|
||||||
|
if thinking_enabled is not None:
|
||||||
|
chat_kwargs["thinking_enabled"] = thinking_enabled
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=timeout_seconds)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if llm_interactions is not None:
|
||||||
|
llm_interactions.append(
|
||||||
|
{
|
||||||
|
"stage": selection_stage,
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"response": {
|
||||||
|
"content": response.content,
|
||||||
|
"finish_reason": response.finish_reason,
|
||||||
|
"provider_name": response.provider_name,
|
||||||
|
"model": response.model,
|
||||||
|
"usage": response.usage,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
if response.finish_reason == "error" or not response.content:
|
if response.finish_reason == "error" or not response.content:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@ -149,15 +215,42 @@ class SkillAssembler:
|
|||||||
for name in parsed:
|
for name in parsed:
|
||||||
if name in candidate_names and name not in filtered:
|
if name in candidate_names and name not in filtered:
|
||||||
filtered.append(name)
|
filtered.append(name)
|
||||||
return filtered
|
return filtered[:max_selected] if max_selected is not None else filtered
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _render_candidates(candidates: list[dict[str, str]]) -> str:
|
def _render_candidates(candidates: list[dict[str, str]]) -> str:
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for item in candidates:
|
for item in candidates:
|
||||||
lines.append(f"- {item['name']}: {item['description']}")
|
content = item.get("content")
|
||||||
|
if content:
|
||||||
|
lines.append(
|
||||||
|
f"## {item['name']}\n"
|
||||||
|
f"Description: {item['description']}\n"
|
||||||
|
f"Skill content:\n{content}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(f"- {item['name']}: {item['description']}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _build_detailed_candidates(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
candidates: list[dict[str, str]],
|
||||||
|
selected_names: list[str],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
by_name = {item["name"]: item for item in candidates}
|
||||||
|
detailed: list[dict[str, str]] = []
|
||||||
|
for name in selected_names:
|
||||||
|
candidate = by_name.get(name)
|
||||||
|
if candidate is None:
|
||||||
|
continue
|
||||||
|
raw_content = self.loader.load_published_skill(name)
|
||||||
|
content = strip_frontmatter(raw_content).strip() if raw_content else ""
|
||||||
|
if len(content) > self.max_candidate_content_chars:
|
||||||
|
content = content[: self.max_candidate_content_chars].rstrip() + "\n...[truncated]"
|
||||||
|
detailed.append({**candidate, "content": content})
|
||||||
|
return detailed
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_selected_names(content: str) -> list[str]:
|
def _parse_selected_names(content: str) -> list[str]:
|
||||||
cleaned = content.strip()
|
cleaned = content.strip()
|
||||||
|
|||||||
@ -244,12 +244,10 @@ class SkillsLoader:
|
|||||||
meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", ""))
|
meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", ""))
|
||||||
available = check_requirements(meta_blob)
|
available = check_requirements(meta_blob)
|
||||||
description = frontmatter.get("description") or record.description or record.name
|
description = frontmatter.get("description") or record.description or record.name
|
||||||
load_hint = f'Use skill_view(name="{record.name}") to load the full skill.'
|
|
||||||
lines.append(f' <skill available="{str(available).lower()}">')
|
lines.append(f' <skill available="{str(available).lower()}">')
|
||||||
lines.append(f" <name>{escape_xml(record.name)}</name>")
|
lines.append(f" <name>{escape_xml(record.name)}</name>")
|
||||||
lines.append(f" <description>{escape_xml(description)}</description>")
|
lines.append(f" <description>{escape_xml(description)}</description>")
|
||||||
lines.append(f" <version>{escape_xml(record.version)}</version>")
|
lines.append(f" <version>{escape_xml(record.version)}</version>")
|
||||||
lines.append(f" <load_hint>{escape_xml(load_hint)}</load_hint>")
|
|
||||||
support_files = self.list_skill_supporting_files(record.name)
|
support_files = self.list_skill_supporting_files(record.name)
|
||||||
if support_files:
|
if support_files:
|
||||||
lines.append(" <supporting_files>")
|
lines.append(" <supporting_files>")
|
||||||
|
|||||||
@ -124,6 +124,9 @@ class DraftService:
|
|||||||
def get_draft(self, skill_name: str, draft_id: str) -> SkillDraft | None:
|
def get_draft(self, skill_name: str, draft_id: str) -> SkillDraft | None:
|
||||||
return self.store.read_draft(skill_name, draft_id)
|
return self.store.read_draft(skill_name, draft_id)
|
||||||
|
|
||||||
|
def delete_draft(self, skill_name: str, draft_id: str) -> bool:
|
||||||
|
return self.store.delete_draft(skill_name, draft_id)
|
||||||
|
|
||||||
|
|
||||||
def _utc_now() -> str:
|
def _utc_now() -> str:
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|||||||
@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
from .evidence import EvidencePacket, EvidenceSelector
|
from .evidence import EvidencePacket, EvidenceSelector
|
||||||
from .eval import SkillDraftEvaluator
|
from .eval import SkillDraftEvaluator
|
||||||
from .missing_skill import MissingSkillDraftResult, MissingSkillSynthesizer
|
from .missing_skill import (
|
||||||
|
EphemeralGuidanceResult,
|
||||||
|
EphemeralGuidanceSynthesizer,
|
||||||
|
MissingSkillDraftResult,
|
||||||
|
MissingSkillSynthesizer,
|
||||||
|
)
|
||||||
from .pipeline import SkillLearningPipelineService
|
from .pipeline import SkillLearningPipelineService
|
||||||
from .service import RunReceiptContext, SkillLearningService
|
from .service import RunReceiptContext, SkillLearningService
|
||||||
from .synthesizer import SkillDraftSynthesizer
|
from .synthesizer import SkillDraftSynthesizer
|
||||||
@ -12,6 +17,8 @@ __all__ = [
|
|||||||
"EvidencePacket",
|
"EvidencePacket",
|
||||||
"EvidenceSelector",
|
"EvidenceSelector",
|
||||||
"SkillDraftEvaluator",
|
"SkillDraftEvaluator",
|
||||||
|
"EphemeralGuidanceResult",
|
||||||
|
"EphemeralGuidanceSynthesizer",
|
||||||
"MissingSkillDraftResult",
|
"MissingSkillDraftResult",
|
||||||
"MissingSkillSynthesizer",
|
"MissingSkillSynthesizer",
|
||||||
"RunReceiptContext",
|
"RunReceiptContext",
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Synthesize draft-only skills for missing sub-agent guidance."""
|
"""Synthesize ephemeral guidance for missing sub-agent skills."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -6,11 +6,10 @@ import json
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from beaver.engine.context import SkillContext
|
from beaver.engine.context import SkillContext
|
||||||
from beaver.engine.providers import ProviderBundle
|
from beaver.engine.providers import ProviderBundle
|
||||||
from beaver.skills.drafts import DraftService
|
|
||||||
from beaver.skills.specs import SkillDraft
|
|
||||||
from beaver.skills.specs.serialization import canonical_hash
|
from beaver.skills.specs.serialization import canonical_hash
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -18,13 +17,14 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class MissingSkillDraftResult:
|
class EphemeralGuidanceResult:
|
||||||
draft: SkillDraft
|
guidance_id: str
|
||||||
|
guidance_name: str
|
||||||
skill_context: SkillContext
|
skill_context: SkillContext
|
||||||
|
|
||||||
|
|
||||||
class MissingSkillSynthesizer:
|
class EphemeralGuidanceSynthesizer:
|
||||||
"""Create a draft skill and an ephemeral SkillContext for the current run."""
|
"""Create one-run guidance for the current delegated sub-agent."""
|
||||||
|
|
||||||
async def synthesize(
|
async def synthesize(
|
||||||
self,
|
self,
|
||||||
@ -37,8 +37,7 @@ class MissingSkillSynthesizer:
|
|||||||
skill_query: str,
|
skill_query: str,
|
||||||
required_capabilities: list[str],
|
required_capabilities: list[str],
|
||||||
provider_bundle: ProviderBundle,
|
provider_bundle: ProviderBundle,
|
||||||
draft_service: DraftService,
|
) -> EphemeralGuidanceResult:
|
||||||
) -> MissingSkillDraftResult:
|
|
||||||
provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider
|
provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider
|
||||||
runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime
|
runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime
|
||||||
model = getattr(runtime, "model", None)
|
model = getattr(runtime, "model", None)
|
||||||
@ -49,14 +48,14 @@ class MissingSkillSynthesizer:
|
|||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": (
|
"content": (
|
||||||
"You create concise Beaver skill drafts. Return only JSON with keys: "
|
"You create concise Beaver ephemeral guidance. Return only JSON with keys: "
|
||||||
"skill_name, description, content, tags."
|
"guidance_name, description, content, tags."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": (
|
"content": (
|
||||||
"Create a procedural skill draft for this missing Task sub-agent guidance.\n\n"
|
"Create procedural guidance for this missing Task sub-agent capability.\n\n"
|
||||||
f"Task goal:\n{task.goal}\n\n"
|
f"Task goal:\n{task.goal}\n\n"
|
||||||
f"Current user request:\n{user_message}\n\n"
|
f"Current user request:\n{user_message}\n\n"
|
||||||
f"Node id: {node_id}\n"
|
f"Node id: {node_id}\n"
|
||||||
@ -64,62 +63,37 @@ class MissingSkillSynthesizer:
|
|||||||
f"Skill query:\n{skill_query}\n"
|
f"Skill query:\n{skill_query}\n"
|
||||||
f"Required capabilities: {required_capabilities}\n\n"
|
f"Required capabilities: {required_capabilities}\n\n"
|
||||||
"The content must be actionable guidance for a temporary sub-agent. "
|
"The content must be actionable guidance for a temporary sub-agent. "
|
||||||
"Do not include implementation claims or publish metadata."
|
"Do not include implementation claims, review metadata, or publish metadata."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
tools=None,
|
tools=None,
|
||||||
model=model,
|
model=model,
|
||||||
max_tokens=1200,
|
max_tokens=4096,
|
||||||
temperature=0,
|
temperature=0,
|
||||||
)
|
)
|
||||||
payload = self._parse_payload(response.content or "") or payload
|
payload = self._parse_payload(response.content or "") or payload
|
||||||
except Exception:
|
except Exception:
|
||||||
payload = payload
|
payload = payload
|
||||||
|
|
||||||
skill_name = _slug(str(payload.get("skill_name") or skill_query or node_id))
|
guidance_name = _slug(str(payload.get("guidance_name") or payload.get("skill_name") or skill_query or node_id))
|
||||||
|
guidance_id = f"eg_{uuid4().hex}"
|
||||||
content = str(payload.get("content") or "").strip()
|
content = str(payload.get("content") or "").strip()
|
||||||
if not content:
|
if not content:
|
||||||
content = str(self._fallback_payload(skill_query=skill_query, node_task=node_task, capabilities=required_capabilities)["content"])
|
content = str(self._fallback_payload(skill_query=skill_query, node_task=node_task, capabilities=required_capabilities)["content"])
|
||||||
frontmatter = {
|
|
||||||
"description": str(payload.get("description") or f"Draft guidance for {skill_query or node_id}").strip(),
|
|
||||||
"tags": [str(item) for item in payload.get("tags") or ["generated", "task-sub-agent"]],
|
|
||||||
"metadata": {
|
|
||||||
"origin": "missing_task_subagent_skill",
|
|
||||||
"task_id": task.task_id,
|
|
||||||
"node_id": node_id,
|
|
||||||
"attempt_index": attempt_index,
|
|
||||||
"skill_query": skill_query,
|
|
||||||
"required_capabilities": list(required_capabilities),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
draft = draft_service.create_new_skill_draft(
|
|
||||||
skill_name=skill_name,
|
|
||||||
proposed_content=content,
|
|
||||||
proposed_frontmatter=frontmatter,
|
|
||||||
created_by="task-skill-resolver",
|
|
||||||
reason="generated_for_missing_task_subagent_skill",
|
|
||||||
trigger_session_id=task.session_id,
|
|
||||||
evidence_refs=[
|
|
||||||
{
|
|
||||||
"task_id": task.task_id,
|
|
||||||
"session_id": task.session_id,
|
|
||||||
"attempt_index": attempt_index,
|
|
||||||
"node_id": node_id,
|
|
||||||
"skill_query": skill_query,
|
|
||||||
"required_capabilities": list(required_capabilities),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
context = SkillContext(
|
context = SkillContext(
|
||||||
name=f"draft:{draft.skill_name}",
|
name=f"ephemeral:{guidance_name}",
|
||||||
content=draft.proposed_content,
|
content=content,
|
||||||
version=f"draft:{draft.draft_id}",
|
version=f"ephemeral:{guidance_id}",
|
||||||
content_hash=canonical_hash(draft.proposed_content),
|
content_hash=canonical_hash(content),
|
||||||
activation_reason="generated_missing_skill",
|
activation_reason="ephemeral_guidance",
|
||||||
tool_hints=[],
|
tool_hints=[],
|
||||||
)
|
)
|
||||||
return MissingSkillDraftResult(draft=draft, skill_context=context)
|
return EphemeralGuidanceResult(
|
||||||
|
guidance_id=guidance_id,
|
||||||
|
guidance_name=guidance_name,
|
||||||
|
skill_context=context,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_payload(text: str) -> dict[str, Any] | None:
|
def _parse_payload(text: str) -> dict[str, Any] | None:
|
||||||
@ -145,7 +119,7 @@ class MissingSkillSynthesizer:
|
|||||||
title = skill_query or node_task or "task subagent guidance"
|
title = skill_query or node_task or "task subagent guidance"
|
||||||
capability_lines = "\n".join(f"- {item}" for item in capabilities) or "- Follow the node task precisely."
|
capability_lines = "\n".join(f"- {item}" for item in capabilities) or "- Follow the node task precisely."
|
||||||
return {
|
return {
|
||||||
"skill_name": _slug(title),
|
"guidance_name": _slug(title),
|
||||||
"description": f"Draft guidance for {title}.",
|
"description": f"Draft guidance for {title}.",
|
||||||
"tags": ["generated", "task-sub-agent"],
|
"tags": ["generated", "task-sub-agent"],
|
||||||
"content": (
|
"content": (
|
||||||
@ -163,4 +137,8 @@ class MissingSkillSynthesizer:
|
|||||||
|
|
||||||
def _slug(value: str) -> str:
|
def _slug(value: str) -> str:
|
||||||
cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
|
cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
|
||||||
return cleaned[:64].strip("-") or "generated-task-subagent-skill"
|
return cleaned[:64].strip("-") or "generated-task-subagent-guidance"
|
||||||
|
|
||||||
|
|
||||||
|
MissingSkillDraftResult = EphemeralGuidanceResult
|
||||||
|
MissingSkillSynthesizer = EphemeralGuidanceSynthesizer
|
||||||
|
|||||||
@ -14,6 +14,12 @@ from beaver.skills.publisher import SkillPublisher
|
|||||||
from beaver.skills.reviews import ReviewService
|
from beaver.skills.reviews import ReviewService
|
||||||
from beaver.skills.specs import SkillDraft, SkillReviewRecord, SkillReviewState, SkillSpec, SkillVersion
|
from beaver.skills.specs import SkillDraft, SkillReviewRecord, SkillReviewState, SkillSpec, SkillVersion
|
||||||
|
|
||||||
|
_REJECTABLE_DRAFT_STATUSES = {
|
||||||
|
SkillReviewState.DRAFT.value,
|
||||||
|
SkillReviewState.IN_REVIEW.value,
|
||||||
|
SkillReviewState.APPROVED.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SkillLearningPipelineService:
|
class SkillLearningPipelineService:
|
||||||
"""Coordinates candidate -> draft -> review -> publish lifecycle."""
|
"""Coordinates candidate -> draft -> review -> publish lifecycle."""
|
||||||
@ -161,6 +167,9 @@ class SkillLearningPipelineService:
|
|||||||
requested_by: str = "system",
|
requested_by: str = "system",
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
) -> SkillReviewRecord:
|
) -> SkillReviewRecord:
|
||||||
|
draft = self.get_draft(skill_name, draft_id)
|
||||||
|
if draft.status != SkillReviewState.DRAFT.value:
|
||||||
|
raise ValueError("Draft must be in draft status before review submission")
|
||||||
safety = self.get_safety_report(skill_name, draft_id)
|
safety = self.get_safety_report(skill_name, draft_id)
|
||||||
if safety is not None and (not safety.passed or safety.risk_level == "critical"):
|
if safety is not None and (not safety.passed or safety.risk_level == "critical"):
|
||||||
raise ValueError("Draft cannot enter review because safety check failed")
|
raise ValueError("Draft cannot enter review because safety check failed")
|
||||||
@ -179,6 +188,12 @@ class SkillLearningPipelineService:
|
|||||||
reviewer: str = "system",
|
reviewer: str = "system",
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
) -> SkillReviewRecord:
|
) -> SkillReviewRecord:
|
||||||
|
draft = self.get_draft(skill_name, draft_id)
|
||||||
|
if draft.status != SkillReviewState.IN_REVIEW.value:
|
||||||
|
raise ValueError("Draft must be in review before approval")
|
||||||
|
safety = self.get_safety_report(skill_name, draft_id)
|
||||||
|
if safety is not None and (not safety.passed or safety.risk_level == "critical"):
|
||||||
|
raise ValueError("Draft cannot be approved because safety check failed")
|
||||||
review = self.review_service.approve(skill_name, draft_id, reviewer=reviewer, notes=notes)
|
review = self.review_service.approve(skill_name, draft_id, reviewer=reviewer, notes=notes)
|
||||||
self._mark_candidate_by_draft(skill_name, draft_id, "approved", "approved")
|
self._mark_candidate_by_draft(skill_name, draft_id, "approved", "approved")
|
||||||
return review
|
return review
|
||||||
@ -191,6 +206,9 @@ class SkillLearningPipelineService:
|
|||||||
reviewer: str = "system",
|
reviewer: str = "system",
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
) -> SkillReviewRecord:
|
) -> SkillReviewRecord:
|
||||||
|
draft = self.get_draft(skill_name, draft_id)
|
||||||
|
if draft.status not in _REJECTABLE_DRAFT_STATUSES:
|
||||||
|
raise ValueError("Draft is not rejectable from its current status")
|
||||||
review = self.review_service.reject(skill_name, draft_id, reviewer=reviewer, notes=notes)
|
review = self.review_service.reject(skill_name, draft_id, reviewer=reviewer, notes=notes)
|
||||||
self._mark_candidate_by_draft(skill_name, draft_id, "rejected", "rejected")
|
self._mark_candidate_by_draft(skill_name, draft_id, "rejected", "rejected")
|
||||||
return review
|
return review
|
||||||
|
|||||||
@ -69,6 +69,94 @@ class SkillLearningService:
|
|||||||
existing_ids.add(candidate.candidate_id)
|
existing_ids.add(candidate.candidate_id)
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
def build_learning_candidates_for_task(self, task_id: str, *, trigger_run_id: str) -> list[SkillLearningCandidate]:
|
||||||
|
"""Build candidates scoped to a single validated and satisfied Task run."""
|
||||||
|
|
||||||
|
runs = [record for record in self.run_store.list_runs() if record.task_id == task_id]
|
||||||
|
trigger_run = next((record for record in runs if record.run_id == trigger_run_id), None)
|
||||||
|
if trigger_run is None or not self._is_confirmed_positive_run(trigger_run):
|
||||||
|
return []
|
||||||
|
|
||||||
|
source_runs = [record for record in runs if self._is_confirmed_positive_run(record)]
|
||||||
|
if not source_runs:
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates: list[SkillLearningCandidate] = []
|
||||||
|
published_receipts = [
|
||||||
|
receipt
|
||||||
|
for record in source_runs
|
||||||
|
for receipt in record.activated_skills
|
||||||
|
if self._is_published_skill_receipt(receipt)
|
||||||
|
]
|
||||||
|
source_run_ids = [record.run_id for record in source_runs]
|
||||||
|
source_session_ids = list(dict.fromkeys(record.session_id for record in source_runs))
|
||||||
|
|
||||||
|
if not published_receipts:
|
||||||
|
candidates.append(
|
||||||
|
SkillLearningCandidate(
|
||||||
|
candidate_id=f"new:task:{task_id}",
|
||||||
|
kind="new_skill",
|
||||||
|
source_run_ids=source_run_ids,
|
||||||
|
source_session_ids=source_session_ids,
|
||||||
|
related_skill_names=[],
|
||||||
|
reason=f"Task {task_id} completed successfully without a published skill; consider extracting reusable guidance.",
|
||||||
|
evidence={"task_id": task_id, "trigger_run_id": trigger_run_id, "theme": self._task_theme(trigger_run.task_text)},
|
||||||
|
status="open",
|
||||||
|
priority=1,
|
||||||
|
confidence=0.8,
|
||||||
|
trigger_reason="validation_accepted_and_user_satisfied",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for receipt in published_receipts:
|
||||||
|
key = (receipt.skill_name, receipt.skill_version)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
skill_runs = [
|
||||||
|
record
|
||||||
|
for record in source_runs
|
||||||
|
if any(
|
||||||
|
item.skill_name == receipt.skill_name
|
||||||
|
and item.skill_version == receipt.skill_version
|
||||||
|
and self._is_published_skill_receipt(item)
|
||||||
|
for item in record.activated_skills
|
||||||
|
)
|
||||||
|
]
|
||||||
|
candidates.append(
|
||||||
|
SkillLearningCandidate(
|
||||||
|
candidate_id=f"revise:{receipt.skill_name}:{receipt.skill_version}:task:{task_id}",
|
||||||
|
kind="revise_skill",
|
||||||
|
source_run_ids=[record.run_id for record in skill_runs],
|
||||||
|
source_session_ids=list(dict.fromkeys(record.session_id for record in skill_runs)),
|
||||||
|
related_skill_names=[receipt.skill_name],
|
||||||
|
reason=(
|
||||||
|
f"Task {task_id} succeeded with published skill "
|
||||||
|
f"{receipt.skill_name}/{receipt.skill_version}; consider whether the skill should capture this evidence."
|
||||||
|
),
|
||||||
|
evidence={
|
||||||
|
"task_id": task_id,
|
||||||
|
"trigger_run_id": trigger_run_id,
|
||||||
|
"skill_version": receipt.skill_version,
|
||||||
|
},
|
||||||
|
status="open",
|
||||||
|
priority=1,
|
||||||
|
confidence=0.7,
|
||||||
|
trigger_reason="validation_accepted_and_user_satisfied",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_ids = {item.candidate_id for item in self.learning_store.list_learning_candidates()}
|
||||||
|
created: list[SkillLearningCandidate] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.candidate_id in existing_ids:
|
||||||
|
continue
|
||||||
|
self.learning_store.record_learning_candidate(candidate)
|
||||||
|
existing_ids.add(candidate.candidate_id)
|
||||||
|
created.append(candidate)
|
||||||
|
return created
|
||||||
|
|
||||||
async def synthesize_draft(self, candidate_id: str, provider_bundle: ProviderBundle) -> Any:
|
async def synthesize_draft(self, candidate_id: str, provider_bundle: ProviderBundle) -> Any:
|
||||||
candidates = {item.candidate_id: item for item in self.learning_store.list_learning_candidates()}
|
candidates = {item.candidate_id: item for item in self.learning_store.list_learning_candidates()}
|
||||||
candidate = candidates.get(candidate_id)
|
candidate = candidates.get(candidate_id)
|
||||||
@ -181,7 +269,7 @@ class SkillLearningService:
|
|||||||
groups.setdefault(key, []).append(record)
|
groups.setdefault(key, []).append(record)
|
||||||
candidates: list[SkillLearningCandidate] = []
|
candidates: list[SkillLearningCandidate] = []
|
||||||
for theme, runs in groups.items():
|
for theme, runs in groups.items():
|
||||||
successful = [record for record in runs if record.success]
|
successful = [record for record in runs if self._is_confirmed_positive_run(record)]
|
||||||
if len(successful) < 2:
|
if len(successful) < 2:
|
||||||
continue
|
continue
|
||||||
if any(record.activated_skills for record in successful):
|
if any(record.activated_skills for record in successful):
|
||||||
@ -202,6 +290,8 @@ class SkillLearningService:
|
|||||||
def _build_merge_candidates(self) -> list[SkillLearningCandidate]:
|
def _build_merge_candidates(self) -> list[SkillLearningCandidate]:
|
||||||
pair_counts: dict[tuple[str, str], list[RunRecord]] = {}
|
pair_counts: dict[tuple[str, str], list[RunRecord]] = {}
|
||||||
for record in self.run_store.list_runs():
|
for record in self.run_store.list_runs():
|
||||||
|
if not self._is_confirmed_positive_run(record):
|
||||||
|
continue
|
||||||
unique = sorted({receipt.skill_name for receipt in record.activated_skills})
|
unique = sorted({receipt.skill_name for receipt in record.activated_skills})
|
||||||
for pair in combinations(unique, 2):
|
for pair in combinations(unique, 2):
|
||||||
pair_counts.setdefault(pair, []).append(record)
|
pair_counts.setdefault(pair, []).append(record)
|
||||||
@ -260,6 +350,25 @@ class SkillLearningService:
|
|||||||
effects.extend(self.run_store.list_skill_effects(receipt.skill_name, version=receipt.skill_version))
|
effects.extend(self.run_store.list_skill_effects(receipt.skill_name, version=receipt.skill_version))
|
||||||
return effects
|
return effects
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_confirmed_positive_run(record: RunRecord) -> bool:
|
||||||
|
validation = record.validation_result or {}
|
||||||
|
feedback = record.feedback or {}
|
||||||
|
return (
|
||||||
|
bool(record.success)
|
||||||
|
and bool(record.task_id)
|
||||||
|
and validation.get("accepted") is True
|
||||||
|
and feedback.get("feedback_type") == "satisfied"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_published_skill_receipt(receipt: SkillActivationReceipt) -> bool:
|
||||||
|
return (
|
||||||
|
not receipt.skill_name.startswith(("draft:", "ephemeral:"))
|
||||||
|
and not receipt.skill_version.startswith(("draft:", "ephemeral:"))
|
||||||
|
and receipt.activation_reason not in {"generated_missing_skill", "ephemeral_guidance"}
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _candidate_id(kind: str, *parts: str) -> str:
|
def _candidate_id(kind: str, *parts: str) -> str:
|
||||||
return f"{kind}:{'|'.join(parts)}"
|
return f"{kind}:{'|'.join(parts)}"
|
||||||
|
|||||||
@ -60,7 +60,7 @@ class SkillDraftSynthesizer:
|
|||||||
],
|
],
|
||||||
tools=None,
|
tools=None,
|
||||||
model=model,
|
model=model,
|
||||||
max_tokens=1500,
|
max_tokens=4096,
|
||||||
temperature=0,
|
temperature=0,
|
||||||
)
|
)
|
||||||
payload = self._parse_payload(response.content or "")
|
payload = self._parse_payload(response.content or "")
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from beaver.skills.catalog.utils import strip_frontmatter
|
from beaver.skills.catalog.utils import strip_frontmatter
|
||||||
from beaver.skills.specs import SkillDraft, SkillReviewState, SkillSpec, SkillSpecStore, SkillStatus, SkillVersion
|
from beaver.skills.specs import SkillDraft, SkillReviewState, SkillSpec, SkillSpecStore, SkillStatus, SkillVersion
|
||||||
from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content
|
from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content
|
||||||
@ -44,6 +47,7 @@ class SkillPublisher:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.store.write_skill_version(version, content)
|
self.store.write_skill_version(version, content)
|
||||||
|
self._copy_uploaded_supporting_files(draft, next_version)
|
||||||
self.store.set_current_version(skill_name, next_version)
|
self.store.set_current_version(skill_name, next_version)
|
||||||
|
|
||||||
spec = self.store.get_skill_spec(skill_name)
|
spec = self.store.get_skill_spec(skill_name)
|
||||||
@ -169,6 +173,27 @@ class SkillPublisher:
|
|||||||
self.store.update_index("published", published)
|
self.store.update_index("published", published)
|
||||||
self.store.update_index("disabled", disabled)
|
self.store.update_index("disabled", disabled)
|
||||||
|
|
||||||
|
def _copy_uploaded_supporting_files(self, draft: SkillDraft, version: str) -> None:
|
||||||
|
for evidence in draft.evidence_refs:
|
||||||
|
if not isinstance(evidence, dict) or evidence.get("kind") != "upload":
|
||||||
|
continue
|
||||||
|
raw_dir = evidence.get("supporting_upload_dir")
|
||||||
|
if not raw_dir:
|
||||||
|
continue
|
||||||
|
source_root = Path(str(raw_dir))
|
||||||
|
if not source_root.exists() or not source_root.is_dir():
|
||||||
|
continue
|
||||||
|
target_root = self.store.root / draft.skill_name / "versions" / version
|
||||||
|
for source in sorted(source_root.rglob("*")):
|
||||||
|
if not source.is_file() or source.is_symlink():
|
||||||
|
continue
|
||||||
|
relative = source.relative_to(source_root)
|
||||||
|
if any(part in {"", ".", ".."} for part in relative.parts):
|
||||||
|
continue
|
||||||
|
target = target_root / relative
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(source, target)
|
||||||
|
|
||||||
def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft:
|
def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft:
|
||||||
draft = self.store.read_draft(skill_name, draft_id)
|
draft = self.store.read_draft(skill_name, draft_id)
|
||||||
if draft is None:
|
if draft is None:
|
||||||
|
|||||||
@ -47,8 +47,6 @@ class ReviewService:
|
|||||||
|
|
||||||
def reject(self, skill_name: str, draft_id: str, reviewer: str, notes: str = "") -> SkillReviewRecord:
|
def reject(self, skill_name: str, draft_id: str, reviewer: str, notes: str = "") -> SkillReviewRecord:
|
||||||
draft = self._require_draft(skill_name, draft_id)
|
draft = self._require_draft(skill_name, draft_id)
|
||||||
draft.status = SkillReviewState.REJECTED.value
|
|
||||||
self.store.write_draft(draft)
|
|
||||||
review = SkillReviewRecord(
|
review = SkillReviewRecord(
|
||||||
review_id=uuid4().hex,
|
review_id=uuid4().hex,
|
||||||
draft_id=draft_id,
|
draft_id=draft_id,
|
||||||
@ -61,6 +59,7 @@ class ReviewService:
|
|||||||
notes=notes,
|
notes=notes,
|
||||||
)
|
)
|
||||||
self.store.write_review(review)
|
self.store.write_review(review)
|
||||||
|
self.store.delete_draft(skill_name, draft_id)
|
||||||
return review
|
return review
|
||||||
|
|
||||||
def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft:
|
def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft:
|
||||||
|
|||||||
@ -87,6 +87,11 @@ class SkillSpecStore:
|
|||||||
return str(self._read_json(current_path).get("current_version") or "") or None
|
return str(self._read_json(current_path).get("current_version") or "") or None
|
||||||
if (directory / "SKILL.md").exists():
|
if (directory / "SKILL.md").exists():
|
||||||
return "legacy"
|
return "legacy"
|
||||||
|
versions_dir = directory / "versions"
|
||||||
|
if versions_dir.exists():
|
||||||
|
versions = [child.name for child in sorted(versions_dir.iterdir()) if child.is_dir()]
|
||||||
|
if versions:
|
||||||
|
return versions[-1]
|
||||||
spec = self.get_skill_spec(name)
|
spec = self.get_skill_spec(name)
|
||||||
if spec is not None and spec.current_version:
|
if spec is not None and spec.current_version:
|
||||||
return spec.current_version
|
return spec.current_version
|
||||||
@ -182,6 +187,13 @@ class SkillSpecStore:
|
|||||||
drafts_dir.mkdir(parents=True, exist_ok=True)
|
drafts_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self._write_json(drafts_dir / f"draft-{draft.draft_id}.json", draft.to_dict())
|
self._write_json(drafts_dir / f"draft-{draft.draft_id}.json", draft.to_dict())
|
||||||
|
|
||||||
|
def delete_draft(self, skill_name: str, draft_id: str) -> bool:
|
||||||
|
path = self._skill_dir(skill_name) / "drafts" / f"draft-{draft_id}.json"
|
||||||
|
if not path.exists():
|
||||||
|
return False
|
||||||
|
path.unlink()
|
||||||
|
return True
|
||||||
|
|
||||||
def list_reviews(self, skill_name: str, draft_id: str | None = None) -> list[SkillReviewRecord]:
|
def list_reviews(self, skill_name: str, draft_id: str | None = None) -> list[SkillReviewRecord]:
|
||||||
reviews_dir = self._skill_dir(skill_name) / "reviews"
|
reviews_dir = self._skill_dir(skill_name) / "reviews"
|
||||||
if not reviews_dir.exists():
|
if not reviews_dir.exists():
|
||||||
@ -199,6 +211,19 @@ class SkillSpecStore:
|
|||||||
reviews_dir.mkdir(parents=True, exist_ok=True)
|
reviews_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self._write_json(reviews_dir / f"review-{review.review_id}.json", review.to_dict())
|
self._write_json(reviews_dir / f"review-{review.review_id}.json", review.to_dict())
|
||||||
|
|
||||||
|
def delete_reviews_for_draft(self, skill_name: str, draft_id: str) -> int:
|
||||||
|
reviews_dir = self._skill_dir(skill_name) / "reviews"
|
||||||
|
if not reviews_dir.exists():
|
||||||
|
return 0
|
||||||
|
deleted = 0
|
||||||
|
for path in sorted(reviews_dir.glob("review-*.json")):
|
||||||
|
record = SkillReviewRecord.from_dict(self._read_json(path))
|
||||||
|
if record.draft_id != draft_id:
|
||||||
|
continue
|
||||||
|
path.unlink()
|
||||||
|
deleted += 1
|
||||||
|
return deleted
|
||||||
|
|
||||||
def update_index(self, index_name: str, values: list[str]) -> None:
|
def update_index(self, index_name: str, values: list[str]) -> None:
|
||||||
self._write_json(self.index_dir / f"{index_name}.json", {"items": list(dict.fromkeys(values))})
|
self._write_json(self.index_dir / f"{index_name}.json", {"items": list(dict.fromkeys(values))})
|
||||||
|
|
||||||
|
|||||||
@ -160,6 +160,9 @@ class MainAgentDecision:
|
|||||||
mode: str
|
mode: str
|
||||||
reason: str
|
reason: str
|
||||||
starts_new_task: bool = False
|
starts_new_task: bool = False
|
||||||
|
closes_task: bool = False
|
||||||
|
abandons_task: bool = False
|
||||||
|
short_title: str | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_task(self) -> bool:
|
def is_task(self) -> bool:
|
||||||
|
|||||||
@ -50,10 +50,10 @@ class TaskExecutionPlan:
|
|||||||
for node in nodes
|
for node in nodes
|
||||||
for name in node.inherited_pinned_skills
|
for name in node.inherited_pinned_skills
|
||||||
],
|
],
|
||||||
"generated_skill_draft_ids": [
|
"ephemeral_guidance_ids": [
|
||||||
item.generated_skill_draft_id
|
item.ephemeral_guidance_id
|
||||||
for item in self.skill_resolution_report
|
for item in self.skill_resolution_report
|
||||||
if item.generated_skill_draft_id
|
if item.ephemeral_guidance_id
|
||||||
],
|
],
|
||||||
"skill_resolution_report": [item.to_dict() for item in self.skill_resolution_report],
|
"skill_resolution_report": [item.to_dict() for item in self.skill_resolution_report],
|
||||||
"fallback_error": self.fallback_error,
|
"fallback_error": self.fallback_error,
|
||||||
@ -108,7 +108,7 @@ class TaskExecutionPlanner:
|
|||||||
],
|
],
|
||||||
tools=None,
|
tools=None,
|
||||||
model=model,
|
model=model,
|
||||||
max_tokens=1200,
|
max_tokens=4096,
|
||||||
temperature=0.0,
|
temperature=0.0,
|
||||||
)
|
)
|
||||||
plan = self.from_json(response.content or "")
|
plan = self.from_json(response.content or "")
|
||||||
|
|||||||
@ -1,40 +1,144 @@
|
|||||||
"""Main Agent routing between simple chat and internal Task mode."""
|
"""LLM-based routing between simple chat and internal Task mode."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .models import MainAgentDecision, TaskRecord
|
from .models import MainAgentDecision, TaskRecord
|
||||||
|
|
||||||
|
|
||||||
class MainAgentRouter:
|
class MainAgentRouter:
|
||||||
"""Small deterministic classifier used before the main AgentLoop.
|
"""Semantic router for deciding whether a message belongs to a Task."""
|
||||||
|
|
||||||
The first version intentionally avoids a mandatory model call so the router
|
async def classify(
|
||||||
stays reliable during provider outages. The rule set is conservative:
|
self,
|
||||||
anything that implies execution, files, tools, iteration, or validation
|
message: str,
|
||||||
becomes Task mode.
|
*,
|
||||||
"""
|
active_task: TaskRecord | None = None,
|
||||||
|
provider: Any | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
recent_messages: list[dict[str, Any]] | None = None,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
|
timeout_seconds: float = 8.0,
|
||||||
|
) -> MainAgentDecision:
|
||||||
|
if provider is None:
|
||||||
|
return self._fallback(active_task=active_task, reason="router_provider_unavailable")
|
||||||
|
try:
|
||||||
|
chat_kwargs: dict[str, Any] = {
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"You route user messages for Beaver's internal Task mode. "
|
||||||
|
"Return only compact JSON. Do not explain."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": self._prompt(
|
||||||
|
message=message,
|
||||||
|
active_task=active_task,
|
||||||
|
recent_messages=recent_messages or [],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"tools": None,
|
||||||
|
"model": model,
|
||||||
|
"max_tokens": 256,
|
||||||
|
"temperature": 0.0,
|
||||||
|
}
|
||||||
|
if thinking_enabled is not None:
|
||||||
|
chat_kwargs["thinking_enabled"] = thinking_enabled
|
||||||
|
response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=timeout_seconds)
|
||||||
|
return self.from_json(response.content or "", active_task=active_task)
|
||||||
|
except Exception as exc:
|
||||||
|
return self._fallback(active_task=active_task, reason=f"router_failed: {exc}")
|
||||||
|
|
||||||
_TASK_PATTERNS = [
|
def from_json(self, text: str, *, active_task: TaskRecord | None = None) -> MainAgentDecision:
|
||||||
r"\b(implement|fix|debug|refactor|migrate|build|create|write|edit|update|test|validate|deploy)\b",
|
payload = self._parse_json_object(text)
|
||||||
r"\b(file|repo|code|project|backend|frontend|api|database|migration|pull request|ci|bug)\b",
|
raw_action = str(payload.get("action") or payload.get("mode") or "").strip().lower()
|
||||||
r"\b(step|multi-step|workflow|plan and|then)\b",
|
reason = str(payload.get("reason") or raw_action or "llm_router")
|
||||||
r"(实现|修复|调试|重构|迁移|构建|创建|编写|修改|更新|测试|验证|部署|文件|代码|项目|前端|后端|接口|数据库|多步|任务)",
|
short_title = _clean_short_title(payload.get("short_title") or payload.get("title"))
|
||||||
]
|
|
||||||
_NEW_TASK_PATTERNS = [
|
|
||||||
r"\b(new task|another task|different task|start over)\b",
|
|
||||||
r"(新任务|另一个任务|换个任务|重新开始)",
|
|
||||||
]
|
|
||||||
|
|
||||||
def classify(self, message: str, *, active_task: TaskRecord | None = None) -> MainAgentDecision:
|
if raw_action in {"continue_task", "continue", "task"}:
|
||||||
text = message.strip()
|
return MainAgentDecision(mode="task", reason=reason, short_title=short_title)
|
||||||
lowered = text.lower()
|
if raw_action in {"new_task", "new"}:
|
||||||
starts_new = any(re.search(pattern, lowered, re.IGNORECASE) for pattern in self._NEW_TASK_PATTERNS)
|
return MainAgentDecision(mode="task", reason=reason, starts_new_task=True, short_title=short_title)
|
||||||
if active_task is not None and active_task.status in {"awaiting_feedback", "needs_revision"} and not starts_new:
|
if raw_action in {"close_task", "close", "done", "finish"}:
|
||||||
return MainAgentDecision(mode="task", reason="continuing_open_task", starts_new_task=False)
|
return MainAgentDecision(mode="simple", reason=reason, closes_task=active_task is not None, short_title=short_title)
|
||||||
if any(re.search(pattern, lowered, re.IGNORECASE) for pattern in self._TASK_PATTERNS):
|
if raw_action in {"abandon_task", "abandon", "cancel_task"}:
|
||||||
return MainAgentDecision(mode="task", reason="task_pattern_matched", starts_new_task=starts_new)
|
return MainAgentDecision(mode="simple", reason=reason, abandons_task=active_task is not None, short_title=short_title)
|
||||||
if len(text) > 240:
|
return MainAgentDecision(mode="simple", reason=reason or "simple_chat", short_title=short_title)
|
||||||
return MainAgentDecision(mode="task", reason="long_request", starts_new_task=starts_new)
|
|
||||||
return MainAgentDecision(mode="simple", reason="simple_question", starts_new_task=False)
|
def _fallback(self, *, active_task: TaskRecord | None, reason: str) -> MainAgentDecision:
|
||||||
|
if active_task is not None:
|
||||||
|
return MainAgentDecision(mode="task", reason=reason)
|
||||||
|
return MainAgentDecision(mode="simple", reason=reason)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prompt(
|
||||||
|
*,
|
||||||
|
message: str,
|
||||||
|
active_task: TaskRecord | None,
|
||||||
|
recent_messages: list[dict[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
active_task_payload = None
|
||||||
|
if active_task is not None:
|
||||||
|
active_task_payload = {
|
||||||
|
"task_id": active_task.task_id,
|
||||||
|
"description": active_task.description,
|
||||||
|
"goal": active_task.goal,
|
||||||
|
"status": active_task.status,
|
||||||
|
"short_title": active_task.metadata.get("short_title"),
|
||||||
|
}
|
||||||
|
recent = [
|
||||||
|
{"role": item.get("role"), "content": str(item.get("content") or "")[:500]}
|
||||||
|
for item in recent_messages[-8:]
|
||||||
|
if item.get("role") in {"user", "assistant"}
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
"Decide how to route the current user message.\n\n"
|
||||||
|
"Actions:\n"
|
||||||
|
"- simple_chat: no Task should be created or continued.\n"
|
||||||
|
"- continue_task: keep the user in the active Task.\n"
|
||||||
|
"- new_task: start a separate new Task.\n"
|
||||||
|
"- close_task: user explicitly says the active Task is done/satisfactory/finished.\n"
|
||||||
|
"- abandon_task: user explicitly says to stop, cancel, abandon, or no longer do the active Task.\n\n"
|
||||||
|
"Critical policy:\n"
|
||||||
|
"- If there is an active Task, choose continue_task unless the user's topic is completely unrelated "
|
||||||
|
"to that Task or the user explicitly closes/abandons it.\n"
|
||||||
|
"- Follow-up questions, corrections, partial changes, extra constraints, and result discussion stay in continue_task.\n"
|
||||||
|
"- Use new_task only when the user clearly asks to start a different task.\n"
|
||||||
|
"- If there is no active Task, choose new_task only for work that requires execution, iteration, tools, files, "
|
||||||
|
"implementation, validation, or multi-step completion. Otherwise choose simple_chat.\n"
|
||||||
|
"- short_title must be 5-15 Chinese characters or a similarly short English phrase when a Task is involved.\n\n"
|
||||||
|
"Return JSON only with keys: action, reason, short_title.\n\n"
|
||||||
|
f"Active task:\n{json.dumps(active_task_payload, ensure_ascii=False)}\n\n"
|
||||||
|
f"Recent conversation:\n{json.dumps(recent, ensure_ascii=False)}\n\n"
|
||||||
|
f"Current user message:\n{message}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_json_object(text: str) -> dict[str, Any]:
|
||||||
|
cleaned = text.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = cleaned.strip("`")
|
||||||
|
if cleaned.lower().startswith("json"):
|
||||||
|
cleaned = cleaned[4:].strip()
|
||||||
|
start = cleaned.find("{")
|
||||||
|
end = cleaned.rfind("}")
|
||||||
|
if start >= 0 and end >= start:
|
||||||
|
cleaned = cleaned[start : end + 1]
|
||||||
|
payload = json.loads(cleaned)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("router response must be a JSON object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_short_title(value: Any) -> str | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
title = " ".join(str(value).strip().split())
|
||||||
|
return title[:40] or None
|
||||||
|
|||||||
@ -24,6 +24,8 @@ class TaskService:
|
|||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
) -> TaskRecord:
|
) -> TaskRecord:
|
||||||
now = self._now()
|
now = self._now()
|
||||||
|
task_metadata = dict(metadata or {})
|
||||||
|
task_metadata.setdefault("short_title", short_task_title(description))
|
||||||
task = TaskRecord(
|
task = TaskRecord(
|
||||||
task_id=uuid4().hex,
|
task_id=uuid4().hex,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@ -35,7 +37,7 @@ class TaskService:
|
|||||||
creator=creator,
|
creator=creator,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
metadata=dict(metadata or {}),
|
metadata=task_metadata,
|
||||||
)
|
)
|
||||||
self.store.upsert_task(task)
|
self.store.upsert_task(task)
|
||||||
self._event(task, "created", payload={"description": description})
|
self._event(task, "created", payload={"description": description})
|
||||||
@ -44,11 +46,45 @@ class TaskService:
|
|||||||
def get_task(self, task_id: str) -> TaskRecord | None:
|
def get_task(self, task_id: str) -> TaskRecord | None:
|
||||||
return self.store.get_task(task_id)
|
return self.store.get_task(task_id)
|
||||||
|
|
||||||
|
def list_tasks(self) -> list[TaskRecord]:
|
||||||
|
return sorted(self.store.list_tasks(), key=lambda item: item.updated_at, reverse=True)
|
||||||
|
|
||||||
|
def list_events(self, task_id: str) -> list[TaskEvent]:
|
||||||
|
return self.store.list_events(task_id=task_id)
|
||||||
|
|
||||||
def get_task_by_run_id(self, run_id: str) -> TaskRecord | None:
|
def get_task_by_run_id(self, run_id: str) -> TaskRecord | None:
|
||||||
return self.store.get_task_by_run_id(run_id)
|
return self.store.get_task_by_run_id(run_id)
|
||||||
|
|
||||||
def get_latest_open_task(self, session_id: str) -> TaskRecord | None:
|
def get_latest_open_task(self, session_id: str, *, include_unengaged_scheduled: bool = False) -> TaskRecord | None:
|
||||||
return self.store.get_latest_open_task(session_id)
|
tasks = [
|
||||||
|
task
|
||||||
|
for task in self.store.list_tasks()
|
||||||
|
if task.session_id == session_id and task.is_open
|
||||||
|
]
|
||||||
|
if not include_unengaged_scheduled:
|
||||||
|
tasks = [task for task in tasks if self._is_user_visible_active_task(task)]
|
||||||
|
if not tasks:
|
||||||
|
return None
|
||||||
|
return sorted(tasks, key=lambda item: item.updated_at)[-1]
|
||||||
|
|
||||||
|
def active_task_view(self, session_id: str) -> dict[str, Any] | None:
|
||||||
|
task = self.get_latest_open_task(session_id)
|
||||||
|
if task is None:
|
||||||
|
return None
|
||||||
|
return self.to_api_dict(task)
|
||||||
|
|
||||||
|
def to_api_dict(self, task: TaskRecord) -> dict[str, Any]:
|
||||||
|
payload = task.to_dict()
|
||||||
|
payload["short_title"] = self.ensure_short_title(task).metadata.get("short_title")
|
||||||
|
payload["is_open"] = task.is_open
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def ensure_short_title(self, task: TaskRecord) -> TaskRecord:
|
||||||
|
if task.metadata.get("short_title"):
|
||||||
|
return task
|
||||||
|
task.metadata["short_title"] = short_task_title(task.description or task.goal or task.task_id)
|
||||||
|
self.store.upsert_task(task)
|
||||||
|
return task
|
||||||
|
|
||||||
def start_run(self, task_id: str, *, user_message: str, attempt_index: int) -> TaskRecord:
|
def start_run(self, task_id: str, *, user_message: str, attempt_index: int) -> TaskRecord:
|
||||||
task = self._require(task_id)
|
task = self._require(task_id)
|
||||||
@ -136,6 +172,38 @@ class TaskService:
|
|||||||
self._event(task, f"feedback_{feedback_type}", run_id=run_id, payload=entry)
|
self._event(task, f"feedback_{feedback_type}", run_id=run_id, payload=entry)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
def close_task(self, task_id: str, *, reason: str = "closed") -> TaskRecord:
|
||||||
|
task = self._require(task_id)
|
||||||
|
now = self._now()
|
||||||
|
task.status = "closed"
|
||||||
|
task.closed_at = now
|
||||||
|
task.close_reason = reason
|
||||||
|
task.updated_at = now
|
||||||
|
self.store.upsert_task(task)
|
||||||
|
self._event(task, "closed", payload={"reason": reason})
|
||||||
|
return task
|
||||||
|
|
||||||
|
def abandon_task(self, task_id: str, *, reason: str = "abandoned") -> TaskRecord:
|
||||||
|
task = self._require(task_id)
|
||||||
|
now = self._now()
|
||||||
|
task.status = "abandoned"
|
||||||
|
task.closed_at = now
|
||||||
|
task.close_reason = reason
|
||||||
|
task.updated_at = now
|
||||||
|
self.store.upsert_task(task)
|
||||||
|
self._event(task, "abandoned", payload={"reason": reason})
|
||||||
|
return task
|
||||||
|
|
||||||
|
def delete_task(self, task_id: str) -> bool:
|
||||||
|
return self.store.delete_task(task_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_user_visible_active_task(task: TaskRecord) -> bool:
|
||||||
|
if task.creator != "cron":
|
||||||
|
return True
|
||||||
|
metadata = task.metadata or {}
|
||||||
|
return bool(metadata.get("user_engaged") or metadata.get("requires_followup"))
|
||||||
|
|
||||||
def _require(self, task_id: str) -> TaskRecord:
|
def _require(self, task_id: str) -> TaskRecord:
|
||||||
task = self.store.get_task(task_id)
|
task = self.store.get_task(task_id)
|
||||||
if task is None:
|
if task is None:
|
||||||
@ -165,3 +233,15 @@ class TaskService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def short_task_title(text: str) -> str:
|
||||||
|
cleaned = " ".join((text or "").strip().split())
|
||||||
|
if not cleaned:
|
||||||
|
return "当前任务"
|
||||||
|
if any("\u4e00" <= char <= "\u9fff" for char in cleaned):
|
||||||
|
return cleaned[:15]
|
||||||
|
words = cleaned.split()
|
||||||
|
if len(words) <= 4:
|
||||||
|
return cleaned[:40]
|
||||||
|
return " ".join(words[:4])[:40]
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from beaver.engine.providers import ProviderBundle
|
|||||||
from beaver.skills.assembler.embedding_retriever import SkillEmbeddingRetriever
|
from beaver.skills.assembler.embedding_retriever import SkillEmbeddingRetriever
|
||||||
from beaver.skills.catalog.loader import SkillsLoader
|
from beaver.skills.catalog.loader import SkillsLoader
|
||||||
from beaver.skills.drafts import DraftService
|
from beaver.skills.drafts import DraftService
|
||||||
from beaver.skills.learning import MissingSkillSynthesizer
|
from beaver.skills.learning import EphemeralGuidanceSynthesizer
|
||||||
from beaver.tasks.models import TaskRecord
|
from beaver.tasks.models import TaskRecord
|
||||||
|
|
||||||
|
|
||||||
@ -21,8 +21,8 @@ class SkillResolutionReport:
|
|||||||
skill_query: str
|
skill_query: str
|
||||||
required_capabilities: list[str] = field(default_factory=list)
|
required_capabilities: list[str] = field(default_factory=list)
|
||||||
selected_skill_names: list[str] = field(default_factory=list)
|
selected_skill_names: list[str] = field(default_factory=list)
|
||||||
generated_skill_draft_id: str | None = None
|
ephemeral_guidance_id: str | None = None
|
||||||
generated_skill_name: str | None = None
|
ephemeral_guidance_name: str | None = None
|
||||||
ephemeral_used: bool = False
|
ephemeral_used: bool = False
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
|
||||||
@ -32,15 +32,15 @@ class SkillResolutionReport:
|
|||||||
"skill_query": self.skill_query,
|
"skill_query": self.skill_query,
|
||||||
"required_capabilities": list(self.required_capabilities),
|
"required_capabilities": list(self.required_capabilities),
|
||||||
"selected_skill_names": list(self.selected_skill_names),
|
"selected_skill_names": list(self.selected_skill_names),
|
||||||
"generated_skill_draft_id": self.generated_skill_draft_id,
|
"ephemeral_guidance_id": self.ephemeral_guidance_id,
|
||||||
"generated_skill_name": self.generated_skill_name,
|
"ephemeral_guidance_name": self.ephemeral_guidance_name,
|
||||||
"ephemeral_used": self.ephemeral_used,
|
"ephemeral_used": self.ephemeral_used,
|
||||||
"reason": self.reason,
|
"reason": self.reason,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TaskSkillResolver:
|
class TaskSkillResolver:
|
||||||
"""Pins published or draft-only skills onto generic team nodes."""
|
"""Pins published skills or one-run guidance onto generic team nodes."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -48,12 +48,12 @@ class TaskSkillResolver:
|
|||||||
skills_loader: SkillsLoader,
|
skills_loader: SkillsLoader,
|
||||||
draft_service: DraftService,
|
draft_service: DraftService,
|
||||||
retriever: SkillEmbeddingRetriever | None = None,
|
retriever: SkillEmbeddingRetriever | None = None,
|
||||||
missing_skill_synthesizer: MissingSkillSynthesizer | None = None,
|
missing_skill_synthesizer: EphemeralGuidanceSynthesizer | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.skills_loader = skills_loader
|
self.skills_loader = skills_loader
|
||||||
self.draft_service = draft_service
|
self.draft_service = draft_service
|
||||||
self.retriever = retriever or SkillEmbeddingRetriever()
|
self.retriever = retriever or SkillEmbeddingRetriever()
|
||||||
self.missing_skill_synthesizer = missing_skill_synthesizer or MissingSkillSynthesizer()
|
self.missing_skill_synthesizer = missing_skill_synthesizer or EphemeralGuidanceSynthesizer()
|
||||||
|
|
||||||
async def resolve_graph(
|
async def resolve_graph(
|
||||||
self,
|
self,
|
||||||
@ -138,7 +138,6 @@ class TaskSkillResolver:
|
|||||||
skill_query=skill_query,
|
skill_query=skill_query,
|
||||||
required_capabilities=required_capabilities,
|
required_capabilities=required_capabilities,
|
||||||
provider_bundle=provider_bundle,
|
provider_bundle=provider_bundle,
|
||||||
draft_service=self.draft_service,
|
|
||||||
)
|
)
|
||||||
resolved = self._generic_node(
|
resolved = self._generic_node(
|
||||||
node,
|
node,
|
||||||
@ -149,8 +148,8 @@ class TaskSkillResolver:
|
|||||||
"skill_query": skill_query,
|
"skill_query": skill_query,
|
||||||
"required_capabilities": required_capabilities,
|
"required_capabilities": required_capabilities,
|
||||||
"selected_skill_names": [],
|
"selected_skill_names": [],
|
||||||
"generated_skill_draft_id": missing.draft.draft_id,
|
"ephemeral_guidance_id": missing.guidance_id,
|
||||||
"generated_skill_name": missing.draft.skill_name,
|
"ephemeral_guidance_name": missing.guidance_name,
|
||||||
"ephemeral_skill_names": [missing.skill_context.name],
|
"ephemeral_skill_names": [missing.skill_context.name],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -158,10 +157,10 @@ class TaskSkillResolver:
|
|||||||
node_id=node.node_id,
|
node_id=node.node_id,
|
||||||
skill_query=skill_query,
|
skill_query=skill_query,
|
||||||
required_capabilities=required_capabilities,
|
required_capabilities=required_capabilities,
|
||||||
generated_skill_draft_id=missing.draft.draft_id,
|
ephemeral_guidance_id=missing.guidance_id,
|
||||||
generated_skill_name=missing.draft.skill_name,
|
ephemeral_guidance_name=missing.guidance_name,
|
||||||
ephemeral_used=True,
|
ephemeral_used=True,
|
||||||
reason="generated draft-only skill for missing sub-agent guidance",
|
reason="generated ephemeral guidance for missing sub-agent capability",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _select_published_skills(self, *, query: str, provider_bundle: ProviderBundle) -> list[str]:
|
async def _select_published_skills(self, *, query: str, provider_bundle: ProviderBundle) -> list[str]:
|
||||||
@ -215,7 +214,7 @@ class TaskSkillResolver:
|
|||||||
],
|
],
|
||||||
tools=None,
|
tools=None,
|
||||||
model=model,
|
model=model,
|
||||||
max_tokens=512,
|
max_tokens=2048,
|
||||||
temperature=0,
|
temperature=0,
|
||||||
)
|
)
|
||||||
parsed = self._parse_names(response.content or "")
|
parsed = self._parse_names(response.content or "")
|
||||||
|
|||||||
@ -40,7 +40,7 @@ class TaskStore:
|
|||||||
tasks = [
|
tasks = [
|
||||||
task
|
task
|
||||||
for task in self.list_tasks()
|
for task in self.list_tasks()
|
||||||
if task.session_id == session_id and task.status in {"awaiting_feedback", "needs_revision", "open", "running"}
|
if task.session_id == session_id and task.is_open
|
||||||
]
|
]
|
||||||
if not tasks:
|
if not tasks:
|
||||||
return None
|
return None
|
||||||
@ -52,6 +52,25 @@ class TaskStore:
|
|||||||
payload[task.task_id] = task.to_dict()
|
payload[task.task_id] = task.to_dict()
|
||||||
self._write_tasks_unlocked(payload)
|
self._write_tasks_unlocked(payload)
|
||||||
|
|
||||||
|
def delete_task(self, task_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
payload = self._read_tasks_unlocked()
|
||||||
|
if task_id not in payload:
|
||||||
|
return False
|
||||||
|
payload.pop(task_id, None)
|
||||||
|
self._write_tasks_unlocked(payload)
|
||||||
|
if self.events_path.exists():
|
||||||
|
kept = []
|
||||||
|
for line in self.events_path.read_text(encoding="utf-8").splitlines():
|
||||||
|
cleaned = line.strip()
|
||||||
|
if not cleaned:
|
||||||
|
continue
|
||||||
|
event_payload = json.loads(cleaned)
|
||||||
|
if not isinstance(event_payload, dict) or str(event_payload.get("task_id")) != task_id:
|
||||||
|
kept.append(cleaned)
|
||||||
|
self.events_path.write_text(("\n".join(kept) + "\n") if kept else "", encoding="utf-8")
|
||||||
|
return True
|
||||||
|
|
||||||
def append_event(self, event: TaskEvent) -> None:
|
def append_event(self, event: TaskEvent) -> None:
|
||||||
self.events_path.parent.mkdir(parents=True, exist_ok=True)
|
self.events_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@ -84,7 +84,7 @@ class ValidationService:
|
|||||||
],
|
],
|
||||||
tools=None,
|
tools=None,
|
||||||
model=model,
|
model=model,
|
||||||
max_tokens=800,
|
max_tokens=4096,
|
||||||
temperature=0.0,
|
temperature=0.0,
|
||||||
)
|
)
|
||||||
payload = self._parse_json_object(response.content or "")
|
payload = self._parse_json_object(response.content or "")
|
||||||
|
|||||||
@ -29,7 +29,7 @@ class ToolAssembler:
|
|||||||
always_tool_names: Sequence[str] | None = None,
|
always_tool_names: Sequence[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.retriever = retriever or EmbeddingRetriever()
|
self.retriever = retriever or EmbeddingRetriever()
|
||||||
self.always_tool_names = tuple(always_tool_names or ("memory", "session_search", "skill_view"))
|
self.always_tool_names = tuple(always_tool_names or ("memory", "session_search"))
|
||||||
|
|
||||||
async def assemble(
|
async def assemble(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@ -39,6 +39,7 @@ class ToolSpec:
|
|||||||
input_schema: dict[str, Any]
|
input_schema: dict[str, Any]
|
||||||
toolset: str = "core"
|
toolset: str = "core"
|
||||||
always_available: bool = False
|
always_available: bool = False
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
def to_mcp_descriptor(self) -> dict[str, Any]:
|
def to_mcp_descriptor(self) -> dict[str, Any]:
|
||||||
"""导出 MCP ListTools 风格的工具描述。
|
"""导出 MCP ListTools 风格的工具描述。
|
||||||
@ -180,6 +181,8 @@ class ObjectBackedTool(BaseTool):
|
|||||||
arguments["current_session_id"] = context.session_id
|
arguments["current_session_id"] = context.session_id
|
||||||
if "workspace" not in arguments and hasattr(self.backend, "workspace"):
|
if "workspace" not in arguments and hasattr(self.backend, "workspace"):
|
||||||
arguments["workspace"] = context.workspace
|
arguments["workspace"] = context.workspace
|
||||||
|
if "metadata" not in arguments:
|
||||||
|
arguments["metadata"] = context.metadata
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_output(content: Any) -> dict[str, Any]:
|
def _normalize_output(content: Any) -> dict[str, Any]:
|
||||||
|
|||||||
@ -1,19 +1,39 @@
|
|||||||
"""Built-in Beaver tools."""
|
"""Built-in Beaver tools."""
|
||||||
|
|
||||||
|
from .cron import CronTool
|
||||||
from .echo import EchoTool, echo_tool
|
from .echo import EchoTool, echo_tool
|
||||||
from .filesystem import ListDirectoryTool, ReadFileTool, SearchFilesTool
|
from .filesystem import ListDirectoryTool, PatchFileTool, ReadFileTool, SearchFilesTool, WriteFileTool
|
||||||
from .memory import MemoryTool, memory_tool
|
from .memory import MemoryTool, memory_tool
|
||||||
|
from .skills_admin import SkillManageTool, SkillsListTool
|
||||||
from .skill_view import SkillViewTool, skill_view
|
from .skill_view import SkillViewTool, skill_view
|
||||||
from .session_search import SessionSearchTool, session_search
|
from .session_search import SessionSearchTool, session_search
|
||||||
|
from .terminal import ExecuteCodeTool, ProcessTool, TerminalTool
|
||||||
|
from .utility import ClarifyTool, DelegateTool, SendMessageTool, SpawnTool, TodoTool
|
||||||
|
from .web import WebFetchTool, WebSearchTool
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"EchoTool",
|
"EchoTool",
|
||||||
|
"ExecuteCodeTool",
|
||||||
|
"CronTool",
|
||||||
|
"DelegateTool",
|
||||||
"ListDirectoryTool",
|
"ListDirectoryTool",
|
||||||
"MemoryTool",
|
"MemoryTool",
|
||||||
|
"PatchFileTool",
|
||||||
|
"ProcessTool",
|
||||||
"ReadFileTool",
|
"ReadFileTool",
|
||||||
"SearchFilesTool",
|
"SearchFilesTool",
|
||||||
|
"SendMessageTool",
|
||||||
|
"SpawnTool",
|
||||||
|
"SkillManageTool",
|
||||||
|
"SkillsListTool",
|
||||||
"SkillViewTool",
|
"SkillViewTool",
|
||||||
"SessionSearchTool",
|
"SessionSearchTool",
|
||||||
|
"TerminalTool",
|
||||||
|
"TodoTool",
|
||||||
|
"ClarifyTool",
|
||||||
|
"WebFetchTool",
|
||||||
|
"WebSearchTool",
|
||||||
|
"WriteFileTool",
|
||||||
"echo_tool",
|
"echo_tool",
|
||||||
"memory_tool",
|
"memory_tool",
|
||||||
"skill_view",
|
"skill_view",
|
||||||
|
|||||||
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"],
|
"required": ["query"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WRITE_FILE_PARAMETERS: dict[str, Any] = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "File path relative to the current workspace."},
|
||||||
|
"content": {"type": "string", "description": "Full file content to write."},
|
||||||
|
},
|
||||||
|
"required": ["path", "content"],
|
||||||
|
}
|
||||||
|
|
||||||
|
PATCH_FILE_PARAMETERS: dict[str, Any] = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "File path relative to the current workspace."},
|
||||||
|
"old_text": {"type": "string", "description": "Exact text to replace."},
|
||||||
|
"new_text": {"type": "string", "description": "Replacement text."},
|
||||||
|
},
|
||||||
|
"required": ["path", "old_text", "new_text"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class WorkspacePathError(ValueError):
|
class WorkspacePathError(ValueError):
|
||||||
"""Raised when a requested path escapes the configured workspace."""
|
"""Raised when a requested path escapes the configured workspace."""
|
||||||
@ -158,6 +177,20 @@ def _resolve_existing_path(workspace: str | None, user_path: str | None) -> tupl
|
|||||||
return root, resolved
|
return root, resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_writable_path(workspace: str | None, user_path: str | None) -> tuple[Path, Path]:
|
||||||
|
root = _workspace_root(workspace)
|
||||||
|
if not user_path or not str(user_path).strip():
|
||||||
|
raise WorkspacePathError("path is required")
|
||||||
|
raw_path = Path(str(user_path)).expanduser()
|
||||||
|
candidate = raw_path if raw_path.is_absolute() else root / raw_path
|
||||||
|
parent = candidate.parent.resolve(strict=True)
|
||||||
|
try:
|
||||||
|
parent.relative_to(root)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise WorkspacePathError(f"path escapes workspace: {user_path}") from exc
|
||||||
|
return root, parent / candidate.name
|
||||||
|
|
||||||
|
|
||||||
def _relative_path(root: Path, path: Path) -> str:
|
def _relative_path(root: Path, path: Path) -> str:
|
||||||
try:
|
try:
|
||||||
return str(path.relative_to(root)) or "."
|
return str(path.relative_to(root)) or "."
|
||||||
@ -440,3 +473,73 @@ class SearchFilesTool:
|
|||||||
)
|
)
|
||||||
except (OSError, WorkspacePathError, ValueError) as exc:
|
except (OSError, WorkspacePathError, ValueError) as exc:
|
||||||
return _json_result(False, error=str(exc), path=path)
|
return _json_result(False, error=str(exc), path=path)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class WriteFileTool:
|
||||||
|
"""Write a UTF-8 text file inside the current workspace."""
|
||||||
|
|
||||||
|
name: str = "write_file"
|
||||||
|
description: str = (
|
||||||
|
"Write a UTF-8 text file inside the current workspace, replacing the full file. "
|
||||||
|
"Use patch_file for targeted edits. Paths outside the workspace are rejected."
|
||||||
|
)
|
||||||
|
toolset: str = "filesystem"
|
||||||
|
always_available: bool = False
|
||||||
|
workspace: str | None = None
|
||||||
|
parameters: dict[str, Any] = field(default_factory=lambda: dict(WRITE_FILE_PARAMETERS))
|
||||||
|
|
||||||
|
async def execute(self, *, path: str, content: str, workspace: str | None = None) -> str:
|
||||||
|
try:
|
||||||
|
root, resolved = _resolve_writable_path(workspace, path)
|
||||||
|
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
resolved.write_text(str(content), encoding="utf-8")
|
||||||
|
return _json_result(True, path=_relative_path(root, resolved), bytes=len(str(content).encode("utf-8")))
|
||||||
|
except (OSError, WorkspacePathError, ValueError) as exc:
|
||||||
|
return _json_result(False, error=str(exc), path=path)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PatchFileTool:
|
||||||
|
"""Replace an exact text fragment inside a workspace file."""
|
||||||
|
|
||||||
|
name: str = "patch_file"
|
||||||
|
description: str = (
|
||||||
|
"Replace an exact text fragment inside a UTF-8 workspace file. "
|
||||||
|
"Fails if old_text is missing or ambiguous."
|
||||||
|
)
|
||||||
|
toolset: str = "filesystem"
|
||||||
|
always_available: bool = False
|
||||||
|
workspace: str | None = None
|
||||||
|
parameters: dict[str, Any] = field(default_factory=lambda: dict(PATCH_FILE_PARAMETERS))
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
old_text: str,
|
||||||
|
new_text: str,
|
||||||
|
workspace: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
root, resolved = _resolve_existing_path(workspace, path)
|
||||||
|
if not resolved.is_file():
|
||||||
|
return _json_result(False, error="not_a_file", path=path)
|
||||||
|
content = _read_text_file(resolved)
|
||||||
|
occurrences = content.count(old_text)
|
||||||
|
if occurrences == 0:
|
||||||
|
return _json_result(False, error="old_text_not_found", path=path)
|
||||||
|
if occurrences > 1:
|
||||||
|
return _json_result(False, error="old_text_ambiguous", occurrences=occurrences, path=path)
|
||||||
|
updated = content.replace(old_text, new_text, 1)
|
||||||
|
resolved.write_text(updated, encoding="utf-8")
|
||||||
|
return _json_result(
|
||||||
|
True,
|
||||||
|
path=_relative_path(root, resolved),
|
||||||
|
old_bytes=len(old_text.encode("utf-8")),
|
||||||
|
new_bytes=len(new_text.encode("utf-8")),
|
||||||
|
)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return _json_result(False, error="file is not valid UTF-8 text", path=path)
|
||||||
|
except (OSError, WorkspacePathError, ValueError) as exc:
|
||||||
|
return _json_result(False, error=str(exc), path=path)
|
||||||
|
|||||||
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."""
|
"""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 内部执行策略。
|
4. Agent Team 已融入 Task mode 内部执行策略。
|
||||||
- `TaskExecutionPlanner` 先用 LLM JSON 规划 `single / team`。
|
- `TaskExecutionPlanner` 先用 LLM JSON 规划 `single / team`。
|
||||||
- team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设。
|
- team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设。
|
||||||
- `TaskSkillResolver` 为每个 generic sub-agent 选择 published skill;未命中时生成 draft-only skill,并作为本次 run 的 ephemeral pinned instruction 使用。
|
- `TaskSkillResolver` 为每个 generic sub-agent 选择 published skill;未命中时生成 ephemeral guidance,并作为本次 run 的 pinned guidance 使用。
|
||||||
- team 模式调用 `TeamService.run_team(...)` 产生 sub-agent runs。
|
- team 模式调用 `TeamService.run_team(...)` 产生 sub-agent runs。
|
||||||
- Team 输出只作为主 Agent synthesis run 的内部上下文。
|
- Team 输出只作为主 Agent synthesis run 的内部上下文。
|
||||||
- 用户可见最终回答仍由主 Agent 生成,并继续走验证、反馈和学习门控。
|
- 用户可见最终回答仍由主 Agent 生成,并继续走验证、反馈和学习门控。
|
||||||
@ -914,15 +914,15 @@ app-instance/backend/
|
|||||||
- sub-agent 是临时 generic worker,不承载固定角色人设。
|
- sub-agent 是临时 generic worker,不承载固定角色人设。
|
||||||
- `TaskExecutionPlanner` 的 team node 输出 `skill_query / required_capabilities / expected_output`。
|
- `TaskExecutionPlanner` 的 team node 输出 `skill_query / required_capabilities / expected_output`。
|
||||||
- `TaskSkillResolver` 从 published skill catalog 中选择合适 skill,并写入 node pinned skills。
|
- `TaskSkillResolver` 从 published skill catalog 中选择合适 skill,并写入 node pinned skills。
|
||||||
- 如果没有命中 published skill,会创建 draft-only skill,并把 draft 内容作为本次 sub-agent 的 ephemeral pinned skill context 使用。
|
- 如果没有命中 published skill,会创建 ephemeral guidance,并作为本次 sub-agent 的 pinned skill context 使用。
|
||||||
- draft 不自动 approve/publish,不进入 runtime catalog;后续仍走 review/publish。
|
- ephemeral guidance 不写入 draft store,不自动 approve/publish,不进入 runtime catalog。
|
||||||
- agent registry / target resolver 不参与 Task sub-agent strategy,可作为未来外部 agent/A2A 管理面保留。
|
- agent registry / target resolver 不参与 Task sub-agent strategy,可作为未来外部 agent/A2A 管理面保留。
|
||||||
|
|
||||||
2. **Task Team Process Projection**
|
2. **Task Team Process Projection**
|
||||||
- Task attempt 隐藏事件增加 `skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report / node_results / task_synthesis_completed`。
|
- Task attempt 隐藏事件增加 `skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report / node_results / task_synthesis_completed`。
|
||||||
- 新增 `GET /api/sessions/{session_id}/process`。
|
- 新增 `GET /api/sessions/{session_id}/process`。
|
||||||
- 前端 `ChatWorkbench` 已接入 `ProcessLane` 和移动端 `Process` tab。
|
- 前端 `ChatWorkbench` 已接入 `ProcessLane` 和移动端 `Process` tab。
|
||||||
- 展示规划、skill selection、draft-only ephemeral guidance、team node、main synthesis、validation/retry,不把 team summary 直接当最终回答。
|
- 展示规划、skill selection、ephemeral guidance、team node、main synthesis、validation/retry,不把 team summary 直接当最终回答。
|
||||||
|
|
||||||
3. **Learning Pipeline 闭环**
|
3. **Learning Pipeline 闭环**
|
||||||
- 新增 `SkillLearningPipelineService`。
|
- 新增 `SkillLearningPipelineService`。
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
└─ future channels(未来扩展入口)
|
└─ future channels(未来扩展入口)
|
||||||
│
|
│
|
||||||
└─ AgentService(统一服务层:所有入口都先汇总到这里)
|
└─ AgentService(统一服务层:所有入口都先汇总到这里)
|
||||||
├─ MainAgentRouter(自动判断 simple / task)
|
├─ MainAgentRouter(LLM 语义判断 simple / continue task / new task / close / abandon)
|
||||||
├─ create_loop()(创建 AgentLoop 运行核心)
|
├─ create_loop()(创建 AgentLoop 运行核心)
|
||||||
├─ start()(启动后台运行模式)
|
├─ start()(启动后台运行模式)
|
||||||
├─ submit_direct()(把任务提交到运行队列)
|
├─ submit_direct()(把任务提交到运行队列)
|
||||||
@ -73,15 +73,20 @@ AgentService.process_direct / submit_direct(聊天入口统一进入服务层
|
|||||||
│
|
│
|
||||||
├─ resolve session_id(复用请求 session,或生成新 session)
|
├─ resolve session_id(复用请求 session,或生成新 session)
|
||||||
├─ task_service.get_latest_open_task(session_id)(查找同会话未关闭 Task)
|
├─ task_service.get_latest_open_task(session_id)(查找同会话未关闭 Task)
|
||||||
├─ MainAgentRouter.classify(message, active_task)(自动分类)
|
├─ MainAgentRouter.classify(message, active_task, recent_messages)(LLM 语义分类)
|
||||||
│ ├─ simple(简单问题)
|
│ ├─ simple(简单问题)
|
||||||
│ │ └─ runner(message)(直接走原有 AgentLoop,不创建 Task)
|
│ │ └─ runner(message, include_skill_assembly=False, include_tools=False)(不创建 Task,不跑 skills/tools)
|
||||||
│ │
|
│ │
|
||||||
│ └─ task(复杂任务)
|
│ ├─ continue_task(继续当前 Task)
|
||||||
│ ├─ if no active task or user starts new task
|
│ │ └─ reuse active Task(只要话题没有完全无关,就继续当前 open Task)
|
||||||
│ │ └─ TaskService.create_task(...)(内部创建 Task)
|
│ │
|
||||||
│ ├─ else
|
│ ├─ new_task(明确开启新任务)
|
||||||
│ │ └─ reuse active Task(复用 awaiting_feedback / needs_revision Task)
|
│ │ └─ TaskService.create_task(...)(内部创建 Task,并保存 short_title)
|
||||||
|
│ │
|
||||||
|
│ ├─ close_task / abandon_task(用户明确结束或放弃)
|
||||||
|
│ │ └─ TaskService.close_task / abandon_task(关闭当前 Task)
|
||||||
|
│ │
|
||||||
|
│ └─ task execution
|
||||||
│ └─ AgentService._run_task_mode(...)(进入 Task 模式执行)
|
│ └─ AgentService._run_task_mode(...)(进入 Task 模式执行)
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -92,6 +97,7 @@ TaskService(内部 Task 状态机)
|
|||||||
│ ├─ task_id
|
│ ├─ task_id
|
||||||
│ ├─ session_id
|
│ ├─ session_id
|
||||||
│ ├─ goal / description / constraints
|
│ ├─ goal / description / constraints
|
||||||
|
│ ├─ metadata.short_title(5-15 字左右的短标题,用于前端当前任务标识)
|
||||||
│ ├─ status
|
│ ├─ status
|
||||||
│ │ ├─ open
|
│ │ ├─ open
|
||||||
│ │ ├─ running
|
│ │ ├─ running
|
||||||
@ -167,17 +173,32 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
|
|||||||
│ ├─ auxiliary_provider(辅助模型调用器,用于选 skill 等)
|
│ ├─ auxiliary_provider(辅助模型调用器,用于选 skill 等)
|
||||||
│ └─ embedding_runtime(向量模型配置,用于语义召回)
|
│ └─ embedding_runtime(向量模型配置,用于语义召回)
|
||||||
│
|
│
|
||||||
├─ skill_assembler.assemble(...)(选择本轮应该激活哪些 skill)
|
├─ if include_skill_assembly=False(simple_chat 默认关闭)
|
||||||
│ ├─ SkillsLoader.build_selection_candidates()(列出候选技能摘要)
|
│ └─ skip SkillAssembler(不激活 skill,不注入 skill 正文)
|
||||||
│ ├─ embedding retrieve skill candidates(用向量召回相关技能)
|
│
|
||||||
│ ├─ LLM select activated skills(让模型从候选里选择技能)
|
├─ if include_skill_assembly=True(Task mode 默认开启,在 Task 创建/复用和规划之后执行)
|
||||||
│ └─ 返回 activated skills(返回本轮被激活的技能)
|
│ └─ skill_assembler.assemble(...)(选择本轮应该激活哪些 published skill)
|
||||||
│ ├─ name(技能名称)
|
│ ├─ input task_description = skill_selection_context or current user input
|
||||||
│ ├─ content(技能正文)
|
│ │ ├─ Task goal / description
|
||||||
│ ├─ version(技能版本)
|
│ │ ├─ current user request
|
||||||
│ ├─ content_hash(技能内容哈希,用于追踪)
|
│ │ ├─ attempt / revision / team synthesis phase
|
||||||
│ ├─ activation_reason(为什么激活)
|
│ │ ├─ validation feedback(重试时)
|
||||||
│ └─ tool_hints(技能建议使用哪些工具)
|
│ │ ├─ team summary / 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(...)(把激活技能变成模型可读消息)
|
├─ ContextBuilder.build_skill_activation_messages(...)(把激活技能变成模型可读消息)
|
||||||
├─ 构造 SkillActivationReceipt[](构造技能激活收据)
|
├─ 构造 SkillActivationReceipt[](构造技能激活收据)
|
||||||
@ -188,7 +209,7 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
|
|||||||
│ ├─ receipts(技能激活收据)
|
│ ├─ receipts(技能激活收据)
|
||||||
│ └─ activation_messages(实际注入给模型的技能消息)
|
│ └─ activation_messages(实际注入给模型的技能消息)
|
||||||
│
|
│
|
||||||
├─ tool_assembler.assemble(...)(选择本轮应该暴露哪些工具)
|
├─ tool_assembler.assemble(...)(选择本轮应该暴露哪些工具;simple_chat 默认跳过)
|
||||||
│ ├─ always tools(默认总是可用的工具)
|
│ ├─ always tools(默认总是可用的工具)
|
||||||
│ ├─ activated skill tool hints(被激活技能推荐的工具)
|
│ ├─ activated skill tool hints(被激活技能推荐的工具)
|
||||||
│ ├─ embedding retrieve tools(用向量召回相关工具)
|
│ ├─ embedding retrieve tools(用向量召回相关工具)
|
||||||
@ -207,6 +228,7 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
|
|||||||
│ └─ append current user input(追加当前用户输入)
|
│ └─ append current user input(追加当前用户输入)
|
||||||
│
|
│
|
||||||
├─ session_manager.update_system_prompt(...)(把本轮 system prompt 快照写回会话)
|
├─ session_manager.update_system_prompt(...)(把本轮 system prompt 快照写回会话)
|
||||||
|
├─ session_manager.append_message(event_type="skill_selection_context_snapshotted", hidden)(完整记录 skill query)
|
||||||
├─ session_manager.append_message(event_type="system_prompt_snapshotted", hidden)(记录隐藏事件:system prompt 快照)
|
├─ session_manager.append_message(event_type="system_prompt_snapshotted", hidden)(记录隐藏事件:system prompt 快照)
|
||||||
├─ session_manager.append_message(event_type="user_message_added")(记录可见事件:用户消息)
|
├─ session_manager.append_message(event_type="user_message_added")(记录可见事件:用户消息)
|
||||||
│
|
│
|
||||||
@ -214,12 +236,12 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
|
|||||||
│
|
│
|
||||||
├─ 成功时(模型正常结束)
|
├─ 成功时(模型正常结束)
|
||||||
│ ├─ session_manager.append_message(event_type="run_completed", hidden)(记录隐藏事件:运行完成)
|
│ ├─ session_manager.append_message(event_type="run_completed", hidden)(记录隐藏事件:运行完成)
|
||||||
│ └─ _record_skill_learning(...)(记录技能使用效果,进入学习闭环)
|
│ └─ _record_run_receipts(...)(记录运行证据,不生成学习候选)
|
||||||
│
|
│
|
||||||
├─ 失败时(运行中出现异常)
|
├─ 失败时(运行中出现异常)
|
||||||
│ ├─ append assistant error message(写入 assistant 错误消息)
|
│ ├─ append assistant error message(写入 assistant 错误消息)
|
||||||
│ ├─ session_manager.append_message(event_type="run_failed", hidden)(记录隐藏事件:运行失败)
|
│ ├─ session_manager.append_message(event_type="run_failed", hidden)(记录隐藏事件:运行失败)
|
||||||
│ └─ _record_skill_learning(...)(即使失败也记录技能效果)
|
│ └─ _record_run_receipts(...)(即使失败也记录运行证据)
|
||||||
│
|
│
|
||||||
└─ return AgentRunResult(返回本轮结果)
|
└─ return AgentRunResult(返回本轮结果)
|
||||||
├─ session_id(会话编号)
|
├─ session_id(会话编号)
|
||||||
@ -242,6 +264,7 @@ AgentLoop.process_direct(task)(直接执行一轮用户任务)
|
|||||||
```text
|
```text
|
||||||
tool loop(工具调用循环)
|
tool loop(工具调用循环)
|
||||||
│
|
│
|
||||||
|
├─ session_manager.append_message(event_type="llm_request_snapshotted", hidden)(完整记录本次 provider messages / tools)
|
||||||
├─ provider.chat(messages, tools=schemas)(把消息和工具 schema 发给模型)
|
├─ provider.chat(messages, tools=schemas)(把消息和工具 schema 发给模型)
|
||||||
├─ session_manager.update_usage(...)(累计 token 用量)
|
├─ session_manager.update_usage(...)(累计 token 用量)
|
||||||
├─ session_manager.append_message(event_type="assistant_message_added")(记录 assistant 回复)
|
├─ session_manager.append_message(event_type="assistant_message_added")(记录 assistant 回复)
|
||||||
@ -259,10 +282,10 @@ tool loop(工具调用循环)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Skills Learning Baseline
|
## 6. Run Evidence / Skill Effect Recording
|
||||||
|
|
||||||
```text
|
```text
|
||||||
AgentLoop._record_skill_learning(...)(记录本轮技能效果)
|
AgentLoop._record_run_receipts(...)(记录本轮运行证据;不直接学习)
|
||||||
│
|
│
|
||||||
├─ 构造 RunRecord(构造本轮运行记录)
|
├─ 构造 RunRecord(构造本轮运行记录)
|
||||||
│ ├─ run_id(运行编号)
|
│ ├─ run_id(运行编号)
|
||||||
@ -286,11 +309,7 @@ AgentLoop._record_skill_learning(...)(记录本轮技能效果)
|
|||||||
│ ├─ RunMemoryStore.append_skill_effect(...)(把 SkillEffectRecord 写入 memory/runs/skill-effects.jsonl)
|
│ ├─ RunMemoryStore.append_skill_effect(...)(把 SkillEffectRecord 写入 memory/runs/skill-effects.jsonl)
|
||||||
│ ├─ SkillLearningService.rescore_skill_versions()(重新统计每个技能版本表现)
|
│ ├─ SkillLearningService.rescore_skill_versions()(重新统计每个技能版本表现)
|
||||||
│ │ └─ SkillLearningStore.update_performance_snapshot(...)(更新表现快照)
|
│ │ └─ SkillLearningStore.update_performance_snapshot(...)(更新表现快照)
|
||||||
│ └─ optionally build learning candidates(默认不生成;只由反馈门控显式触发)
|
│ └─ never build learning candidates in runtime hot path(运行完成时永不生成候选)
|
||||||
│ ├─ revise_skill(建议修改已有技能)
|
|
||||||
│ ├─ new_skill(建议创建新技能)
|
|
||||||
│ ├─ merge_skills(建议合并相似技能)
|
|
||||||
│ └─ retire_skill(建议退役长期不用的技能)
|
|
||||||
│
|
│
|
||||||
└─ session_manager.append_message(...)(记录隐藏事件:技能效果快照)
|
└─ session_manager.append_message(...)(记录隐藏事件:技能效果快照)
|
||||||
├─ event_type="skill_effects_snapshotted"(技能效果已快照)
|
├─ event_type="skill_effects_snapshotted"(技能效果已快照)
|
||||||
@ -298,10 +317,23 @@ AgentLoop._record_skill_learning(...)(记录本轮技能效果)
|
|||||||
└─ payload(隐藏数据)
|
└─ payload(隐藏数据)
|
||||||
├─ run_record(本轮运行记录)
|
├─ run_record(本轮运行记录)
|
||||||
├─ skill_effects(技能效果记录)
|
├─ skill_effects(技能效果记录)
|
||||||
├─ learning_candidate_enabled(本轮是否允许生成候选,默认 false)
|
├─ candidate_generation_allowed(本轮是否允许生成候选;runtime 固定 false)
|
||||||
└─ learning_candidates(学习候选;默认空)
|
└─ learning_candidates(学习候选;默认空)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
runtime invariant(运行期不直接学习)
|
||||||
|
│
|
||||||
|
├─ run completed / run failed
|
||||||
|
│ └─ 只写 RunRecord + SkillEffectRecord + performance snapshot
|
||||||
|
│
|
||||||
|
├─ simple chat
|
||||||
|
│ └─ 不创建 Task,不触发 learning candidate
|
||||||
|
│
|
||||||
|
└─ Task attempt / sub-agent run
|
||||||
|
└─ 只留下证据,等待 feedback gate 决定是否学习
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Chat Feedback / Learning Gate
|
## 7. Chat Feedback / Learning Gate
|
||||||
@ -328,17 +360,20 @@ POST /api/chat/feedback(聊天反馈接口,不是 Task 管理 API)
|
|||||||
├─ satisfied
|
├─ satisfied
|
||||||
│ ├─ if validation accepted
|
│ ├─ if validation accepted
|
||||||
│ │ ├─ Task status -> closed
|
│ │ ├─ Task status -> closed
|
||||||
│ │ └─ SkillLearningService.build_learning_candidates()
|
│ │ └─ SkillLearningService.build_learning_candidates_for_task(task_id, trigger_run_id)
|
||||||
│ └─ if validation not accepted
|
│ └─ if validation not accepted
|
||||||
│ └─ 记录人工接受但保留验证风险
|
│ └─ 记录人工接受但保留验证风险;不自动生成 learning candidate
|
||||||
│
|
│
|
||||||
├─ revise
|
├─ revise
|
||||||
│ ├─ Task status -> needs_revision
|
│ ├─ Task status -> needs_revision
|
||||||
│ └─ 下一条用户消息默认复用该 Task
|
│ ├─ 更新 run / skill effect 为需修订证据
|
||||||
|
│ └─ 下一条用户消息默认复用该 Task;不生成 learning candidate
|
||||||
│
|
│
|
||||||
└─ abandon
|
└─ abandon
|
||||||
├─ Task status -> abandoned
|
├─ Task status -> abandoned
|
||||||
└─ write Failure Memory(不生成成功 Skill draft)
|
├─ 更新 run / skill effect 为失败证据
|
||||||
|
├─ 追加 task_failure_evidence_recorded 隐藏事件
|
||||||
|
└─ 默认不写主 memory,不生成成功 Skill draft
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -356,15 +391,15 @@ TeamService.run_team(...)(内部 team 执行入口,不暴露产品级 Task A
|
|||||||
│ │ └─ reserved strategies: moa / hierarchy / heavy / group_chat / forest / maker / router
|
│ │ └─ reserved strategies: moa / hierarchy / heavy / group_chat / forest / maker / router
|
||||||
│ ├─ provider_bundle_factory(node)(推荐:每个节点拿 fresh provider bundle)
|
│ ├─ provider_bundle_factory(node)(推荐:每个节点拿 fresh provider bundle)
|
||||||
│ ├─ inherited_pinned_skills(主 agent 明确委派给 sub-agent 的 pinned skills)
|
│ ├─ inherited_pinned_skills(主 agent 明确委派给 sub-agent 的 pinned skills)
|
||||||
│ ├─ inherited_pinned_skill_contexts(missing skill draft 生成的 ephemeral skill guidance)
|
│ ├─ inherited_pinned_skill_contexts(missing skill 生成的一次性 ephemeral guidance)
|
||||||
│ └─ learning_candidate_enabled=False(默认只写 receipts,不绕过 Task feedback gate)
|
│ └─ allow_candidate_generation=False(默认只写 receipts,不绕过 Task feedback gate)
|
||||||
│
|
│
|
||||||
├─ LocalAgentRunner.run(envelope)
|
├─ LocalAgentRunner.run(envelope)
|
||||||
│ ├─ 生成 child_session_id
|
│ ├─ 生成 child_session_id
|
||||||
│ ├─ parent_session_id -> 主 session(建立 session lineage)
|
│ ├─ parent_session_id -> 主 session(建立 session lineage)
|
||||||
│ ├─ AgentLoop.process_direct / submit_direct(...)(复用主 AgentLoop / ContextBuilder / ToolAssembler / SkillAssembler / MemoryService)
|
│ ├─ AgentLoop.process_direct / submit_direct(...)(复用主 AgentLoop / ContextBuilder / ToolAssembler / SkillAssembler / MemoryService)
|
||||||
│ ├─ pinned_skill_names -> AgentLoop(published pinned skill 必须注入)
|
│ ├─ 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 禁止混用
|
│ └─ provider_bundle + node model/provider override 禁止混用
|
||||||
│
|
│
|
||||||
├─ strategy execution
|
├─ strategy execution
|
||||||
@ -522,7 +557,6 @@ SkillsLoader(技能加载器)
|
|||||||
├─ build_skills_summary()(构造技能摘要索引)
|
├─ build_skills_summary()(构造技能摘要索引)
|
||||||
├─ build_selection_candidates()(构造给 SkillAssembler 的候选摘要)
|
├─ build_selection_candidates()(构造给 SkillAssembler 的候选摘要)
|
||||||
├─ list_skill_supporting_files()(列出技能支持文件)
|
├─ list_skill_supporting_files()(列出技能支持文件)
|
||||||
├─ view_skill()(查看技能正文或支持文件)
|
|
||||||
└─ get_always_skills()(获取 always 类型技能)
|
└─ get_always_skills()(获取 always 类型技能)
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -530,13 +564,17 @@ SkillsLoader(技能加载器)
|
|||||||
SkillAssembler(技能选择器)
|
SkillAssembler(技能选择器)
|
||||||
│
|
│
|
||||||
├─ input(输入)
|
├─ input(输入)
|
||||||
│ ├─ task_description(用户任务描述)
|
│ ├─ task_description(Task-aware query:Task 描述 / 当前用户消息 / previous skills / attempt context / validation revision context / team context)
|
||||||
│ ├─ candidate skill summaries(候选技能摘要)
|
│ ├─ candidate skill summaries(候选技能摘要)
|
||||||
│ ├─ embedding runtime(向量模型配置)
|
│ ├─ embedding runtime(向量模型配置)
|
||||||
│ └─ selector provider/model(用于选择技能的模型)
|
│ └─ selector provider/model(用于选择技能的模型)
|
||||||
│
|
│
|
||||||
├─ embedding retrieve candidates(先用向量召回相关技能)
|
├─ embedding retrieve candidates(先用向量召回相关技能)
|
||||||
├─ LLM select names(再让 LLM 选择技能名)
|
├─ LLM shortlist names(用摘要粗选需要查看正文的候选)
|
||||||
|
│ └─ skip when candidate count <= max_detailed_candidates(候选很少时直接读取正文)
|
||||||
|
├─ internal load shortlisted SKILL.md(SkillAssembler 内部读取候选正文)
|
||||||
|
├─ LLM final select names(结合候选正文选择最终技能名)
|
||||||
|
├─ no match returns [](没有对应 published skill 时返回空,不阻塞任务)
|
||||||
└─ return SkillContext[](返回技能上下文)
|
└─ return SkillContext[](返回技能上下文)
|
||||||
├─ name(技能名)
|
├─ name(技能名)
|
||||||
├─ content(技能正文)
|
├─ content(技能正文)
|
||||||
@ -586,7 +624,6 @@ ToolRegistry(工具注册表)
|
|||||||
│
|
│
|
||||||
├─ echo(回显工具)
|
├─ echo(回显工具)
|
||||||
├─ memory(写入/管理长期记忆)
|
├─ memory(写入/管理长期记忆)
|
||||||
├─ skill_view(查看完整 skill)
|
|
||||||
├─ session_search(搜索会话历史)
|
├─ session_search(搜索会话历史)
|
||||||
├─ list_directory(列目录)
|
├─ list_directory(列目录)
|
||||||
├─ read_file(读文件)
|
├─ read_file(读文件)
|
||||||
@ -599,7 +636,7 @@ ToolAssembler(工具选择器)
|
|||||||
├─ selected = always tools(先加入默认工具)
|
├─ selected = always tools(先加入默认工具)
|
||||||
├─ selected += activated skill tool hints(再加入技能推荐工具)
|
├─ selected += activated skill tool hints(再加入技能推荐工具)
|
||||||
├─ selected += embedding top-k tools(再用向量召回任务相关工具)
|
├─ selected += embedding top-k tools(再用向量召回任务相关工具)
|
||||||
└─ return ToolSpec[](返回本轮可用工具列表)
|
└─ return ToolSpec[](返回本轮可用工具列表;不通过工具动态加载 skill)
|
||||||
```
|
```
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@ -703,11 +740,11 @@ TaskExecutionPlanner(Task 内部执行规划)
|
|||||||
│ ├─ 从 published skill catalog 检索候选
|
│ ├─ 从 published skill catalog 检索候选
|
||||||
│ ├─ 按 skill_query / required_capabilities / node task 选择 skill
|
│ ├─ 按 skill_query / required_capabilities / node task 选择 skill
|
||||||
│ ├─ 命中 published skill 后写入 graph.nodes[].inherited_pinned_skills
|
│ ├─ 命中 published skill 后写入 graph.nodes[].inherited_pinned_skills
|
||||||
│ └─ 无命中时创建 draft-only skill,并写入 graph.nodes[].inherited_pinned_skill_contexts
|
│ └─ 无命中时创建 ephemeral guidance,并写入 graph.nodes[].inherited_pinned_skill_contexts
|
||||||
│
|
│
|
||||||
└─ TaskExecutionPlan
|
└─ TaskExecutionPlan
|
||||||
├─ graph.nodes[].agent 只是 generic runtime trace identity
|
├─ graph.nodes[].agent 只是 generic runtime trace identity
|
||||||
└─ to_event_payload() 写入 skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report
|
└─ to_event_payload() 写入 skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report
|
||||||
```
|
```
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@ -748,8 +785,21 @@ Frontend process projection
|
|||||||
```text
|
```text
|
||||||
Learning pipeline
|
Learning pipeline
|
||||||
│
|
│
|
||||||
|
├─ evidence recording
|
||||||
|
│ ├─ every run -> RunRecord
|
||||||
|
│ ├─ activated skills -> SkillEffectRecord
|
||||||
|
│ └─ no candidates generated here
|
||||||
|
│
|
||||||
├─ feedback gate
|
├─ feedback gate
|
||||||
│ └─ validation accepted + satisfied 才生成 learning candidate
|
│ ├─ validation accepted + satisfied -> scoped learning candidate
|
||||||
|
│ ├─ validation rejected + satisfied -> 记录人工接受风险,不生成候选
|
||||||
|
│ ├─ revise -> 保持 Task 打开,不生成候选
|
||||||
|
│ └─ abandon -> 失败证据,不写主 memory,不生成成功候选
|
||||||
|
│
|
||||||
|
├─ scoped candidate generation
|
||||||
|
│ ├─ source = current task run_ids
|
||||||
|
│ ├─ no published skill -> new_skill
|
||||||
|
│ └─ published skill used -> revise_skill
|
||||||
│
|
│
|
||||||
├─ SkillLearningPipelineService
|
├─ SkillLearningPipelineService
|
||||||
│ ├─ candidate -> queued / synthesizing
|
│ ├─ candidate -> queued / synthesizing
|
||||||
@ -799,6 +849,12 @@ Web(网页入口)
|
|||||||
│ ├─ agent_service.submit_direct(...)(把用户消息提交给 AgentService)
|
│ ├─ agent_service.submit_direct(...)(把用户消息提交给 AgentService)
|
||||||
│ └─ return WebChatResponse(返回模型回复 + run/task/validation 元数据)
|
│ └─ return WebChatResponse(返回模型回复 + run/task/validation 元数据)
|
||||||
│
|
│
|
||||||
|
├─ WS /ws/{session_id}(网页 WebSocket 适配层)
|
||||||
|
│ ├─ ping -> pong
|
||||||
|
│ ├─ message -> agent_service.submit_direct(...)
|
||||||
|
│ ├─ return status / assistant message(携带 run/task/validation 元数据)
|
||||||
|
│ └─ return session_updated(通知前端刷新 session/process)
|
||||||
|
│
|
||||||
└─ POST /api/chat/feedback(聊天反馈接口)
|
└─ POST /api/chat/feedback(聊天反馈接口)
|
||||||
├─ validate WebChatFeedbackRequest
|
├─ validate WebChatFeedbackRequest
|
||||||
├─ agent_service.submit_feedback(...)
|
├─ agent_service.submit_feedback(...)
|
||||||
@ -820,8 +876,10 @@ Skills learning admin API
|
|||||||
Gateway(消息通道入口)
|
Gateway(消息通道入口)
|
||||||
│
|
│
|
||||||
├─ MessageBus(内部消息总线)
|
├─ MessageBus(内部消息总线)
|
||||||
|
├─ ChannelAdapter(Telegram / Slack / Email / WhatsApp 等只作为 adapter)
|
||||||
├─ inbound -> AgentService.handle_inbound_message(...)(外部消息进入 AgentService)
|
├─ 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"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anthropic>=0.51.0,<1.0.0",
|
"anthropic>=0.51.0,<1.0.0",
|
||||||
|
"croniter>=6.0.0,<7.0.0",
|
||||||
"fastmcp>=3.0.0,<4.0.0",
|
"fastmcp>=3.0.0,<4.0.0",
|
||||||
"fastapi>=0.115.0,<1.0.0",
|
"fastapi>=0.115.0,<1.0.0",
|
||||||
"httpx>=0.28.0,<1.0.0",
|
"httpx>=0.28.0,<1.0.0",
|
||||||
@ -12,6 +13,7 @@ dependencies = [
|
|||||||
"litellm>=1.79.0,<2.0.0",
|
"litellm>=1.79.0,<2.0.0",
|
||||||
"openai>=1.79.0,<2.0.0",
|
"openai>=1.79.0,<2.0.0",
|
||||||
"pydantic>=2.12.0,<3.0.0",
|
"pydantic>=2.12.0,<3.0.0",
|
||||||
|
"python-multipart>=0.0.20,<1.0.0",
|
||||||
"typer>=0.20.0,<1.0.0",
|
"typer>=0.20.0,<1.0.0",
|
||||||
"uvicorn[standard]>=0.34.0,<1.0.0",
|
"uvicorn[standard]>=0.34.0,<1.0.0",
|
||||||
]
|
]
|
||||||
|
|||||||
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()
|
self.release_first = asyncio.Event()
|
||||||
|
|
||||||
async def assemble(self, **kwargs) -> SkillAssemblyResult:
|
async def assemble(self, **kwargs) -> SkillAssemblyResult:
|
||||||
if kwargs["task_description"] == "task first":
|
if "task first" in kwargs["task_description"]:
|
||||||
self.first_started.set()
|
self.first_started.set()
|
||||||
await self.release_first.wait()
|
await self.release_first.wait()
|
||||||
return SkillAssemblyResult()
|
return SkillAssemblyResult()
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from beaver.engine import AgentLoop, EngineLoader
|
from beaver.engine import AgentLoop, EngineLoader
|
||||||
from beaver.engine.providers import make_provider_bundle
|
from beaver.engine.providers import make_provider_bundle
|
||||||
@ -42,6 +43,37 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None:
|
|||||||
assert target["extra_headers"] == {"X-Test": "1"}
|
assert target["extra_headers"] == {"X-Test": "1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": str(tmp_path / "workspace"),
|
||||||
|
"model": "qwen-plus",
|
||||||
|
"provider": "custom",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"custom": {},
|
||||||
|
"openai": {
|
||||||
|
"apiKey": "sk-test",
|
||||||
|
"apiBase": "https://oai.example.com/v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_config(config_path=config_path)
|
||||||
|
|
||||||
|
assert config.resolve_provider_target()["provider_name"] == "openai"
|
||||||
|
assert config.resolve_provider_target(provider_name="custom")["provider_name"] == "openai"
|
||||||
|
assert config.resolve_provider_target(provider_name="deepseek")["provider_name"] == "openai"
|
||||||
|
|
||||||
|
|
||||||
def test_engine_loader_uses_config_workspace(tmp_path) -> None:
|
def test_engine_loader_uses_config_workspace(tmp_path) -> None:
|
||||||
workspace = tmp_path / "workspace"
|
workspace = tmp_path / "workspace"
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
@ -105,3 +137,40 @@ def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
|
|||||||
assert bundle.main_runtime.api_base == "https://oai.example.com/v1"
|
assert bundle.main_runtime.api_base == "https://oai.example.com/v1"
|
||||||
assert isinstance(bundle.main_provider, LiteLLMProvider)
|
assert isinstance(bundle.main_provider, LiteLLMProvider)
|
||||||
assert bundle.main_provider._resolve_model("qwen-plus") == "openai/qwen-plus"
|
assert bundle.main_provider._resolve_model("qwen-plus") == "openai/qwen-plus"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_reads_stevenli_mcp_authz_identity() -> None:
|
||||||
|
repo_root = Path(__file__).resolve().parents[4]
|
||||||
|
config_path = repo_root / "app-instance" / "runtime" / "instances" / "stevenli" / "nanobot-home" / "config.json"
|
||||||
|
config = load_config(config_path=config_path)
|
||||||
|
|
||||||
|
server = config.tools.mcp_servers["outlook_mcp"]
|
||||||
|
assert server.transport == "http"
|
||||||
|
assert server.url == "http://10.6.80.29:8000/mcp"
|
||||||
|
assert server.auth_mode == "oauth_backend_token"
|
||||||
|
assert server.auth_audience == "mcp:outlook_mcp"
|
||||||
|
assert "tool:mail_list_messages" in server.auth_scopes
|
||||||
|
assert server.tool_timeout == 60
|
||||||
|
assert server.sensitive is True
|
||||||
|
|
||||||
|
assert config.authz.enabled is True
|
||||||
|
assert config.authz.base_url == "http://nano-authz-service:19090"
|
||||||
|
assert config.backend_identity.backend_id == "stevenli"
|
||||||
|
assert config.backend_identity.client_id == "stevenli"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_adds_managed_local_mcp_servers(tmp_path) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps({"tools": {"mcpServers": {}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_config(config_path=config_path)
|
||||||
|
|
||||||
|
local = config.tools.mcp_servers["local_filesystem_mcp"]
|
||||||
|
assert local.transport == "stdio"
|
||||||
|
assert local.kind == "local"
|
||||||
|
assert local.category == "filesystem"
|
||||||
|
assert local.managed is True
|
||||||
|
assert "beaver.interfaces.mcp.tools_server" in local.args
|
||||||
|
|||||||
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"
|
provider_name: str | None = "fake"
|
||||||
model: str | None = "fake-model"
|
model: str | None = "fake-model"
|
||||||
usage: dict[str, Any] = field(default_factory=dict)
|
usage: dict[str, Any] = field(default_factory=dict)
|
||||||
|
task_id: str | None = "task-1"
|
||||||
|
task_status: str | None = "awaiting_feedback"
|
||||||
|
validation_result: dict[str, Any] | None = field(default_factory=lambda: {"accepted": True})
|
||||||
|
|
||||||
|
|
||||||
class FakeService:
|
class FakeService:
|
||||||
@ -75,6 +78,9 @@ def test_gateway_routes_memory_channel_roundtrip() -> None:
|
|||||||
assert message.content == "echo:hello"
|
assert message.content == "echo:hello"
|
||||||
assert message.session_id == "s1"
|
assert message.session_id == "s1"
|
||||||
assert message.finish_reason == "stop"
|
assert message.finish_reason == "stop"
|
||||||
|
assert message.metadata["task_id"] == "task-1"
|
||||||
|
assert message.metadata["task_status"] == "awaiting_feedback"
|
||||||
|
assert message.metadata["validation_result"] == {"accepted": True}
|
||||||
|
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
await asyncio.wait_for(task, timeout=2)
|
await asyncio.wait_for(task, timeout=2)
|
||||||
@ -183,6 +189,50 @@ def test_agent_service_maps_stopped_runtime_to_stopped_outbound() -> None:
|
|||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_manager_keeps_unknown_channel_outbound_undeliverable() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
manager = ChannelManager(bus)
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
await bus.publish_outbound(
|
||||||
|
AgentService.build_outbound_message(
|
||||||
|
InboundMessage(channel="missing", content="hello", session_id="missing:1"),
|
||||||
|
FakeResult(session_id="missing:1", output_text="ok"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stop_event.set()
|
||||||
|
|
||||||
|
await manager.dispatch_outbound(stop_event)
|
||||||
|
|
||||||
|
assert len(manager.undeliverable) == 1
|
||||||
|
assert manager.undeliverable[0].channel == "missing"
|
||||||
|
assert manager.undeliverable[0].session_id == "missing:1"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
channel = MemoryChannelAdapter(bus, name="telegram")
|
||||||
|
inbound = await channel.publish_external_text(
|
||||||
|
"hello",
|
||||||
|
chat_id="chat-1",
|
||||||
|
message_id="message-1",
|
||||||
|
raw_payload={"platform": "telegram", "text": "hello"},
|
||||||
|
)
|
||||||
|
|
||||||
|
queued = await bus.consume_inbound()
|
||||||
|
assert queued is inbound
|
||||||
|
assert queued.channel == "telegram"
|
||||||
|
assert queued.session_id == "telegram:chat-1"
|
||||||
|
assert queued.metadata["chat_id"] == "chat-1"
|
||||||
|
assert queued.metadata["message_id"] == "message-1"
|
||||||
|
assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"}
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
|
def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
|
||||||
class StartedChannel:
|
class StartedChannel:
|
||||||
name = "started"
|
name = "started"
|
||||||
|
|||||||
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,
|
ended_at=recent,
|
||||||
success=True,
|
success=True,
|
||||||
finish_reason="stop",
|
finish_reason="stop",
|
||||||
|
feedback={"feedback_type": "satisfied"},
|
||||||
|
activated_skills=[],
|
||||||
|
task_id=f"task-new-{index}",
|
||||||
|
attempt_index=1,
|
||||||
|
validation_result={"accepted": True, "score": 0.9},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for index in range(2):
|
||||||
|
run_store.append_run_record(
|
||||||
|
RunRecord(
|
||||||
|
run_id=f"simple-chat-{index}",
|
||||||
|
session_id="session-simple",
|
||||||
|
task_text="你是谁",
|
||||||
|
started_at=recent,
|
||||||
|
ended_at=recent,
|
||||||
|
success=True,
|
||||||
|
finish_reason="stop",
|
||||||
feedback={},
|
feedback={},
|
||||||
activated_skills=[],
|
activated_skills=[],
|
||||||
|
task_id=None,
|
||||||
|
attempt_index=None,
|
||||||
|
validation_result=None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -329,8 +350,11 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path:
|
|||||||
ended_at=recent,
|
ended_at=recent,
|
||||||
success=True,
|
success=True,
|
||||||
finish_reason="stop",
|
finish_reason="stop",
|
||||||
feedback={},
|
feedback={"feedback_type": "satisfied"},
|
||||||
activated_skills=receipts,
|
activated_skills=receipts,
|
||||||
|
task_id=f"task-merge-{index}",
|
||||||
|
attempt_index=1,
|
||||||
|
validation_result={"accepted": True, "score": 0.9},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for receipt in receipts:
|
for receipt in receipts:
|
||||||
@ -382,6 +406,9 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path:
|
|||||||
kinds = {candidate.kind for candidate in candidates}
|
kinds = {candidate.kind for candidate in candidates}
|
||||||
|
|
||||||
assert {"revise_skill", "new_skill", "merge_skills", "retire_skill"} <= kinds
|
assert {"revise_skill", "new_skill", "merge_skills", "retire_skill"} <= kinds
|
||||||
|
new_candidates = [candidate for candidate in candidates if candidate.kind == "new_skill"]
|
||||||
|
assert new_candidates
|
||||||
|
assert all("simple-chat" not in run_id for candidate in new_candidates for run_id in candidate.source_run_ids)
|
||||||
|
|
||||||
retire_candidate = next(candidate for candidate in candidates if candidate.kind == "retire_skill")
|
retire_candidate = next(candidate for candidate in candidates if candidate.kind == "retire_skill")
|
||||||
retire_draft = asyncio.run(
|
retire_draft = asyncio.run(
|
||||||
@ -396,6 +423,100 @@ def test_skill_learning_service_generates_candidates_and_retire_draft(tmp_path:
|
|||||||
assert store.read_draft("svn-migration", retire_draft.draft_id) is not None
|
assert store.read_draft("svn-migration", retire_draft.draft_id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_learning_service_generates_task_scoped_candidates(tmp_path: Path) -> None:
|
||||||
|
store = SkillSpecStore(tmp_path)
|
||||||
|
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||||
|
learning_store = SkillLearningStore(tmp_path / "memory" / "skills")
|
||||||
|
service = SkillLearningService(
|
||||||
|
run_store=run_store,
|
||||||
|
learning_store=learning_store,
|
||||||
|
draft_service=DraftService(store),
|
||||||
|
evidence_selector=EvidenceSelector(run_store),
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
receipt = _receipt(
|
||||||
|
run_id="task-run-1",
|
||||||
|
session_id="session-task",
|
||||||
|
skill_name="api-review",
|
||||||
|
skill_version="v0001",
|
||||||
|
activated_at=now,
|
||||||
|
)
|
||||||
|
run_store.append_run_record(
|
||||||
|
RunRecord(
|
||||||
|
run_id="task-run-1",
|
||||||
|
session_id="session-task",
|
||||||
|
task_id="task-1",
|
||||||
|
attempt_index=1,
|
||||||
|
task_text="Review API compatibility",
|
||||||
|
started_at=now,
|
||||||
|
ended_at=now,
|
||||||
|
success=True,
|
||||||
|
finish_reason="stop",
|
||||||
|
feedback={"feedback_type": "satisfied"},
|
||||||
|
activated_skills=[receipt],
|
||||||
|
validation_result={"accepted": True, "score": 0.9},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
run_store.append_run_record(
|
||||||
|
RunRecord(
|
||||||
|
run_id="other-task-run",
|
||||||
|
session_id="session-other",
|
||||||
|
task_id="task-2",
|
||||||
|
attempt_index=1,
|
||||||
|
task_text="Review API compatibility",
|
||||||
|
started_at=now,
|
||||||
|
ended_at=now,
|
||||||
|
success=True,
|
||||||
|
finish_reason="stop",
|
||||||
|
feedback={"feedback_type": "satisfied"},
|
||||||
|
activated_skills=[],
|
||||||
|
validation_result={"accepted": True, "score": 0.9},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = service.build_learning_candidates_for_task("task-1", trigger_run_id="task-run-1")
|
||||||
|
|
||||||
|
assert [candidate.candidate_id for candidate in candidates] == ["revise:api-review:v0001:task:task-1"]
|
||||||
|
assert candidates[0].source_run_ids == ["task-run-1"]
|
||||||
|
assert candidates[0].related_skill_names == ["api-review"]
|
||||||
|
assert candidates[0].evidence["task_id"] == "task-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_learning_service_generates_new_skill_for_task_without_published_skills(tmp_path: Path) -> None:
|
||||||
|
store = SkillSpecStore(tmp_path)
|
||||||
|
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||||
|
learning_store = SkillLearningStore(tmp_path / "memory" / "skills")
|
||||||
|
service = SkillLearningService(
|
||||||
|
run_store=run_store,
|
||||||
|
learning_store=learning_store,
|
||||||
|
draft_service=DraftService(store),
|
||||||
|
evidence_selector=EvidenceSelector(run_store),
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
run_store.append_run_record(
|
||||||
|
RunRecord(
|
||||||
|
run_id="task-run-1",
|
||||||
|
session_id="session-task",
|
||||||
|
task_id="task-1",
|
||||||
|
attempt_index=1,
|
||||||
|
task_text="Generate migration checklist",
|
||||||
|
started_at=now,
|
||||||
|
ended_at=now,
|
||||||
|
success=True,
|
||||||
|
finish_reason="stop",
|
||||||
|
feedback={"feedback_type": "satisfied"},
|
||||||
|
activated_skills=[],
|
||||||
|
validation_result={"accepted": True, "score": 0.9},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = service.build_learning_candidates_for_task("task-1", trigger_run_id="task-run-1")
|
||||||
|
|
||||||
|
assert [candidate.candidate_id for candidate in candidates] == ["new:task:task-1"]
|
||||||
|
assert candidates[0].kind == "new_skill"
|
||||||
|
assert candidates[0].source_run_ids == ["task-run-1"]
|
||||||
|
|
||||||
|
|
||||||
def test_agent_loop_records_skill_receipts_and_effects(tmp_path: Path) -> None:
|
def test_agent_loop_records_skill_receipts_and_effects(tmp_path: Path) -> None:
|
||||||
skill = SkillContext(
|
skill = SkillContext(
|
||||||
name="docker-debug",
|
name="docker-debug",
|
||||||
@ -446,7 +567,7 @@ def test_agent_loop_records_skill_receipts_and_effects(tmp_path: Path) -> None:
|
|||||||
skill_effects = next(event for event in events if event.event_type == "skill_effects_snapshotted")
|
skill_effects = next(event for event in events if event.event_type == "skill_effects_snapshotted")
|
||||||
assert skill_effects.event_payload["run_record"]["activated_skills"][0]["skill_version"] == "v0007"
|
assert skill_effects.event_payload["run_record"]["activated_skills"][0]["skill_version"] == "v0007"
|
||||||
assert skill_effects.event_payload["skill_effects"][0]["skill_name"] == "docker-debug"
|
assert skill_effects.event_payload["skill_effects"][0]["skill_name"] == "docker-debug"
|
||||||
assert skill_effects.event_payload["learning_candidate_enabled"] is False
|
assert skill_effects.event_payload["candidate_generation_allowed"] is False
|
||||||
assert skill_effects.event_payload["learning_candidates"] == []
|
assert skill_effects.event_payload["learning_candidates"] == []
|
||||||
|
|
||||||
run_records = loaded.run_memory_store.list_runs()
|
run_records = loaded.run_memory_store.list_runs()
|
||||||
|
|||||||
@ -53,7 +53,8 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
|||||||
"node_id": "research",
|
"node_id": "research",
|
||||||
"skill_query": "research workflow",
|
"skill_query": "research workflow",
|
||||||
"selected_skill_names": ["research-workflow"],
|
"selected_skill_names": ["research-workflow"],
|
||||||
"generated_skill_draft_id": None,
|
"ephemeral_guidance_id": None,
|
||||||
|
"ephemeral_guidance_name": None,
|
||||||
"ephemeral_used": False,
|
"ephemeral_used": False,
|
||||||
"reason": "matched published skill",
|
"reason": "matched published skill",
|
||||||
}
|
}
|
||||||
@ -80,7 +81,8 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
|||||||
"skill_query": "research workflow",
|
"skill_query": "research workflow",
|
||||||
"selected_skill_names": ["research-workflow"],
|
"selected_skill_names": ["research-workflow"],
|
||||||
"ephemeral_skill_names": [],
|
"ephemeral_skill_names": [],
|
||||||
"generated_skill_draft_id": None,
|
"ephemeral_guidance_id": None,
|
||||||
|
"ephemeral_guidance_name": None,
|
||||||
"ephemeral_used": False,
|
"ephemeral_used": False,
|
||||||
"finish_reason": "stop",
|
"finish_reason": "stop",
|
||||||
}
|
}
|
||||||
@ -118,5 +120,83 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
|||||||
sub_run = next(run for run in projection["runs"] if run["run_id"] == "sub-run")
|
sub_run = next(run for run in projection["runs"] if run["run_id"] == "sub-run")
|
||||||
assert sub_run["metadata"]["selected_skill_names"] == ["research-workflow"]
|
assert sub_run["metadata"]["selected_skill_names"] == ["research-workflow"]
|
||||||
assert sub_run["metadata"]["skill_query"] == "research workflow"
|
assert sub_run["metadata"]["skill_query"] == "research workflow"
|
||||||
|
assert sub_run["metadata"]["ephemeral_guidance_id"] is None
|
||||||
assert any(event["actor_name"] == "Validator" for event in projection["events"])
|
assert any(event["actor_name"] == "Validator" for event in projection["events"])
|
||||||
assert any(run["session_id"] == "web:test" for run in projection["runs"])
|
assert any(run["session_id"] == "web:test" for run in projection["runs"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_projection_exposes_ephemeral_guidance_artifacts(tmp_path: Path) -> None:
|
||||||
|
session = SessionManager(tmp_path)
|
||||||
|
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||||
|
run_store.append_run_record(
|
||||||
|
RunRecord(
|
||||||
|
run_id="sub-run",
|
||||||
|
session_id="sub-session",
|
||||||
|
task_id="task-1",
|
||||||
|
attempt_index=1,
|
||||||
|
task_text="sub task",
|
||||||
|
started_at="2026-01-01T00:00:01+00:00",
|
||||||
|
ended_at="2026-01-01T00:00:02+00:00",
|
||||||
|
success=True,
|
||||||
|
finish_reason="stop",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.append_message(
|
||||||
|
"web:test",
|
||||||
|
role="system",
|
||||||
|
event_type="task_execution_planned",
|
||||||
|
event_payload={
|
||||||
|
"task_id": "task-1",
|
||||||
|
"attempt_index": 1,
|
||||||
|
"plan_mode": "team",
|
||||||
|
"strategy": "sequence",
|
||||||
|
"node_ids": ["research"],
|
||||||
|
"ephemeral_guidance_ids": ["eg_123"],
|
||||||
|
"skill_resolution_report": [
|
||||||
|
{
|
||||||
|
"node_id": "research",
|
||||||
|
"skill_query": "research workflow",
|
||||||
|
"selected_skill_names": [],
|
||||||
|
"ephemeral_guidance_id": "eg_123",
|
||||||
|
"ephemeral_guidance_name": "research-workflow",
|
||||||
|
"ephemeral_used": True,
|
||||||
|
"reason": "generated ephemeral guidance",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
context_visible=False,
|
||||||
|
)
|
||||||
|
session.append_message(
|
||||||
|
"web:test",
|
||||||
|
role="system",
|
||||||
|
event_type="task_team_run_completed",
|
||||||
|
event_payload={
|
||||||
|
"task_id": "task-1",
|
||||||
|
"attempt_index": 1,
|
||||||
|
"team_success": True,
|
||||||
|
"team_run_ids": ["sub-run"],
|
||||||
|
"node_results": [
|
||||||
|
{
|
||||||
|
"node_id": "research",
|
||||||
|
"success": True,
|
||||||
|
"output_text": "evidence",
|
||||||
|
"run_id": "sub-run",
|
||||||
|
"skill_query": "research workflow",
|
||||||
|
"selected_skill_names": [],
|
||||||
|
"ephemeral_skill_names": ["ephemeral:research-workflow"],
|
||||||
|
"ephemeral_guidance_id": "eg_123",
|
||||||
|
"ephemeral_guidance_name": "research-workflow",
|
||||||
|
"ephemeral_used": True,
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
context_visible=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||||
|
|
||||||
|
sub_run = next(run for run in projection["runs"] if run["run_id"] == "sub-run")
|
||||||
|
assert sub_run["metadata"]["ephemeral_guidance_id"] == "eg_123"
|
||||||
|
assert projection["artifacts"][0]["artifact_id"] == "sub-run:ephemeral-guidance:eg_123"
|
||||||
|
assert projection["artifacts"][0]["metadata"]["ephemeral_guidance_name"] == "research-workflow"
|
||||||
|
|||||||
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()))
|
report = asyncio.run(pipeline.evaluate_draft("candidate-1", draft.skill_name, draft.draft_id, provider_bundle=_bundle()))
|
||||||
safety = pipeline.check_safety(draft.skill_name, draft.draft_id)
|
safety = pipeline.check_safety(draft.skill_name, draft.draft_id)
|
||||||
|
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||||
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||||
published = pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
|
published = pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
|
||||||
|
|
||||||
@ -111,6 +112,7 @@ def test_eval_regression_blocks_publish(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
report = asyncio.run(pipeline.evaluate_draft("candidate-1", draft.skill_name, draft.draft_id, provider_bundle=_bundle()))
|
report = asyncio.run(pipeline.evaluate_draft("candidate-1", draft.skill_name, draft.draft_id, provider_bundle=_bundle()))
|
||||||
pipeline.check_safety(draft.skill_name, draft.draft_id)
|
pipeline.check_safety(draft.skill_name, draft.draft_id)
|
||||||
|
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||||
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||||
|
|
||||||
assert report.passed is False
|
assert report.passed is False
|
||||||
|
|||||||
@ -68,6 +68,39 @@ def test_pipeline_lists_candidates_and_moves_draft_through_review(tmp_path: Path
|
|||||||
assert pipeline.get_draft(draft.skill_name, draft.draft_id).status == SkillReviewState.PUBLISHED.value
|
assert pipeline.get_draft(draft.skill_name, draft.draft_id).status == SkillReviewState.PUBLISHED.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_approve_requires_submitted_review(tmp_path: Path) -> None:
|
||||||
|
pipeline = _pipeline(tmp_path)
|
||||||
|
draft = pipeline.draft_service.create_new_skill_draft(
|
||||||
|
skill_name="needs-review",
|
||||||
|
proposed_content="# Needs Review\n\nDo the thing.",
|
||||||
|
proposed_frontmatter={"description": "needs review"},
|
||||||
|
created_by="test",
|
||||||
|
reason="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="in review before approval"):
|
||||||
|
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_does_not_resubmit_terminal_draft(tmp_path: Path) -> None:
|
||||||
|
pipeline = _pipeline(tmp_path)
|
||||||
|
draft = pipeline.draft_service.create_new_skill_draft(
|
||||||
|
skill_name="already-published",
|
||||||
|
proposed_content="# Already Published\n\nDo the thing.",
|
||||||
|
proposed_frontmatter={"description": "already published"},
|
||||||
|
created_by="test",
|
||||||
|
reason="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||||
|
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||||
|
pipeline.check_safety(draft.skill_name, draft.draft_id)
|
||||||
|
pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="draft status before review submission"):
|
||||||
|
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||||
|
|
||||||
|
|
||||||
def test_pipeline_reject_blocks_publish(tmp_path: Path) -> None:
|
def test_pipeline_reject_blocks_publish(tmp_path: Path) -> None:
|
||||||
pipeline = _pipeline(tmp_path)
|
pipeline = _pipeline(tmp_path)
|
||||||
draft = pipeline.draft_service.create_new_skill_draft(
|
draft = pipeline.draft_service.create_new_skill_draft(
|
||||||
@ -80,5 +113,22 @@ def test_pipeline_reject_blocks_publish(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
pipeline.reject(draft.skill_name, draft.draft_id, reviewer="tester")
|
pipeline.reject(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="approved"):
|
with pytest.raises(ValueError, match="Draft not found"):
|
||||||
pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
|
pipeline.publish(draft.skill_name, draft.draft_id, publisher="tester")
|
||||||
|
assert pipeline.draft_service.get_draft(draft.skill_name, draft.draft_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_reject_removes_draft_from_review_list(tmp_path: Path) -> None:
|
||||||
|
pipeline = _pipeline(tmp_path)
|
||||||
|
draft = pipeline.draft_service.create_new_skill_draft(
|
||||||
|
skill_name="remove-skill",
|
||||||
|
proposed_content="# Remove\n\nNo longer needed.",
|
||||||
|
proposed_frontmatter={"description": "remove"},
|
||||||
|
created_by="test",
|
||||||
|
reason="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
review = pipeline.reject(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||||
|
|
||||||
|
assert review.status == SkillReviewState.REJECTED.value
|
||||||
|
assert pipeline.list_drafts() == []
|
||||||
|
|||||||
@ -65,6 +65,7 @@ def test_safety_marks_dangerous_tools_high_and_requires_confirm(tmp_path: Path)
|
|||||||
)
|
)
|
||||||
|
|
||||||
report = pipeline.check_safety(draft.skill_name, draft.draft_id)
|
report = pipeline.check_safety(draft.skill_name, draft.draft_id)
|
||||||
|
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||||
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||||
|
|
||||||
assert report.passed is True
|
assert report.passed is True
|
||||||
@ -84,6 +85,7 @@ def test_publish_requires_safety_report(tmp_path: Path) -> None:
|
|||||||
created_by="test",
|
created_by="test",
|
||||||
reason="test",
|
reason="test",
|
||||||
)
|
)
|
||||||
|
pipeline.submit_review(draft.skill_name, draft.draft_id, requested_by="tester")
|
||||||
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
pipeline.approve(draft.skill_name, draft.draft_id, reviewer="tester")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="safety report"):
|
with pytest.raises(ValueError, match="safety report"):
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from beaver.engine.context.builder import ContextBuilder, ContextBuildInput
|
|||||||
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
||||||
from beaver.engine.providers.factory import ProviderBundle
|
from beaver.engine.providers.factory import ProviderBundle
|
||||||
from beaver.services.agent_service import AgentService
|
from beaver.services.agent_service import AgentService
|
||||||
|
from beaver.skills.assembler import SkillAssemblyResult
|
||||||
from beaver.tasks import TaskExecutionPlan, TaskService, ValidationResult, ValidationService
|
from beaver.tasks import TaskExecutionPlan, TaskService, ValidationResult, ValidationService
|
||||||
|
|
||||||
|
|
||||||
@ -67,7 +68,25 @@ class FakeLearningCandidate:
|
|||||||
return {"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
|
return {"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
|
||||||
|
|
||||||
|
|
||||||
def _bundle(*responses: str) -> ProviderBundle:
|
class RecordingSkillAssembler:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.task_descriptions: list[str] = []
|
||||||
|
|
||||||
|
async def assemble(self, **kwargs) -> SkillAssemblyResult:
|
||||||
|
self.task_descriptions.append(kwargs["task_description"])
|
||||||
|
return SkillAssemblyResult()
|
||||||
|
|
||||||
|
|
||||||
|
def _route_response(action: str = "new_task", short_title: str = "Test task") -> LLMResponse:
|
||||||
|
return LLMResponse(
|
||||||
|
content=f'{{"action":"{action}","reason":"test route","short_title":"{short_title}"}}',
|
||||||
|
finish_reason="stop",
|
||||||
|
provider_name="stub",
|
||||||
|
model="stub-model",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle(*responses: str, route_action: str = "new_task") -> ProviderBundle:
|
||||||
return ProviderBundle(
|
return ProviderBundle(
|
||||||
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||||
main_provider=StubProvider(
|
main_provider=StubProvider(
|
||||||
@ -81,6 +100,8 @@ def _bundle(*responses: str) -> ProviderBundle:
|
|||||||
for response in responses
|
for response in responses
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||||
|
auxiliary_provider=StubProvider([_route_response(route_action)]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -110,6 +131,25 @@ def _provider_bundle(provider: StubProvider) -> ProviderBundle:
|
|||||||
return ProviderBundle(
|
return ProviderBundle(
|
||||||
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||||
main_provider=provider,
|
main_provider=provider,
|
||||||
|
auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||||
|
auxiliary_provider=StubProvider([_route_response("new_task")]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _main_only_bundle(*responses: str) -> ProviderBundle:
|
||||||
|
return ProviderBundle(
|
||||||
|
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||||
|
main_provider=StubProvider(
|
||||||
|
[
|
||||||
|
LLMResponse(
|
||||||
|
content=response,
|
||||||
|
finish_reason="stop",
|
||||||
|
provider_name="stub",
|
||||||
|
model="stub-model",
|
||||||
|
)
|
||||||
|
for response in responses
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -126,7 +166,7 @@ def test_simple_question_does_not_create_task(tmp_path: Path) -> None:
|
|||||||
service.process_direct(
|
service.process_direct(
|
||||||
"hello?",
|
"hello?",
|
||||||
session_id="web:simple",
|
session_id="web:simple",
|
||||||
provider_bundle=_bundle("hi"),
|
provider_bundle=_bundle("hi", route_action="simple_chat"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
loaded = service.create_loop().boot()
|
loaded = service.create_loop().boot()
|
||||||
@ -165,8 +205,89 @@ def test_complex_request_creates_task_and_records_validation(tmp_path: Path) ->
|
|||||||
assert any(event.event_type == "task_validation_snapshotted" for event in events)
|
assert any(event.event_type == "task_validation_snapshotted" for event in events)
|
||||||
assert run_record.task_id == result.task_id
|
assert run_record.task_id == result.task_id
|
||||||
assert run_record.validation_result["accepted"] is True
|
assert run_record.validation_result["accepted"] is True
|
||||||
assert skill_effects.event_payload["learning_candidate_enabled"] is False
|
assert skill_effects.event_payload["candidate_generation_allowed"] is False
|
||||||
assert skill_effects.event_payload["learning_candidates"] == []
|
assert skill_effects.event_payload["learning_candidates"] == []
|
||||||
|
assert task.metadata["short_title"] == "Test task"
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_mode_uses_task_aware_skill_selection_context(tmp_path: Path) -> None:
|
||||||
|
skill_assembler = RecordingSkillAssembler()
|
||||||
|
service = AgentService(
|
||||||
|
loader=EngineLoader(
|
||||||
|
workspace=tmp_path,
|
||||||
|
task_execution_planner=_single_planner(),
|
||||||
|
validation_service=StubValidationService(
|
||||||
|
[ValidationResult(passed=True, score=1.0, validator="test")]
|
||||||
|
),
|
||||||
|
skill_assembler=skill_assembler,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
service.process_direct(
|
||||||
|
"继续按刚才的方案改",
|
||||||
|
session_id="web:task-skill-query",
|
||||||
|
provider_bundle=_bundle("done", route_action="new_task"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.task_id
|
||||||
|
assert skill_assembler.task_descriptions
|
||||||
|
query = skill_assembler.task_descriptions[0]
|
||||||
|
assert "Task goal:" in query
|
||||||
|
assert "Current user request:" in query
|
||||||
|
assert "Previously activated skills:" in query
|
||||||
|
assert "If no published skill matches, return []" in query
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_task_continues_until_llm_closes_it(tmp_path: Path) -> None:
|
||||||
|
service = AgentService(
|
||||||
|
loader=EngineLoader(
|
||||||
|
workspace=tmp_path,
|
||||||
|
task_execution_planner=_single_planner(),
|
||||||
|
validation_service=StubValidationService(
|
||||||
|
[
|
||||||
|
ValidationResult(passed=True, score=0.9, validator="test"),
|
||||||
|
ValidationResult(passed=True, score=0.9, validator="test"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
first = asyncio.run(
|
||||||
|
service.process_direct(
|
||||||
|
"implement the search workflow",
|
||||||
|
session_id="web:continue",
|
||||||
|
provider_bundle=_bundle("first done", route_action="new_task"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
second = asyncio.run(
|
||||||
|
service.process_direct(
|
||||||
|
"also add tests for it",
|
||||||
|
session_id="web:continue",
|
||||||
|
provider_bundle=_bundle("tests added", route_action="continue_task"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loaded = service.create_loop().boot()
|
||||||
|
task = loaded.task_service.get_task(first.task_id)
|
||||||
|
|
||||||
|
assert task is not None
|
||||||
|
assert second.task_id == first.task_id
|
||||||
|
assert len(task.run_ids) == 2
|
||||||
|
|
||||||
|
closed = asyncio.run(
|
||||||
|
service.process_direct(
|
||||||
|
"这个任务结束了",
|
||||||
|
session_id="web:continue",
|
||||||
|
provider_bundle=_bundle("好的,已结束。", route_action="close_task"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task = loaded.task_service.get_task(first.task_id)
|
||||||
|
|
||||||
|
assert closed.task_id is None
|
||||||
|
assert task is not None
|
||||||
|
assert task.status == "closed"
|
||||||
|
assert loaded.task_service.active_task_view("web:continue") is None
|
||||||
|
|
||||||
|
|
||||||
def test_validation_failure_retries_once(tmp_path: Path) -> None:
|
def test_validation_failure_retries_once(tmp_path: Path) -> None:
|
||||||
@ -229,11 +350,11 @@ def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None:
|
|||||||
loaded = service.create_loop().boot()
|
loaded = service.create_loop().boot()
|
||||||
learning_calls = []
|
learning_calls = []
|
||||||
|
|
||||||
def build_learning_candidates() -> list[FakeLearningCandidate]:
|
def build_learning_candidates_for_task(task_id: str, *, trigger_run_id: str) -> list[FakeLearningCandidate]:
|
||||||
learning_calls.append("called")
|
learning_calls.append((task_id, trigger_run_id))
|
||||||
return [FakeLearningCandidate()]
|
return [FakeLearningCandidate()]
|
||||||
|
|
||||||
loaded.skill_learning_service.build_learning_candidates = build_learning_candidates
|
loaded.skill_learning_service.build_learning_candidates_for_task = build_learning_candidates_for_task
|
||||||
|
|
||||||
feedback = asyncio.run(
|
feedback = asyncio.run(
|
||||||
service.submit_feedback(
|
service.submit_feedback(
|
||||||
@ -247,7 +368,7 @@ def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None:
|
|||||||
assert feedback["learning_candidates"] == [
|
assert feedback["learning_candidates"] == [
|
||||||
{"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
|
{"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
|
||||||
]
|
]
|
||||||
assert learning_calls == ["called"]
|
assert learning_calls == [(result.task_id, result.run_id)]
|
||||||
|
|
||||||
service2 = AgentService(
|
service2 = AgentService(
|
||||||
loader=EngineLoader(
|
loader=EngineLoader(
|
||||||
@ -279,6 +400,14 @@ def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
assert abandon_feedback["task_status"] == "abandoned"
|
assert abandon_feedback["task_status"] == "abandoned"
|
||||||
assert abandon_feedback["learning_candidates"] == []
|
assert abandon_feedback["learning_candidates"] == []
|
||||||
|
loaded2 = service2.create_loop().boot()
|
||||||
|
failure_events = [
|
||||||
|
event
|
||||||
|
for event in loaded2.session_manager.get_run_event_records(abandoned.session_id, abandoned.run_id)
|
||||||
|
if event.event_type == "task_failure_evidence_recorded"
|
||||||
|
]
|
||||||
|
assert len(failure_events) == 1
|
||||||
|
assert loaded2.memory_service.get_store().memory_entries == []
|
||||||
|
|
||||||
|
|
||||||
def test_feedback_is_idempotent_and_projected_to_assistant_message(tmp_path: Path) -> None:
|
def test_feedback_is_idempotent_and_projected_to_assistant_message(tmp_path: Path) -> None:
|
||||||
@ -466,7 +595,7 @@ def test_task_mode_team_retry_hides_first_synthesis_run(tmp_path: Path) -> None:
|
|||||||
events = loaded.session_manager.get_run_event_records(record.session_id, run_id)
|
events = loaded.session_manager.get_run_event_records(record.session_id, run_id)
|
||||||
skill_effects = [event for event in events if event.event_type == "skill_effects_snapshotted"]
|
skill_effects = [event for event in events if event.event_type == "skill_effects_snapshotted"]
|
||||||
assert skill_effects
|
assert skill_effects
|
||||||
assert skill_effects[-1].event_payload["learning_candidate_enabled"] is False
|
assert skill_effects[-1].event_payload["candidate_generation_allowed"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_context_builder_strips_ui_projection_fields_from_provider_history() -> None:
|
def test_context_builder_strips_ui_projection_fields_from_provider_history() -> None:
|
||||||
@ -490,17 +619,43 @@ def test_context_builder_strips_ui_projection_fields_from_provider_history() ->
|
|||||||
assert assistant == {"role": "assistant", "content": "done"}
|
assert assistant == {"role": "assistant", "content": "done"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_builder_normalizes_persisted_tool_arguments() -> None:
|
||||||
|
result = ContextBuilder().build_messages(
|
||||||
|
ContextBuildInput(
|
||||||
|
history=[
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": None,
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call-1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "cron",
|
||||||
|
"arguments": {"action": "add", "mode": "notification"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_call = result.messages[-1]["tool_calls"][0]
|
||||||
|
assert tool_call["function"]["arguments"] == '{"action": "add", "mode": "notification"}'
|
||||||
|
|
||||||
|
|
||||||
def test_llm_validator_parse_failure_is_not_accepted(tmp_path: Path) -> None:
|
def test_llm_validator_parse_failure_is_not_accepted(tmp_path: Path) -> None:
|
||||||
task_service = TaskService(tmp_path / "tasks")
|
task_service = TaskService(tmp_path / "tasks")
|
||||||
task = task_service.create_task(session_id="web:validator", description="implement validator handling")
|
task = task_service.create_task(session_id="web:validator", description="implement validator handling")
|
||||||
validation = asyncio.run(
|
validation = asyncio.run(
|
||||||
ValidationService().validate_task_result(
|
ValidationService().validate_task_result(
|
||||||
task=task,
|
task=task,
|
||||||
user_message="implement validator handling",
|
user_message="implement validator handling",
|
||||||
final_output="done",
|
final_output="done",
|
||||||
provider_bundle=_bundle("not json"),
|
provider_bundle=_main_only_bundle("not json"),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
assert validation.accepted is False
|
assert validation.accepted is False
|
||||||
assert validation.validator == "llm_error"
|
assert validation.validator == "llm_error"
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from beaver.engine.context import SkillContext
|
|||||||
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
||||||
from beaver.engine.providers.factory import ProviderBundle
|
from beaver.engine.providers.factory import ProviderBundle
|
||||||
from beaver.skills.drafts import DraftService
|
from beaver.skills.drafts import DraftService
|
||||||
from beaver.skills.learning import MissingSkillSynthesizer
|
from beaver.skills.learning import EphemeralGuidanceSynthesizer
|
||||||
from beaver.skills.publisher import SkillPublisher
|
from beaver.skills.publisher import SkillPublisher
|
||||||
from beaver.skills.reviews import ReviewService
|
from beaver.skills.reviews import ReviewService
|
||||||
from beaver.skills.specs import SkillSpecStore
|
from beaver.skills.specs import SkillSpecStore
|
||||||
@ -116,12 +116,12 @@ def test_task_skill_resolver_pins_matching_published_skill(tmp_path: Path) -> No
|
|||||||
assert reports[0].ephemeral_used is False
|
assert reports[0].ephemeral_used is False
|
||||||
|
|
||||||
|
|
||||||
def test_task_skill_resolver_generates_draft_only_ephemeral_skill_when_missing(tmp_path: Path) -> None:
|
def test_task_skill_resolver_generates_ephemeral_guidance_when_missing(tmp_path: Path) -> None:
|
||||||
provider = RecordingProvider(
|
provider = RecordingProvider(
|
||||||
[
|
[
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"skill_name": "api-compatibility-review",
|
"guidance_name": "api-compatibility-review",
|
||||||
"description": "Review API compatibility",
|
"description": "Review API compatibility",
|
||||||
"content": "# API Compatibility Review\\n\\nCheck schema compatibility.",
|
"content": "# API Compatibility Review\\n\\nCheck schema compatibility.",
|
||||||
"tags": ["api", "review"]
|
"tags": ["api", "review"]
|
||||||
@ -133,7 +133,7 @@ def test_task_skill_resolver_generates_draft_only_ephemeral_skill_when_missing(t
|
|||||||
resolver = TaskSkillResolver(
|
resolver = TaskSkillResolver(
|
||||||
skills_loader=SkillsLoader(tmp_path),
|
skills_loader=SkillsLoader(tmp_path),
|
||||||
draft_service=DraftService(store),
|
draft_service=DraftService(store),
|
||||||
missing_skill_synthesizer=MissingSkillSynthesizer(),
|
missing_skill_synthesizer=EphemeralGuidanceSynthesizer(),
|
||||||
)
|
)
|
||||||
graph = ExecutionGraph(
|
graph = ExecutionGraph(
|
||||||
strategy="sequence",
|
strategy="sequence",
|
||||||
@ -163,13 +163,14 @@ def test_task_skill_resolver_generates_draft_only_ephemeral_skill_when_missing(t
|
|||||||
)
|
)
|
||||||
|
|
||||||
drafts = store.list_drafts("api-compatibility-review")
|
drafts = store.list_drafts("api-compatibility-review")
|
||||||
assert len(drafts) == 1
|
assert drafts == []
|
||||||
assert store.list_published_skill_names() == []
|
assert store.list_published_skill_names() == []
|
||||||
assert resolved.nodes[0].inherited_pinned_skills == []
|
assert resolved.nodes[0].inherited_pinned_skills == []
|
||||||
assert len(resolved.nodes[0].inherited_pinned_skill_contexts) == 1
|
assert len(resolved.nodes[0].inherited_pinned_skill_contexts) == 1
|
||||||
context: SkillContext = resolved.nodes[0].inherited_pinned_skill_contexts[0]
|
context: SkillContext = resolved.nodes[0].inherited_pinned_skill_contexts[0]
|
||||||
assert context.name == "draft:api-compatibility-review"
|
assert context.name == "ephemeral:api-compatibility-review"
|
||||||
assert context.version == f"draft:{drafts[0].draft_id}"
|
assert context.version.startswith("ephemeral:eg_")
|
||||||
assert context.activation_reason == "generated_missing_skill"
|
assert context.activation_reason == "ephemeral_guidance"
|
||||||
assert reports[0].generated_skill_draft_id == drafts[0].draft_id
|
assert reports[0].ephemeral_guidance_id is not None
|
||||||
|
assert reports[0].ephemeral_guidance_name == "api-compatibility-review"
|
||||||
assert reports[0].ephemeral_used is True
|
assert reports[0].ephemeral_used is True
|
||||||
|
|||||||
@ -83,7 +83,6 @@ tools:
|
|||||||
|
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
registry.register(DummyTool("memory", toolset="memory", always_available=True))
|
registry.register(DummyTool("memory", toolset="memory", always_available=True))
|
||||||
registry.register(DummyTool("skill_view", toolset="skills", always_available=True))
|
|
||||||
registry.register(DummyTool("terminal", toolset="shell"))
|
registry.register(DummyTool("terminal", toolset="shell"))
|
||||||
registry.register(DummyTool("search_files", toolset="file"))
|
registry.register(DummyTool("search_files", toolset="file"))
|
||||||
registry.register(DummyTool("echo", toolset="debug"))
|
registry.register(DummyTool("echo", toolset="debug"))
|
||||||
@ -100,7 +99,7 @@ tools:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert [spec.name for spec in selected] == ["memory", "skill_view", "terminal", "search_files"]
|
assert [spec.name for spec in selected] == ["memory", "terminal", "search_files"]
|
||||||
|
|
||||||
|
|
||||||
def test_embedding_fallback_can_return_all_or_top_k() -> None:
|
def test_embedding_fallback_can_return_all_or_top_k() -> None:
|
||||||
|
|||||||
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 = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
|
{ name = "croniter" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "fastmcp" },
|
{ name = "fastmcp" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
@ -245,6 +246,7 @@ dependencies = [
|
|||||||
{ name = "litellm" },
|
{ name = "litellm" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "typer" },
|
{ name = "typer" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
@ -257,6 +259,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "anthropic", specifier = ">=0.51.0,<1.0.0" },
|
{ name = "anthropic", specifier = ">=0.51.0,<1.0.0" },
|
||||||
|
{ name = "croniter", specifier = ">=6.0.0,<7.0.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
|
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
|
||||||
{ name = "fastmcp", specifier = ">=3.0.0,<4.0.0" },
|
{ name = "fastmcp", specifier = ">=3.0.0,<4.0.0" },
|
||||||
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" },
|
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" },
|
||||||
@ -265,6 +268,7 @@ requires-dist = [
|
|||||||
{ name = "openai", specifier = ">=1.79.0,<2.0.0" },
|
{ name = "openai", specifier = ">=1.79.0,<2.0.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.20,<1.0.0" },
|
||||||
{ name = "typer", specifier = ">=0.20.0,<1.0.0" },
|
{ name = "typer", specifier = ">=0.20.0,<1.0.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" },
|
||||||
]
|
]
|
||||||
@ -493,6 +497,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "croniter"
|
||||||
|
version = "6.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "48.0.0"
|
version = "48.0.0"
|
||||||
@ -1927,6 +1943,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dateutil"
|
||||||
|
version = "2.9.0.post0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@ -2317,6 +2345,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sniffio"
|
name = "sniffio"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
|
|||||||
@ -35,7 +35,7 @@
|
|||||||
- `task_id`
|
- `task_id`
|
||||||
- `task_mode`
|
- `task_mode`
|
||||||
- `attempt_index`
|
- `attempt_index`
|
||||||
- `learning_candidate_enabled`
|
- `allow_candidate_generation`
|
||||||
4. `RunRecord` 已记录:
|
4. `RunRecord` 已记录:
|
||||||
- `task_id`
|
- `task_id`
|
||||||
- `attempt_index`
|
- `attempt_index`
|
||||||
@ -55,7 +55,7 @@
|
|||||||
8. 学习触发已经收紧。
|
8. 学习触发已经收紧。
|
||||||
- Task 模式 run 不再直接生成成功学习候选
|
- Task 模式 run 不再直接生成成功学习候选
|
||||||
- 只有“自动验证通过 + 用户点击满意”才触发成功学习候选
|
- 只有“自动验证通过 + 用户点击满意”才触发成功学习候选
|
||||||
- “放弃”写 Failure Memory,不生成成功 Skill draft
|
- “放弃”只写失败证据,不默认写主 memory,不生成成功 Skill draft
|
||||||
9. Agent Team v1 已落地为 Beaver 自有轻量 coordinator。
|
9. Agent Team v1 已落地为 Beaver 自有轻量 coordinator。
|
||||||
- 新增 `AgentDescriptor / DelegationEnvelope / ExecutionNode / ExecutionGraph / TeamRunResult`
|
- 新增 `AgentDescriptor / DelegationEnvelope / ExecutionNode / ExecutionGraph / TeamRunResult`
|
||||||
- 新增 `TeamService.run_team(...)` 作为内部服务入口
|
- 新增 `TeamService.run_team(...)` 作为内部服务入口
|
||||||
@ -72,7 +72,7 @@
|
|||||||
- `TaskExecutionPlanner` 使用 LLM JSON 规划 `single / team`
|
- `TaskExecutionPlanner` 使用 LLM JSON 规划 `single / team`
|
||||||
- team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设
|
- team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设
|
||||||
- 新增 `beaver/tasks/skill_resolver.py`
|
- 新增 `beaver/tasks/skill_resolver.py`
|
||||||
- `TaskSkillResolver` 为 generic sub-agent 选择 published skill;未命中时生成 draft-only skill,并作为本次 run 的 ephemeral pinned instruction 使用
|
- `TaskSkillResolver` 为 generic sub-agent 选择 published skill;未命中时生成 ephemeral guidance,并作为本次 run 的 pinned guidance 使用
|
||||||
- 只允许 v1 已实现的 `sequence / parallel / dag`
|
- 只允许 v1 已实现的 `sequence / parallel / dag`
|
||||||
- planner 失败或 graph 非法时降级为 `single`
|
- planner 失败或 graph 非法时降级为 `single`
|
||||||
- team run 先作为 sub-agent 内部执行,输出注入主 Agent synthesis run
|
- team run 先作为 sub-agent 内部执行,输出注入主 Agent synthesis run
|
||||||
@ -1407,7 +1407,7 @@ Hermes 官方公开说明里,明确把这些能力作为它的核心区别:
|
|||||||
│ ├─ provider/chat/tool loop
|
│ ├─ provider/chat/tool loop
|
||||||
│ ├─ sessions.append_message(event_type="run_completed" 或 "run_failed", hidden)
|
│ ├─ sessions.append_message(event_type="run_completed" 或 "run_failed", hidden)
|
||||||
│ │
|
│ │
|
||||||
│ └─ AgentLoop._record_skill_learning(...)
|
│ └─ AgentLoop._record_run_receipts(...)
|
||||||
│ ├─ 构造 `RunRecord`
|
│ ├─ 构造 `RunRecord`
|
||||||
│ ├─ 构造 `SkillEffectRecord[]`
|
│ ├─ 构造 `SkillEffectRecord[]`
|
||||||
│ ├─ 默认只记录 receipts/effects,不生成学习候选
|
│ ├─ 默认只记录 receipts/effects,不生成学习候选
|
||||||
@ -1750,7 +1750,10 @@ app-instance 镜像也已经切到新 Beaver 后端:
|
|||||||
- 当前 channel 职责很窄:
|
- 当前 channel 职责很窄:
|
||||||
- 把外部输入发布成 `InboundMessage`
|
- 把外部输入发布成 `InboundMessage`
|
||||||
- 接收并投递 `OutboundMessage`
|
- 接收并投递 `OutboundMessage`
|
||||||
|
- old-style 平台字段(如 `chat_id/message_id/thread_id/raw_channel_payload`)只能在 adapter 层映射和保留
|
||||||
|
- adapter 负责生成稳定 `session_id`,例如 `telegram:{chat_id}` / `slack:{channel_id}:{thread_ts}`
|
||||||
- `MemoryChannelAdapter` 只用于本地测试和内嵌接入,不是正式消息 broker
|
- `MemoryChannelAdapter` 只用于本地测试和内嵌接入,不是正式消息 broker
|
||||||
|
- WebSocket 是 Web 入口适配层,不是 Gateway channel;真实多渠道仍统一走 `ChannelAdapter -> MessageBus -> AgentService.handle_inbound_message(...)`
|
||||||
|
|
||||||
所以现在已经明确:
|
所以现在已经明确:
|
||||||
|
|
||||||
@ -2191,13 +2194,13 @@ app-instance 镜像也已经切到新 Beaver 后端:
|
|||||||
1. planner team JSON 支持 `skill_query / required_capabilities`,不要求 agent role。
|
1. planner team JSON 支持 `skill_query / required_capabilities`,不要求 agent role。
|
||||||
2. `TaskSkillResolver` 命中 published skill 时,写入 `ExecutionNode.inherited_pinned_skills`。
|
2. `TaskSkillResolver` 命中 published skill 时,写入 `ExecutionNode.inherited_pinned_skills`。
|
||||||
3. sub-agent run 的 published pinned skill receipt 记录 `activation_reason=pinned_delegation`。
|
3. sub-agent run 的 published pinned skill receipt 记录 `activation_reason=pinned_delegation`。
|
||||||
4. 未命中 skill 时创建 draft-only skill,并写入 `ExecutionNode.inherited_pinned_skill_contexts`。
|
4. 未命中 skill 时创建 ephemeral guidance,并写入 `ExecutionNode.inherited_pinned_skill_contexts`。
|
||||||
5. draft-only skill receipt 记录 `activation_reason=generated_missing_skill`。
|
5. ephemeral guidance receipt 记录 `activation_reason=ephemeral_guidance`。
|
||||||
6. missing skill draft 不自动 approve/publish,不进入 runtime skill catalog。
|
6. ephemeral guidance 不写入 draft store,不自动 approve/publish,不进入 runtime skill catalog。
|
||||||
7. plan event 写入 `skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report`。
|
7. plan event 写入 `skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report`。
|
||||||
8. `/api/sessions/{session_id}/process` 能把隐藏 Task/team/validation 事件投影成 `processRuns / processEvents`。
|
8. `/api/sessions/{session_id}/process` 能把隐藏 Task/team/validation 事件投影成 `processRuns / processEvents`。
|
||||||
9. ChatWorkbench 桌面端有 `ProcessLane`,移动端有 `Process` tab。
|
9. ChatWorkbench 桌面端有 `ProcessLane`,移动端有 `Process` tab。
|
||||||
10. process view 展示 selected skills、generated draft id、ephemeral skill used,不展示 specialist agent selection。
|
10. process view 展示 selected skills、ephemeral guidance id、ephemeral skill used,不展示 specialist agent selection。
|
||||||
11. team 部分失败时,process view 显示失败节点,但最终回答仍来自主 Agent。
|
11. team 部分失败时,process view 显示失败节点,但最终回答仍来自主 Agent。
|
||||||
12. `SkillLearningPipelineService` 能串起 candidate -> draft -> safety/eval -> review -> approve/reject -> publish。
|
12. `SkillLearningPipelineService` 能串起 candidate -> draft -> safety/eval -> review -> approve/reject -> publish。
|
||||||
13. rejected draft 不能 publish。
|
13. rejected draft 不能 publish。
|
||||||
|
|||||||
@ -1,419 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
export default function CronRedirectPage() {
|
||||||
import {
|
redirect('/tasks?tab=scheduled');
|
||||||
Clock,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Play,
|
|
||||||
RefreshCw,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
|
||||||
listCronJobs,
|
|
||||||
addCronJob,
|
|
||||||
removeCronJob,
|
|
||||||
toggleCronJob,
|
|
||||||
runCronJob,
|
|
||||||
} from '@/lib/api';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { pickAppText } from '@/lib/i18n/core';
|
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
|
||||||
import { useChatStore } from '@/lib/store';
|
|
||||||
import type { CronJob } from '@/types';
|
|
||||||
|
|
||||||
export default function CronPage() {
|
|
||||||
const { locale } = useAppI18n();
|
|
||||||
const sessionId = useChatStore((s) => s.sessionId);
|
|
||||||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
|
||||||
const targetSessionKey = sessionId.startsWith('web:') ? sessionId : 'web:default';
|
|
||||||
|
|
||||||
const loadJobs = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const data = await listCronJobs(true);
|
|
||||||
setJobs(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || pickAppText(locale, '加载任务失败', 'Failed to load jobs'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadJobs();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggle = async (jobId: string, enabled: boolean) => {
|
|
||||||
try {
|
|
||||||
await toggleCronJob(jobId, enabled);
|
|
||||||
loadJobs();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (jobId: string) => {
|
|
||||||
try {
|
|
||||||
await removeCronJob(jobId);
|
|
||||||
loadJobs();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRun = async (jobId: string) => {
|
|
||||||
try {
|
|
||||||
await runCronJob(jobId);
|
|
||||||
loadJobs();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = async (params: {
|
|
||||||
name: string;
|
|
||||||
message: string;
|
|
||||||
every_seconds?: number;
|
|
||||||
cron_expr?: string;
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
await addCronJob({
|
|
||||||
...params,
|
|
||||||
session_key: targetSessionKey,
|
|
||||||
});
|
|
||||||
setShowAdd(false);
|
|
||||||
loadJobs();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (ms: number | null) => {
|
|
||||||
if (!ms) return '-';
|
|
||||||
return new Date(ms).toLocaleString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-20">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
|
||||||
<TaskManagementTabs />
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
||||||
<Clock className="w-6 h-6" />
|
|
||||||
{pickAppText(locale, '定时任务', 'Scheduled tasks')}
|
|
||||||
</h1>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button onClick={loadJobs} variant="outline" size="sm">
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
{pickAppText(locale, '刷新', 'Refresh')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setShowAdd(true)} size="sm">
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
{pickAppText(locale, '新建任务', 'New job')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Card className="border-destructive">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center gap-2 text-destructive text-sm">
|
|
||||||
<AlertCircle className="w-4 h-4" />
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add Job Form */}
|
|
||||||
{showAdd && (
|
|
||||||
<AddJobForm
|
|
||||||
targetSessionKey={targetSessionKey}
|
|
||||||
onAdd={handleAdd}
|
|
||||||
onCancel={() => setShowAdd(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Jobs Table */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{jobs.length === 0 ? (
|
|
||||||
<div className="py-12 text-center text-muted-foreground">
|
|
||||||
<Clock className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
|
||||||
<p className="font-medium">{pickAppText(locale, '暂无定时任务', 'No scheduled tasks yet')}</p>
|
|
||||||
<p className="text-sm mt-1">{pickAppText(locale, '新建一个任务,让智能体按计划自动执行。', 'Create a job to let the agent run on a schedule.')}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-16">{pickAppText(locale, '启用', 'Enabled')}</TableHead>
|
|
||||||
<TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead>
|
|
||||||
<TableHead>{pickAppText(locale, '计划', 'Schedule')}</TableHead>
|
|
||||||
<TableHead>{pickAppText(locale, '消息', 'Message')}</TableHead>
|
|
||||||
<TableHead>{pickAppText(locale, '上次运行', 'Last run')}</TableHead>
|
|
||||||
<TableHead>{pickAppText(locale, '下次运行', 'Next run')}</TableHead>
|
|
||||||
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
|
|
||||||
<TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{jobs.map((job) => (
|
|
||||||
<TableRow key={job.id}>
|
|
||||||
<TableCell>
|
|
||||||
<Switch
|
|
||||||
checked={job.enabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handleToggle(job.id, checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<div>
|
|
||||||
<span>{job.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
|
||||||
{job.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
|
||||||
{job.schedule_display}
|
|
||||||
</code>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-sm truncate max-w-[200px] block">
|
|
||||||
{job.message}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{formatTime(job.last_run_at_ms)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{formatTime(job.next_run_at_ms)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{job.last_status === 'ok' && (
|
|
||||||
<Badge variant="default" className="text-xs bg-green-600">
|
|
||||||
OK
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{job.last_status === 'error' && (
|
|
||||||
<Badge variant="destructive" className="text-xs">
|
|
||||||
{pickAppText(locale, '错误', 'Error')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{!job.last_status && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
-
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => handleRun(job.id)}
|
|
||||||
title={pickAppText(locale, '立即执行', 'Run now')}
|
|
||||||
>
|
|
||||||
<Play className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
|
||||||
onClick={() => handleDelete(job.id)}
|
|
||||||
title={pickAppText(locale, '删除', 'Delete')}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddJobForm({
|
|
||||||
targetSessionKey,
|
|
||||||
onAdd,
|
|
||||||
onCancel,
|
|
||||||
}: {
|
|
||||||
targetSessionKey: string;
|
|
||||||
onAdd: (params: {
|
|
||||||
name: string;
|
|
||||||
message: string;
|
|
||||||
every_seconds?: number;
|
|
||||||
cron_expr?: string;
|
|
||||||
}) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}) {
|
|
||||||
const { locale } = useAppI18n();
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [scheduleType, setScheduleType] = useState<'every' | 'cron'>('every');
|
|
||||||
const [everySeconds, setEverySeconds] = useState('3600');
|
|
||||||
const [cronExpr, setCronExpr] = useState('0 9 * * *');
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!name.trim() || !message.trim()) return;
|
|
||||||
|
|
||||||
const params: any = { name: name.trim(), message: message.trim() };
|
|
||||||
if (scheduleType === 'every') {
|
|
||||||
params.every_seconds = parseInt(everySeconds, 10) || 3600;
|
|
||||||
} else {
|
|
||||||
params.cron_expr = cronExpr.trim();
|
|
||||||
}
|
|
||||||
onAdd(params);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-base">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</CardTitle>
|
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">{pickAppText(locale, '任务名称', 'Job name')}</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder={pickAppText(locale, '例如:日报汇总', 'Example: daily summary')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="schedule-type">{pickAppText(locale, '调度类型', 'Schedule type')}</Label>
|
|
||||||
<Select
|
|
||||||
value={scheduleType}
|
|
||||||
onValueChange={(v) => setScheduleType(v as 'every' | 'cron')}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="every">{pickAppText(locale, '固定间隔(每 N 秒)', 'Fixed interval (every N seconds)')}</SelectItem>
|
|
||||||
<SelectItem value="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{scheduleType === 'every' ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="every">{pickAppText(locale, '间隔(秒)', 'Interval (seconds)')}</Label>
|
|
||||||
<Input
|
|
||||||
id="every"
|
|
||||||
type="number"
|
|
||||||
value={everySeconds}
|
|
||||||
onChange={(e) => setEverySeconds(e.target.value)}
|
|
||||||
min="10"
|
|
||||||
placeholder="3600"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{parseInt(everySeconds, 10) >= 3600
|
|
||||||
? pickAppText(locale, `约 ${Math.floor(parseInt(everySeconds, 10) / 3600)} 小时 ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)} 分`, `About ${Math.floor(parseInt(everySeconds, 10) / 3600)}h ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}m`)
|
|
||||||
: parseInt(everySeconds, 10) >= 60
|
|
||||||
? pickAppText(locale, `约 ${Math.floor(parseInt(everySeconds, 10) / 60)} 分 ${parseInt(everySeconds, 10) % 60} 秒`, `About ${Math.floor(parseInt(everySeconds, 10) / 60)}m ${parseInt(everySeconds, 10) % 60}s`)
|
|
||||||
: ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</Label>
|
|
||||||
<Input
|
|
||||||
id="cron"
|
|
||||||
value={cronExpr}
|
|
||||||
onChange={(e) => setCronExpr(e.target.value)}
|
|
||||||
placeholder="0 9 * * *"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{pickAppText(locale, '格式:分钟 小时 日 月 周', 'Format: minute hour day month weekday')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="message">{pickAppText(locale, '发送给智能体的消息', 'Message for the agent')}</Label>
|
|
||||||
<Input
|
|
||||||
id="message"
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
placeholder={pickAppText(locale, '例如:检查我的邮件并生成摘要', 'Example: check my email and generate a summary')}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{pickAppText(locale, '任务结果会自动回写到当前 Web 会话:', 'Results are written back to the current web session:')} <code className="bg-muted px-1 py-0.5 rounded">{targetSessionKey}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>
|
|
||||||
{pickAppText(locale, '取消', 'Cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={!name.trim() || !message.trim()}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
{pickAppText(locale, '创建任务', 'Create job')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,9 @@
|
|||||||
import Header from '@/components/Header';
|
import { AppShell } from '@/components/AppShell';
|
||||||
import AuthGuard from '@/components/AuthGuard';
|
|
||||||
import { AppRuntimeBridge } from '@/components/AppRuntimeBridge';
|
|
||||||
|
|
||||||
export default function AppLayout({
|
export default function AppLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return <AppShell>{children}</AppShell>;
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
|
||||||
<Header />
|
|
||||||
<main className="pt-16">
|
|
||||||
<AuthGuard>
|
|
||||||
<AppRuntimeBridge />
|
|
||||||
{children}
|
|
||||||
</AuthGuard>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { AlertCircle, ArrowLeft, Check, Download, Loader2, Search, Star } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Store,
|
getSkillHubDetail,
|
||||||
RefreshCw,
|
getSkillHubVersion,
|
||||||
Loader2,
|
installSkillHubSkill,
|
||||||
AlertCircle,
|
searchSkillHubSkills,
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Download,
|
|
||||||
Check,
|
|
||||||
X,
|
|
||||||
Globe,
|
|
||||||
FolderOpen,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
|
||||||
listMarketplaces,
|
|
||||||
addMarketplace,
|
|
||||||
removeMarketplace,
|
|
||||||
updateMarketplace,
|
|
||||||
listMarketplacePlugins,
|
|
||||||
installMarketplacePlugin,
|
|
||||||
uninstallPlugin,
|
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import type { Marketplace, MarketplacePlugin } from '@/types';
|
import type { SkillHubSearchItem, SkillHubVersionResponse } from '@/types';
|
||||||
import { pickAppText } from '@/lib/i18n/core';
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
|
|
||||||
|
type SortMode = 'relevance' | 'downloads' | 'newest';
|
||||||
|
|
||||||
|
function publishedVersion(skill: SkillHubSearchItem | null): string {
|
||||||
|
return skill?.publishedVersion?.version || skill?.headlineVersion?.version || '';
|
||||||
|
}
|
||||||
|
|
||||||
export default function MarketplacePage() {
|
export default function MarketplacePage() {
|
||||||
const { locale } = useAppI18n();
|
const { locale } = useAppI18n();
|
||||||
const [marketplaces, setMarketplaces] = useState<Marketplace[]>([]);
|
const t = useCallback((zh: string, en: string) => pickAppText(locale, zh, en), [locale]);
|
||||||
const [selectedMarketplace, setSelectedMarketplace] = useState<string | null>(null);
|
const [query, setQuery] = useState('');
|
||||||
const [plugins, setPlugins] = useState<MarketplacePlugin[]>([]);
|
const [sort, setSort] = useState<SortMode>('newest');
|
||||||
|
const [starredOnly, setStarredOnly] = useState(false);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [items, setItems] = useState<SkillHubSearchItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [pluginsLoading, setPluginsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [selected, setSelected] = useState<SkillHubSearchItem | null>(null);
|
||||||
const [addSource, setAddSource] = useState('');
|
const [versionDetail, setVersionDetail] = useState<SkillHubVersionResponse | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [actionPlugin, setActionPlugin] = useState<string | null>(null);
|
const [installing, setInstalling] = useState(false);
|
||||||
const [updatingMarketplace, setUpdatingMarketplace] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadMarketplaces = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await listMarketplaces();
|
const result = await searchSkillHubSkills({ q: query, sort, page, size: 12 });
|
||||||
const list = Array.isArray(data) ? data : [];
|
const nextItems = Array.isArray(result.items) ? result.items : [];
|
||||||
setMarketplaces(list);
|
setItems(starredOnly ? nextItems.filter((item) => (item.starCount || 0) > 0) : nextItems);
|
||||||
// Auto-select first marketplace if none selected or selected was removed
|
setTotal(result.total || 0);
|
||||||
if (list.length > 0) {
|
|
||||||
setSelectedMarketplace((prev) => {
|
|
||||||
if (prev && list.some((m) => m.name === prev)) return prev;
|
|
||||||
return list[0].name;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setSelectedMarketplace(null);
|
|
||||||
setPlugins([]);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || pickAppText(locale, '加载市场失败', 'Failed to load marketplaces'));
|
setError(err.message || t('加载 SkillHub 失败', 'Failed to load SkillHub'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [page, query, sort, starredOnly, t]);
|
||||||
|
|
||||||
const loadPlugins = useCallback(async (marketplaceName: string) => {
|
|
||||||
setPluginsLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await listMarketplacePlugins(marketplaceName);
|
|
||||||
setPlugins(Array.isArray(data) ? data : []);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || pickAppText(locale, '加载插件失败', 'Failed to load plugins'));
|
|
||||||
} finally {
|
|
||||||
setPluginsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMarketplaces();
|
void load();
|
||||||
}, [loadMarketplaces]);
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
const openDetail = async (item: SkillHubSearchItem) => {
|
||||||
if (selectedMarketplace) {
|
setSelected(item);
|
||||||
loadPlugins(selectedMarketplace);
|
setVersionDetail(null);
|
||||||
}
|
setDetailLoading(true);
|
||||||
}, [selectedMarketplace, loadPlugins]);
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
if (!addSource.trim()) return;
|
|
||||||
setAdding(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const marketplace = await addMarketplace(addSource.trim());
|
const detail = await getSkillHubDetail(item.namespace, item.slug);
|
||||||
setAddSource('');
|
setSelected(detail);
|
||||||
setShowAddForm(false);
|
const version = publishedVersion(detail);
|
||||||
await loadMarketplaces();
|
if (version) {
|
||||||
setSelectedMarketplace(marketplace.name);
|
setVersionDetail(await getSkillHubVersion(detail.namespace, detail.slug, version));
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || pickAppText(locale, '添加市场失败', 'Failed to add the marketplace'));
|
|
||||||
} finally {
|
|
||||||
setAdding(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = async (name: string) => {
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await removeMarketplace(name);
|
|
||||||
if (selectedMarketplace === name) {
|
|
||||||
setSelectedMarketplace(null);
|
|
||||||
setPlugins([]);
|
|
||||||
}
|
|
||||||
await loadMarketplaces();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || pickAppText(locale, '移除市场失败', 'Failed to remove the marketplace'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateMarketplace = async (name: string) => {
|
|
||||||
setUpdatingMarketplace(name);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await updateMarketplace(name);
|
|
||||||
await loadPlugins(name);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || pickAppText(locale, '更新市场失败', 'Failed to update the marketplace'));
|
|
||||||
} finally {
|
|
||||||
setUpdatingMarketplace(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdatePlugin = async (marketplaceName: string, pluginName: string) => {
|
|
||||||
setActionPlugin(pluginName);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await installMarketplacePlugin(marketplaceName, pluginName);
|
|
||||||
await loadPlugins(marketplaceName);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || pickAppText(locale, '更新插件失败', 'Failed to update the plugin'));
|
|
||||||
} finally {
|
|
||||||
setActionPlugin(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInstall = async (marketplaceName: string, pluginName: string) => {
|
|
||||||
setActionPlugin(pluginName);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await installMarketplacePlugin(marketplaceName, pluginName);
|
|
||||||
await loadPlugins(marketplaceName);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || pickAppText(locale, '安装插件失败', 'Failed to install the plugin'));
|
|
||||||
} finally {
|
|
||||||
setActionPlugin(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUninstall = async (pluginName: string) => {
|
|
||||||
setActionPlugin(pluginName);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await uninstallPlugin(pluginName);
|
|
||||||
if (selectedMarketplace) {
|
|
||||||
await loadPlugins(selectedMarketplace);
|
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || pickAppText(locale, '卸载插件失败', 'Failed to uninstall the plugin'));
|
setError(err.message || t('加载技能详情失败', 'Failed to load skill details'));
|
||||||
} finally {
|
} finally {
|
||||||
setActionPlugin(null);
|
setDetailLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const installSelected = async () => {
|
||||||
await loadMarketplaces();
|
if (!selected) return;
|
||||||
if (selectedMarketplace) {
|
setInstalling(true);
|
||||||
await loadPlugins(selectedMarketplace);
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await installSkillHubSkill(selected.namespace, selected.slug, publishedVersion(selected));
|
||||||
|
setSelected({ ...selected, installed: true, installed_version: result.version });
|
||||||
|
await load();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || t('安装技能失败', 'Failed to install skill'));
|
||||||
|
} finally {
|
||||||
|
setInstalling(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / 12)), [total]);
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-20">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
<div className="mx-auto max-w-7xl p-6">
|
||||||
{/* Page header */}
|
<div className="mx-auto mb-10 max-w-4xl">
|
||||||
<div className="flex items-center justify-between">
|
<form
|
||||||
<div>
|
className="flex gap-3"
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
onSubmit={(event) => {
|
||||||
<Store className="w-6 h-6" />
|
event.preventDefault();
|
||||||
{pickAppText(locale, '插件市场', 'Plugin marketplace')}
|
setPage(0);
|
||||||
</h1>
|
void load();
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
}}
|
||||||
{pickAppText(locale, '浏览并安装已注册市场中的插件', 'Browse and install plugins from registered marketplaces')}
|
>
|
||||||
</p>
|
<div className="relative flex-1">
|
||||||
</div>
|
<Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||||
<div className="flex items-center gap-2">
|
<Input
|
||||||
<Button
|
value={query}
|
||||||
onClick={() => setShowAddForm((v) => !v)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
variant="outline"
|
placeholder={t('搜索技能...', 'Search skills...')}
|
||||||
size="sm"
|
className="h-14 rounded-2xl pl-12 text-base"
|
||||||
>
|
/>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
</div>
|
||||||
{pickAppText(locale, '添加市场', 'Add marketplace')}
|
<Button type="submit" className="h-14 rounded-2xl px-10 text-base">
|
||||||
|
{t('搜索', 'Search')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleRefresh} variant="outline" size="sm">
|
</form>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
{pickAppText(locale, '刷新', 'Refresh')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<Card className="border-destructive">
|
<Card className="mb-6 border-destructive">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
|
||||||
<div className="flex items-center justify-between gap-2 text-destructive text-sm">
|
<AlertCircle className="h-4 w-4" />
|
||||||
<div className="flex items-center gap-2">
|
{error}
|
||||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
</CardContent>
|
||||||
{error}
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Button variant="ghost" onClick={() => setSelected(null)}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
{t('返回搜索', 'Back to search')}
|
||||||
|
</Button>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<Badge variant="outline">@{selected.namespace}</Badge>
|
||||||
|
{selected.installed && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
{t('已安装', 'Installed')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">{selected.displayName || selected.slug}</CardTitle>
|
||||||
|
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">{selected.summary}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={installSelected} disabled={installing || detailLoading}>
|
||||||
|
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||||
|
{selected.installed ? t('重新安装/更新', 'Reinstall/update') : t('安装', 'Install')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
</CardHeader>
|
||||||
variant="ghost"
|
<CardContent className="space-y-4">
|
||||||
size="sm"
|
{detailLoading ? (
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
<div className="flex justify-center py-10">
|
||||||
onClick={() => setError(null)}
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
>
|
</div>
|
||||||
<X className="w-4 h-4" />
|
) : (
|
||||||
</Button>
|
<>
|
||||||
</div>
|
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||||
</CardContent>
|
<Badge variant="outline">v{publishedVersion(selected) || '-'}</Badge>
|
||||||
</Card>
|
<span>{t('下载', 'Downloads')}: {selected.downloadCount || 0}</span>
|
||||||
)}
|
<span>{t('收藏', 'Stars')}: {selected.starCount || 0}</span>
|
||||||
|
</div>
|
||||||
{/* Add marketplace form */}
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||||||
{showAddForm && (
|
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||||
<Card>
|
<div className="mb-2 text-sm font-medium">SKILL.md</div>
|
||||||
<CardContent className="pt-6">
|
<pre className="max-h-[520px] overflow-auto whitespace-pre-wrap text-xs">
|
||||||
<div className="flex items-center gap-2">
|
{versionDetail?.detail?.parsedMetadataJson || t('暂无预览', 'No preview available')}
|
||||||
<Input
|
</pre>
|
||||||
placeholder={pickAppText(locale, '本地路径或 Git 地址(例如 /path/to/marketplace 或 https://github.com/...)', 'Local path or Git URL (for example /path/to/marketplace or https://github.com/...)')}
|
</div>
|
||||||
value={addSource}
|
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||||
onChange={(e) => setAddSource(e.target.value)}
|
<div className="mb-3 text-sm font-medium">{t('版本文件', 'Version files')}</div>
|
||||||
onKeyDown={(e) => {
|
<div className="space-y-2">
|
||||||
if (e.key === 'Enter') handleAdd();
|
{(versionDetail?.files || []).map((file) => (
|
||||||
}}
|
<div key={file.filePath} className="flex items-center justify-between gap-3 rounded-md bg-background px-3 py-2 text-xs">
|
||||||
disabled={adding}
|
<span className="break-all font-mono">{file.filePath}</span>
|
||||||
className="flex-1"
|
<span className="shrink-0 text-muted-foreground">{file.fileSize} B</span>
|
||||||
/>
|
</div>
|
||||||
<Button onClick={handleAdd} disabled={adding || !addSource.trim()} size="sm">
|
))}
|
||||||
{adding ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{pickAppText(locale, '添加', 'Add')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddForm(false);
|
|
||||||
setAddSource('');
|
|
||||||
}}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{pickAppText(locale, '取消', 'Cancel')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Marketplace tabs */}
|
|
||||||
{marketplaces.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{marketplaces.map((marketplace) => (
|
|
||||||
<div key={marketplace.name} className="flex items-center gap-0.5">
|
|
||||||
<Button
|
|
||||||
variant={selectedMarketplace === marketplace.name ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setSelectedMarketplace(marketplace.name)}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
{marketplace.type === 'git' ? (
|
|
||||||
<Globe className="w-3.5 h-3.5" />
|
|
||||||
) : (
|
|
||||||
<FolderOpen className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
{marketplace.name}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-primary"
|
|
||||||
disabled={updatingMarketplace === marketplace.name}
|
|
||||||
onClick={() => handleUpdateMarketplace(marketplace.name)}
|
|
||||||
title={pickAppText(locale, '更新市场', 'Update marketplace')}
|
|
||||||
>
|
|
||||||
{updatingMarketplace === marketplace.name ? (
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => handleRemove(marketplace.name)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{marketplaces.length === 0 && !error && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-16 text-center text-muted-foreground">
|
|
||||||
<Store className="w-12 h-12 mx-auto mb-4 opacity-30" />
|
|
||||||
<p className="font-medium">{pickAppText(locale, '还没有注册任何市场', 'No marketplaces are registered yet')}</p>
|
|
||||||
<p className="text-sm mt-2 max-w-sm mx-auto">
|
|
||||||
{pickAppText(locale, '点击上方的', 'Use the')}<strong>{pickAppText(locale, '添加市场', 'Add marketplace')}</strong>{pickAppText(locale, ',填入本地路径或 Git 地址即可开始使用。', ' action above and provide a local path or Git URL to get started.')}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Plugin list */}
|
|
||||||
{selectedMarketplace && (
|
|
||||||
<>
|
|
||||||
{pluginsLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : plugins.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
|
||||||
<Store className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
|
||||||
<p className="font-medium">{pickAppText(locale, '暂无可用插件', 'No plugins available')}</p>
|
|
||||||
<p className="text-sm mt-1">{pickAppText(locale, '这个市场里暂时还没有插件。', 'There are no plugins in this marketplace yet.')}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{plugins.map((plugin) => (
|
|
||||||
<Card key={plugin.name}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<CardTitle className="text-base font-semibold">
|
|
||||||
{plugin.name}
|
|
||||||
</CardTitle>
|
|
||||||
{plugin.installed && (
|
|
||||||
<Badge variant="secondary" className="text-xs gap-1">
|
|
||||||
<Check className="w-3 h-3" />
|
|
||||||
{pickAppText(locale, '已安装', 'Installed')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{plugin.description && (
|
|
||||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
|
||||||
{plugin.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 flex items-center gap-2">
|
|
||||||
{plugin.installed ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={actionPlugin === plugin.name}
|
|
||||||
onClick={() =>
|
|
||||||
handleUpdatePlugin(plugin.marketplace_name, plugin.name)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{actionPlugin === plugin.name ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{pickAppText(locale, '更新', 'Update')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={actionPlugin === plugin.name}
|
|
||||||
onClick={() => handleUninstall(plugin.name)}
|
|
||||||
>
|
|
||||||
{actionPlugin === plugin.name ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{pickAppText(locale, '卸载', 'Uninstall')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
disabled={actionPlugin === plugin.name}
|
|
||||||
onClick={() =>
|
|
||||||
handleInstall(plugin.marketplace_name, plugin.name)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{actionPlugin === plugin.name ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{pickAppText(locale, '安装', 'Install')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">{t('排序:', 'Sort:')}</span>
|
||||||
|
{([
|
||||||
|
['relevance', t('相关性', 'Relevance')],
|
||||||
|
['downloads', t('下载量', 'Downloads')],
|
||||||
|
['newest', t('最新', 'Newest')],
|
||||||
|
] as Array<[SortMode, string]>).map(([value, label]) => (
|
||||||
|
<Button key={value} size="sm" variant={sort === value ? 'default' : 'outline'} onClick={() => { setSort(value); setPage(0); }}>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<span className="ml-4 text-sm font-medium text-muted-foreground">{t('筛选:', 'Filter:')}</span>
|
||||||
|
<Button size="sm" variant={starredOnly ? 'default' : 'outline'} onClick={() => setStarredOnly((value) => !value)}>
|
||||||
|
<Star className="mr-2 h-4 w-4" />
|
||||||
|
{t('只看已收藏', 'Starred only')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-20">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Card key={`${item.namespace}/${item.slug}`} className="cursor-pointer transition hover:border-primary" onClick={() => void openDetail(item)}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<CardTitle className="text-xl">{item.displayName || item.slug}</CardTitle>
|
||||||
|
<Badge variant="outline">@{item.namespace}</Badge>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<p className="line-clamp-3 min-h-[4.5rem] text-sm leading-6 text-muted-foreground">{item.summary}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||||
|
<Badge variant="secondary">v{publishedVersion(item) || '-'}</Badge>
|
||||||
|
<span>{item.downloadCount || 0}</span>
|
||||||
|
<span>{item.starCount || 0}</span>
|
||||||
|
{item.installed && <Badge variant="outline">{t('已安装', 'Installed')}</Badge>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<Button variant="outline" disabled={page <= 0} onClick={() => setPage((value) => Math.max(0, value - 1))}>
|
||||||
|
{t('上一页', 'Previous')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">{page + 1} / {totalPages}</span>
|
||||||
|
<Button variant="outline" disabled={page + 1 >= totalPages} onClick={() => setPage((value) => value + 1)}>
|
||||||
|
{t('下一页', 'Next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user