feat(beaver): 完成Task Team功能v1实现,重构后端架构支持统一内核

新增内部Task系统,包括验证、反馈门控机制,实现自动质量验证
(通过率>=0.75)和用户反馈闭环(satisfied/revise/abandon)。

实现Agent Team v1协调器,支持sequence/parallel/dag执行策略,
sub-agent复用主AgentLoop,每个run使用独立memory snapshot。

建立Skill学习pipeline,包含draft/审核/发布/回滚完整生命周期,
通过Task验证通过且用户满意才生成学习候选。

重构目录结构,移除third_party依赖,建立统一engine内核,
所有agent共享运行时基础组件。

更新ContextBuilder清理provider消息字段,增强SkillContext版本管理,
集成TaskExecutionPlanner和TaskSkillResolver实现技能解析机制。
This commit is contained in:
2026-05-08 17:14:14 +08:00
parent 5ba5c7e4c1
commit 8a12c30141
93 changed files with 16724 additions and 1247 deletions

View File

@ -1,2 +1,34 @@
"""Multi-agent coordination layer."""
from .models import (
AgentDescriptor,
DelegationEnvelope,
ExecutionGraph,
ExecutionNode,
NodeRunResult,
TeamRunResult,
)
def __getattr__(name: str):
if name == "LocalAgentRunner":
from .local import LocalAgentRunner
return LocalAgentRunner
if name == "TeamGraphScheduler":
from .execution import TeamGraphScheduler
return TeamGraphScheduler
raise AttributeError(name)
__all__ = [
"AgentDescriptor",
"DelegationEnvelope",
"ExecutionGraph",
"ExecutionNode",
"LocalAgentRunner",
"NodeRunResult",
"TeamGraphScheduler",
"TeamRunResult",
]

View File

@ -1,2 +1,5 @@
"""Execution control, retry, and aggregation."""
from .scheduler import TeamGraphScheduler
__all__ = ["TeamGraphScheduler"]

View File

