Skip to content

Commit 353291b

Browse files
mios-devclaude
andcommitted
phase-2 multi-agent: Critic Agent reflexion loop over compose draft
Continues the migration plan from usr/share/mios/docs/multi-agent-architecture.md (phase 1 was structured Compose handoff from Hermes session JSON; phase 2 adds the actor + critic reflexion loop). Architecture: refine -> hermes -> compose (draft) -> critic (reviews draft vs structured tool history; verdict JSON) -> [if revise] recompose with critic feedback fed back -> ship Critic Agent (NEW): * CRITIC_ENABLED valve (default True). When ON and the pipe loaded structured tool history from the Hermes session, the critic passes the {user_ask, structured_history, compose_draft} to a small model (qwen3:1.7b on iGPU per operator's "iGPU = micro-LLM only" directive). Output: JSON {verdict: approve|revise, issues: [...]}. * Checks for: false success claims (claim "launched X" when the tool_result for that call had success=false), fabricated steps (claim a step completed when no tool_call for it exists), wrong tool attribution, missing critical info that IS in the history, fabricated specifics (paths/ids/numbers not anywhere in any tool_result). * Format=json mode (Ollama structured output) so the parse step is robust. * Fail-open: any HTTP/timeout/parse error returns {} -> draft ships unchanged. The critic NEVER blocks the response. Reflexion loop: * CRITIC_MAX_ITERATIONS valve (default 1, bounded). * If critic verdict=revise, _recompose_with_critic_feedback re-runs compose with the issues list appended to the system prompt: "Your previous draft had these issues -- FIX each one in the revised answer". Compose sees the previous draft + the structured history + the specific issues -> writes a corrected answer. * Re-strip fence + reasoning leaks on the revised text. * CRITIC_MAX_ITERATIONS=0 means "audit-only" (run critic, never revise) -- useful for telemetry without behaviour change. Status emits (sanitized per the earlier global sweep; no English narrative, no model names): 🧑‍⚖️ critic -- review started 🧑‍⚖️ ✓ critic approve -- draft passes ground truth 🧑‍⚖️ ✎ critic: <N> issue(s) → revise -- compose re-runs 🧑‍⚖️ ⚠ critic err → ship draft -- failure mode (rare) Why this matters (vs. the regex tower it replaces): * _KNOWN_AGENT_ERROR_RE was guessing from text whether the agent had really failed; the critic READS the structured success field directly. No false positives on real successes. * "NEVER report 'launched' unless tool_result success:true" ban list in the polish prompt was relying on the model to remember a rule; the critic enforces it externally. * The pattern is OpenAI-API-compliant (standard Chat Completions structured-output + tool_use messages -- works against any OpenAI-compatible backend, not just local Ollama). * Day-0 from clone: all valves, methods, and prompts ship in /usr/share/mios/owui/pipes/mios_agent_pipe.py (image-immutable). First chat with this pipe triggers Ollama to load qwen3:1.7b (1.4 GB; lands on iGPU when wsl2-amd.yaml CDI spec is present per the earlier iGPU passthrough commit). Operator-requested cache wipe (--all) also done as the prelude: * 0 chats / messages / memories * Preserved: 1 admin user, 2 models (with params + meta.knowledge bindings), 2 tools (with full specs), 3 functions, the MiOS Documentation knowledge collection (33 files including the multi-agent research doc). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6707ed8 commit 353291b

1 file changed

Lines changed: 201 additions & 0 deletions

File tree

usr/share/mios/owui/pipes/mios_agent_pipe.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,32 @@ class Valves(BaseModel):
199199
default=240,
200200
description="If the raw agent output is shorter than this and contains no narration markers, pass through unpolished -- no value in spinning up the CPU model for a one-liner result.",
201201
)
202+
# ── Phase-2 Critic loop (see docs/multi-agent-architecture.md)
203+
# The Critic Agent reviews the compose draft against the
204+
# structured tool history. If the draft claims success on a
205+
# tool that failed, claims a step ran when no tool_call for
206+
# it exists, or otherwise mismatches the structured truth,
207+
# critic returns issues; compose revises once. Bounded loop.
208+
CRITIC_ENABLED: bool = Field(
209+
default=True,
210+
description="After compose drafts an answer, run a small Critic Agent that reviews against the structured tool history. Catches false-success claims, missing steps, fabrications -- replaces the hardcoded KNOWN_AGENT_ERROR_RE rewrite path with a natural multi-agent reflexion loop.",
211+
)
212+
CRITIC_MODEL: str = Field(
213+
default="qwen3:1.7b",
214+
description="Small model for the critic pass. Per operator directive 2026-05-17 'iGPU's are ONLY micro-llms' -- micro-LLMs land on the AMD/Intel iGPU CDI lane when present, leaving the dGPU free for big-model work. qwen3:1.7b ~1.4 GB.",
215+
)
216+
CRITIC_TIMEOUT_S: int = Field(
217+
default=45,
218+
description="Cap the critic call. Small model + structured input + JSON-only output = sub-10s typical; 45s is the safety ceiling.",
219+
)
220+
CRITIC_MAX_TOKENS: int = Field(
221+
default=300,
222+
description="Cap critic output. JSON verdict + issue list fits comfortably.",
223+
)
224+
CRITIC_MAX_ITERATIONS: int = Field(
225+
default=1,
226+
description="Max revise cycles. 0 = run critic but never revise (audit-only). 1 = one revision pass (compose, critique, revise, done). Bounded reflexion -- never infinite loop.",
227+
)
202228
AGENT_THINKING_LABEL: str = Field(
203229
default="🧠 MiOS-Hermes",
204230
description="The <summary> rendered above the collapsed reasoning block. Per-agent label so the operator can tell which agent (hermes / opencode / etc.) produced the thinking. Kept short + symbol-led so it reads the same across operator locales (operator directive 2026-05-17 GLOBAL SWEEP for hardcoded English).",
@@ -1017,8 +1043,183 @@ async def _polish_via_cpu(
10171043
# Strip <think>...</think> + leading "Thought" leaks.
10181044
polished = self._strip_reasoning_leaks(polished)
10191045

1046+
# ── Phase-2 Critic reflexion loop ──────────────────────────
1047+
# Only runs when we HAVE structured tool history to reason
1048+
# over (the critic's whole value-add is checking the draft
1049+
# against ground truth); skipped on the text-blob fallback
1050+
# path. Bounded by CRITIC_MAX_ITERATIONS.
1051+
if (self.valves.CRITIC_ENABLED and tool_history_json
1052+
and int(self.valves.CRITIC_MAX_ITERATIONS) > 0):
1053+
for attempt in range(int(self.valves.CRITIC_MAX_ITERATIONS)):
1054+
verdict = await self._critic_via_cpu(
1055+
user_text, polished, tool_history_json, emitter,
1056+
)
1057+
if verdict.get("verdict") == "approve":
1058+
await self._emit(emitter, "🧑‍⚖️ ✓ critic approve")
1059+
break
1060+
issues = verdict.get("issues") or []
1061+
if not issues:
1062+
break
1063+
await self._emit(emitter,
1064+
f"🧑‍⚖️ ✎ critic: {len(issues)} issue(s) → revise")
1065+
# Re-compose with the critic's issues fed back.
1066+
polished = await self._recompose_with_critic_feedback(
1067+
user_text, raw_output, tool_history_json, polished,
1068+
issues, emitter,
1069+
) or polished
1070+
polished = self._strip_outer_md_fence(polished)
1071+
polished = self._strip_reasoning_leaks(polished)
10201072
return polished
10211073

1074+
_CRITIC_SYSTEM = (
1075+
"You are a Critic Agent. The Compose Agent drafted the answer\n"
1076+
"below. Your job: check the draft against the structured tool\n"
1077+
"history (authoritative ground truth). Spot:\n"
1078+
" 1. Claims of success on tools whose tool_result had success=false\n"
1079+
" 2. Claims of completion for steps NOT present in the history\n"
1080+
" 3. Fabricated specifics (paths/ids/numbers not in any result)\n"
1081+
" 4. Wrong tool attribution (saying tool X was used when Y ran)\n"
1082+
" 5. Missing critical info that IS in the history\n"
1083+
"\n"
1084+
"Output JSON ONLY:\n"
1085+
" {\"verdict\": \"approve\" | \"revise\",\n"
1086+
" \"issues\": [\"<one-line issue>\", ...]}\n"
1087+
"\n"
1088+
"If draft accurately reflects the history, verdict=\"approve\",\n"
1089+
"issues=[]. Otherwise verdict=\"revise\", issues=[ specific\n"
1090+
"actionable items the Compose Agent should fix ].\n"
1091+
"Be terse. NO prose preamble.\n"
1092+
)
1093+
1094+
async def _critic_via_cpu(
1095+
self,
1096+
user_text: str,
1097+
draft: str,
1098+
tool_history_json: str,
1099+
emitter: Optional[Callable[..., Awaitable[None]]],
1100+
) -> dict:
1101+
"""Critic pass over compose draft. Returns the parsed JSON
1102+
verdict; returns {} on any error (fail-open: skip revision,
1103+
ship the draft)."""
1104+
await self._emit(emitter, "🧑‍⚖️ critic")
1105+
user_msg = (
1106+
f"## OPERATOR ASK\n{user_text[:1500]}\n\n"
1107+
f"## STRUCTURED TOOL HISTORY (authoritative)\n"
1108+
f"{tool_history_json[:6000]}\n\n"
1109+
f"## DRAFT ANSWER (from Compose Agent)\n"
1110+
f"{draft[:4000]}\n\n"
1111+
"## VERDICT (JSON only):"
1112+
)
1113+
body = {
1114+
"model": self.valves.CRITIC_MODEL,
1115+
"messages": [
1116+
{"role": "system", "content": self._CRITIC_SYSTEM},
1117+
{"role": "user", "content": user_msg},
1118+
],
1119+
"options": {
1120+
"num_gpu": 0,
1121+
"num_predict": int(self.valves.CRITIC_MAX_TOKENS),
1122+
"temperature": 0.0,
1123+
},
1124+
"format": "json",
1125+
"keep_alive": -1,
1126+
"stream": False,
1127+
}
1128+
try:
1129+
timeout = aiohttp.ClientTimeout(total=int(self.valves.CRITIC_TIMEOUT_S))
1130+
async with aiohttp.ClientSession(timeout=timeout) as session:
1131+
async with session.post(
1132+
self.valves.REFINE_ENDPOINT.rstrip("/") + "/api/chat",
1133+
data=json.dumps(body).encode(),
1134+
headers={"Content-Type": "application/json"},
1135+
) as resp:
1136+
if resp.status != 200:
1137+
return {}
1138+
data = await resp.json()
1139+
except (asyncio.TimeoutError, aiohttp.ClientError):
1140+
await self._emit(emitter, "🧑‍⚖️ ⚠ critic err → ship draft")
1141+
return {}
1142+
except Exception:
1143+
return {}
1144+
msg = (data.get("message") or {})
1145+
content = (msg.get("content") or "").strip()
1146+
if not content:
1147+
return {}
1148+
# Strip code fences a chatty model might add.
1149+
content = re.sub(r"^\s*```(?:json)?\s*\n?", "", content)
1150+
content = re.sub(r"\n?```\s*$", "", content)
1151+
try:
1152+
parsed = json.loads(content)
1153+
if isinstance(parsed, dict):
1154+
return parsed
1155+
except json.JSONDecodeError:
1156+
pass
1157+
return {}
1158+
1159+
async def _recompose_with_critic_feedback(
1160+
self,
1161+
user_text: str,
1162+
raw_output: str,
1163+
tool_history_json: str,
1164+
prev_draft: str,
1165+
issues: list,
1166+
emitter: Optional[Callable[..., Awaitable[None]]],
1167+
) -> str:
1168+
"""Re-run compose with the critic's specific issue list fed
1169+
back in. The compose model sees:
1170+
* Original system prompt + structured history
1171+
* The previous draft
1172+
* The critic's list of issues to fix
1173+
Returns the revised answer; empty string on any failure (the
1174+
caller keeps the prev_draft in that case)."""
1175+
sys_content = self._POLISH_SYSTEM.format(
1176+
user_prompt=user_text[:2000],
1177+
raw_output=raw_output[:12000],
1178+
)
1179+
sys_content += (
1180+
"\n\n## STRUCTURED TOOL HISTORY (authoritative)\n"
1181+
f"{tool_history_json[:6000]}\n"
1182+
"\n## CRITIC FEEDBACK on your previous draft\n"
1183+
"Your previous draft had these issues -- FIX each one in\n"
1184+
"the revised answer. Use the structured history above to\n"
1185+
"ground every claim. Output the revised final answer only;\n"
1186+
"no preamble, no 'here is the revised version'.\n\n"
1187+
)
1188+
for i, issue in enumerate(issues, 1):
1189+
sys_content += f" {i}. {str(issue)[:300]}\n"
1190+
sys_content += f"\n## PREVIOUS DRAFT\n{prev_draft[:6000]}\n"
1191+
body = {
1192+
"model": self.valves.POLISH_MODEL,
1193+
"messages": [
1194+
{"role": "system", "content": sys_content},
1195+
{"role": "user", "content": "Emit the revised final answer."},
1196+
],
1197+
"options": {
1198+
"num_gpu": 0,
1199+
"num_predict": int(self.valves.POLISH_MAX_TOKENS),
1200+
"temperature": 0.0,
1201+
},
1202+
"keep_alive": -1,
1203+
"stream": False,
1204+
}
1205+
try:
1206+
timeout = aiohttp.ClientTimeout(total=int(self.valves.POLISH_TIMEOUT_S))
1207+
async with aiohttp.ClientSession(timeout=timeout) as session:
1208+
async with session.post(
1209+
self.valves.REFINE_ENDPOINT.rstrip("/") + "/api/chat",
1210+
data=json.dumps(body).encode(),
1211+
headers={"Content-Type": "application/json"},
1212+
) as resp:
1213+
if resp.status != 200:
1214+
return ""
1215+
data = await resp.json()
1216+
except (asyncio.TimeoutError, aiohttp.ClientError):
1217+
return ""
1218+
except Exception:
1219+
return ""
1220+
msg = (data.get("message") or {})
1221+
return (msg.get("content") or msg.get("thinking") or "").strip()
1222+
10221223
# Match a leading ```markdown / ``` fence. The closing ``` is
10231224
# OPTIONAL because polish sometimes truncates mid-output (token
10241225
# cap hit on a long table) -- in that case there's an open fence

0 commit comments

Comments
 (0)