Files
2026-07-01 17:56:31 +08:00

438 lines
18 KiB
Python

"""LLM-judged gate harness for company-mode work items.
This module used to encode ~700 lines of hard-coded "if blocker_type ==
X then action = Y" rules. That approach kept misclassifying real
deliveries (e.g. reading `ready_for_final_release: false` as a hard
release blocker even when the same report explicitly said
`ready_for_downstream_execution: true`) and could not generalize across
the variety of user tasks the runtime is supposed to handle.
The current design is intentionally minimal:
1. Build a *descriptive* evidence packet for the completed work item. The
packet only describes what happened — it makes no policy decisions.
It contains: the work item's original requirements, the upstream context
the agent saw, the agent's actual output (result + summary + artifact
index + work_item_summary_for_downstream), the agent's own self-reported risks and
blockers, and a small number of cheap objective signals (task status,
dependency health, prior rework history).
2. Hand the packet to an LLM judge with a tightly scoped prompt. The
judge compares "what was asked" vs "what was produced" and returns
one of exactly three actions: `pass`, `rework_same_work_item`, or
`escalate`. The reason text is fed back to the original agent
session via `gate_harness_rework_feedback` so the same agent can fix
it on the next turn.
3. Apply a single safety net: a stagnation cap. If the same blocker
fingerprint has caused N reworks in a row without converging,
upgrade to `escalate` so the user gets a chance to break the loop.
This is the only deterministic decision the harness still makes.
If the LLM is unavailable, evaluate() returns `pass` with a logged
warning rather than blocking — the runtime is designed to trust agent
self-reports when there is no second opinion available.
"""
from __future__ import annotations
import hashlib
import json
import logging
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable
from opc.core.config import GateHarnessPolicyConfig
from opc.core.models import Task
from opc.layer2_organization.work_item_identity import projection_id_for_task
from opc.llm.retry import LLMRetryError, call_llm_json_with_retry
logger = logging.getLogger(__name__)
# Callback signature for spawning a fresh judge session.
#
# The runner is expected to:
# 1. Inspect `source_task.assigned_external_agent` (or fall back to a
# native model) to pick the SAME agent type that produced the
# work item being judged. The point is consistency — codex-produced
# work is judged by codex, claude_code-produced work by
# claude_code, native-produced work by the native LLM.
# 2. Open a NEW session for the judge (not a resume of the source
# agent's session) so the judge starts with a clean context and
# cannot be biased by the agent's prior reasoning trace.
# 3. Send `system_prompt` as the system message and the JSON-encoded
# packet as the user message.
# 4. Return the raw text the judge produced.
#
# gate_harness then parses that text as JSON. If the runner returns
# anything that doesn't parse, the harness falls back to `pass` with a
# logged warning rather than blocking the runtime.
JudgeRunner = Callable[
["GateEvidencePacket", str, Task],
Awaitable[str],
]
GATE_HARNESS_AGENT_PROMPT = """\
You are the gate-harness judge for one completed work item of an AI company runtime.
You receive an evidence packet describing what the work item was asked
to produce, what context the agent had, and what the agent actually
produced. Decide whether the output meets the work item's requirements.
Return STRICT JSON with exactly these two fields and no extras:
{
"action": "pass" | "rework_same_work_item" | "escalate",
"reason": "<your explanation; if action is rework_same_work_item, include the concrete actionable steps the agent should take>"
}
Action semantics:
- `pass` — the deliverables meet the stated requirements.
- `rework_same_work_item` — the same agent can fix the gap; your `reason`
must spell out what is wrong and what to do about it.
- `escalate` — the gap needs a human decision, OR the same problem
has already caused multiple unsuccessful reworks.
Return JSON only — no markdown fences, no commentary.
"""
@dataclass
class GateEvidencePacket:
"""Minimal snapshot fed to the LLM judge: 5 fields only.
- requirements: what the work item was asked to produce (task.title +
task.description — the "task brief" that was given to the agent)
- output: what the agent actually produced (task.result raw stdout)
- prior_rework_count / prior_rework_feedback: history of previous
gate judge rework cycles for this work item
"""
projection_id: str
requirements: str
output: str
prior_rework_count: int = 0
prior_rework_feedback: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"projection_id": self.projection_id,
"requirements": self.requirements,
"output": self.output,
"prior_rework_count": self.prior_rework_count,
"prior_rework_feedback": self.prior_rework_feedback,
}
@dataclass
class GateHarnessDecision:
"""Projection-only decision returned to company_mode."""
action: str
summary: str
target_projection_id: str = ""
target_projection_ids: list[str] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
constraints: list[str] = field(default_factory=list)
blockers: list[str] = field(default_factory=list)
blocker_types: list[str] = field(default_factory=list)
residual_risks: list[str] = field(default_factory=list)
source: str = "llm_judge"
blocker_fingerprint: str = ""
def __post_init__(self) -> None:
projection_id = str(self.target_projection_id or "").strip()
projection_ids = [
str(item).strip()
for item in list(self.target_projection_ids or [])
if str(item).strip()
]
if not projection_ids and projection_id:
projection_ids = [projection_id]
self.target_projection_id = projection_id or (projection_ids[0] if projection_ids else "")
self.target_projection_ids = projection_ids
def to_dict(self) -> dict[str, Any]:
return {
"action": self.action,
"summary": self.summary,
"target_projection_id": self.target_projection_id,
"target_projection_ids": list(self.target_projection_ids),
"notes": list(self.notes),
"constraints": list(self.constraints),
"blockers": list(self.blockers),
"blocker_types": list(self.blocker_types),
"residual_risks": list(self.residual_risks),
"source": self.source,
"blocker_fingerprint": self.blocker_fingerprint,
}
class GateHarness:
"""Evaluates one completed work item using an LLM judge."""
ALLOWED_ACTIONS = ("pass", "rework_same_work_item", "escalate")
def __init__(
self,
*,
policy: GateHarnessPolicyConfig | dict[str, Any] | None = None,
llm: Any | None = None,
org_engine: Any | None = None,
judge_runner: JudgeRunner | None = None,
) -> None:
if isinstance(policy, GateHarnessPolicyConfig):
self.policy = policy
else:
self.policy = GateHarnessPolicyConfig.model_validate(dict(policy or {}))
self.llm = llm
self.org_engine = org_engine
# Preferred path: company_mode wires a runner that spawns a
# fresh session of the SAME external agent type that produced
# the work item being judged (codex → codex, claude_code →
# claude_code, native → native llm). When `judge_runner` is
# set, `_invoke_judge` uses it. Otherwise we fall back to
# `self.llm.simple_chat` so the harness still works in tests
# and in setups that have not yet wired the per-agent dispatch.
self.judge_runner = judge_runner
# ------------------------------------------------------------------
# Public entrypoint
# ------------------------------------------------------------------
async def evaluate(
self,
task: Task,
task_by_projection_id: dict[str, Task],
) -> tuple[GateEvidencePacket, GateHarnessDecision]:
packet = self.build_evidence_packet(task, task_by_projection_id)
decision = await self._invoke_judge(packet, source_task=task)
decision = self._apply_stagnation_cap(task, decision)
return packet, decision
# ------------------------------------------------------------------
# Evidence packet construction (Class B: descriptive only)
# ------------------------------------------------------------------
def build_evidence_packet(
self,
task: Task,
task_by_projection_id: dict[str, Task],
) -> GateEvidencePacket:
metadata = dict(task.metadata or {})
projection_id = projection_id_for_task(task)
# Requirements = the task brief the agent received.
requirements = f"{task.title or ''}\n\n{task.description or ''}".strip()
# Output = agent's raw stdout result.
if isinstance(task.result, dict):
output = str(task.result.get("content", "") or "")
elif task.result is not None:
output = str(task.result)
else:
output = ""
return GateEvidencePacket(
projection_id=projection_id,
requirements=requirements,
output=output,
prior_rework_count=int(metadata.get("gate_harness_rework_count", 0) or 0),
prior_rework_feedback=str(metadata.get("gate_harness_rework_feedback", "") or "").strip(),
)
# ------------------------------------------------------------------
# LLM judge
# ------------------------------------------------------------------
async def _invoke_judge(
self,
packet: GateEvidencePacket,
*,
source_task: Task,
) -> GateHarnessDecision:
# Preferred: company_mode-supplied runner that spawns a fresh
# session of the SAME external agent type that produced this
# work item (codex → codex, etc.). Fallback: native simple_chat.
# Strategy: the judge_runner path spawns a fresh agent session to
# produce the JSON. Per system design, errors inside a running
# agent are handled by the agent itself, so we keep that path
# single-shot. Only the simple_chat fallback is wrapped in the
# retry helper, because there is no agent to self-correct.
if self.judge_runner is not None:
try:
raw = await self.judge_runner(packet, GATE_HARNESS_AGENT_PROMPT, source_task)
except Exception as exc: # pragma: no cover - runner transport errors
logger.warning(
"[gate_harness] Judge runner failed for work item `%s`: %s; defaulting to pass.",
packet.projection_id,
exc,
)
return self._fallback_pass(packet, reason=f"Judge runner failed: {exc}")
try:
data = json.loads(self._strip_markdown_fences(raw))
except Exception:
logger.warning(
"[gate_harness] Judge runner returned non-JSON for work item `%s`; defaulting to pass. Raw=%r",
packet.projection_id,
str(raw)[:200],
)
return self._fallback_pass(packet, reason="Judge runner returned non-JSON output.")
if not isinstance(data, dict):
return self._fallback_pass(packet, reason="Judge runner returned non-object output.")
action = str(data.get("action", "") or "").strip()
if action not in self.ALLOWED_ACTIONS:
logger.warning(
"[gate_harness] Judge runner returned unknown action `%s` for work item `%s`; defaulting to pass.",
action,
packet.projection_id,
)
return self._fallback_pass(packet, reason=f"Judge runner returned unrecognized action `{action}`.")
elif self.llm is not None:
allowed_actions = self.ALLOWED_ACTIONS
def _validate_judge_response(parsed: Any) -> str | None:
if not isinstance(parsed, dict):
return "Top-level response must be a JSON object."
act = str(parsed.get("action", "") or "").strip()
if act not in allowed_actions:
return (
f"Unknown action `{act}`. Choose one of: "
f"{', '.join(sorted(allowed_actions))}."
)
return None
try:
data = await call_llm_json_with_retry(
self.llm,
system=GATE_HARNESS_AGENT_PROMPT,
payload=packet.to_dict(),
task_type="quick_tasks",
validator=_validate_judge_response,
label=f"gate_harness:{packet.projection_id}",
)
except LLMRetryError as exc:
logger.warning(
"[gate_harness] simple_chat judge fallback failed for work item `%s` after retries: %s; defaulting to pass.",
packet.projection_id,
exc.last_error,
)
return self._fallback_pass(
packet,
reason=f"LLM judge failed after retries: {exc.last_error}",
)
action = str(data.get("action", "") or "").strip()
else:
logger.warning(
"[gate_harness] No judge_runner and no llm available for work item `%s`; defaulting to pass.",
packet.projection_id,
)
return self._fallback_pass(packet, reason="Judge unavailable; trusting agent self-report.")
reason = str(data.get("reason", "") or "").strip() or "(no reason provided)"
# `reason` is the canonical text fed back to the agent's session
# via `gate_harness_rework_feedback` on the next turn. The judge
# is instructed to put any "what to fix and how" content directly
# in `reason` when the action is rework_same_work_item.
return GateHarnessDecision(
action=action,
summary=reason,
target_projection_id=packet.projection_id if action == "rework_same_work_item" else "",
target_projection_ids=[packet.projection_id] if action == "rework_same_work_item" else [],
source="llm_judge",
blocker_fingerprint=self._fingerprint(action, reason),
)
def _fallback_pass(self, packet: GateEvidencePacket, *, reason: str) -> GateHarnessDecision:
return GateHarnessDecision(
action="pass",
summary=reason,
source="llm_judge_fallback",
)
# ------------------------------------------------------------------
# Stagnation cap (Class C: the only deterministic decision left)
# ------------------------------------------------------------------
def _apply_stagnation_cap(
self,
task: Task,
decision: GateHarnessDecision,
) -> GateHarnessDecision:
"""Escalate only when the SAME problem keeps repeating.
Total rework count is irrelevant — a work item that gets reworked
10 times for 10 different reasons is healthy iteration (e.g.
user keeps asking for changes). What indicates stagnation is
N consecutive reworks whose fingerprints match: the judge
keeps saying the same thing, meaning the agent is unable to
fix the problem.
We count consecutive trailing rework entries in history that
share the current decision's fingerprint. If that streak
reaches the threshold, we escalate.
"""
if decision.action != "rework_same_work_item":
return decision
threshold = max(2, int(getattr(self.policy, "stagnation_threshold", 3) or 3))
current_fp = decision.blocker_fingerprint
if not current_fp:
return decision
history = [
dict(item)
for item in list(task.metadata.get("gate_harness_history", []) or [])
if isinstance(item, dict)
]
# Count consecutive trailing reworks with the same fingerprint.
consecutive = 0
for item in reversed(history):
if (
str(item.get("action", "") or "") == "rework_same_work_item"
and str(item.get("blocker_fingerprint", "") or "") == current_fp
):
consecutive += 1
else:
break
if consecutive + 1 < threshold:
return decision
upgraded_summary = (
f"{decision.summary}\n\n"
f"[stagnation] The same problem has been flagged {consecutive + 1} "
f"consecutive times without progress. Escalating to user."
)
return GateHarnessDecision(
action="escalate",
summary=upgraded_summary,
target_projection_id=decision.target_projection_id,
target_projection_ids=list(decision.target_projection_ids),
source=f"{decision.source}+stagnation_cap",
blocker_fingerprint=decision.blocker_fingerprint,
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _fingerprint(action: str, reason: str) -> str:
if not action and not reason:
return ""
joined = f"{action}||{reason.strip()[:240]}"
return hashlib.sha1(joined.encode("utf-8", errors="replace")).hexdigest()[:16]
@staticmethod
def _strip_markdown_fences(text: str) -> str:
value = str(text or "").strip()
if value.startswith("```"):
value = value.split("\n", 1)[1] if "\n" in value else value[3:]
if value.endswith("```"):
value = value[:-3]
return value.strip()