@ -0,0 +1,256 @@
"""Minimal scheduler for Beaver-native team execution graphs."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import TYPE_CHECKING
from beaver.engine.providers import ProviderBundle
from ..local import LocalAgentRunner
from ..models import DelegationEnvelope, ExecutionGraph, ExecutionNode, NodeRunResult, TeamRunResult
if TYPE_CHECKING:
from beaver.engine.context import SkillContext
class TeamGraphScheduler:
"""Execute sequence, parallel, and DAG team graphs."""
def __init__(self, runner: LocalAgentRunner) -> None:
self.runner = runner
async def run(
self,
graph: ExecutionGraph,
*,
parent_task_id: str | None,
parent_session_id: str,
parent_run_id: str | None = None,
provider_bundle: ProviderBundle | None = None,
provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None = None,
inherited_pinned_skills: list[str] | None = None,
inherited_pinned_skill_contexts: list["SkillContext"] | None = None,
learning_candidate_enabled: bool = False,
) -> TeamRunResult:
graph.validate()
if provider_bundle is not None and len(graph.nodes) > 1:
raise ValueError("provider_bundle can only be used for single-node team graphs; use provider_bundle_factory")
inherited = list(inherited_pinned_skills or [])
inherited_contexts = list(inherited_pinned_skill_contexts or [])
if graph.strategy == "sequence":
results = await self._run_sequence(
graph.nodes,
parent_task_id=parent_task_id,
parent_session_id=parent_session_id,
parent_run_id=parent_run_id,
provider_bundle=provider_bundle,
provider_bundle_factory=provider_bundle_factory,
inherited_pinned_skills=inherited,
inherited_pinned_skill_contexts=inherited_contexts,
learning_candidate_enabled=learning_candidate_enabled,
)
elif graph.strategy == "parallel":
results = await self._run_parallel(
graph.nodes,
parent_task_id=parent_task_id,
parent_session_id=parent_session_id,
parent_run_id=parent_run_id,
provider_bundle=provider_bundle,
provider_bundle_factory=provider_bundle_factory,
inherited_pinned_skills=inherited,
inherited_pinned_skill_contexts=inherited_contexts,
learning_candidate_enabled=learning_candidate_enabled,
)
else:
results = await self._run_dag(
graph.nodes,
parent_task_id=parent_task_id,
parent_session_id=parent_session_id,
parent_run_id=parent_run_id,
provider_bundle=provider_bundle,
provider_bundle_factory=provider_bundle_factory,
inherited_pinned_skills=inherited,
inherited_pinned_skill_contexts=inherited_contexts,
learning_candidate_enabled=learning_candidate_enabled,
)
return self._summarize(results, task_id=parent_task_id)
async def _run_sequence(
self,
nodes: list[ExecutionNode],
**kwargs,
) -> list[NodeRunResult]:
results: list[NodeRunResult] = []
for node in nodes:
if any(not item.success for item in results):
results.append(self._blocked(node, results))
continue
dependency_outputs = {item.node_id: item.output_text for item in results if item.success}
results.append(await self._run_node(node, dependency_outputs=dependency_outputs, **kwargs))
return results
async def _run_parallel(
self,
nodes: list[ExecutionNode],
**kwargs,
) -> list[NodeRunResult]:
return list(await asyncio.gather(*(self._run_node(node, dependency_outputs={}, **kwargs) for node in nodes)))
async def _run_dag(
self,
nodes: list[ExecutionNode],
**kwargs,
) -> list[NodeRunResult]:
pending = {node.node_id: node for node in nodes}
completed: dict[str, NodeRunResult] = {}
ordered: list[NodeRunResult] = []
while pending:
blocked_ids = {
node_id
for node_id, node in pending.items()
if any(dep in completed and not completed[dep].success for dep in node.depends_on)
}
for node_id in sorted(blocked_ids):
node = pending.pop(node_id)
result = self._blocked(node, list(completed.values()))
completed[node_id] = result
ordered.append(result)
ready = [
node
for node in pending.values()
if all(dep in completed and completed[dep].success for dep in node.depends_on)
]
if not ready:
if pending:
unresolved = ", ".join(sorted(pending))
raise ValueError(f"ExecutionGraph has cyclic or unresolved dependencies: {unresolved}")
break
batch = await asyncio.gather(
*(
self._run_node(
node,
dependency_outputs={
dep: completed[dep].output_text
for dep in node.depends_on
if dep in completed
},
**kwargs,
)
for node in ready
)
)
for result in batch:
pending.pop(result.node_id, None)
completed[result.node_id] = result
ordered.append(result)
return ordered
async def _run_node(
self,
node: ExecutionNode,
*,
parent_task_id: str | None,
parent_session_id: str,
parent_run_id: str | None,
provider_bundle: ProviderBundle | None,
provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None,
inherited_pinned_skills: list[str],
inherited_pinned_skill_contexts: list["SkillContext"],
learning_candidate_enabled: bool,
dependency_outputs: dict[str, str],
) -> NodeRunResult:
try:
pinned = self._merge_pinned(inherited_pinned_skills, node.inherited_pinned_skills)
pinned_contexts = self._merge_skill_contexts(
inherited_pinned_skill_contexts,
node.inherited_pinned_skill_contexts,
)
envelope = DelegationEnvelope(
parent_task_id=parent_task_id,
parent_session_id=parent_session_id,
parent_run_id=parent_run_id,
agent=node.agent,
task=node.task,
inherited_pinned_skills=pinned,
inherited_pinned_skill_contexts=pinned_contexts,
constraints=list(node.constraints),
expected_output=node.expected_output,
node_id=node.node_id,
dependency_outputs=dict(dependency_outputs),
)
node_provider_bundle = provider_bundle_factory(node) if provider_bundle_factory is not None else provider_bundle
return await self.runner.run(
envelope,
provider_bundle=node_provider_bundle,
learning_candidate_enabled=learning_candidate_enabled,
)
except asyncio.CancelledError:
raise
except Exception as exc:
return NodeRunResult(
node_id=node.node_id,
success=False,
output_text="",
finish_reason="error",
error=str(exc),
)
@staticmethod
def _merge_pinned(parent: list[str], local: list[str]) -> list[str]:
result: list[str] = []
for name in [*parent, *local]:
if name and name not in result:
result.append(name)
return result
@staticmethod
def _merge_skill_contexts(parent: list["SkillContext"], local: list["SkillContext"]) -> list["SkillContext"]:
result: list["SkillContext"] = []
seen: set[str] = set()
for skill in [*parent, *local]:
name = getattr(skill, "name", "")
if not name or name in seen:
continue
seen.add(name)
result.append(skill)
return result
@staticmethod
def _blocked(node: ExecutionNode, prior_results: list[NodeRunResult]) -> NodeRunResult:
failed = [item.node_id for item in prior_results if not item.success]
detail = ", ".join(failed) or "unknown dependency"
return NodeRunResult(
node_id=node.node_id,
success=False,
output_text="",
finish_reason="blocked",
error=f"Blocked by failed dependency: {detail}",
)
@staticmethod
def _summarize(results: list[NodeRunResult], *, task_id: str | None) -> TeamRunResult:
success = all(item.success for item in results)
successful_outputs = [item.output_text.strip() for item in results if item.success and item.output_text.strip()]
summary_parts = list(successful_outputs)
failed = [item for item in results if not item.success]
if failed:
failure_lines = [
f"- {item.node_id}: {item.error or item.finish_reason}"
for item in failed
]
summary_parts.append("Failed nodes:\n" + "\n".join(failure_lines))
summary = "\n\n".join(summary_parts)
return TeamRunResult(
success=success,
summary=summary,
node_results=results,
run_ids=[item.run_id for item in results if item.run_id],
session_ids=[item.session_id for item in results if item.session_id],
task_id=task_id,
)

View File

@ -0,0 +1,92 @@
"""Local delegated-agent runner built on the shared AgentLoop."""
from __future__ import annotations
from uuid import uuid4
from beaver.engine import AgentLoop
from beaver.engine.providers import ProviderBundle
from .models import DelegationEnvelope, NodeRunResult
class LocalAgentRunner:
"""Run delegated agents through the same AgentLoop implementation."""
def __init__(self, loop: AgentLoop) -> None:
self.loop = loop
async def run(
self,
envelope: DelegationEnvelope,
*,
provider_bundle: ProviderBundle | None = None,
learning_candidate_enabled: bool = False,
) -> NodeRunResult:
if provider_bundle is not None and (envelope.agent.model or envelope.agent.provider_name):
raise ValueError(
"provider_bundle cannot be combined with AgentDescriptor.model/provider_name; "
"build a node-specific provider bundle instead."
)
child_session_id = self._child_session_id(envelope)
runner = self.loop.submit_direct if self.loop.is_running else self.loop.process_direct
result = await runner(
envelope.task,
session_id=child_session_id,
parent_session_id=envelope.parent_session_id,
source=f"team:{envelope.agent.name}",
title=envelope.agent.role or envelope.agent.name,
execution_context=self._execution_context(envelope),
model=envelope.agent.model,
provider_name=envelope.agent.provider_name,
provider_bundle=provider_bundle,
task_id=envelope.parent_task_id,
task_mode=bool(envelope.parent_task_id),
pinned_skill_names=envelope.inherited_pinned_skills,
pinned_skill_contexts=envelope.inherited_pinned_skill_contexts,
learning_candidate_enabled=learning_candidate_enabled,
)
success = result.finish_reason == "stop"
return NodeRunResult(
node_id=envelope.node_id or envelope.agent.name,
success=success,
output_text=result.output_text,
run_id=result.run_id,
session_id=result.session_id,
finish_reason=result.finish_reason,
error=None if success else (result.output_text or result.finish_reason),
)
@staticmethod
def _child_session_id(envelope: DelegationEnvelope) -> str:
node = envelope.node_id or envelope.agent.name or "node"
return f"{envelope.parent_session_id}:team:{node}:{uuid4().hex[:8]}"
@staticmethod
def _execution_context(envelope: DelegationEnvelope) -> str:
sections: list[str] = []
if envelope.parent_task_id:
sections.append(f"Parent task ID: {envelope.parent_task_id}")
if envelope.parent_run_id:
sections.append(f"Parent run ID: {envelope.parent_run_id}")
sections.append("Delegated worker: generic task sub-agent. Follow active pinned skills as the primary guidance.")
if envelope.agent.system_prompt:
sections.append(f"Additional delegated instructions:\n{envelope.agent.system_prompt}")
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.dependency_outputs:
rendered = "\n\n".join(
f"Dependency {node_id} output:\n{output}"
for node_id, output in envelope.dependency_outputs.items()
)
sections.append("Dependency outputs:\n" + rendered)
if 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:
sections.append(
"Ephemeral pinned skill drafts:\n"
+ "\n".join(f"- {item.name} ({item.version})" for item in envelope.inherited_pinned_skill_contexts)
)
return "\n\n".join(sections)

View File

@ -0,0 +1,151 @@
"""Core models for Beaver team coordination."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from beaver.engine.context import SkillContext
TeamStrategy = Literal[
"sequence",
"parallel",
"dag",
"moa",
"hierarchy",
"heavy",
"group_chat",
"forest",
"maker",
"router",
]
@dataclass(slots=True)
class AgentDescriptor:
"""Runtime identity for a delegated local agent."""
name: str
role: str = ""
system_prompt: str = ""
model: str | None = None
provider_name: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class DelegationEnvelope:
"""All context passed from a parent agent run to one delegated run."""
parent_task_id: str | None
parent_session_id: str
parent_run_id: str | None
agent: AgentDescriptor
task: str
inherited_pinned_skills: list[str] = field(default_factory=list)
inherited_pinned_skill_contexts: list["SkillContext"] = field(default_factory=list)
constraints: list[str] = field(default_factory=list)
expected_output: str | None = None
node_id: str | None = None
dependency_outputs: dict[str, str] = field(default_factory=dict)
@dataclass(slots=True)
class ExecutionNode:
"""One node in a team execution graph."""
node_id: str
task: str
agent: AgentDescriptor
depends_on: list[str] = field(default_factory=list)
inherited_pinned_skills: list[str] = field(default_factory=list)
inherited_pinned_skill_contexts: list["SkillContext"] = field(default_factory=list)
constraints: list[str] = field(default_factory=list)
expected_output: str | None = None
@dataclass(slots=True)
class ExecutionGraph:
"""A lightweight team graph built from Beaver-native execution nodes."""
strategy: TeamStrategy
nodes: list[ExecutionNode]
def validate(self) -> None:
if self.strategy not in {"sequence", "parallel", "dag"}:
raise NotImplementedError(f"Team strategy {self.strategy!r} is reserved but not implemented in v1")
if not self.nodes:
raise ValueError("ExecutionGraph requires at least one node")
node_ids = [node.node_id for node in self.nodes]
if len(node_ids) != len(set(node_ids)):
raise ValueError("ExecutionGraph node_id values must be unique")
known = set(node_ids)
for node in self.nodes:
missing = [item for item in node.depends_on if item not in known]
if missing:
raise ValueError(f"ExecutionNode {node.node_id!r} depends on unknown node(s): {missing}")
visiting: set[str] = set()
visited: set[str] = set()
deps = {node.node_id: list(node.depends_on) for node in self.nodes}
def visit(node_id: str) -> None:
if node_id in visited:
return
if node_id in visiting:
raise ValueError(f"ExecutionGraph has cyclic or unresolved dependencies involving {node_id!r}")
visiting.add(node_id)
for dep in deps[node_id]:
visit(dep)
visiting.remove(node_id)
visited.add(node_id)
for node_id in node_ids:
visit(node_id)
@dataclass(slots=True)
class NodeRunResult:
"""Normalized result for one team node."""
node_id: str
success: bool
output_text: str
run_id: str | None = None
session_id: str | None = None
finish_reason: str = "stop"
error: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"node_id": self.node_id,
"success": self.success,
"output_text": self.output_text,
"run_id": self.run_id,
"session_id": self.session_id,
"finish_reason": self.finish_reason,
"error": self.error,
}
@dataclass(slots=True)
class TeamRunResult:
"""Normalized result returned by a Beaver team run."""
success: bool
summary: str
node_results: list[NodeRunResult] = field(default_factory=list)
run_ids: list[str] = field(default_factory=list)
session_ids: list[str] = field(default_factory=list)
task_id: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"success": self.success,
"summary": self.summary,
"node_results": [item.to_dict() for item in self.node_results],
"run_ids": list(self.run_ids),
"session_ids": list(self.session_ids),
"task_id": self.task_id,
}

