feat(tasks): add skill-templated task graph execution
This commit is contained in:
@ -4,10 +4,12 @@ import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from beaver.engine import EngineLoader
|
||||
from beaver.engine import AgentRunResult, EngineLoader
|
||||
from beaver.engine.context import SkillContext
|
||||
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
||||
from beaver.engine.providers.factory import ProviderBundle
|
||||
from beaver.services.agent_service import AgentService
|
||||
from beaver.skills.assembler import SkillAssemblyResult
|
||||
from beaver.tasks import TaskExecutionPlan, TaskService
|
||||
|
||||
|
||||
@ -39,6 +41,44 @@ class StubTaskExecutionPlanner:
|
||||
return TaskExecutionPlan.single("test-single")
|
||||
|
||||
|
||||
class RecordingTaskExecutionPlanner:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def plan(self, **kwargs) -> TaskExecutionPlan:
|
||||
self.calls.append(dict(kwargs))
|
||||
return TaskExecutionPlan.single("test-single")
|
||||
|
||||
|
||||
class RecordingSkillAssembler:
|
||||
def __init__(self, skills: list[SkillContext]) -> None:
|
||||
self.skills = list(skills)
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def assemble(self, **kwargs) -> SkillAssemblyResult:
|
||||
self.calls.append(dict(kwargs))
|
||||
return SkillAssemblyResult(activated_skills=list(self.skills))
|
||||
|
||||
|
||||
class RecordingTaskAttemptOrchestrator:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def run(self, **kwargs) -> AgentRunResult:
|
||||
self.calls.append(dict(kwargs))
|
||||
task = kwargs["task"]
|
||||
task.task_id = "task-from-orchestrator"
|
||||
return AgentRunResult(
|
||||
session_id=kwargs["kwargs"]["session_id"],
|
||||
run_id="run-from-orchestrator",
|
||||
output_text="orchestrated",
|
||||
finish_reason="stop",
|
||||
tool_iterations=0,
|
||||
task_id=task.task_id,
|
||||
task_status=task.status,
|
||||
)
|
||||
|
||||
|
||||
class FakeLearningCandidate:
|
||||
def to_dict(self) -> dict:
|
||||
return {"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
|
||||
@ -101,6 +141,91 @@ def test_task_run_records_evidence_and_waits_for_acceptance(tmp_path: Path) -> N
|
||||
assert "validated" not in event_types
|
||||
|
||||
|
||||
def test_agent_service_records_router_latency(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"draft release notes",
|
||||
session_id="web:latency",
|
||||
provider_bundle=_bundle("Done"),
|
||||
)
|
||||
)
|
||||
|
||||
latency = result.usage["latency_ms"]
|
||||
assert latency["router_ms"] > 0
|
||||
|
||||
|
||||
def test_task_mode_preselects_skills_for_planner_and_reuses_them_in_main_run(tmp_path: Path) -> None:
|
||||
skill = SkillContext(
|
||||
name="docker-debug",
|
||||
content="Use docker logs before editing config.",
|
||||
version="v1",
|
||||
content_hash="hash-v1",
|
||||
activation_reason="llm_selected",
|
||||
tool_hints=["terminal"],
|
||||
)
|
||||
skill_assembler = RecordingSkillAssembler([skill])
|
||||
planner = RecordingTaskExecutionPlanner()
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
skill_assembler=skill_assembler,
|
||||
task_execution_planner=planner,
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"debug this workflow",
|
||||
session_id="web:skill-aware-task",
|
||||
provider_bundle=_bundle("Done"),
|
||||
)
|
||||
)
|
||||
|
||||
assert result.task_id
|
||||
assert len(skill_assembler.calls) == 1
|
||||
assert planner.calls
|
||||
assert planner.calls[0]["skill_summaries"] == ["docker-debug: Use docker logs before editing config."]
|
||||
assert planner.calls[0]["tool_hints"] == ["terminal"]
|
||||
|
||||
task_service = service.create_loop().boot().task_service
|
||||
assert task_service is not None
|
||||
task = task_service.get_task(result.task_id)
|
||||
assert task is not None
|
||||
assert task.skill_names == ["docker-debug"]
|
||||
|
||||
|
||||
def test_task_mode_delegates_attempt_execution_to_orchestrator(tmp_path: Path) -> None:
|
||||
orchestrator = RecordingTaskAttemptOrchestrator()
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
service._build_task_attempt_orchestrator = lambda loaded: orchestrator # type: ignore[attr-defined]
|
||||
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"draft release notes",
|
||||
session_id="web:orchestrator",
|
||||
provider_bundle=_bundle("main runner should not be used"),
|
||||
)
|
||||
)
|
||||
|
||||
assert result.output_text == "orchestrated"
|
||||
assert result.run_id == "run-from-orchestrator"
|
||||
assert len(orchestrator.calls) == 1
|
||||
assert orchestrator.calls[0]["message"] == "draft release notes"
|
||||
assert orchestrator.calls[0]["task"].description == "draft release notes"
|
||||
|
||||
|
||||
def test_task_mode_injects_prompt_locale_output_language(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
|
||||
Reference in New Issue
Block a user