feat(tasks): add skill-templated task graph execution

This commit is contained in:
2026-06-23 10:22:58 +08:00
parent 6843d89b2c
commit 53b13e8eac
53 changed files with 4773 additions and 756 deletions

View File

@ -7,9 +7,11 @@ from dataclasses import dataclass, field, replace
from typing import Any
from beaver.coordinator.models import AgentDescriptor, ExecutionGraph, ExecutionNode
from beaver.engine.context import SkillContext
from beaver.engine.providers import ProviderBundle
from beaver.skills.assembler.embedding_retriever import SkillEmbeddingRetriever
from beaver.skills.catalog.loader import SkillsLoader
from beaver.skills.catalog.utils import strip_frontmatter
from beaver.skills.drafts import DraftService
from beaver.skills.learning import EphemeralGuidanceSynthesizer
from beaver.tasks.models import TaskRecord
@ -24,6 +26,9 @@ class SkillResolutionReport:
ephemeral_guidance_id: str | None = None
ephemeral_guidance_name: str | None = None
ephemeral_used: bool = False
requested_skill_name: str | None = None
exact_binding_used: bool = False
warnings: list[str] = field(default_factory=list)
reason: str = ""
def to_dict(self) -> dict[str, Any]:
@ -35,6 +40,9 @@ class SkillResolutionReport:
"ephemeral_guidance_id": self.ephemeral_guidance_id,
"ephemeral_guidance_name": self.ephemeral_guidance_name,
"ephemeral_used": self.ephemeral_used,
"requested_skill_name": self.requested_skill_name,
"exact_binding_used": self.exact_binding_used,
"warnings": list(self.warnings),
"reason": self.reason,
}
@ -87,12 +95,45 @@ class TaskSkillResolver:
attempt_index: int,
provider_bundle: ProviderBundle,
) -> tuple[ExecutionNode, SkillResolutionReport]:
use_skill = str(node.agent.metadata.get("use_skill") or "").strip()
skill_query = str(node.agent.metadata.get("skill_query") or node.task or node.node_id).strip()
warnings: list[str] = []
required_capabilities = [
str(item).strip()
for item in node.agent.metadata.get("required_capabilities", [])
if str(item).strip()
]
if use_skill:
exact_context = self._load_exact_skill_context(use_skill)
if exact_context is not None:
resolved = self._generic_node(
node,
pinned_skill_names=_merge_names(node.inherited_pinned_skills, [use_skill]),
pinned_skill_contexts=_merge_skill_contexts(
node.inherited_pinned_skill_contexts,
[exact_context],
),
metadata={
**node.agent.metadata,
"use_skill": use_skill,
"skill_query": skill_query,
"required_capabilities": required_capabilities,
"selected_skill_names": [use_skill],
"ephemeral_skill_names": [],
"exact_binding_used": True,
},
)
return resolved, SkillResolutionReport(
node_id=node.node_id,
skill_query=skill_query,
required_capabilities=required_capabilities,
selected_skill_names=[use_skill],
requested_skill_name=use_skill,
exact_binding_used=True,
reason="exact use_skill binding",
)
warnings.append(f"use_skill unresolved: {use_skill}")
if self._is_summary_only_node(node, skill_query=skill_query, required_capabilities=required_capabilities):
resolved = self._generic_node(
node,
@ -104,6 +145,7 @@ class TaskSkillResolver:
"required_capabilities": required_capabilities,
"selected_skill_names": [],
"ephemeral_skill_names": [],
"exact_binding_used": False,
"summary_uses_dependency_outputs_only": True,
},
)
@ -113,6 +155,9 @@ class TaskSkillResolver:
required_capabilities=required_capabilities,
selected_skill_names=[],
ephemeral_used=False,
requested_skill_name=use_skill or None,
exact_binding_used=False,
warnings=warnings,
reason="summary node uses dependency outputs directly",
)
@ -141,6 +186,7 @@ class TaskSkillResolver:
"required_capabilities": required_capabilities,
"selected_skill_names": selected,
"ephemeral_skill_names": [],
"exact_binding_used": False,
},
)
return resolved, SkillResolutionReport(
@ -149,6 +195,9 @@ class TaskSkillResolver:
required_capabilities=required_capabilities,
selected_skill_names=selected,
ephemeral_used=False,
requested_skill_name=use_skill or None,
exact_binding_used=False,
warnings=warnings,
reason="matched published skill",
)
@ -174,6 +223,7 @@ class TaskSkillResolver:
"ephemeral_guidance_id": missing.guidance_id,
"ephemeral_guidance_name": missing.guidance_name,
"ephemeral_skill_names": [missing.skill_context.name],
"exact_binding_used": False,
},
)
return resolved, SkillResolutionReport(
@ -183,9 +233,27 @@ class TaskSkillResolver:
ephemeral_guidance_id=missing.guidance_id,
ephemeral_guidance_name=missing.guidance_name,
ephemeral_used=True,
requested_skill_name=use_skill or None,
exact_binding_used=False,
warnings=warnings,
reason="generated ephemeral guidance for missing sub-agent capability",
)
def _load_exact_skill_context(self, name: str) -> SkillContext | None:
record = self.skills_loader.get_skill_record(name)
raw_content = self.skills_loader.load_published_skill(name)
content = strip_frontmatter(raw_content).strip() if raw_content else ""
if record is None or not content:
return None
return SkillContext(
name=name,
content=content,
version=record.version,
content_hash=record.content_hash or "",
activation_reason="explicit_node_binding",
tool_hints=list(record.tool_hints),
)
async def _select_published_skills(self, *, query: str, provider_bundle: ProviderBundle) -> list[str]:
candidates = self.skills_loader.build_selection_candidates()
if not candidates:
@ -336,3 +404,14 @@ def _merge_names(parent: list[str], selected: list[str]) -> list[str]:
if name and name not in result:
result.append(name)
return result
def _merge_skill_contexts(parent: list[SkillContext], selected: list[SkillContext]) -> list[SkillContext]:
result: list[SkillContext] = []
seen: set[str] = set()
for context in [*parent, *selected]:
if context.name in seen:
continue
seen.add(context.name)
result.append(context)
return result