Skip to content
This repository was archived by the owner on Sep 12, 2026. It is now read-only.
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
268 changes: 190 additions & 78 deletions personal_agent/arbiter.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
"""Arbiter: Independent review of worker results with structured decisions.

Receives the original task, worker's plan/result, diff, and test results.
Returns a structured ArbiterDecision (accept/reject with findings).

Secrets are redacted from prompts and feedback. Deterministic hard gates
are enforced by CodingAgent before this reviewer is consulted.
"""
"""Arbiter: independent review of worker results with verification gates."""

from __future__ import annotations

Expand All @@ -29,35 +22,11 @@
"type": "object",
"required": ["decision", "confidence", "reason"],
"properties": {
"decision": {
"type": "string",
"enum": ["accept", "reject"],
},
"confidence": {
"type": "number",
"minimum": 0.0,
"maximum": 1.0,
},
"decision": {"type": "string", "enum": ["accept", "reject"]},
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
"reason": {"type": "string"},
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"severity": {
"type": "string",
"enum": ["critical", "high", "medium", "low"],
},
"description": {"type": "string"},
"file": {"type": "string"},
"suggestion": {"type": "string"},
},
},
},
"required_changes": {
"type": "array",
"items": {"type": "string"},
},
"findings": {"type": "array"},
"required_changes": {"type": "array", "items": {"type": "string"}},
},
}

Expand Down Expand Up @@ -98,42 +67,113 @@
"suggestion": "How to fix it"
}}
],
"required_changes": [
"Specific change that must be made before acceptance"
]
"required_changes": ["Specific change required before acceptance"]
}}

Decision criteria:
- ACCEPT if changes correctly address the task and tests pass
- REJECT if there are correctness issues, missing test coverage, or the task is not properly addressed
- A few LOW/MEDIUM findings with passing tests can still be ACCEPTED
- Any CRITICAL finding requires REJECT
- You cannot override failed tests or security policy violations; those are handled separately
- You cannot override failed tests or security policy violations
"""


class Arbiter:
"""Reviews worker results and issues structured decisions.
class _RouterBackend:
"""Adapt the agent router to evaluation-ai's LLMBackend protocol."""

def __init__(self, router: Any) -> None:
self._router = router

async def chat(
self, messages: list[Any], *, model: str = "", **kwargs: Any
) -> Any:
return await self._router.chat(messages, model=model or None, **kwargs)

Uses a DIFFERENT model call than the worker (when possible)
to provide independent review.
"""

def __init__(self, router: Any, repo: Repo) -> None:
class Arbiter:
"""Review worker results and require independent verification when available."""

def __init__(
self,
router: Any,
repo: Repo,
*,
verification_panel_size: int = 3,
require_independent_verification: bool = False,
) -> None:
self._router = router
self._repo = repo
self._verification_panel_size = verification_panel_size
self._require_independent_verification = require_independent_verification

async def review(self, task: Task, worker_result: WorkerResult) -> ArbiterDecision:
"""Review worker's changes and return a structured decision."""
decision = await self._review_with_llm(task, worker_result)
verification = await self._independent_verification(task, worker_result)
if verification is None:
if self._require_independent_verification:
return ArbiterDecision(
decision=Decision.REJECT,
confidence=0.0,
reason="Independent verification is required but evaluation-ai is unavailable or no panel exists.",
findings=[
ArbiterFinding(
severity="critical",
description="No independent verification panel was available.",
)
],
required_changes=[
"Install/configure evaluation-ai with at least two independent models."
],
model_used=decision.model_used,
)
return decision

verdict, confidence, panel_models = verification
if verdict != "CONFIRMED":
return ArbiterDecision(
decision=Decision.REJECT,
confidence=confidence,
reason=(
f"Independent verification returned {verdict}; "
"the worker result is not safe to accept."
),
findings=[
ArbiterFinding(
severity="high" if verdict == "UNCERTAIN" else "critical",
description=f"Adversarial verification verdict: {verdict}.",
)
],
required_changes=[
"Address independent verification findings before acceptance."
],
model_used=decision.model_used,
)

if decision.decision != Decision.ACCEPT:
return decision
return ArbiterDecision(
decision=Decision.ACCEPT,
confidence=min(decision.confidence, confidence),
reason=(
decision.reason
+ f" Independent verification confirmed by {len(panel_models)} panel models."
),
findings=decision.findings,
required_changes=decision.required_changes,
model_used=decision.model_used,
)

async def _review_with_llm(
self, task: Task, worker_result: WorkerResult
) -> ArbiterDecision:
diff = redact_secrets(self._repo.git_diff())
tree = self._repo.tree(max_depth=2)

test_summary = "\n".join(
f"$ {r.command}\n exit={r.returncode}\n "
f"{redact_secrets(r.stdout[:500])}\n {redact_secrets(r.stderr[:500])}"
for r in worker_result.test_results
) or "(no tests run)"

changed_files = ""
for change in worker_result.changes:
try:
Expand All @@ -144,47 +184,121 @@
)
except Exception:
continue

findings_text = "\n".join(
f"- {redact_secrets(f)}" for f in worker_result.findings
) or "(none)"

