Download core/workers.py from LoveLogicAI/kanbot-backend: direct link, hf CLI and curl.
- Browser
- Download file 13 kB
-
https://huggingface.co/spaces/LoveLogicAI/kanbot-backend/resolve/main/core/workers.py
- Command line
-
hf download hf://spaces/LoveLogicAI/kanbot-backend/core/workers.py
-
curl -L -o workers.py https://huggingface.co/spaces/LoveLogicAI/kanbot-backend/resolve/main/core/workers.py
13 kB
| """ | |
| Agent workers — the three roles that make the board move itself. | |
| PLANNER : decomposes a spec into child executor cards, then marks itself done. | |
| EXECUTOR : does the work. In DRY-RUN (v0 default) it PROPOSES the tool calls as | |
| a thread comment and BLOCKS for human approval instead of executing. | |
| When free, it runs the plan and posts an artifact. | |
| CRITIC : scores the artifact against acceptance_criteria -> pass/fail. | |
| Every worker only ever touches the board through the same operations the MCP | |
| tools expose (imported directly here for the in-process Conductor; external | |
| clients hit them over MCP). One contract, four faces. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import os | |
| import uuid | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| def _load_repo_env() -> None: | |
| """Pick up the repo .env before DRY_RUN is read. Existing env wins.""" | |
| env = Path(__file__).resolve().parents[1] / ".env" | |
| if not env.is_file(): | |
| return | |
| for raw in env.read_text().splitlines(): | |
| line = raw.strip() | |
| if not line or line.startswith("#") or "=" not in line: | |
| continue | |
| if line.startswith("export "): | |
| line = line[len("export "):] | |
| key, value = line.split("=", 1) | |
| os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) | |
| _load_repo_env() | |
| from . import db, llm, sandbox | |
| from .contract import ( | |
| LEASE_SECONDS_DEFAULT, | |
| MAX_ATTEMPTS_DEFAULT, | |
| Card, | |
| Column, | |
| Role, | |
| ThreadEntry, | |
| ) | |
| from .mcp_server import ( | |
| block_card, | |
| comment, | |
| move_card, | |
| request_human, | |
| spawn_subtask, | |
| ) | |
| DRY_RUN = os.environ.get("KANBOT_DRY_RUN", "1") == "1" | |
| SWARM_N = max(1, int(os.environ.get("KANBOT_SWARM_N", "1"))) # executors per card | |
| MAX_ATTEMPTS = int(os.environ.get("KANBOT_MAX_ATTEMPTS", str(MAX_ATTEMPTS_DEFAULT))) # 0 = unlimited | |
| WORKER_ID = lambda role: f"{role}-{uuid.uuid4().hex[:6]}" # noqa: E731 | |
| def _now() -> str: | |
| return datetime.now(timezone.utc).isoformat() | |
| # --------------------------------------------------------------------------- # | |
| # PLANNER | |
| # --------------------------------------------------------------------------- # | |
| PLANNER_SYS = ( | |
| "You are KanBot's PLANNER. Decompose a task spec into an ordered list of " | |
| "concrete, independently-executable subtasks. Each subtask must have a short " | |
| "title, a precise spec, and 1-3 acceptance_criteria that are objectively " | |
| "checkable. Prefer 2-6 subtasks. Return JSON: " | |
| '{"subtasks":[{"title":"","spec":"","acceptance_criteria":[""],"heavy":false}]}' | |
| ) | |
| async def run_planner(card: Card) -> None: | |
| plan = await llm.chat_json(PLANNER_SYS, | |
| f"TASK: {card.title}\nSPEC: {card.spec}", tier="daily") | |
| subtasks = plan.get("subtasks", []) | |
| if not subtasks: | |
| await block_card(card.id, "planner produced no subtasks", actor="planner") | |
| return | |
| for st in subtasks: | |
| await spawn_subtask( | |
| parent=card.id, title=st["title"], spec=st.get("spec", ""), | |
| role="executor", acceptance_criteria=st.get("acceptance_criteria", []), | |
| tags=["heavy"] if st.get("heavy") else []) | |
| await comment(card.id, f"decomposed into {len(subtasks)} subtasks", | |
| author="planner", kind="comment") | |
| await move_card(card.id, "done", actor="planner", reason="decomposition complete") | |
| # --------------------------------------------------------------------------- # | |
| # leaf-card promotion (backlog -> ready) for executor cards | |
| # --------------------------------------------------------------------------- # | |
| async def promote_to_ready(card: Card) -> None: | |
| await move_card(card.id, "ready", actor="system", reason="queued for execution") | |
| # --------------------------------------------------------------------------- # | |
| # EXECUTOR | |
| # --------------------------------------------------------------------------- # | |
| EXECUTOR_SYS = ( | |
| "You are KanBot's EXECUTOR. Given a subtask, produce a concrete PLAN OF TOOL " | |
| "CALLS to accomplish it (shell, code edits, deploys, searches). Then produce " | |
| "the resulting ARTIFACT (the actual output/result). Return JSON: " | |
| '{"tool_calls":[{"tool":"","args":"","why":""}],"artifact":"the concrete result"}' | |
| ) | |
| async def _plan_one(card: Card, worker: str) -> dict: | |
| """One executor's candidate: a plan of tool calls + a claimed artifact.""" | |
| return await llm.chat_json( | |
| EXECUTOR_SYS, | |
| f"SUBTASK: {card.title}\nSPEC: {card.spec}\n" | |
| f"ACCEPTANCE: {card.acceptance_criteria}", | |
| tier="daily", heavy=card.is_heavy()) | |
| async def claim_and_execute(card: Card) -> None: | |
| """Claim a READY card and produce work. With SWARM_N>1, several executors | |
| race the same card in parallel; the critic later ranks their candidates. | |
| DRY-RUN gates ALL of them: nothing runs until a human approves.""" | |
| lead = WORKER_ID("executor") | |
| from .mcp_server import claim_card | |
| res = await claim_card(card.id, lead, LEASE_SECONDS_DEFAULT) | |
| if '"error"' in res: | |
| return # lost the race or wrong state | |
| # --- swarm: N candidate plans in parallel (N=1 => single executor) ------- | |
| workers_ids = [lead] + [WORKER_ID("executor") for _ in range(SWARM_N - 1)] | |
| plans = await asyncio.gather( | |
| *[_plan_one(card, w) for w in workers_ids], return_exceptions=True) | |
| candidates = [(w, p) for w, p in zip(workers_ids, plans) | |
| if isinstance(p, dict)] | |
| if not candidates: | |
| await block_card(card.id, "all executor candidates failed to plan", actor=lead) | |
| return | |
| if DRY_RUN: | |
| # PROPOSE every candidate; park for human approval. Nothing executes. | |
| for w, plan in candidates: | |
| tcs = plan.get("tool_calls", []) | |
| proposal = (f"CANDIDATE from {w} (dry-run — awaiting approval), " | |
| f"{len(tcs)} tool call(s):\n" + "\n".join( | |
| f" - {tc.get('tool')}({tc.get('args')}) # {tc.get('why','')}" | |
| for tc in tcs)) | |
| await comment(card.id, proposal, author=w, kind="tool_call") | |
| await request_human( | |
| card.id, | |
| f"Approve execution for '{card.title}'? " | |
| f"{len(candidates)} candidate(s) proposed. " | |
| f"kb approve {card.id} | kb reject {card.id}", | |
| actor=lead) | |
| await block_card(card.id, "dry-run: awaiting human approval", actor=lead) | |
| return | |
| # --- FREE mode: run each candidate's tool calls through the SANDBOX ------ | |
| sandbox.ensure_root() | |
| scored: list[tuple[str, str]] = [] # (worker, artifact) | |
| for w, plan in candidates: | |
| logs = [] | |
| for tc in plan.get("tool_calls", []): | |
| cmd = str(tc.get("args", "")) if tc.get("tool") in ("shell", "bash", "sh") \ | |
| else "" | |
| if cmd: | |
| result = await sandbox.run(cmd) | |
| await comment(card.id, | |
| f"[{w}] $ {cmd}\n{result['stdout'] or result['stderr']}", | |
| author=w, kind="tool_result") | |
| logs.append(f"$ {cmd} -> code {result['code']}") | |
| artifact = plan.get("artifact", "") + ("\n\n[exec log]\n" + "\n".join(logs) | |
| if logs else "") | |
| scored.append((w, artifact)) | |
| # P1 guard: if NO candidate produced any real output (no tool calls ran and | |
| # every artifact is empty), don't post a hollow artifact that only sets up a | |
| # guaranteed critic FAIL. Park it for a human instead. | |
| if not any((art or "").strip() for _, art in scored): | |
| await block_card( | |
| card.id, | |
| "executor produced no artifact and ran no tool calls — nothing to review", | |
| actor=lead) | |
| return | |
| # winner = first candidate for N=1; for N>1 post all, let critic pick best. | |
| if len(scored) == 1: | |
| w, artifact = scored[0] | |
| await comment(card.id, artifact, author=w, kind="artifact") | |
| else: | |
| for w, artifact in scored: | |
| await comment(card.id, f"[candidate {w}]\n{artifact}", | |
| author=w, kind="artifact") | |
| await comment(card.id, f"SWARM: {len(scored)} candidates — critic to pick winner", | |
| author=lead, kind="comment") | |
| await move_card(card.id, "review", actor=lead, worker=lead, | |
| reason="execution complete") | |
| async def approve_and_run(card_id: str) -> None: | |
| """Human approved a dry-run proposal (via CLI/Telegram): unblock -> ready. | |
| On the next pass the executor re-plans; flip KANBOT_DRY_RUN=0 to let it run | |
| free (through the sandbox).""" | |
| await move_card(card_id, "ready", actor="human:remy", | |
| reason="human approved proposed tool calls") | |
| # --------------------------------------------------------------------------- # | |
| # CRITIC | |
| # --------------------------------------------------------------------------- # | |
| CRITIC_SYS = ( | |
| "You are KanBot's CRITIC. Judge whether the ARTIFACT satisfies EVERY listed " | |
| "acceptance criterion. Be strict and specific. Return JSON: " | |
| '{"verdict":"pass"|"fail","reasons":["per-criterion note"]}' | |
| ) | |
| SWARM_CRITIC_SYS = ( | |
| "You are KanBot's CRITIC judging a SWARM. Multiple candidate artifacts attempt " | |
| "the same task. Pick the single best one against the acceptance criteria and " | |
| "judge whether it passes. Return JSON: " | |
| '{"winner_index":<0-based int>,"verdict":"pass"|"fail","reasons":["..."]}' | |
| ) | |
| async def run_critic(card: Card) -> None: | |
| artifacts = [(e.author, e.content) for e in card.thread if e.kind == "artifact"] | |
| if not artifacts: | |
| await block_card(card.id, "no artifact to review", actor="critic") | |
| return | |
| # --- swarm: pick a winner among candidates, then judge it --------------- | |
| if len(artifacts) > 1: | |
| listing = "\n\n".join(f"[CANDIDATE {i}] by {a}\n{c}" | |
| for i, (a, c) in enumerate(artifacts)) | |
| pick = await llm.chat_json( | |
| SWARM_CRITIC_SYS, | |
| f"CANDIDATES:\n{listing}\n\nACCEPTANCE CRITERIA:\n{card.acceptance_criteria}", | |
| tier="daily") | |
| idx = int(pick.get("winner_index", 0)) | |
| idx = idx if 0 <= idx < len(artifacts) else 0 | |
| winner_author, _ = artifacts[idx] | |
| v = pick.get("verdict", "fail") | |
| reasons = "; ".join(pick.get("reasons", [])) | |
| await comment(card.id, | |
| f"SWARM WINNER: candidate {idx} ({winner_author}) — [{v.upper()}] {reasons}", | |
| author="critic", kind="eval") | |
| else: | |
| _, artifact = artifacts[0] | |
| verdict = await llm.chat_json( | |
| CRITIC_SYS, | |
| f"ARTIFACT:\n{artifact}\n\nACCEPTANCE CRITERIA:\n{card.acceptance_criteria}", | |
| tier="daily") | |
| v = verdict.get("verdict", "fail") | |
| reasons = "; ".join(verdict.get("reasons", [])) | |
| await comment(card.id, f"[{v.upper()}] {reasons}", author="critic", kind="eval") | |
| if v == "pass": | |
| await move_card(card.id, "done", actor="critic", | |
| critic_verdict="pass", reason="acceptance criteria met") | |
| else: | |
| # BRAKE: cap the critic FAIL -> ready -> re-execute loop. `attempt` has | |
| # already counted this card's prior review->ready bounces; once it reaches | |
| # the ceiling, park for a human instead of burning compute forever. | |
| if MAX_ATTEMPTS and card.attempt >= MAX_ATTEMPTS - 1: | |
| await request_human( | |
| card.id, | |
| f"'{card.title}' failed critic {card.attempt + 1}x " | |
| f"(cap {MAX_ATTEMPTS}). Last feedback: {reasons}. " | |
| f"kb approve {card.id} to force-retry | kb block {card.id} to abandon.", | |
| actor="critic") | |
| await block_card( | |
| card.id, | |
| f"retry cap reached ({card.attempt + 1}/{MAX_ATTEMPTS}): {reasons}", | |
| actor="critic") | |
| return | |
| await move_card(card.id, "ready", actor="critic", | |
| critic_verdict="fail", reason=f"retry: {reasons}") | |
| # --------------------------------------------------------------------------- # | |
| # parent completion check (child DONE -> maybe close the parent) | |
| # --------------------------------------------------------------------------- # | |
| async def check_parent_completion(parent: Card) -> None: | |
| children = [await db.get_card(cid) for cid in parent.children] | |
| children = [c for c in children if c] | |
| if children and all(c.column == Column.DONE for c in children): | |
| if parent.column not in (Column.DONE,): | |
| await comment(parent.id, "all subtasks done", author="system") | |
| # parent may still need its own critic pass; nudge to review if it has an artifact | |
| if parent.has_artifact() and parent.column == Column.IN_FLIGHT: | |
| await move_card(parent.id, "review", actor="system", | |
| reason="all children complete") | |