Files
FarmHouse-Holdings/engine/protocol.py
T
fez5587andClaude Fable 5 a858337edc Dispatcher self-healing + QA report classification fix
- Poll now includes blocked work items: when Hermes retries a transiently
  blocked task and it is active again, the item auto-resumes (task.resumed
  event) instead of staying blocked forever. Blocked items whose kanban
  task is done/review are left alone — re-finishing them every tick would
  spam events and duplicate cost entries.
- Extraction prompt: a QA/review task that finished its checks is
  task.completed even when it found defects — findings are the
  deliverable. Previously "accessibility fails" wording classified the
  task as blocked.

Verified live: misclassified QA task re-extracted to done; transiently
blocked task auto-resumed; wall-clock cap fired on a 30-min runaway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 12:58:40 -05:00

123 lines
4.8 KiB
Python

"""Structured extraction from free-form agent output.
Kanban runs return free text; the protocol requires AgentMessage. Rather than
trusting the worker to format correctly mid-run, the engine converts each raw
report with one schema-enforced pool call (qwen3.5:9b scored 100% validity
under `format` enforcement — benchmarks/RESULTS.md).
"""
import json
from typing import Any
import httpx
from engine.config import settings
from engine.schemas.events import AGENT_MESSAGE_FORMAT, AgentMessage
MODEL = "qwen3.5:9b"
EXTRACT_SYSTEM = (
"You convert an AI employee's raw work report into exactly one structured "
"protocol message. Choose the message_type that matches what actually "
"happened:\n"
"- task.completed: work finished, evidence given\n"
"- task.progressed: partial progress, more to do\n"
"- task.blocked: cannot continue for technical reasons\n"
"- clarification.requested: needs a decision/answer from a human; include "
"the options given\n"
"- approval.requested: needs authority the employee lacks\n"
"- incident.declared: something broke that affects others\n"
"A QA/review/audit task that finished its checks is task.completed even "
"when it found defects in the thing under test — findings are its "
"deliverable, not a blocker.\n"
"Copy concrete facts (files, commits, test counts) into summary/detail. "
"Do not invent facts not present in the report."
)
DECOMPOSE_FORMAT: dict[str, Any] = {
"type": "object",
"properties": {
"tasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string"},
"description": {"type": "string"},
"role": {"type": "string", "enum": ["engineer", "qa", "manager"]},
"acceptance_criteria": {"type": "array", "items": {"type": "string"}},
},
"required": ["title", "description", "role", "acceptance_criteria"],
},
},
"clarification_needed": {"type": "string"},
},
"required": ["tasks"],
}
async def _chat_structured(system: str, user: str, fmt: dict) -> dict:
async with httpx.AsyncClient(timeout=180) as client:
for attempt in (1, 2): # pool occasionally returns empty content; one retry
r = await client.post(
f"{settings.ollama_pool_url}/api/chat",
json={
"model": MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"stream": False,
"think": False,
"format": fmt,
},
)
r.raise_for_status()
try:
return json.loads(r.json()["message"]["content"])
except json.JSONDecodeError:
if attempt == 2:
raise
raise RuntimeError("unreachable")
async def extract_agent_message(work_item_id: str, raw_report: str) -> AgentMessage:
data = await _chat_structured(
EXTRACT_SYSTEM,
f"Work item ID: {work_item_id}\n\nRaw report:\n{raw_report[:6000]}",
AGENT_MESSAGE_FORMAT,
)
data["work_item_id"] = work_item_id # never trust the model with the ID
return AgentMessage.model_validate(data)
CEO_UPDATE_FORMAT: dict[str, Any] = {
"type": "object",
"properties": {"update": {"type": "string"}},
"required": ["update"],
}
async def ceo_update(company_name: str, objective_title: str, task_lines: list[str]) -> str:
"""CEO-voiced 2-4 sentence shareholder update for a completed objective."""
system = (
f"You are the CEO of {company_name} writing a short shareholder update "
"(2-4 sentences). Report the milestone plainly: what shipped and what "
"comes next. No hype. Do not invent facts not in the task list."
)
user = (f"Milestone reached: {objective_title}\nCompleted tasks:\n"
+ "\n".join(f"- {t}" for t in task_lines))
data = await _chat_structured(system, user, CEO_UPDATE_FORMAT)
return data["update"]
async def decompose_objective(brief: str, company_name: str) -> dict:
"""Manager-style decomposition into 2-5 small tasks. Returns validated dict."""
system = (
f"You are the engineering manager at {company_name}. Decompose the "
"objective into 2-5 small, independently completable tasks, each with "
"concrete acceptance criteria. Assign each to the right role. If the "
"objective is materially ambiguous, fill clarification_needed with the "
"question and give your best-guess decomposition anyway."
)
return await _chat_structured(system, brief, DECOMPOSE_FORMAT)