findings_text = (
"\n".join(f"- {redact_secrets(f)}" for f in worker_result.findings)
or "(none)"
)
prompt = REVIEW_PROMPT.format(
task_description=task.description,
plan=redact_secrets(worker_result.plan),
findings=findings_text,
diff=diff or "(no diff no changes made)",
diff=diff or "(no diff - no changes made)",
test_results=test_summary,
tree=tree,
changed_files=changed_files,
)

resp = await self._router.chat(
[{"role": "user", "content": prompt}],
temperature=0.2,
max_tokens=4096,
)

return self._parse_decision(resp)

async def _independent_verification(
self, task: Task, worker_result: WorkerResult
) -> tuple[str, float, list[str]] | None:
try:
from evaluation_ai import AdversarialVerifier
except ImportError:
return None
try:
models = await self._router.list_models()
except (AttributeError, TypeError, RuntimeError):
return None

model_ids = sorted(
{
str(m.model_id)
for m in models
if getattr(m, "model_id", None)
}
)
if len(model_ids) < 2:
return None

verifier = AdversarialVerifier(
backend=_RouterBackend(self._router),
available_models=model_ids,
panel_size=self._verification_panel_size,
)
result = await verifier.verify(
self._verification_candidate(worker_result),
task=task.description,
candidate_model=worker_result.model_used,
)

verdict = result.verdict
confidence = result.confidence
try:
from consensus_ai import ChatResponse, MajorityVoteStrategy

votes = [
ChatResponse(
content=item.verdict,
model=item.model,
provider="evaluation-ai",
)
for item in result.panel_results
]
if votes:
outcome = MajorityVoteStrategy().select(votes)
verdict = outcome.selected.content
selected_index = next(
i
for i, item in enumerate(votes)
if item.model == outcome.selected.model
)
confidence = outcome.scores[selected_index]
except (ImportError, AttributeError, TypeError, KeyError, StopIteration):
logger.debug(
"Consensus aggregation unavailable; using evaluation result"
)
return verdict, confidence, result.panel_models

@staticmethod
def _verification_candidate(worker_result: WorkerResult) -> str:
return json.dumps(
{
"plan": worker_result.plan,
"findings": worker_result.findings,
"changes": [
{"path": c.path, "action": c.action, "content": c.content}
for c in worker_result.changes
],
"tests": [
{"command": r.command, "returncode": r.returncode}
for r in worker_result.test_results
],
},
indent=2,
)

def _parse_decision(self, resp: Any) -> ArbiterDecision:
"""Parse LLM response into a structured ArbiterDecision."""
text = resp.content.strip()

if "```json" in text:
text = text.split("```json", 1)[1].split("```", 1)[0]
elif "```" in text:
text = text.split("```", 1)[1].split("```", 1)[0]

start = text.find("{")
end = text.rfind("}") + 1

if start >= 0 and end > start:
try:
data = json.loads(text[start:end])
return ArbiterDecision(
decision=Decision(data.get("decision", "reject")),
confidence=float(data.get("confidence", 0.0)),
confidence=max(
0.0, min(1.0, float(data.get("confidence", 0.0)))
),
reason=data.get("reason", ""),
findings=[
ArbiterFinding(
Expand All @@ -198,9 +312,8 @@
required_changes=data.get("required_changes", []),
model_used=getattr(resp, "model", ""),
)
except (json.JSONDecodeError, ValueError, KeyError) as e:
logger.warning("Failed to parse arbiter response: %s", e)

except (json.JSONDecodeError, ValueError, KeyError, TypeError) as exc:

Check warning on line 315 in personal_agent/arbiter.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant Exception class; it derives from another which is already caught.

See more on https://sonarcloud.io/project/issues?id=FlossWare_personal-agent2&issues=AaBofj8_EGPpgVdiUuTo&open=AaBofj8_EGPpgVdiUuTo&pullRequest=20
logger.warning("Failed to parse arbiter response: %s", exc)
return ArbiterDecision(
decision=Decision.REJECT,
confidence=0.0,
Expand All @@ -209,26 +322,25 @@
)

def format_feedback(self, decision: ArbiterDecision) -> str:
"""Format arbiter decision as actionable feedback for workers."""
parts = [f"DECISION: {decision.decision.value.upper()}"]
parts.append(f"REASON: {redact_secrets(decision.reason)}")

parts = [
f"DECISION: {decision.decision.value.upper()}",
f"REASON: {redact_secrets(decision.reason)}",
]
if decision.findings:
parts.append("\nFINDINGS:")
for f in decision.findings:
for finding in decision.findings:
parts.append(
f" [{f.severity.upper()}] {redact_secrets(f.description)}"
+ (f" (in {f.file})" if f.file else "")
f" [{finding.severity.upper()}] "
f"{redact_secrets(finding.description)}"
+ (f" (in {finding.file})" if finding.file else "")
+ (
f"\n Suggestion: {redact_secrets(f.suggestion)}"
if f.suggestion
f"\n Suggestion: {redact_secrets(finding.suggestion)}"
if finding.suggestion
else ""
)
)

if decision.required_changes:
parts.append("\nREQUIRED CHANGES:")
for c in decision.required_changes:
parts.append(f" - {redact_secrets(c)}")

for change in decision.required_changes:
parts.append(f" - {redact_secrets(change)}")
return "\n".join(parts)
Loading