| """TaskRunner — deterministic execution of a static `TaskList`. Zero LLM. |
| |
| Executes tasks in dependency order, parallelizing each ready "wave" with |
| `asyncio.gather`. For each task it resolves `${t<id>}` placeholders from upstream |
| results, does an internal `validate_args`, invokes each tool via the `ToolInvoker` |
| seam, and records a `TaskResult`. On failure it **degrades and continues**: the |
| task is marked failed, its dependents are skipped, independent branches keep |
| running. There is no replanning and no mid-run LLM (INV-6). |
| |
| `success_criteria` is *not* machine-evaluated here (it is free text); task status |
| is derived from tool execution outcomes and carried to the Assembler to report. |
| |
| See AGENT_ARCHITECTURE_CONTEXT_new.md §7.4. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| from typing import Any |
|
|
| from src.middlewares.logging import get_logger |
|
|
| from ..planner.contracts import ToolOutput, ToolRegistry |
| from ..planner.schemas import PLACEHOLDER_RE, Task |
| from ..planner.schemas import TaskList as PlanTaskList |
| from .invoker import ToolInvoker |
| from .schemas import RunState, TaskResult, TaskStatus |
|
|
| logger = get_logger("task_runner") |
|
|
|
|
| class TaskRunner: |
| """Runs a `TaskList` against a `ToolInvoker`, producing a `RunState`.""" |
|
|
| def __init__(self, invoker: ToolInvoker, registry: ToolRegistry) -> None: |
| self._invoker = invoker |
| self._registry = registry |
|
|
| async def run(self, task_list: PlanTaskList, business_context_id: str) -> RunState: |
| tasks_by_id: dict[str, Task] = {t.id: t for t in task_list.tasks} |
| results: dict[str, TaskResult] = {} |
| remaining: set[str] = set(tasks_by_id) |
|
|
| while remaining: |
| ready = [ |
| tid |
| for tid in remaining |
| if all(dep in results for dep in tasks_by_id[tid].depends_on) |
| ] |
| if not ready: |
| |
| |
| for tid in list(remaining): |
| results[tid] = TaskResult( |
| task_id=tid, |
| stage=tasks_by_id[tid].stage, |
| status="failure", |
| objective=tasks_by_id[tid].objective, |
| error="unresolved dependency; task could not run", |
| ) |
| remaining.discard(tid) |
| break |
|
|
| |
| to_run: list[Task] = [] |
| for tid in ready: |
| task = tasks_by_id[tid] |
| failed = [d for d in task.depends_on if results[d].status == "failure"] |
| if failed: |
| results[tid] = TaskResult( |
| task_id=tid, |
| stage=task.stage, |
| status="failure", |
| objective=task.objective, |
| error=f"skipped: upstream {failed} did not succeed", |
| ) |
| remaining.discard(tid) |
| else: |
| to_run.append(task) |
|
|
| if not to_run: |
| continue |
|
|
| wave = await asyncio.gather( |
| *(self._run_task(task, results) for task in to_run) |
| ) |
| for tr in wave: |
| results[tr.task_id] = tr |
| remaining.discard(tr.task_id) |
|
|
| return RunState( |
| plan_id=task_list.plan_id, |
| business_context_id=business_context_id, |
| results=results, |
| open_questions=list(task_list.open_questions), |
| ) |
|
|
| async def _run_task(self, task: Task, results: dict[str, TaskResult]) -> TaskResult: |
| outputs: list[ToolOutput] = [] |
| for call in task.tool_calls: |
| resolved = self._resolve_args(call.args, results) |
| arg_error = self._validate_args(call.tool, resolved) |
| if arg_error is not None: |
| outputs.append(ToolOutput(tool=call.tool, kind="error", error=arg_error)) |
| continue |
| outputs.append(await self._safe_invoke(call.tool, resolved)) |
|
|
| status = _label(outputs) |
| error: str | None = None |
| if status == "failure": |
| errs = [o.error for o in outputs if o.kind == "error" and o.error] |
| error = errs[0] if errs else "all tool calls failed" |
| return TaskResult( |
| task_id=task.id, |
| stage=task.stage, |
| status=status, |
| objective=task.objective, |
| outputs=outputs, |
| error=error, |
| ) |
|
|
| def _resolve_args( |
| self, args: dict[str, Any], results: dict[str, TaskResult] |
| ) -> dict[str, Any]: |
| return {k: self._resolve_value(v, results) for k, v in args.items()} |
|
|
| @staticmethod |
| def _resolve_value(value: Any, results: dict[str, TaskResult]) -> Any: |
| |
| |
| |
| if isinstance(value, str): |
| match = PLACEHOLDER_RE.fullmatch(value.strip()) |
| if match: |
| upstream = results.get(match.group(1)) |
| if upstream is None or not upstream.outputs: |
| return None |
| return upstream.outputs[-1] |
| return value |
|
|
| def _validate_args(self, tool: str, resolved: dict[str, Any]) -> str | None: |
| spec = self._registry.get(tool) |
| if spec is None: |
| return f"tool {tool!r} not in registry" |
| required = spec.input_schema.get("required", []) |
| missing = [r for r in required if resolved.get(r) is None] |
| if missing: |
| return f"missing required arg(s): {sorted(missing)}" |
| return None |
|
|
| async def _safe_invoke(self, tool: str, args: dict[str, Any]) -> ToolOutput: |
| try: |
| return await self._invoker.invoke(tool, args) |
| except Exception as exc: |
| logger.warning("tool invoker raised", tool=tool, error=str(exc)) |
| return ToolOutput(tool=tool, kind="error", error=f"invoker raised: {exc}") |
|
|
|
|
| def _label(outputs: list[ToolOutput]) -> TaskStatus: |
| if not outputs: |
| return "failure" |
| errors = sum(1 for o in outputs if o.kind == "error") |
| if errors == 0: |
| return "success" |
| if errors == len(outputs): |
| return "failure" |
| return "partial" |
|
|