Files
beaver_project/app-instance/backend/tests/unit/test_task_skill_resolver.py
steven_li a27560102b feat(task): 添加任务修订功能和超时处理机制
添加了 `revise_task` 路由动作类型,允许用户修改、纠正或重新执行最新活动任务结果。
实现了工具失败指导原则,防止相同类别工具重复失败。
为任务规划器添加了超时处理机制,避免长时间等待。

BREAKING CHANGE: 任务路由逻辑已更新,新增 `revise_task` 动作类型。

fix(api): 修复任务详情API返回完整流程投影

修复了任务详情API端点,现在会包含过滤后的流程运行、事件和工件信息,
并确保时间戳字段正确序列化。

refactor(engine): 优化任务技能解析器摘要节点处理

改进了任务技能解析器对摘要节点的处理逻辑,对于仅依赖文本生成功能的摘要节
点不再分配具体技能,直接使用依赖项输出进行汇总。

test: 增加任务修订和超时处理测试用例

添加了测试用例验证任务修订输入记录反馈、超时回退到单模式以及
摘要节点技能解析等新功能。
2026-05-21 16:40:44 +08:00

225 lines
7.8 KiB
Python

from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
from beaver.coordinator import AgentDescriptor, ExecutionGraph, ExecutionNode
from beaver.engine.context import SkillContext
from beaver.engine.providers.base import LLMProvider, LLMResponse
from beaver.engine.providers.factory import ProviderBundle
from beaver.skills.drafts import DraftService
from beaver.skills.learning import EphemeralGuidanceSynthesizer
from beaver.skills.publisher import SkillPublisher
from beaver.skills.reviews import ReviewService
from beaver.skills.specs import SkillSpecStore
from beaver.skills import SkillsLoader
from beaver.tasks import TaskRecord, TaskSkillResolver
class RecordingProvider(LLMProvider):
def __init__(self, responses: list[str]) -> None:
super().__init__()
self.responses = list(responses)
self.calls: 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,
) -> LLMResponse:
self.calls.append(messages)
content = self.responses.pop(0) if self.responses else "[]"
return LLMResponse(content=content, finish_reason="stop", provider_name="stub", model="stub-model")
def get_default_model(self) -> str:
return "stub-model"
def _bundle(provider: RecordingProvider) -> ProviderBundle:
return ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=provider,
)
def _task() -> TaskRecord:
return TaskRecord(
task_id="task-1",
session_id="session-1",
description="review api compatibility",
goal="review api compatibility",
constraints=[],
priority=0,
status="open",
creator="test",
created_at="now",
updated_at="now",
)
def _publish_skill(workspace: Path, *, skill_name: str) -> None:
store = SkillSpecStore(workspace)
draft = DraftService(store).create_new_skill_draft(
skill_name=skill_name,
proposed_content=f"# {skill_name}\n\nCheck schema compatibility and breaking changes.",
proposed_frontmatter={"description": f"{skill_name} capability", "tools": []},
created_by="tester",
reason="test",
)
ReviewService(store).approve(skill_name, draft.draft_id, reviewer="tester")
SkillPublisher(store).publish(skill_name, draft.draft_id, publisher="tester")
def test_task_skill_resolver_pins_matching_published_skill(tmp_path: Path) -> None:
_publish_skill(tmp_path, skill_name="api-contract-review")
provider = RecordingProvider(['["api-contract-review"]'])
resolver = TaskSkillResolver(
skills_loader=SkillsLoader(tmp_path),
draft_service=DraftService(SkillSpecStore(tmp_path)),
)
graph = ExecutionGraph(
strategy="sequence",
nodes=[
ExecutionNode(
"api_review",
"review API compatibility",
AgentDescriptor(
name="api_review",
metadata={
"skill_query": "API contract compatibility review",
"required_capabilities": ["schema compatibility"],
},
),
)
],
)
resolved, reports = asyncio.run(
resolver.resolve_graph(
graph,
task=_task(),
user_message="review api",
attempt_index=1,
provider_bundle=_bundle(provider),
)
)
assert resolved.nodes[0].agent.name == "api_review"
assert resolved.nodes[0].agent.role == ""
assert resolved.nodes[0].inherited_pinned_skills == ["api-contract-review"]
assert resolved.nodes[0].inherited_pinned_skill_contexts == []
assert reports[0].selected_skill_names == ["api-contract-review"]
assert reports[0].ephemeral_used is False
def test_task_skill_resolver_generates_ephemeral_guidance_when_missing(tmp_path: Path) -> None:
provider = RecordingProvider(
[
"""
{
"guidance_name": "api-compatibility-review",
"description": "Review API compatibility",
"content": "# API Compatibility Review\\n\\nCheck schema compatibility.",
"tags": ["api", "review"]
}
"""
]
)
store = SkillSpecStore(tmp_path)
resolver = TaskSkillResolver(
skills_loader=SkillsLoader(tmp_path),
draft_service=DraftService(store),
missing_skill_synthesizer=EphemeralGuidanceSynthesizer(),
)
graph = ExecutionGraph(
strategy="sequence",
nodes=[
ExecutionNode(
"api_review",
"review API compatibility",
AgentDescriptor(
name="api_review",
metadata={
"skill_query": "API compatibility review",
"required_capabilities": ["schema compatibility"],
},
),
)
],
)
resolved, reports = asyncio.run(
resolver.resolve_graph(
graph,
task=_task(),
user_message="review api",
attempt_index=1,
provider_bundle=_bundle(provider),
)
)
drafts = store.list_drafts("api-compatibility-review")
assert drafts == []
assert store.list_published_skill_names() == []
assert resolved.nodes[0].inherited_pinned_skills == []
assert len(resolved.nodes[0].inherited_pinned_skill_contexts) == 1
context: SkillContext = resolved.nodes[0].inherited_pinned_skill_contexts[0]
assert context.name == "ephemeral:api-compatibility-review"
assert context.version.startswith("ephemeral:eg_")
assert context.activation_reason == "ephemeral_guidance"
assert reports[0].ephemeral_guidance_id is not None
assert reports[0].ephemeral_guidance_name == "api-compatibility-review"
assert reports[0].ephemeral_used is True
def test_task_skill_resolver_keeps_summary_nodes_skillless(tmp_path: Path) -> None:
_publish_skill(tmp_path, skill_name="multi-search-engine")
provider = RecordingProvider(['["multi-search-engine"]'])
resolver = TaskSkillResolver(
skills_loader=SkillsLoader(tmp_path),
draft_service=DraftService(SkillSpecStore(tmp_path)),
)
graph = ExecutionGraph(
strategy="dag",
nodes=[
ExecutionNode(
"summarize",
"Compile a clear, concise summary from dependency outputs for the user.",
AgentDescriptor(
name="summarize",
metadata={
"skill_query": "Summarization",
"required_capabilities": ["text generation"],
},
),
depends_on=["verify_result"],
inherited_pinned_skills=["multi-search-engine"],
inherited_pinned_skill_contexts=[
SkillContext(name="ephemeral:search-guidance", content="Search again.")
],
)
],
)
resolved, reports = asyncio.run(
resolver.resolve_graph(
graph,
task=_task(),
user_message="summarize result",
attempt_index=2,
provider_bundle=_bundle(provider),
)
)
assert resolved.nodes[0].inherited_pinned_skills == []
assert resolved.nodes[0].inherited_pinned_skill_contexts == []
assert resolved.nodes[0].agent.metadata["selected_skill_names"] == []
assert reports[0].selected_skill_names == []
assert reports[0].ephemeral_used is False
assert reports[0].reason == "summary node uses dependency outputs directly"
assert provider.calls == []