View File

@ -1,2 +1,14 @@
"""Agent registry and descriptors."""
"""Workspace specialist agent registry."""
from .models import AgentMatch, RegisteredAgent, TargetResolutionReport
from .resolver import TargetResolver
from .store import AgentRegistry
__all__ = [
"AgentMatch",
"AgentRegistry",
"RegisteredAgent",
"TargetResolutionReport",
"TargetResolver",
]

View File

@ -0,0 +1,184 @@
"""Workspace agent registry models."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal
from beaver.coordinator.models import AgentDescriptor
AgentRegistryStatus = Literal["active", "disabled"]
AgentRegistrySource = Literal["builtin", "workspace", "learned"]
@dataclass(slots=True)
class RegisteredAgent:
agent_id: str
name: str
display_name: str
role: str
description: str
system_prompt: str
capabilities: list[str] = field(default_factory=list)
skill_names: list[str] = field(default_factory=list)
tool_hints: list[str] = field(default_factory=list)
model: str | None = None
provider_name: str | None = None
tags: list[str] = field(default_factory=list)
priority: int = 0
status: AgentRegistryStatus = "active"
source: AgentRegistrySource = "workspace"
metadata: dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=lambda: _utc_now())
updated_at: str = field(default_factory=lambda: _utc_now())
def to_descriptor(self) -> AgentDescriptor:
return AgentDescriptor(
name=self.name,
role=self.role,
system_prompt=self.system_prompt,
model=self.model,
provider_name=self.provider_name,
metadata={
**self.metadata,
"agent_id": self.agent_id,
"display_name": self.display_name,
"description": self.description,
"capabilities": list(self.capabilities),
"skill_names": list(self.skill_names),
"tool_hints": list(self.tool_hints),
"tags": list(self.tags),
"source": self.source,
"resolution": "registered",
},
)
def to_dict(self) -> dict[str, Any]:
return {
"agent_id": self.agent_id,
"name": self.name,
"display_name": self.display_name,
"role": self.role,
"description": self.description,
"system_prompt": self.system_prompt,
"capabilities": list(self.capabilities),
"skill_names": list(self.skill_names),
"tool_hints": list(self.tool_hints),
"model": self.model,
"provider_name": self.provider_name,
"tags": list(self.tags),
"priority": self.priority,
"status": self.status,
"source": self.source,
"metadata": dict(self.metadata),
"created_at": self.created_at,
"updated_at": self.updated_at,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "RegisteredAgent":
now = _utc_now()
agent_id = str(payload.get("agent_id") or payload.get("id") or payload.get("name") or "").strip()
if not agent_id:
raise ValueError("RegisteredAgent requires agent_id")
name = str(payload.get("name") or agent_id).strip()
return cls(
agent_id=agent_id,
name=name,
display_name=str(payload.get("display_name") or payload.get("displayName") or name).strip(),
role=str(payload.get("role") or "").strip(),
description=str(payload.get("description") or "").strip(),
system_prompt=str(payload.get("system_prompt") or payload.get("systemPrompt") or "").strip(),
capabilities=_string_list(payload.get("capabilities")),
skill_names=_string_list(payload.get("skill_names") or payload.get("skillNames")),
tool_hints=_string_list(payload.get("tool_hints") or payload.get("toolHints")),
model=_optional_str(payload.get("model")),
provider_name=_optional_str(payload.get("provider_name") or payload.get("providerName")),
tags=_string_list(payload.get("tags")),
priority=int(payload.get("priority", 0) or 0),
status="disabled" if str(payload.get("status") or "active") == "disabled" else "active",
source=_source(payload.get("source")),
metadata=dict(payload.get("metadata") or {}),
created_at=str(payload.get("created_at") or payload.get("createdAt") or now),
updated_at=str(payload.get("updated_at") or payload.get("updatedAt") or now),
)
@dataclass(slots=True)
class AgentMatch:
agent_id: str
score: float
reasons: list[str]
matched_capabilities: list[str]
resolved_descriptor: AgentDescriptor
def to_dict(self) -> dict[str, Any]:
return {
"agent_id": self.agent_id,
"score": self.score,
"reasons": list(self.reasons),
"matched_capabilities": list(self.matched_capabilities),
"resolved_descriptor": {
"name": self.resolved_descriptor.name,
"role": self.resolved_descriptor.role,
"model": self.resolved_descriptor.model,
"provider_name": self.resolved_descriptor.provider_name,
"metadata": dict(self.resolved_descriptor.metadata),
},
}
@dataclass(slots=True)
class TargetResolutionReport:
node_id: str
requested_role: str
requested_capabilities: list[str]
selected_agent_id: str | None
fallback_used: bool
score: float
reason: str
def to_dict(self) -> dict[str, Any]:
return {
"node_id": self.node_id,
"requested_role": self.requested_role,
"requested_capabilities": list(self.requested_capabilities),
"selected_agent_id": self.selected_agent_id,
"fallback_used": self.fallback_used,
"score": self.score,
"reason": self.reason,
}
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _optional_str(value: Any) -> str | None:
if value in (None, ""):
return None
text = str(value).strip()
return text or None
def _string_list(value: Any) -> list[str]:
if not isinstance(value, list):
if isinstance(value, str):
value = [item.strip() for item in value.split(",")]
else:
return []
result: list[str] = []
for item in value:
text = str(item).strip()
if text and text not in result:
result.append(text)
return result
def _source(value: Any) -> AgentRegistrySource:
text = str(value or "workspace").strip()
if text in {"builtin", "workspace", "learned"}:
return text # type: ignore[return-value]
return "workspace"

View File

@ -0,0 +1,208 @@
"""Resolve planner node requirements to registered specialist agents."""
from __future__ import annotations
from dataclasses import replace
from typing import Any, TYPE_CHECKING
from beaver.coordinator.models import AgentDescriptor, ExecutionGraph, ExecutionNode
from .models import AgentMatch, RegisteredAgent, TargetResolutionReport
from .store import AgentRegistry
if TYPE_CHECKING:
from beaver.tasks.models import TaskRecord
class TargetResolver:
def __init__(self, registry: AgentRegistry) -> None:
self.registry = registry
def resolve_graph(
self,
graph: ExecutionGraph,
*,
task: "TaskRecord",
user_message: str,
attempt_index: int,
) -> tuple[ExecutionGraph, list[TargetResolutionReport]]:
reports: list[TargetResolutionReport] = []
resolved_nodes: list[ExecutionNode] = []
for node in graph.nodes:
descriptor, report = self.resolve_node(
node,
task=task,
user_message=user_message,
attempt_index=attempt_index,
)
resolved_nodes.append(replace(node, agent=descriptor))
reports.append(report)
return ExecutionGraph(strategy=graph.strategy, nodes=resolved_nodes), reports
def resolve_node(
self,
node: ExecutionNode,
*,
task: "TaskRecord",
user_message: str,
attempt_index: int,
) -> tuple[AgentDescriptor, TargetResolutionReport]:
requested_role = (node.agent.role or node.agent.name or node.node_id).strip()
requested_capabilities = [
str(item).strip()
for item in node.agent.metadata.get("requested_capabilities", [])
if str(item).strip()
]
requested_tags = [
str(item).strip()
for item in node.agent.metadata.get("requested_tags", [])
if str(item).strip()
]
pinned_skills = list(node.inherited_pinned_skills)
match = self.best_match(
requested_role=requested_role,
requested_capabilities=requested_capabilities,
requested_tags=requested_tags,
pinned_skills=pinned_skills,
task_text=" ".join([task.goal, task.description, user_message, node.task]),
)
if match is not None and match.score > 0:
descriptor = match.resolved_descriptor
descriptor.metadata.update(
{
"node_id": node.node_id,
"attempt_index": attempt_index,
"requested_role": requested_role,
"requested_capabilities": requested_capabilities,
}
)
return descriptor, TargetResolutionReport(
node_id=node.node_id,
requested_role=requested_role,
requested_capabilities=requested_capabilities,
selected_agent_id=match.agent_id,
fallback_used=False,
score=match.score,
reason="; ".join(match.reasons),
)
fallback = AgentDescriptor(
name=node.agent.name or node.node_id,
role=node.agent.role,
system_prompt=node.agent.system_prompt,
model=node.agent.model,
provider_name=node.agent.provider_name,
metadata={
**node.agent.metadata,
"node_id": node.node_id,
"attempt_index": attempt_index,
"requested_role": requested_role,
"requested_capabilities": requested_capabilities,
"resolution": "fallback_ephemeral",
},
)
return fallback, TargetResolutionReport(
node_id=node.node_id,
requested_role=requested_role,
requested_capabilities=requested_capabilities,
selected_agent_id=None,
fallback_used=True,
score=0.0,
reason="no active registered specialist matched planner requirements",
)
def best_match(
self,
*,
requested_role: str,
requested_capabilities: list[str],
requested_tags: list[str],
pinned_skills: list[str],
task_text: str,
) -> AgentMatch | None:
matches = [
self._score_agent(
agent,
requested_role=requested_role,
requested_capabilities=requested_capabilities,
requested_tags=requested_tags,
pinned_skills=pinned_skills,
task_text=task_text,
)
for agent in self.registry.list_active_agents()
]
matches = [match for match in matches if match.score > 0]
if not matches:
return None
matches.sort(key=lambda item: (item.score, item.resolved_descriptor.metadata.get("priority", 0)), reverse=True)
return matches[0]
def _score_agent(
self,
agent: RegisteredAgent,
*,
requested_role: str,
requested_capabilities: list[str],
requested_tags: list[str],
pinned_skills: list[str],
task_text: str,
) -> AgentMatch:
score = 0.0
reasons: list[str] = []
requested_role_terms = _terms(requested_role)
capability_terms = _terms(" ".join(requested_capabilities))
tag_terms = _terms(" ".join(requested_tags))
skill_terms = _terms(" ".join(pinned_skills))
task_terms = _terms(task_text)
agent_role_terms = _terms(agent.role + " " + agent.name + " " + agent.display_name)
agent_capability_terms = _terms(" ".join(agent.capabilities))
agent_tag_terms = _terms(" ".join(agent.tags))
agent_skill_terms = _terms(" ".join(agent.skill_names))
agent_all_terms = (
agent_role_terms
| agent_capability_terms
| agent_tag_terms
| agent_skill_terms
| _terms(agent.description)
)
role_hits = requested_role_terms & agent_role_terms
if role_hits:
score += 60 + 5 * len(role_hits)
reasons.append(f"role matched: {', '.join(sorted(role_hits))}")
capability_hits = capability_terms & agent_capability_terms
if capability_hits:
score += 30 + 5 * len(capability_hits)
reasons.append(f"capabilities matched: {', '.join(sorted(capability_hits))}")
tag_hits = tag_terms & agent_tag_terms
if tag_hits:
score += 10 + 3 * len(tag_hits)
reasons.append(f"tags matched: {', '.join(sorted(tag_hits))}")
skill_hits = skill_terms & agent_skill_terms
if skill_hits:
score += 25 + 5 * len(skill_hits)
reasons.append(f"skills matched: {', '.join(sorted(skill_hits))}")
task_hits = task_terms & agent_all_terms
if task_hits:
score += min(20, len(task_hits) * 2)
reasons.append("task text matched registry profile")
score += agent.priority / 100.0
descriptor = agent.to_descriptor()
descriptor.metadata["priority"] = agent.priority
return AgentMatch(
agent_id=agent.agent_id,
score=round(score, 3),
reasons=reasons or ["priority fallback"],
matched_capabilities=sorted(capability_hits),
resolved_descriptor=descriptor,
)
def _terms(value: Any) -> set[str]:
text = str(value or "")
normalized = "".join(ch.lower() if ch.isalnum() else " " for ch in text)
return {part for part in normalized.split() if part}

View File

@ -0,0 +1,185 @@
"""File-backed workspace agent registry."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from .models import RegisteredAgent
class AgentRegistry:
def __init__(self, workspace: str | Path) -> None:
self.workspace = Path(workspace)
self.path = self.workspace / "agents" / "registry.json"
self.path.parent.mkdir(parents=True, exist_ok=True)
if not self.path.exists():
self._write_agents(_builtin_agents())
def list_agents(self, *, include_disabled: bool = True) -> list[RegisteredAgent]:
agents = self._read_agents()
if include_disabled:
return agents
return [agent for agent in agents if agent.status == "active"]
def list_active_agents(self) -> list[RegisteredAgent]:
return self.list_agents(include_disabled=False)
def get_agent(self, agent_id: str) -> RegisteredAgent | None:
needle = agent_id.strip()
for agent in self.list_agents():
if agent.agent_id == needle:
return agent
return None
def upsert_agent(self, payload: dict[str, Any] | RegisteredAgent) -> RegisteredAgent:
agent = payload if isinstance(payload, RegisteredAgent) else RegisteredAgent.from_dict(payload)
agents = self.list_agents()
for index, existing in enumerate(agents):
if existing.agent_id == agent.agent_id:
if existing.source == "builtin" and agent.source == "workspace":
agent.source = "builtin"
agent.created_at = existing.created_at
agents[index] = agent
self._write_agents(agents)
return agent
agents.append(agent)
self._write_agents(agents)
return agent
def disable_agent(self, agent_id: str) -> RegisteredAgent:
agents = self.list_agents()
for index, agent in enumerate(agents):
if agent.agent_id != agent_id:
continue
agent.status = "disabled"
agents[index] = agent
self._write_agents(agents)
return agent
raise ValueError(f"Unknown agent_id: {agent_id}")
def search(
self,
*,
role: str = "",
capabilities: list[str] | None = None,
tags: list[str] | None = None,
skills: list[str] | None = None,
) -> list[RegisteredAgent]:
role_terms = _terms(role)
capability_terms = set(_terms(" ".join(capabilities or [])))
tag_terms = set(_terms(" ".join(tags or [])))
skill_terms = set(_terms(" ".join(skills or [])))
matches: list[RegisteredAgent] = []
for agent in self.list_active_agents():
haystack = set(
_terms(
" ".join(
[
agent.agent_id,
agent.name,
agent.display_name,
agent.role,
agent.description,
" ".join(agent.capabilities),
" ".join(agent.tags),
" ".join(agent.skill_names),
]
)
)
)
if role_terms and not role_terms.intersection(haystack):
continue
if capability_terms and not capability_terms.intersection(haystack):
continue
if tag_terms and not tag_terms.intersection(haystack):
continue
if skill_terms and not skill_terms.intersection(haystack):
continue
matches.append(agent)
return matches
def _read_agents(self) -> list[RegisteredAgent]:
if not self.path.exists():
return []
payload = json.loads(self.path.read_text(encoding="utf-8"))
raw_agents = payload.get("agents") if isinstance(payload, dict) else payload
if not isinstance(raw_agents, list):
return []
return [RegisteredAgent.from_dict(item) for item in raw_agents if isinstance(item, dict)]
def _write_agents(self, agents: list[RegisteredAgent]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = {"version": 1, "agents": [agent.to_dict() for agent in agents]}
self.path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _terms(text: str) -> set[str]:
normalized = "".join(ch.lower() if ch.isalnum() else " " for ch in text)
return {part for part in normalized.split() if part}
def _builtin_agents() -> list[RegisteredAgent]:
return [
RegisteredAgent(
agent_id="researcher",
name="researcher",
display_name="Researcher",
role="research",
description="Finds facts, references, constraints, and implementation options.",
system_prompt="You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
capabilities=["research", "analysis", "source review", "requirements"],
tags=["planning", "research"],
priority=50,
source="builtin",
),
RegisteredAgent(
agent_id="implementer",
name="implementer",
display_name="Implementer",
role="implementation",
description="Builds scoped implementation slices and proposes concrete changes.",
system_prompt="You are an implementation specialist. Produce practical, scoped implementation output.",
capabilities=["implementation", "coding", "refactor", "integration"],
tags=["coding", "build"],
priority=45,
source="builtin",
),
RegisteredAgent(
agent_id="reviewer",
name="reviewer",
display_name="Reviewer",
role="review",
description="Reviews plans, code, outputs, and risks before final synthesis.",
system_prompt="You are a review specialist. Focus on defects, missing requirements, and risks.",
capabilities=["review", "quality", "risk", "verification"],
tags=["review", "quality"],
priority=45,
source="builtin",
),
RegisteredAgent(
agent_id="tester",
name="tester",
display_name="Tester",
role="testing",
description="Designs and executes verification checks for task outputs.",
system_prompt="You are a testing specialist. Identify focused checks and report pass/fail evidence.",
capabilities=["testing", "verification", "regression", "qa"],
tags=["test", "quality"],
priority=40,
source="builtin",
),
RegisteredAgent(
agent_id="documenter",
name="documenter",
display_name="Documenter",
role="documentation",
description="Writes and reconciles user-facing and internal documentation updates.",
system_prompt="You are a documentation specialist. Produce concise docs aligned with the implementation.",
capabilities=["documentation", "explanation", "migration notes", "release notes"],
tags=["docs", "communication"],
priority=35,
source="builtin",
),
]

View File

@ -1,2 +1,19 @@
"""Team models and orchestration objects."""
from ..models import (
AgentDescriptor,
DelegationEnvelope,
ExecutionGraph,
ExecutionNode,
NodeRunResult,
TeamRunResult,
)
__all__ = [
"AgentDescriptor",
"DelegationEnvelope",
"ExecutionGraph",
"ExecutionNode",
"NodeRunResult",
"TeamRunResult",
]