P0-1 migration: detect the same-named stage-3 execution_stage CHECK by sqltext (must contain rework + manager_review), SQLite rebuild / PG-MySQL drop+add, PG boolean default TRUE, validate_schema checks constraint content + seat_tasks status CHECK/unique/FKs; tests start from a real stage-3 schema. P0-2: a missing/invalid manager review (timeout/exception/empty/invalid JSON/missing per-seat decision) is never an acceptance - system rework under the cap, contract fallback (reassign/controlled_gap/ask_user) afterwards, source=system events. P0-3: unaccepted deliveries are isolated from the fact ledger, cross-review and final report (deferred merge, delivery_json envelope, accepted_task_results in checkpoint, accepted-only synthesis, no turn_results fallback). P0-4: seat-task init is atomic get-or-create over the full expected set; recovery fills missing tasks without overwriting frozen contracts; quality gate blocks missing required tasks. P1: zero config values honoured; duplicate detection persists SHA-256 conclusion digests in history (survives restarts); findings never satisfy evidence, evidence_refs must resolve to trusted ledger refs (unverified_evidence_ref); conversation SSE projection tests added and manager/finalizer messages are no longer mislabelled as seat.delivery.submitted.
2429 lines
107 KiB
Python
2429 lines
107 KiB
Python
"""MAF Workflow node executors for the roundtable experiment.
|
||
|
||
Each executor is a real MAF ``Executor`` subclass instance. Every seat gets
|
||
its own executor bound to a fixed ``agent_id`` — no shared function that
|
||
guesses the current speaker from input metadata.
|
||
|
||
Phase 3 structured topology:
|
||
- PlannerExecutor → Expert SeatExecutors (fan-out) → CrossReviewExecutor →
|
||
ApprovalGate → Finalizer | WaitingTerminal
|
||
|
||
Group-chat still uses CoordinatorExecutor for speaker selection.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
from typing import Any
|
||
|
||
from agent_framework import Executor, WorkflowContext, handler
|
||
|
||
from app.maf_roundtable.action_coordinator import (
|
||
ActionCoordinator,
|
||
classify_tool,
|
||
tool_needs_approval,
|
||
)
|
||
from app.maf_roundtable.context_builder import build_manager_synthesis_input
|
||
from app.maf_roundtable.dispatch_contracts import (
|
||
InvalidDispatchPlanError,
|
||
MafDispatchCandidateSet,
|
||
MafDispatchCatalog,
|
||
MafDispatchPlan,
|
||
)
|
||
from app.maf_roundtable.event_normalizer import (
|
||
make_manager_review_decision,
|
||
make_seat_delivery_submitted,
|
||
make_seat_reassigned,
|
||
make_seat_rework_assigned,
|
||
make_stage_quality_gate,
|
||
)
|
||
from app.maf_roundtable.fact_ledger import (
|
||
confirm_candidates,
|
||
empty_ledger,
|
||
known_evidence_refs,
|
||
merge_seat_output,
|
||
normalize_ledger,
|
||
record_action_transition,
|
||
record_tool_result,
|
||
)
|
||
from app.maf_roundtable.quality_contracts import (
|
||
OUTCOME_ACCEPTED,
|
||
OUTCOME_ASK_USER,
|
||
OUTCOME_CONTROLLED_GAP,
|
||
OUTCOME_REASSIGNED,
|
||
OUTCOME_REWORK,
|
||
TASK_ACCEPTED,
|
||
TASK_CONTROLLED_GAP,
|
||
TASK_EXHAUSTED,
|
||
TASK_REASSIGNED,
|
||
TASK_REWORK,
|
||
TASK_SUBMITTED,
|
||
TASK_TERMINAL_STATUSES,
|
||
MafTaskContract,
|
||
build_manager_review_prompt,
|
||
build_task_contract,
|
||
check_delivery_deterministic,
|
||
conclusion_digest,
|
||
delivery_from_seat_output,
|
||
finalize_review_decision,
|
||
make_controlled_gap,
|
||
parse_manager_review,
|
||
)
|
||
from app.maf_roundtable.seat_output import (
|
||
build_repair_prompt,
|
||
parse_cross_review_output,
|
||
seat_output_instruction,
|
||
validate_or_repair_seat_output,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class WriteGuardConfigurationError(RuntimeError):
|
||
"""Raised when WriteGuard should have intercepted a write tool but didn't."""
|
||
|
||
|
||
# ── Serializable workflow state ─────────────────────────────────────────────
|
||
|
||
|
||
class RoundtableState:
|
||
"""Serializable shared state for the MAF roundtable workflow.
|
||
|
||
Persisted to the run's ``maf_checkpoint_json`` so the workflow survives
|
||
restarts and approval-pause/resume cycles.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
session_id: str,
|
||
run_id: str,
|
||
task_text: str,
|
||
mode: str,
|
||
model_name: str | None,
|
||
reasoning_effort: str | None,
|
||
max_rounds: int,
|
||
write_policy: str,
|
||
manager_agent_id: str | None,
|
||
seat_agent_ids: list[str],
|
||
thread_map: dict[str, str],
|
||
capability_snapshot: dict[str, Any] | None = None,
|
||
seat_timeout_seconds: float = 600.0,
|
||
schema_repair_enabled: bool = True,
|
||
intent_type: str | None = None,
|
||
dispatch_plan: dict[str, Any] | None = None,
|
||
candidate_seat_agent_ids: list[str] | None = None,
|
||
dispatch_contract_enabled: bool | None = None,
|
||
quality_gate_enabled: bool | None = None,
|
||
) -> None:
|
||
self.session_id = session_id
|
||
self.run_id = run_id
|
||
self.task_text = task_text
|
||
self.mode = mode
|
||
self.model_name = model_name
|
||
self.reasoning_effort = reasoning_effort
|
||
self.max_rounds = max_rounds
|
||
self.write_policy = write_policy
|
||
self.manager_agent_id = manager_agent_id
|
||
self.seat_agent_ids = seat_agent_ids
|
||
self.thread_map = thread_map
|
||
self.capability_snapshot = capability_snapshot
|
||
self.seat_timeout_seconds = seat_timeout_seconds
|
||
self.schema_repair_enabled = schema_repair_enabled
|
||
# Stage 3 server-validated routing metadata. The workflow may use
|
||
# this for diagnostics/checkpoints, but it never accepts a new agent
|
||
# id from manager model output.
|
||
self.intent_type = intent_type
|
||
self.dispatch_plan = dispatch_plan
|
||
self.dispatch_contract_enabled = (
|
||
bool(dispatch_plan is not None or intent_type is not None)
|
||
if dispatch_contract_enabled is None
|
||
else bool(dispatch_contract_enabled)
|
||
)
|
||
# Stage 4 (R3-Q): quality gate for V2 structured dispatch. Defaults to
|
||
# the dispatch-contract value when not explicitly resolved, so
|
||
# checkpoints written before Stage 4 keep the new behavior.
|
||
self.quality_gate_enabled = (
|
||
bool(self.dispatch_contract_enabled)
|
||
if quality_gate_enabled is None
|
||
else bool(quality_gate_enabled)
|
||
)
|
||
plan_seats = list((dispatch_plan or {}).get("seat_agent_ids") or [])
|
||
self.candidate_seat_agent_ids = list(
|
||
(dispatch_plan or {}).get("candidate_seat_agent_ids")
|
||
or candidate_seat_agent_ids
|
||
or seat_agent_ids
|
||
)
|
||
self.selected_seat_agent_ids: list[str] | None = plan_seats or None
|
||
|
||
self.current_round = 0
|
||
self.turn_results: list[dict[str, Any]] = []
|
||
self.fact_ledger: dict[str, Any] = empty_ledger()
|
||
self.final_result: dict[str, Any] | None = None
|
||
self.status = "running"
|
||
self.pending_approvals: list[dict[str, Any]] = []
|
||
|
||
# Phase 3 orchestration fields
|
||
self.plan: dict[str, Any] | None = None
|
||
self.seat_outcomes: dict[str, Any] = {}
|
||
self.completed_nodes: list[str] = []
|
||
self.stage: str = "planning"
|
||
self.partial_failure: bool = False
|
||
self.cross_review_result: dict[str, Any] | None = None
|
||
# Stage 4 (R3-Q): per-task quality state machine summary.
|
||
# task_key → {status, agent_id, original_agent_id, attempt_count,
|
||
# rework_count, reassign_count, required}
|
||
self.seat_task_statuses: dict[str, Any] = {}
|
||
# Controlled-gap records to be carried into the final report's risk
|
||
# section. Each entry is a make_controlled_gap(...) dict.
|
||
self.controlled_gaps: list[dict[str, Any]] = []
|
||
# Stage 4 (R3-Q): the ONLY seat outputs that cross-review and the
|
||
# final synthesis may consume when the quality gate is enabled. An
|
||
# entry is appended exactly when a task's final decision is
|
||
# ``accepted`` — reworked / reassigned / gap deliveries never land
|
||
# here, so unaccepted content cannot leak into the final report.
|
||
self.accepted_task_results: list[dict[str, Any]] = []
|
||
|
||
def mark_completed(self, node_id: str) -> None:
|
||
if node_id not in self.completed_nodes:
|
||
self.completed_nodes.append(node_id)
|
||
|
||
def is_completed(self, node_id: str) -> bool:
|
||
return node_id in self.completed_nodes
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
return {
|
||
"session_id": self.session_id,
|
||
"run_id": self.run_id,
|
||
"task_text": self.task_text,
|
||
"mode": self.mode,
|
||
"model_name": self.model_name,
|
||
"reasoning_effort": self.reasoning_effort,
|
||
"max_rounds": self.max_rounds,
|
||
"write_policy": self.write_policy,
|
||
"manager_agent_id": self.manager_agent_id,
|
||
"seat_agent_ids": self.seat_agent_ids,
|
||
"thread_map": self.thread_map,
|
||
"capability_snapshot": self.capability_snapshot,
|
||
"seat_timeout_seconds": self.seat_timeout_seconds,
|
||
"schema_repair_enabled": self.schema_repair_enabled,
|
||
"intent_type": self.intent_type,
|
||
"dispatch_plan": self.dispatch_plan,
|
||
"dispatch_contract_enabled": self.dispatch_contract_enabled,
|
||
"quality_gate_enabled": self.quality_gate_enabled,
|
||
"candidate_seat_agent_ids": list(self.candidate_seat_agent_ids),
|
||
"selected_seat_agent_ids": self.selected_seat_agent_ids,
|
||
"current_round": self.current_round,
|
||
"turn_results": self.turn_results,
|
||
"fact_ledger": self.fact_ledger,
|
||
"final_result": self.final_result,
|
||
"status": self.status,
|
||
"pending_approvals": self.pending_approvals,
|
||
"plan": self.plan,
|
||
"seat_outcomes": self.seat_outcomes,
|
||
"completed_nodes": list(self.completed_nodes),
|
||
"stage": self.stage,
|
||
"partial_failure": self.partial_failure,
|
||
"cross_review_result": self.cross_review_result,
|
||
"seat_task_statuses": dict(self.seat_task_statuses),
|
||
"controlled_gaps": list(self.controlled_gaps),
|
||
"accepted_task_results": list(self.accepted_task_results),
|
||
}
|
||
|
||
@classmethod
|
||
def from_dict(cls, d: dict[str, Any]) -> RoundtableState:
|
||
state = cls(
|
||
session_id=d["session_id"],
|
||
run_id=d["run_id"],
|
||
task_text=d["task_text"],
|
||
mode=d["mode"],
|
||
model_name=d.get("model_name"),
|
||
reasoning_effort=d.get("reasoning_effort"),
|
||
max_rounds=d.get("max_rounds", 8),
|
||
write_policy=d.get("write_policy", "confirm_high_risk"),
|
||
manager_agent_id=d.get("manager_agent_id"),
|
||
seat_agent_ids=d.get("seat_agent_ids", []),
|
||
thread_map=d.get("thread_map", {}),
|
||
capability_snapshot=d.get("capability_snapshot"),
|
||
seat_timeout_seconds=float(d.get("seat_timeout_seconds") or 600.0),
|
||
schema_repair_enabled=bool(d.get("schema_repair_enabled", True)),
|
||
intent_type=d.get("intent_type"),
|
||
dispatch_plan=d.get("dispatch_plan"),
|
||
candidate_seat_agent_ids=d.get("candidate_seat_agent_ids"),
|
||
dispatch_contract_enabled=d.get("dispatch_contract_enabled"),
|
||
quality_gate_enabled=d.get("quality_gate_enabled"),
|
||
)
|
||
state.current_round = d.get("current_round", 0)
|
||
state.turn_results = d.get("turn_results", [])
|
||
state.fact_ledger = normalize_ledger(d.get("fact_ledger"))
|
||
state.final_result = d.get("final_result")
|
||
state.status = d.get("status", "running")
|
||
pending = d.get("pending_approvals")
|
||
if pending is None and d.get("pending_approval"):
|
||
pending = [d["pending_approval"]]
|
||
state.pending_approvals = pending or []
|
||
state.plan = d.get("plan")
|
||
state.seat_outcomes = d.get("seat_outcomes") or {}
|
||
state.completed_nodes = list(d.get("completed_nodes") or [])
|
||
state.stage = d.get("stage") or "planning"
|
||
state.partial_failure = bool(d.get("partial_failure", False))
|
||
state.cross_review_result = d.get("cross_review_result")
|
||
state.seat_task_statuses = dict(d.get("seat_task_statuses") or {})
|
||
state.controlled_gaps = list(d.get("controlled_gaps") or [])
|
||
state.accepted_task_results = list(d.get("accepted_task_results") or [])
|
||
state.candidate_seat_agent_ids = list(
|
||
d.get("candidate_seat_agent_ids") or state.candidate_seat_agent_ids
|
||
)
|
||
selected = d.get("selected_seat_agent_ids")
|
||
state.selected_seat_agent_ids = list(selected) if selected is not None else None
|
||
state.dispatch_contract_enabled = bool(
|
||
d.get("dispatch_contract_enabled", state.dispatch_contract_enabled)
|
||
)
|
||
return state
|
||
|
||
|
||
# ── Planner (structured) ────────────────────────────────────────────────────
|
||
|
||
|
||
class ManagerAnswerExecutor(Executor):
|
||
"""Manager-only topology for ordinary direct answers."""
|
||
|
||
def __init__(self, adapter: Any, state: RoundtableState) -> None:
|
||
super().__init__(id="manager_answer")
|
||
self._adapter = adapter
|
||
self._state = state
|
||
|
||
@handler(input=Any, workflow_output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
if self._state.is_completed("manager_answer") and self._state.final_result is not None:
|
||
self._state.status = "completed"
|
||
await ctx.yield_output({"final_result": self._state.final_result, "replayed": True})
|
||
return
|
||
|
||
manager_id = self._state.manager_agent_id or ""
|
||
thread_id = self._state.thread_map.get(manager_id, "")
|
||
if not thread_id:
|
||
self._state.status = "failed"
|
||
self._state.final_result = {
|
||
"content": "MAF manager thread is unavailable",
|
||
"agent_id": manager_id,
|
||
"error_code": "manager_thread_unavailable",
|
||
}
|
||
else:
|
||
try:
|
||
response = await asyncio.wait_for(
|
||
self._adapter.run_seat_turn(
|
||
session_id=self._state.session_id,
|
||
run_id=self._state.run_id,
|
||
agent_id=manager_id,
|
||
turn_index=0,
|
||
execution_stage="direct_answer",
|
||
message=self._state.task_text,
|
||
thread_id=thread_id,
|
||
model_name=self._state.model_name,
|
||
reasoning_effort=self._state.reasoning_effort,
|
||
task_text=self._state.task_text,
|
||
stage_instruction="Answer the user directly; do not delegate to expert seats",
|
||
fact_ledger=self._state.fact_ledger,
|
||
capability_snapshot=self._state.capability_snapshot,
|
||
write_policy=self._state.write_policy,
|
||
wait_timeout_seconds=self._state.seat_timeout_seconds,
|
||
),
|
||
timeout=self._state.seat_timeout_seconds,
|
||
)
|
||
if response.error:
|
||
self._state.status = "failed"
|
||
else:
|
||
self._state.status = "completed"
|
||
self._state.final_result = {
|
||
"content": response.content,
|
||
"agent_id": manager_id,
|
||
"error": response.error,
|
||
"quality_decision": "accept" if not response.error else "controlled_gap",
|
||
}
|
||
except Exception as exc: # noqa: BLE001 - runner persists a safe failure
|
||
self._state.status = "failed"
|
||
self._state.final_result = {
|
||
"content": "MAF manager answer failed",
|
||
"agent_id": manager_id,
|
||
"error_code": type(exc).__name__,
|
||
}
|
||
|
||
self._state.stage = "done"
|
||
self._state.mark_completed("manager_answer")
|
||
dispatch_plan = MafDispatchPlan.model_validate(self._state.dispatch_plan or {
|
||
"manager_agent_id": manager_id,
|
||
"seat_agent_ids": [],
|
||
"assignments": [],
|
||
"intent_type": "direct_answer",
|
||
"max_rounds": self._state.max_rounds,
|
||
"candidate_seat_agent_ids": list(self._state.candidate_seat_agent_ids),
|
||
"execution_mode": "manager_only",
|
||
"convergence_path": ["manager answer"],
|
||
"reason": "direct answer uses manager only",
|
||
"source": "server",
|
||
})
|
||
self._state.dispatch_plan = dispatch_plan.model_dump(mode="json")
|
||
store = getattr(getattr(self._adapter, "_service", None), "store", None)
|
||
if store is not None:
|
||
await store.runs.update_run(
|
||
self._state.run_id,
|
||
{"maf_checkpoint": self._state.to_dict()},
|
||
)
|
||
if not self._state.dispatch_contract_enabled:
|
||
await self._adapter._emit_critical(self._state.run_id, {
|
||
"event_type": "manager.dispatch",
|
||
"agent_id": manager_id,
|
||
"turn_index": 0,
|
||
"payload": {
|
||
"intent_type": dispatch_plan.intent_type,
|
||
"execution_mode": dispatch_plan.execution_mode,
|
||
"convergence_path": list(dispatch_plan.convergence_path),
|
||
"selected_seat_agent_ids": [],
|
||
"assignments": [],
|
||
"reason": dispatch_plan.reason,
|
||
"source": dispatch_plan.source,
|
||
},
|
||
})
|
||
from app.maf_roundtable.event_normalizer import make_run_completed
|
||
|
||
await self._adapter._emit_critical(
|
||
self._state.run_id,
|
||
make_run_completed(manager_id, 1, {"manager_only": True}),
|
||
)
|
||
await ctx.yield_output({"final_result": self._state.final_result})
|
||
|
||
|
||
class ManagerDispatchExecutor(Executor):
|
||
"""Understand intent and emit one server-validated dispatch decision."""
|
||
|
||
def __init__(
|
||
self, adapter: Any, state: RoundtableState, *, executor_id: str = "manager_dispatch"
|
||
) -> None:
|
||
super().__init__(id=executor_id)
|
||
self._adapter = adapter
|
||
self._state = state
|
||
self._node_id = executor_id
|
||
|
||
def _dispatch_decision(self) -> str:
|
||
plan = self._state.dispatch_plan or {}
|
||
if self._state.intent_type == "clarification" or not plan:
|
||
return "clarification"
|
||
if plan.get("execution_mode") == "manager_only":
|
||
return "manager_only"
|
||
if plan.get("mode") == "group_chat" or plan.get("execution_mode") == "group_chat":
|
||
return "group_chat"
|
||
return "selected_seats"
|
||
|
||
@handler(input=Any, output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
# V2 clarification checkpoints must always re-enter the clarification
|
||
# recovery branch, even when manager_dispatch is already marked
|
||
# completed — otherwise a recovered clarification run would stay
|
||
# "running" instead of landing in waiting_input.
|
||
if self._state.dispatch_contract_enabled and self._state.intent_type == "clarification":
|
||
self._state.stage = "planning"
|
||
await _resolve_manager_dispatch(self, ctx)
|
||
return
|
||
|
||
if self._state.is_completed(self._node_id):
|
||
await ctx.send_message({
|
||
"decision": self._dispatch_decision(),
|
||
"agents": list(self._state.selected_seat_agent_ids or []),
|
||
"execution_mode": (self._state.dispatch_plan or {}).get("execution_mode"),
|
||
"replayed": True,
|
||
})
|
||
return
|
||
|
||
self._state.stage = "planning"
|
||
|
||
# Legacy MAF roundtable runs do not carry the V2 conversation dispatch
|
||
# contract. Keep their planner semantics isolated from the new MAF
|
||
# conversation workflow; in particular, old seat-only runs may have no
|
||
# manager at all.
|
||
if not self._state.dispatch_contract_enabled:
|
||
self._state.current_round = max(self._state.current_round, 1)
|
||
await _run_legacy_planner(self, ctx)
|
||
return
|
||
|
||
# The manager plan is a strict JSON contract. Legacy free-text
|
||
# planning is isolated in _run_legacy_planner; new Stage 3 runs
|
||
# always return through this verifier.
|
||
await _resolve_manager_dispatch(self, ctx)
|
||
return
|
||
|
||
|
||
async def _run_legacy_planner(executor: ManagerDispatchExecutor, ctx: WorkflowContext) -> None:
|
||
"""Keep the pre-V2 free-text planner isolated from new conversations."""
|
||
state = executor._state
|
||
if state.is_completed(executor._node_id):
|
||
await ctx.send_message({"decision": "parallel_seats", "agents": list(state.seat_agent_ids), "replayed": True})
|
||
return
|
||
assignments = {
|
||
agent_id: {
|
||
"role": f"Expert seat {agent_id}",
|
||
"instruction": f"Independently analyze the task. {seat_output_instruction()}",
|
||
}
|
||
for agent_id in state.seat_agent_ids
|
||
}
|
||
manager_id = state.manager_agent_id
|
||
thread_id = state.thread_map.get(manager_id or "", "")
|
||
if manager_id and thread_id:
|
||
try:
|
||
response = await asyncio.wait_for(
|
||
executor._adapter.run_seat_turn(
|
||
session_id=state.session_id,
|
||
run_id=state.run_id,
|
||
agent_id=manager_id,
|
||
turn_index=0,
|
||
execution_stage="planner",
|
||
message=state.task_text,
|
||
thread_id=thread_id,
|
||
model_name=state.model_name,
|
||
reasoning_effort=state.reasoning_effort,
|
||
task_text=state.task_text,
|
||
stage_instruction="Produce a concise assignment for each configured expert seat.",
|
||
fact_ledger=state.fact_ledger,
|
||
capability_snapshot=state.capability_snapshot,
|
||
write_policy=state.write_policy,
|
||
wait_timeout_seconds=state.seat_timeout_seconds,
|
||
),
|
||
timeout=state.seat_timeout_seconds,
|
||
)
|
||
plan_text = (response.content or "").strip()
|
||
if plan_text:
|
||
for agent_id in state.seat_agent_ids:
|
||
assignments[agent_id]["instruction"] = f"{plan_text[:1500]}\n\nSeat id: {agent_id}.\n{seat_output_instruction()}"
|
||
state.plan = {"source": "manager", "text": plan_text, "assignments": assignments}
|
||
else:
|
||
state.plan = {"source": "deterministic", "assignments": assignments}
|
||
except Exception as exc: # noqa: BLE001 - legacy path deliberately degrades
|
||
logger.warning("legacy planner failed (%s); using deterministic plan", type(exc).__name__)
|
||
state.plan = {"source": "deterministic", "assignments": assignments}
|
||
else:
|
||
state.plan = {"source": "deterministic", "assignments": assignments}
|
||
state.mark_completed(executor._node_id)
|
||
state.stage = "experts"
|
||
await ctx.send_message({"decision": "parallel_seats", "agents": list(state.seat_agent_ids)})
|
||
|
||
|
||
class PlannerExecutor(ManagerDispatchExecutor):
|
||
"""Compatibility node retained solely for pre-V2 MAF roundtable runs."""
|
||
|
||
def __init__(self, adapter: Any, state: RoundtableState) -> None:
|
||
super().__init__(adapter, state, executor_id="planner")
|
||
|
||
|
||
class DispatchRouteExecutor(Executor):
|
||
"""Route only from the verified, persisted manager dispatch decision."""
|
||
|
||
def __init__(self, state: RoundtableState) -> None:
|
||
super().__init__(id="dispatch_route")
|
||
self._state = state
|
||
|
||
@handler(input=Any, output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
plan = self._state.dispatch_plan or {}
|
||
if self._state.intent_type == "clarification" or not plan:
|
||
decision = "clarification"
|
||
elif plan.get("execution_mode") == "manager_only":
|
||
decision = "manager_only"
|
||
elif plan.get("mode") == "group_chat" or plan.get("execution_mode") == "group_chat":
|
||
decision = "group_chat"
|
||
else:
|
||
decision = "selected_seats"
|
||
await ctx.send_message({"decision": decision, "agents": list(self._state.selected_seat_agent_ids or [])})
|
||
|
||
|
||
class DispatchSeatEntryExecutor(Executor):
|
||
"""Start seat work only after the manager's plan and threads are durable."""
|
||
|
||
def __init__(self, state: RoundtableState) -> None:
|
||
super().__init__(id="dispatch_seat_entry")
|
||
self._state = state
|
||
|
||
@handler(input=Any, output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
self._state.stage = "experts"
|
||
await ctx.send_message({"agents": list(self._state.selected_seat_agent_ids or [])})
|
||
|
||
|
||
class ClarificationTerminalExecutor(Executor):
|
||
"""Safe terminal branch for invalid, unauthorized, or timed-out planning."""
|
||
|
||
def __init__(self, state: RoundtableState) -> None:
|
||
super().__init__(id="clarification_terminal")
|
||
self._state = state
|
||
|
||
@handler(input=Any, workflow_output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
self._state.status = "waiting_input"
|
||
self._state.stage = "waiting_input"
|
||
self._state.mark_completed("clarification_terminal")
|
||
await ctx.yield_output({"status": "waiting_input", "clarification_prompt": (self._state.dispatch_plan or {}).get("clarification_prompt")})
|
||
|
||
|
||
class DispatchPreparationTerminalExecutor(Executor):
|
||
"""End the manager-only preparation graph before topology is selected."""
|
||
|
||
def __init__(self, state: RoundtableState) -> None:
|
||
super().__init__(id="dispatch_preparation_terminal")
|
||
self._state = state
|
||
|
||
@handler(input=Any, workflow_output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
self._state.mark_completed("dispatch_preparation_terminal")
|
||
await ctx.yield_output({
|
||
"decision": "clarification" if self._state.intent_type == "clarification" else "dispatched",
|
||
"status": self._state.status,
|
||
})
|
||
|
||
|
||
class CoordinatorExecutor(Executor):
|
||
"""Choose the next selected group-chat speaker without widening the plan."""
|
||
|
||
def __init__(self, adapter: Any, state: RoundtableState) -> None:
|
||
super().__init__(id="coordinator")
|
||
self._adapter = adapter
|
||
self._state = state
|
||
|
||
@handler(input=Any, output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
if self._state.stage == "dispatch_validated" and self._state.current_round == 0:
|
||
self._state.current_round = 1
|
||
else:
|
||
self._state.current_round += 1
|
||
if self._state.current_round > self._state.max_rounds:
|
||
await ctx.send_message({"decision": "done", "reason": "max_rounds_reached"})
|
||
return
|
||
eligible = list(self._state.selected_seat_agent_ids) if self._state.selected_seat_agent_ids is not None else list(self._state.seat_agent_ids)
|
||
remaining = [
|
||
agent_id for agent_id in eligible
|
||
if not any(row.get("agent_id") == agent_id and row.get("turn_index", 0) >= self._state.current_round - 1 for row in self._state.turn_results)
|
||
]
|
||
if not remaining:
|
||
await ctx.send_message({"decision": "done", "reason": "all_selected_seats_spoke"})
|
||
return
|
||
next_agent = remaining[(self._state.current_round - 1) % len(remaining)]
|
||
await self._adapter._emit_critical(self._state.run_id, {
|
||
"event_type": "manager.decision",
|
||
"agent_id": self._state.manager_agent_id or "coordinator",
|
||
"turn_index": self._state.current_round,
|
||
"payload": {"next_speaker": next_agent, "reason": "selected_plan_round_robin", "round": self._state.current_round},
|
||
})
|
||
await ctx.send_message({"decision": "next_speaker", "agent_id": next_agent})
|
||
|
||
|
||
async def _persist_dispatch_checkpoint(adapter: Any, state: RoundtableState) -> None:
|
||
store = getattr(getattr(adapter, "_service", None), "store", None)
|
||
if store is not None:
|
||
await store.runs.update_run(state.run_id, {"maf_checkpoint": state.to_dict()})
|
||
|
||
|
||
async def _emit_dispatch_event(
|
||
adapter: Any,
|
||
state: RoundtableState,
|
||
*,
|
||
clarification_prompt: str | None = None,
|
||
) -> None:
|
||
plan = state.dispatch_plan or {}
|
||
payload: dict[str, Any] = {
|
||
"intent_type": state.intent_type,
|
||
"execution_mode": plan.get("execution_mode", "manager_only"),
|
||
"selected_seat_agent_ids": list(state.selected_seat_agent_ids or []),
|
||
"assignments": plan.get("assignments", []),
|
||
"convergence_path": plan.get("convergence_path", []),
|
||
"reason": plan.get("reason", "manager_plan_invalid_safe_clarification"),
|
||
"source": plan.get("source", "deterministic_fallback"),
|
||
}
|
||
if clarification_prompt:
|
||
payload["clarification_prompt"] = clarification_prompt
|
||
await adapter._emit_critical(state.run_id, {
|
||
"event_type": "manager.dispatch",
|
||
"agent_id": state.manager_agent_id or "",
|
||
"turn_index": 0,
|
||
"payload": payload,
|
||
})
|
||
|
||
|
||
async def _ensure_selected_seat_threads(adapter: Any, state: RoundtableState) -> None:
|
||
"""Persist and bind threads only for server-validated selected seats."""
|
||
selected = list(state.selected_seat_agent_ids or [])
|
||
if not selected:
|
||
return
|
||
store = getattr(getattr(adapter, "_service", None), "store", None)
|
||
participants_repo = getattr(store, "participants", None)
|
||
if participants_repo is None:
|
||
return
|
||
route_mode = state.mode if state.mode in {"structured", "group_chat"} else "structured"
|
||
candidate_set = MafDispatchCandidateSet(
|
||
manager_agent_id=str(state.manager_agent_id or ""),
|
||
candidate_seat_agent_ids=list(state.candidate_seat_agent_ids),
|
||
mode=route_mode,
|
||
max_rounds=state.max_rounds,
|
||
)
|
||
candidate_rows = MafDispatchCatalog.candidate_participants(
|
||
candidate_set,
|
||
capability_snapshot=state.capability_snapshot,
|
||
)
|
||
selected_rows = [row for row in candidate_rows if row.get("agent_id") in selected]
|
||
await participants_repo.ensure_participants(state.session_id, selected_rows)
|
||
persisted = await participants_repo.list_participants(state.session_id)
|
||
selected_persisted = [row for row in persisted if row.get("agent_id") in selected]
|
||
if selected_persisted:
|
||
state.thread_map.update(
|
||
await adapter.ensure_threads(state.session_id, state.run_id, selected_persisted)
|
||
)
|
||
|
||
|
||
async def _resolve_manager_dispatch(
|
||
executor: ManagerDispatchExecutor, ctx: WorkflowContext
|
||
) -> None:
|
||
"""Run, validate, persist, and route the sole V2 manager dispatch turn."""
|
||
adapter = executor._adapter
|
||
state = executor._state
|
||
manager_id = str(state.manager_agent_id or "").strip()
|
||
route_mode = state.mode if state.mode in {"structured", "group_chat"} else "structured"
|
||
candidate_set = MafDispatchCandidateSet(
|
||
manager_agent_id=manager_id,
|
||
candidate_seat_agent_ids=list(state.candidate_seat_agent_ids),
|
||
mode=route_mode,
|
||
max_rounds=state.max_rounds,
|
||
)
|
||
|
||
async def clarification(reason: str) -> None:
|
||
prompt = "Please clarify the goal, expected outcome, and relevant business context."
|
||
state.intent_type = "clarification"
|
||
state.dispatch_plan = {
|
||
"execution_mode": "manager_only",
|
||
"convergence_path": ["clarification"],
|
||
"reason": reason,
|
||
"source": "deterministic_fallback",
|
||
"clarification_prompt": prompt,
|
||
}
|
||
state.selected_seat_agent_ids = []
|
||
state.plan = None
|
||
state.status = "waiting_input"
|
||
state.stage = "waiting_input"
|
||
state.mark_completed(executor._node_id)
|
||
await _persist_dispatch_checkpoint(adapter, state)
|
||
await _emit_dispatch_event(adapter, state, clarification_prompt=prompt)
|
||
await adapter._emit_critical(state.run_id, {
|
||
"event_type": "clarification.required",
|
||
"agent_id": manager_id,
|
||
"turn_index": 0,
|
||
"payload": {"prompt": prompt, "reason": reason},
|
||
})
|
||
await ctx.send_message({"decision": "clarification", "agents": []})
|
||
|
||
# ── P1-2: Clarification checkpoint recovery ──────────────────────────
|
||
# A clarification checkpoint stores a lightweight dict (not a full
|
||
# MafDispatchPlan). Recovery must restore the original prompt without
|
||
# calling the manager model again, creating seat threads, or emitting
|
||
# duplicate audit events.
|
||
if state.intent_type == "clarification":
|
||
prompt = str((state.dispatch_plan or {}).get("clarification_prompt") or "").strip()
|
||
if not prompt:
|
||
await clarification("checkpoint_clarification_prompt_missing")
|
||
return
|
||
|
||
state.selected_seat_agent_ids = []
|
||
state.plan = None
|
||
state.status = "waiting_input"
|
||
state.stage = "waiting_input"
|
||
state.mark_completed(executor._node_id)
|
||
await _persist_dispatch_checkpoint(adapter, state)
|
||
await ctx.send_message({"decision": "clarification", "agents": [], "replayed": True})
|
||
return
|
||
|
||
# A checkpoint written after validation survives process recovery and is
|
||
# never re-planned. It still creates any selected threads that were not
|
||
# bound before interruption.
|
||
if state.dispatch_plan and state.intent_type:
|
||
try:
|
||
existing = MafDispatchPlan.model_validate(state.dispatch_plan)
|
||
except (TypeError, ValueError):
|
||
await clarification("checkpoint_dispatch_plan_invalid")
|
||
return
|
||
state.selected_seat_agent_ids = list(existing.seat_agent_ids)
|
||
state.plan = {
|
||
"source": existing.source,
|
||
"assignments": {
|
||
assignment.agent_id: {
|
||
"role": assignment.role,
|
||
"instruction": "\n".join(
|
||
[assignment.task_goal, *assignment.delivery_requirements]
|
||
),
|
||
}
|
||
for assignment in existing.assignments
|
||
},
|
||
}
|
||
await _ensure_selected_seat_threads(adapter, state)
|
||
state.mark_completed(executor._node_id)
|
||
await _persist_dispatch_checkpoint(adapter, state)
|
||
await ctx.send_message({
|
||
"decision": executor._dispatch_decision(),
|
||
"agents": list(state.selected_seat_agent_ids or []),
|
||
"replayed": True,
|
||
})
|
||
return
|
||
|
||
thread_id = state.thread_map.get(manager_id, "")
|
||
if not manager_id or not thread_id:
|
||
await clarification("manager_thread_unavailable")
|
||
return
|
||
|
||
capability_summary = MafDispatchCatalog.manager_capability_summary(
|
||
state.capability_snapshot,
|
||
candidate_set,
|
||
)
|
||
manager_tool_names = [
|
||
str(tool.get("tool_name"))
|
||
for agent in (state.capability_snapshot or {}).get("agents", [])
|
||
if isinstance(agent, dict) and agent.get("agent_id") == manager_id
|
||
for tool in (agent.get("tools") or [])
|
||
if isinstance(tool, dict) and tool.get("tool_name")
|
||
]
|
||
contract = {
|
||
"intent_type": "direct_answer|clarification|single_seat|parallel_consultation|chain_consultation|tool_write",
|
||
"selected_seat_agent_ids": "candidate IDs only",
|
||
"assignments": "one ordered task_goal and delivery_requirements entry per selected seat",
|
||
"execution_mode": "manager_only|single|parallel|chain|group_chat",
|
||
"convergence_path": ["how work converges"],
|
||
"reason": "brief explanation",
|
||
"clarification_prompt": "required only for clarification",
|
||
}
|
||
prompt = (
|
||
"You are the MAF manager. Determine the user intent and return exactly one JSON object, "
|
||
"without Markdown. You may select only from the trusted candidate seats and their redacted "
|
||
"capabilities. Invalid or uncertain requests must use clarification; never invent a seat, "
|
||
"tool, capability, or route.\n\n"
|
||
f"User task:\n{state.task_text}\n\n"
|
||
f"Trusted route mode: {candidate_set.mode}\n"
|
||
f"Trusted candidate seats: {json.dumps(candidate_set.candidate_seat_agent_ids)}\n"
|
||
f"Trusted capability summary: {json.dumps(capability_summary, ensure_ascii=False)}\n"
|
||
f"Required JSON shape: {json.dumps(contract)}"
|
||
)
|
||
try:
|
||
response = await asyncio.wait_for(
|
||
adapter.run_seat_turn(
|
||
session_id=state.session_id,
|
||
run_id=state.run_id,
|
||
agent_id=manager_id,
|
||
turn_index=0,
|
||
execution_stage="manager_dispatch",
|
||
message=prompt,
|
||
thread_id=thread_id,
|
||
model_name=state.model_name,
|
||
reasoning_effort=state.reasoning_effort,
|
||
task_text=state.task_text,
|
||
stage_instruction="Return only the strict MAF dispatch JSON object.",
|
||
excluded_tools=manager_tool_names,
|
||
fact_ledger=state.fact_ledger,
|
||
capability_snapshot=state.capability_snapshot,
|
||
write_policy=state.write_policy,
|
||
wait_timeout_seconds=state.seat_timeout_seconds,
|
||
),
|
||
timeout=state.seat_timeout_seconds,
|
||
)
|
||
except TimeoutError:
|
||
await clarification("manager_dispatch_timeout")
|
||
return
|
||
except Exception: # noqa: BLE001 - model/runtime details must not reach the user
|
||
await clarification("manager_dispatch_failed")
|
||
return
|
||
if response.error:
|
||
await clarification("manager_dispatch_failed")
|
||
return
|
||
|
||
try:
|
||
proposed, plan = MafDispatchCatalog.parse_manager_decision(
|
||
response.content or "",
|
||
candidate_set=candidate_set,
|
||
max_seats=len(candidate_set.candidate_seat_agent_ids),
|
||
capability_snapshot=state.capability_snapshot,
|
||
)
|
||
except InvalidDispatchPlanError:
|
||
await clarification("manager_dispatch_invalid")
|
||
return
|
||
|
||
state.intent_type = proposed.intent_type
|
||
if plan is None:
|
||
state.dispatch_plan = {
|
||
"execution_mode": "manager_only",
|
||
"convergence_path": list(proposed.convergence_path),
|
||
"reason": proposed.reason,
|
||
"source": "manager",
|
||
"clarification_prompt": proposed.clarification_prompt,
|
||
}
|
||
state.selected_seat_agent_ids = []
|
||
state.plan = None
|
||
state.status = "waiting_input"
|
||
state.stage = "waiting_input"
|
||
state.mark_completed(executor._node_id)
|
||
await _persist_dispatch_checkpoint(adapter, state)
|
||
await _emit_dispatch_event(
|
||
adapter,
|
||
state,
|
||
clarification_prompt=proposed.clarification_prompt,
|
||
)
|
||
await adapter._emit_critical(state.run_id, {
|
||
"event_type": "clarification.required",
|
||
"agent_id": manager_id,
|
||
"turn_index": 0,
|
||
"payload": {"prompt": proposed.clarification_prompt, "reason": proposed.reason},
|
||
})
|
||
await ctx.send_message({"decision": "clarification", "agents": []})
|
||
return
|
||
|
||
state.dispatch_plan = plan.model_dump(mode="json")
|
||
state.selected_seat_agent_ids = list(plan.seat_agent_ids)
|
||
state.plan = {
|
||
"source": plan.source,
|
||
"assignments": {
|
||
assignment.agent_id: {
|
||
"role": assignment.role,
|
||
"instruction": "\n".join(
|
||
[assignment.task_goal, *assignment.delivery_requirements]
|
||
),
|
||
}
|
||
for assignment in plan.assignments
|
||
},
|
||
}
|
||
state.stage = "dispatch_validated"
|
||
# Persist before adding a participant or thread: recovery cannot obtain a
|
||
# different model decision after a process interruption.
|
||
await _persist_dispatch_checkpoint(adapter, state)
|
||
await _ensure_selected_seat_threads(adapter, state)
|
||
state.mark_completed(executor._node_id)
|
||
await _persist_dispatch_checkpoint(adapter, state)
|
||
await _emit_dispatch_event(adapter, state)
|
||
await ctx.send_message({
|
||
"decision": executor._dispatch_decision(),
|
||
"agents": list(state.selected_seat_agent_ids),
|
||
"execution_mode": plan.execution_mode,
|
||
})
|
||
|
||
|
||
# ── Seat executor ───────────────────────────────────────────────────────────
|
||
|
||
|
||
class SeatExecutor(Executor):
|
||
"""Runs one seat turn via DeerFlowAgentAdapter.
|
||
|
||
Phase 3: failures/timeouts do NOT raise through the graph — they are
|
||
recorded in ``seat_outcomes`` so fan-in / cross-review can proceed.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
adapter: Any,
|
||
state: RoundtableState,
|
||
agent_id: str,
|
||
*,
|
||
action_coordinator: ActionCoordinator | None = None,
|
||
execution_stage: str | None = None,
|
||
defer_fact_ledger_merge: bool = False,
|
||
) -> None:
|
||
super().__init__(id=f"seat_{agent_id}")
|
||
self._adapter = adapter
|
||
self._state = state
|
||
self._agent_id = agent_id
|
||
self._action_coordinator = action_coordinator
|
||
# Stage 4 (R3-Q): optional override for the execution_stage passed to
|
||
# run_seat_turn. When None, falls back to the mode-based default
|
||
# ("expert" for structured, "group_chat" for group_chat).
|
||
self._execution_stage = execution_stage
|
||
# Stage 4 (R3-Q): when True the structured output is validated, tool
|
||
# results are audited and events are recorded as usual, but the output
|
||
# is NOT merged into the fact ledger. The quality loop merges it only
|
||
# if the manager's final decision accepts the delivery — reworked,
|
||
# reassigned or gapped content must never become candidate facts.
|
||
self._defer_fact_ledger_merge = defer_fact_ledger_merge
|
||
|
||
@handler(input=Any, output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
node_id = self.id
|
||
if self._state.is_completed(node_id):
|
||
prior = self._state.seat_outcomes.get(self._agent_id) or {"status": "completed", "replayed": True}
|
||
await ctx.send_message({"agent_id": self._agent_id, **prior, "replayed": True})
|
||
return
|
||
|
||
selected = self._state.selected_seat_agent_ids
|
||
if selected is not None and self._agent_id not in selected:
|
||
self._state.seat_outcomes[self._agent_id] = {"status": "skipped", "reason": "not_selected"}
|
||
self._state.mark_completed(node_id)
|
||
await ctx.send_message({
|
||
"agent_id": self._agent_id,
|
||
"status": "skipped",
|
||
"reason": "not_selected",
|
||
})
|
||
return
|
||
|
||
thread_id = self._state.thread_map.get(self._agent_id, "")
|
||
if not thread_id:
|
||
self._record_failure("no_thread", f"no thread for agent {self._agent_id}")
|
||
await ctx.send_message({"agent_id": self._agent_id, "error": "no_thread", "status": "failed"})
|
||
return
|
||
|
||
prior_turns = [
|
||
{"agent_id": r["agent_id"], "turn_index": r["turn_index"], "summary": r.get("content", "")[:500]}
|
||
for r in self._state.turn_results
|
||
]
|
||
caps = _agent_capabilities_from_snapshot(self._state.capability_snapshot, self._agent_id)
|
||
excluded = list(caps.get("excluded_tools", []))
|
||
|
||
assignment = {}
|
||
if isinstance(self._state.plan, dict):
|
||
assignment = (self._state.plan.get("assignments") or {}).get(self._agent_id) or {}
|
||
stage_instruction = str(
|
||
assignment.get("instruction")
|
||
or f"第{self._state.current_round}轮分析。{seat_output_instruction()}"
|
||
)
|
||
role_description = str(assignment.get("role") or "")
|
||
|
||
try:
|
||
response = await asyncio.wait_for(
|
||
self._adapter.run_seat_turn(
|
||
session_id=self._state.session_id,
|
||
run_id=self._state.run_id,
|
||
agent_id=self._agent_id,
|
||
turn_index=self._state.current_round,
|
||
execution_stage=self._execution_stage or ("group_chat" if self._state.mode == "group_chat" else "expert"),
|
||
message=self._state.task_text,
|
||
thread_id=thread_id,
|
||
model_name=caps.get("model_name") or self._state.model_name,
|
||
reasoning_effort=caps.get("reasoning_effort") or self._state.reasoning_effort,
|
||
thinking_enabled=caps.get("thinking_enabled", True),
|
||
subagent_enabled=caps.get("subagent_enabled", False),
|
||
task_text=self._state.task_text,
|
||
stage_instruction=stage_instruction,
|
||
role_description=role_description,
|
||
prior_turns=prior_turns,
|
||
fact_ledger=self._state.fact_ledger,
|
||
excluded_tools=excluded,
|
||
memory_recall_disabled=caps.get("memory_recall_disabled"),
|
||
memory_injection_enabled=caps.get("memory_injection_enabled"),
|
||
capability_snapshot=self._state.capability_snapshot,
|
||
write_policy=self._state.write_policy,
|
||
wait_timeout_seconds=self._state.seat_timeout_seconds,
|
||
),
|
||
timeout=self._state.seat_timeout_seconds,
|
||
)
|
||
except TimeoutError:
|
||
self._record_failure("timeout", f"seat {self._agent_id} timed out")
|
||
await ctx.send_message({"agent_id": self._agent_id, "status": "timeout", "error": "timeout"})
|
||
return
|
||
except Exception as exc: # noqa: BLE001
|
||
self._record_failure("error", f"{type(exc).__name__}")
|
||
await ctx.send_message({"agent_id": self._agent_id, "status": "failed", "error": type(exc).__name__})
|
||
return
|
||
|
||
if response.error:
|
||
self._record_failure("error", str(response.error)[:200])
|
||
# Still capture any partial content / tool calls below.
|
||
|
||
raw_content = response.content or ""
|
||
schema_result = await self._validate_output(raw_content)
|
||
content = schema_result.get("raw_content") or raw_content
|
||
|
||
if schema_result.get("output") and not self._defer_fact_ledger_merge:
|
||
merge_seat_output(self._state.fact_ledger, schema_result["output"], agent_id=self._agent_id)
|
||
|
||
await self._capture_pending_approvals(response.tool_calls or [])
|
||
|
||
if response.tool_calls:
|
||
for tc in response.tool_calls:
|
||
tc_name = tc.get("name", "")
|
||
is_intercepted = any(
|
||
a.get("tool_name") == tc_name for a in (self._state.pending_approvals or [])
|
||
)
|
||
record_tool_result(
|
||
self._state.fact_ledger,
|
||
tool_name=tc_name,
|
||
status="waiting_approval" if is_intercepted else "completed",
|
||
agent_id=self._agent_id,
|
||
turn_index=self._state.current_round,
|
||
)
|
||
|
||
turn_row = {
|
||
"agent_id": self._agent_id,
|
||
"turn_index": self._state.current_round,
|
||
# Phase-3 P0-1: record which execution stage produced this turn so
|
||
# recovery can distinguish an expert's original output from its
|
||
# format_repair / cross_review / finalizer re-runs.
|
||
"execution_stage": self._execution_stage or ("group_chat" if self._state.mode == "group_chat" else "expert"),
|
||
"content": content,
|
||
"tool_calls": response.tool_calls,
|
||
"error": response.error,
|
||
"usage": response.usage,
|
||
"schema_status": schema_result.get("schema_status"),
|
||
"schema_errors": schema_result.get("errors") or [],
|
||
"structured_output": schema_result.get("output"),
|
||
}
|
||
self._state.turn_results.append(turn_row)
|
||
|
||
status = "failed" if response.error else "completed"
|
||
if schema_result.get("schema_status") == "schema_invalid":
|
||
# Invalid schema does not by itself fail the seat — content is kept.
|
||
pass
|
||
self._state.seat_outcomes[self._agent_id] = {
|
||
"status": status,
|
||
"schema_status": schema_result.get("schema_status"),
|
||
"error": response.error,
|
||
}
|
||
if status == "failed":
|
||
self._state.partial_failure = True
|
||
|
||
self._state.mark_completed(node_id)
|
||
await ctx.send_message({
|
||
"agent_id": self._agent_id,
|
||
"content": content,
|
||
"error": response.error,
|
||
"status": status,
|
||
"schema_status": schema_result.get("schema_status"),
|
||
})
|
||
|
||
async def _validate_output(self, raw_content: str) -> dict[str, Any]:
|
||
if self._state.mode != "structured":
|
||
return {
|
||
"schema_status": "skipped",
|
||
"output": None,
|
||
"errors": [],
|
||
"raw_content": raw_content,
|
||
"repair_attempted": False,
|
||
}
|
||
|
||
repair_runner = None
|
||
if self._state.schema_repair_enabled:
|
||
async def _repair(prompt: str) -> str:
|
||
thread_id = self._state.thread_map.get(self._agent_id, "")
|
||
resp = await self._adapter.run_seat_turn(
|
||
session_id=self._state.session_id,
|
||
run_id=self._state.run_id,
|
||
agent_id=self._agent_id,
|
||
turn_index=self._state.current_round,
|
||
execution_stage="format_repair",
|
||
message=prompt,
|
||
thread_id=thread_id,
|
||
model_name=self._state.model_name,
|
||
reasoning_effort=self._state.reasoning_effort,
|
||
task_text=prompt,
|
||
stage_instruction="格式修复(仅一次)",
|
||
fact_ledger=self._state.fact_ledger,
|
||
capability_snapshot=self._state.capability_snapshot,
|
||
write_policy=self._state.write_policy,
|
||
wait_timeout_seconds=self._state.seat_timeout_seconds,
|
||
)
|
||
return resp.content or ""
|
||
|
||
repair_runner = _repair
|
||
|
||
return await validate_or_repair_seat_output(raw_content=raw_content, repair_runner=repair_runner)
|
||
|
||
async def _capture_pending_approvals(self, tool_calls: list[dict[str, Any]]) -> None:
|
||
if not tool_calls or self._state.write_policy == "auto":
|
||
return
|
||
pending_calls = [
|
||
tc for tc in tool_calls
|
||
if tc.get("name") and tool_needs_approval(
|
||
tc.get("name"), self._state.write_policy,
|
||
self._state.capability_snapshot, self._agent_id,
|
||
)
|
||
]
|
||
if not pending_calls:
|
||
return
|
||
|
||
all_actions = await self._store.actions.list_actions(
|
||
run_id=self._state.run_id, status="waiting_approval"
|
||
)
|
||
turn_actions = [
|
||
a for a in all_actions
|
||
if a.get("agent_id") == self._agent_id
|
||
and a.get("turn_index") == self._state.current_round
|
||
]
|
||
matched: list[dict[str, Any]] = []
|
||
for tc in pending_calls:
|
||
tc_id = tc.get("id") or ""
|
||
tc_name = tc.get("name", "")
|
||
if tc_id:
|
||
hits = [a for a in turn_actions if (a.get("logical_action_id") or "") == tc_id]
|
||
else:
|
||
hits = [
|
||
a for a in turn_actions
|
||
if a.get("tool_name") == tc_name and not a.get("logical_action_id")
|
||
]
|
||
if len(hits) > 1:
|
||
raise RuntimeError(
|
||
f"seat {self._agent_id} turn {self._state.current_round}: "
|
||
f"pending tool call '{tc_name}' (id={tc_id or '<none>'}) matched "
|
||
f"{len(hits)} waiting actions (expected exactly 1) — refusing to guess"
|
||
)
|
||
if len(hits) == 0:
|
||
logger.warning(
|
||
"seat %s turn %s: pending tool call '%s' (id=%s) classified as "
|
||
"needing approval but matched 0 waiting actions — tool may be "
|
||
"unclassified or executed as read by WriteGuard; skipping",
|
||
self._agent_id, self._state.current_round, tc_name, tc_id or "<none>",
|
||
)
|
||
continue
|
||
matched.append(hits[0])
|
||
|
||
# Merge into pending_approvals (multi-seat).
|
||
existing_ids = {p.get("action_id") for p in self._state.pending_approvals}
|
||
for a in matched:
|
||
if a["id"] in existing_ids:
|
||
continue
|
||
self._state.pending_approvals.append({
|
||
"action_id": a["id"],
|
||
"approval_id": a.get("approval_id", ""),
|
||
"agent_id": self._agent_id,
|
||
"tool_name": a.get("tool_name", ""),
|
||
"turn_index": self._state.current_round,
|
||
})
|
||
# Phase-3 P1-3: mirror the pending external write into the ledger so
|
||
# the approval lifecycle is fully traceable there. This row moves
|
||
# to executed/failed/rejected only via record_action_transition in
|
||
# WorkflowRunner.resume — it must not be left dangling.
|
||
record_action_transition(
|
||
self._state.fact_ledger,
|
||
action_id=a["id"],
|
||
tool_name=a.get("tool_name", ""),
|
||
status="pending",
|
||
agent_id=self._agent_id,
|
||
)
|
||
|
||
def _record_failure(self, status: str, detail: str) -> None:
|
||
self._state.partial_failure = True
|
||
self._state.seat_outcomes[self._agent_id] = {"status": status, "error": detail}
|
||
self._state.turn_results.append({
|
||
"agent_id": self._agent_id,
|
||
"turn_index": self._state.current_round,
|
||
"content": "",
|
||
"tool_calls": [],
|
||
"error": detail,
|
||
"usage": None,
|
||
"schema_status": None,
|
||
})
|
||
self._state.mark_completed(self.id)
|
||
|
||
@property
|
||
def _store(self) -> Any:
|
||
return self._adapter._service.store
|
||
|
||
|
||
# ── Cross-review (structured) ───────────────────────────────────────────────
|
||
|
||
|
||
class CrossReviewExecutor(Executor):
|
||
"""Barrier after parallel experts: review evidence, conflicts, tool/write status."""
|
||
|
||
def __init__(self, adapter: Any, state: RoundtableState) -> None:
|
||
super().__init__(id="cross_review")
|
||
self._adapter = adapter
|
||
self._state = state
|
||
|
||
@handler(input=Any, output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
if self._state.is_completed("cross_review"):
|
||
await ctx.send_message({"decision": "reviewed", "replayed": True})
|
||
return
|
||
|
||
self._state.stage = "review"
|
||
|
||
# P0-3: with the Stage-4 quality gate the cross-review may only see
|
||
# ACCEPTED seat results — reworked/reassigned/gapped deliveries never
|
||
# enter the review prompt (their content is already absent from the
|
||
# fact ledger because the merge was deferred until acceptance).
|
||
if self._state.quality_gate_enabled:
|
||
accepted_results = list(self._state.accepted_task_results)
|
||
review_seats = sorted({
|
||
str(r.get("agent_id") or "")
|
||
for r in accepted_results
|
||
if r.get("agent_id")
|
||
})
|
||
else:
|
||
accepted_results = None
|
||
# Barrier: ensure every seat has an outcome (fan-in should guarantee this).
|
||
review_seats = self._state.selected_seat_agent_ids or self._state.seat_agent_ids
|
||
missing = [a for a in review_seats if a not in self._state.seat_outcomes]
|
||
for aid in missing:
|
||
self._state.seat_outcomes[aid] = {"status": "missing", "error": "no_outcome"}
|
||
self._state.partial_failure = True
|
||
|
||
failed_seats = [
|
||
a for a, o in self._state.seat_outcomes.items()
|
||
if isinstance(o, dict) and o.get("status") in ("failed", "timeout", "missing", "error")
|
||
]
|
||
tool_failures = [
|
||
tr for tr in self._state.fact_ledger.get("tool_results") or []
|
||
if isinstance(tr, dict) and tr.get("status") in ("failed", "unknown")
|
||
]
|
||
pending_writes = list(self._state.fact_ledger.get("pending_actions") or [])
|
||
# Also count pending approvals still in workflow state.
|
||
if self._state.pending_approvals:
|
||
pending_writes = pending_writes or list(self._state.pending_approvals)
|
||
|
||
review: dict[str, Any] = {
|
||
"summary": "交叉审查完成",
|
||
"failed_seats": failed_seats,
|
||
"tool_failures": tool_failures,
|
||
"pending_writes": len(self._state.pending_approvals),
|
||
"conflicts": list(self._state.fact_ledger.get("conflicts") or []),
|
||
"partial_failure": self._state.partial_failure,
|
||
}
|
||
|
||
manager_id = self._state.manager_agent_id
|
||
thread_id = self._state.thread_map.get(manager_id or "", "")
|
||
if manager_id and thread_id:
|
||
try:
|
||
if accepted_results is not None:
|
||
seat_results_for_review: list[dict[str, Any]] = accepted_results
|
||
else:
|
||
seat_results_for_review = [
|
||
r for r in self._state.turn_results
|
||
if r.get("agent_id") in review_seats
|
||
]
|
||
prompt = build_manager_synthesis_input(
|
||
task_text=self._state.task_text,
|
||
seat_results=seat_results_for_review,
|
||
fact_ledger=self._state.fact_ledger,
|
||
)
|
||
# Surface the candidate ids + the evidence refs the ledger has
|
||
# backing for, so the manager can confirm by id rather than
|
||
# re-asserting facts in prose. Phase-3 P1-1: confirmations must
|
||
# bind evidence; only listed evidence refs may be cited.
|
||
candidates = self._state.fact_ledger.get("candidate_facts") or []
|
||
candidate_ids = [c.get("candidate_id") for c in candidates if isinstance(c, dict) and c.get("candidate_id")]
|
||
from app.maf_roundtable.fact_ledger import known_evidence_refs
|
||
valid_refs = sorted(known_evidence_refs(self._state.fact_ledger))
|
||
prompt = (
|
||
"【交叉审查】请检查证据是否充分、冲突、遗漏、工具失败与外部写入状态。"
|
||
"只输出一个 JSON 对象,字段:summary(必填)、evidence_ok(bool,默认false)、"
|
||
"gaps(string[])、conflicts(string[])、tool_failures(string[])、"
|
||
"write_status_notes(string[])、recommendations(string[])、"
|
||
"confirmed_candidates(对象数组,每项 {candidate_id, evidence_refs:string[]})。"
|
||
"evidence_ok 只在确有充分证据时设 true。"
|
||
"confirmed_candidates.candidate_id 只能从下列候选 id 中选择:"
|
||
f"{candidate_ids[:200]};evidence_refs 只能从下列已记录的证据引用中选择:"
|
||
f"{valid_refs[:200]};无证据支撑的候选不得确认。\n\n" + prompt
|
||
)
|
||
response = await asyncio.wait_for(
|
||
self._adapter.run_seat_turn(
|
||
session_id=self._state.session_id,
|
||
run_id=self._state.run_id,
|
||
agent_id=manager_id,
|
||
turn_index=self._state.current_round + 1,
|
||
execution_stage="cross_review",
|
||
message=prompt,
|
||
thread_id=thread_id,
|
||
model_name=self._state.model_name,
|
||
reasoning_effort=self._state.reasoning_effort,
|
||
task_text=prompt,
|
||
stage_instruction="交叉审查",
|
||
fact_ledger=self._state.fact_ledger,
|
||
capability_snapshot=self._state.capability_snapshot,
|
||
write_policy=self._state.write_policy,
|
||
wait_timeout_seconds=self._state.seat_timeout_seconds,
|
||
),
|
||
timeout=self._state.seat_timeout_seconds,
|
||
)
|
||
raw = response.content or ""
|
||
await self._apply_cross_review_output(review, raw, manager_id)
|
||
except Exception as exc: # noqa: BLE001
|
||
self._apply_deterministic_degradation(review, type(exc).__name__)
|
||
else:
|
||
self._apply_deterministic_degradation(review, None)
|
||
if failed_seats:
|
||
review["gaps"] = [f"席位失败: {', '.join(failed_seats)}"]
|
||
|
||
# The quality gate is server-owned. A manager may recommend acceptance,
|
||
# but gaps/failures/schema degradation always become a controlled gap
|
||
# and are carried into the final result instead of being labelled as a
|
||
# verified fact.
|
||
has_quality_gap = bool(
|
||
review.get("partial_failure")
|
||
or review.get("failed_seats")
|
||
or review.get("gaps")
|
||
or review.get("conflicts")
|
||
or review.get("tool_failures")
|
||
or review.get("schema_status") == "schema_invalid"
|
||
# Stage 4: a controlled gap is authoritative — the cross-review
|
||
# decision must reflect it even when everything else looks clean.
|
||
or (self._state.quality_gate_enabled and self._state.controlled_gaps)
|
||
)
|
||
review["decision"] = "controlled_gap" if has_quality_gap else "accept"
|
||
review["reason"] = (
|
||
"required evidence or seat output is incomplete"
|
||
if has_quality_gap
|
||
else "required seat deliveries passed deterministic checks"
|
||
)
|
||
self._state.cross_review_result = review
|
||
# Surface unresolved conflicts from review notes.
|
||
for c in review.get("conflicts") or []:
|
||
if c and c not in self._state.fact_ledger["conflicts"]:
|
||
self._state.fact_ledger["conflicts"].append(c)
|
||
|
||
await self._adapter._emit_critical(self._state.run_id, {
|
||
"event_type": "manager.review.decision",
|
||
"agent_id": self._state.manager_agent_id or "",
|
||
"turn_index": self._state.current_round,
|
||
"payload": {
|
||
"decision": review["decision"],
|
||
"reason": review["reason"],
|
||
"quality": {
|
||
"failed_seat_count": len(review.get("failed_seats") or []),
|
||
"gap_count": len(review.get("gaps") or []),
|
||
"conflict_count": len(review.get("conflicts") or []),
|
||
},
|
||
},
|
||
})
|
||
|
||
self._state.mark_completed("cross_review")
|
||
self._state.stage = "approval"
|
||
await ctx.send_message({"decision": "reviewed", "review": review})
|
||
|
||
async def _apply_cross_review_output(self, review: dict[str, Any], raw: str, manager_id: str) -> None:
|
||
"""Parse + validate the manager's cross-review output.
|
||
|
||
On success, write the validated fields and confirm candidate facts. On
|
||
schema failure, run one dedicated ``cross_review_repair`` seat turn; if
|
||
that still fails, fall back to a deterministic review (never paste the
|
||
raw JSON into ``summary``).
|
||
|
||
Phase-3 P1-1: confirmation is evidence-bound. The model supplies
|
||
``confirmed_candidates`` (``[{candidate_id, evidence_refs}]``); the
|
||
ledger only promotes a candidate whose evidence resolves to entries it
|
||
actually recorded (see :func:`fact_ledger.confirm_candidates`).
|
||
``evidence_ok`` defaults to ``False`` so a model that omits it never
|
||
silently passes the evidence gate.
|
||
"""
|
||
model, errors = parse_cross_review_output(raw)
|
||
if model is not None:
|
||
self._write_validated_review(review, model, source="manager", schema_status="ok")
|
||
self._confirm_reviewed_candidates(model, manager_id, review)
|
||
return
|
||
|
||
# Schema invalid — one repair attempt.
|
||
repaired_model = await self._run_cross_review_repair(raw, errors, manager_id)
|
||
if repaired_model is not None:
|
||
self._write_validated_review(review, repaired_model, source="manager", schema_status="repaired")
|
||
self._confirm_reviewed_candidates(repaired_model, manager_id, review)
|
||
return
|
||
|
||
self._apply_deterministic_degradation(review, None)
|
||
|
||
def _confirm_reviewed_candidates(self, model: Any, manager_id: str, review: dict[str, Any]) -> None:
|
||
"""Promote only evidence-bound confirmed candidates; record rejections.
|
||
|
||
Phase-3 P1-2: if the model says evidence is NOT sufficient
|
||
(``evidence_ok=False``), no candidate may be promoted regardless of
|
||
what ``confirmed_candidates`` it supplies. The attempted confirmations
|
||
are recorded as rejections for audit.
|
||
"""
|
||
if not getattr(model, "evidence_ok", False):
|
||
attempted = [
|
||
c.candidate_id
|
||
for c in (getattr(model, "confirmed_candidates", None) or [])
|
||
]
|
||
if attempted:
|
||
review["confirmation_rejections"] = [
|
||
{
|
||
"candidate_id": candidate_id,
|
||
"reason": "review_did_not_confirm_evidence",
|
||
}
|
||
for candidate_id in attempted
|
||
]
|
||
return
|
||
|
||
rejections: list[dict[str, Any]] = []
|
||
accepted: list[dict[str, Any]] = []
|
||
if getattr(model, "confirmed_candidates", None):
|
||
confirm_candidates(
|
||
self._state.fact_ledger,
|
||
confirmed_candidates=[
|
||
{"candidate_id": c.candidate_id, "evidence_refs": list(c.evidence_refs or [])}
|
||
for c in model.confirmed_candidates
|
||
],
|
||
confirmed_by=manager_id,
|
||
rejections=rejections,
|
||
accepted=accepted,
|
||
)
|
||
elif getattr(model, "confirmed_candidate_ids", None):
|
||
from app.maf_roundtable.fact_ledger import confirm_candidate_facts
|
||
|
||
confirm_candidate_facts(
|
||
self._state.fact_ledger,
|
||
confirmed_candidate_ids=list(model.confirmed_candidate_ids or []),
|
||
confirmed_by=manager_id,
|
||
evidence_refs=[],
|
||
accepted=accepted,
|
||
)
|
||
# ``confirmed_candidates`` is an API-facing success field. Populate it
|
||
# only from the server-validated promotions, never from the model's
|
||
# request. The raw model request remains in ``requested_confirmations``
|
||
# for audit/debugging.
|
||
review["confirmed_candidates"] = accepted
|
||
review["confirmed_candidate_ids"] = [item["candidate_id"] for item in accepted]
|
||
if rejections:
|
||
review["confirmation_rejections"] = rejections
|
||
|
||
async def _run_cross_review_repair(self, raw: str, errors: list[str], manager_id: str) -> Any:
|
||
"""One-shot repair turn for an invalid CrossReviewOutput.
|
||
|
||
Returns the validated model or ``None``. Runs the repair through the
|
||
DeerFlow adapter so it executes under the full WriteGuard /
|
||
approval regime (a repair turn can itself produce tool calls).
|
||
"""
|
||
thread_id = self._state.thread_map.get(manager_id or "", "")
|
||
if not (manager_id and thread_id and self._state.schema_repair_enabled):
|
||
return None
|
||
repair_prompt = build_repair_prompt(raw, errors, schema_name="CrossReviewOutput")
|
||
try:
|
||
response = await asyncio.wait_for(
|
||
self._adapter.run_seat_turn(
|
||
session_id=self._state.session_id,
|
||
run_id=self._state.run_id,
|
||
agent_id=manager_id,
|
||
turn_index=self._state.current_round + 1,
|
||
execution_stage="cross_review_repair",
|
||
message=repair_prompt,
|
||
thread_id=thread_id,
|
||
model_name=self._state.model_name,
|
||
reasoning_effort=self._state.reasoning_effort,
|
||
task_text=repair_prompt,
|
||
stage_instruction="交叉审查格式修复(仅一次)",
|
||
fact_ledger=self._state.fact_ledger,
|
||
capability_snapshot=self._state.capability_snapshot,
|
||
write_policy=self._state.write_policy,
|
||
wait_timeout_seconds=self._state.seat_timeout_seconds,
|
||
),
|
||
timeout=self._state.seat_timeout_seconds,
|
||
)
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
model, _errors = parse_cross_review_output(response.content or "")
|
||
return model
|
||
|
||
def _write_validated_review(
|
||
self, review: dict[str, Any], model: Any, *, source: str, schema_status: str
|
||
) -> None:
|
||
review["source"] = source
|
||
review["schema_status"] = schema_status
|
||
review["summary"] = model.summary
|
||
review["gaps"] = list(model.gaps or [])
|
||
review["conflicts"] = list(model.conflicts or [])
|
||
review["tool_failures"] = list(model.tool_failures or [])
|
||
review["write_status_notes"] = list(model.write_status_notes or [])
|
||
review["recommendations"] = list(model.recommendations or [])
|
||
review["evidence_ok"] = bool(model.evidence_ok)
|
||
# Preserve the model's request for audit, but do not label it as an
|
||
# accepted confirmation. _confirm_reviewed_candidates fills the latter
|
||
# only after the evidence and candidate-ID checks pass server-side.
|
||
review["requested_confirmations"] = [
|
||
{
|
||
"candidate_id": item.candidate_id,
|
||
"evidence_refs": list(item.evidence_refs),
|
||
}
|
||
for item in (getattr(model, "confirmed_candidates", None) or [])
|
||
]
|
||
review["requested_confirmations"].extend(
|
||
{
|
||
"candidate_id": candidate_id,
|
||
"evidence_refs": [],
|
||
}
|
||
for candidate_id in (model.confirmed_candidate_ids or [])
|
||
)
|
||
review["confirmed_candidates"] = []
|
||
review["confirmed_candidate_ids"] = []
|
||
|
||
def _apply_deterministic_degradation(self, review: dict[str, Any], error: str | None) -> None:
|
||
"""Deterministic fallback when the manager output is unparseable.
|
||
|
||
Never pastes the raw model JSON into ``summary``; instead marks the
|
||
review as deterministic so downstream knows it is not model-derived.
|
||
"""
|
||
review["source"] = "deterministic"
|
||
review["schema_status"] = "schema_invalid"
|
||
review["raw_response_ignored"] = True
|
||
if error:
|
||
review["manager_error"] = error
|
||
|
||
|
||
# ── Approval gate ───────────────────────────────────────────────────────────
|
||
|
||
|
||
class ApprovalGateExecutor(Executor):
|
||
"""Pauses for external-write approval before proceeding."""
|
||
|
||
def __init__(self, adapter: Any, state: RoundtableState, service: Any) -> None:
|
||
super().__init__(id="approval_gate")
|
||
self._adapter = adapter
|
||
self._state = state
|
||
self._service = service
|
||
|
||
@handler(input=Any, output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
if self._state.pending_approvals:
|
||
all_decided = True
|
||
for pa in self._state.pending_approvals:
|
||
approval_id = pa.get("approval_id", "")
|
||
approval = await self._service.store.approvals.get_approval(approval_id)
|
||
if approval is None or approval["status"] == "pending":
|
||
all_decided = False
|
||
continue
|
||
if approval["status"] == "approved":
|
||
from app.maf_roundtable.event_normalizer import make_action_approved
|
||
await self._adapter._emit_critical(self._state.run_id, make_action_approved(
|
||
pa.get("agent_id", ""),
|
||
self._state.current_round,
|
||
action_id=pa.get("action_id", ""),
|
||
approval_id=approval_id,
|
||
decided_by=approval.get("decided_by", ""),
|
||
))
|
||
elif approval["status"] == "rejected":
|
||
from app.maf_roundtable.event_normalizer import make_action_rejected
|
||
await self._adapter._emit_critical(self._state.run_id, make_action_rejected(
|
||
pa.get("agent_id", ""),
|
||
self._state.current_round,
|
||
action_id=pa.get("action_id", ""),
|
||
approval_id=approval_id,
|
||
decided_by=approval.get("decided_by", ""),
|
||
))
|
||
|
||
if all_decided:
|
||
self._state.pending_approvals = []
|
||
self._state.mark_completed("approval_gate")
|
||
await ctx.send_message({"decision": "approved"})
|
||
return
|
||
|
||
await ctx.send_message({"decision": "waiting_approval"})
|
||
return
|
||
|
||
needs_approval = self._check_needs_approval()
|
||
if not needs_approval:
|
||
self._state.mark_completed("approval_gate")
|
||
await ctx.send_message({"decision": "approved", "policy": self._state.write_policy})
|
||
return
|
||
|
||
if not self._state.pending_approvals:
|
||
raise WriteGuardConfigurationError(
|
||
f"Write policy '{self._state.write_policy}' requires approval "
|
||
f"for detected write tools, but WriteGuard did not create any "
|
||
f"pending actions. Check that write_guard.enabled is true and "
|
||
f"the provider is configured in config.yaml."
|
||
)
|
||
|
||
await self._service.store.runs.update_run(self._state.run_id, {"status": "waiting_approval"})
|
||
await self._service.store.sessions.update_session(
|
||
self._state.session_id, user_id=self._adapter._user_id, fields={"status": "waiting_approval"}
|
||
)
|
||
self._state.status = "waiting_approval"
|
||
await ctx.send_message({"decision": "waiting_approval"})
|
||
|
||
def _check_needs_approval(self) -> bool:
|
||
if self._state.write_policy == "auto":
|
||
return False
|
||
# pending_approvals is the authoritative source: it is populated by
|
||
# SeatExecutor._capture_pending_approvals which only adds tool calls
|
||
# that WriteGuard actually intercepted (i.e. have a database action).
|
||
# Tools that were classified as needing approval but had 0 matching
|
||
# actions (unclassified/hallucinated/read tools) are intentionally
|
||
# skipped and must NOT re-trigger the gate here.
|
||
return bool(self._state.pending_approvals)
|
||
|
||
|
||
# ── Finalizer ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[str, Any]:
|
||
"""Run the manager synthesis and set ``state.final_result``."""
|
||
from app.maf_roundtable.event_normalizer import make_run_completed
|
||
|
||
manager_id = state.manager_agent_id or "coordinator"
|
||
thread_id = state.thread_map.get(manager_id, "")
|
||
|
||
if state.quality_gate_enabled:
|
||
# P0-3: Stage 4 synthesizes ONLY accepted task results. There is
|
||
# deliberately NO fallback to ``turn_results`` — when no delivery was
|
||
# accepted, the report must be built from the controlled-gap risk
|
||
# section alone instead of smuggling rejected content back in.
|
||
seat_results_for_synthesis: list[dict[str, Any]] = list(state.accepted_task_results)
|
||
else:
|
||
usable = [
|
||
r for r in state.turn_results
|
||
if not r.get("error") or r.get("content")
|
||
]
|
||
if state.partial_failure:
|
||
usable = [r for r in usable if (state.seat_outcomes.get(r.get("agent_id")) or {}).get("status") == "completed"]
|
||
seat_results_for_synthesis = usable or state.turn_results
|
||
|
||
synthesis_input = build_manager_synthesis_input(
|
||
task_text=state.task_text,
|
||
seat_results=seat_results_for_synthesis,
|
||
fact_ledger=state.fact_ledger,
|
||
)
|
||
if state.partial_failure:
|
||
failed = [
|
||
a for a, o in state.seat_outcomes.items()
|
||
if isinstance(o, dict) and o.get("status") not in ("completed", None)
|
||
]
|
||
synthesis_input = (
|
||
f"【降级综合】以下席位失败/超时,请仅基于可用结果与事实账本总结:"
|
||
f"{', '.join(failed)}\n\n" + synthesis_input
|
||
)
|
||
if state.cross_review_result:
|
||
synthesis_input += f"\n\n【交叉审查摘要】\n{state.cross_review_result.get('summary', '')[:2000]}"
|
||
|
||
# Stage 4 (R3-Q): controlled gaps must reach the final report traceably and
|
||
# must NEVER be presented as verified facts. They are injected into the
|
||
# synthesis prompt as an explicit risk/pending-info section.
|
||
if state.controlled_gaps:
|
||
gap_lines = [
|
||
f"- 任务 {gap.get('task_key')}(席位 {gap.get('agent_id')},"
|
||
f"尝试 {gap.get('attempts', 0)} 次):{gap.get('reason')}"
|
||
for gap in state.controlled_gaps
|
||
]
|
||
synthesis_input += (
|
||
"\n\n【受控缺口(尚未获得合格席位交付,严禁写成已验证事实)】\n"
|
||
+ "\n".join(gap_lines)
|
||
+ "\n要求:把上述每一条缺口完整列入最终结论的『风险/待补信息』章节,"
|
||
"逐条说明其对结论的影响;不得将缺口内容陈述为已验证结论。"
|
||
)
|
||
|
||
has_gaps = bool(state.controlled_gaps)
|
||
quality_decision = (
|
||
"controlled_gap"
|
||
if has_gaps
|
||
else (
|
||
(state.cross_review_result or {}).get("decision")
|
||
or ("controlled_gap" if state.partial_failure else "accept")
|
||
)
|
||
)
|
||
|
||
if thread_id:
|
||
response = await adapter.run_seat_turn(
|
||
session_id=state.session_id,
|
||
run_id=state.run_id,
|
||
agent_id=manager_id,
|
||
turn_index=state.current_round + 1,
|
||
execution_stage="finalizer",
|
||
message=synthesis_input,
|
||
thread_id=thread_id,
|
||
model_name=state.model_name,
|
||
reasoning_effort=state.reasoning_effort,
|
||
task_text=synthesis_input,
|
||
stage_instruction="综合所有席位分析,生成最终结论",
|
||
fact_ledger=state.fact_ledger,
|
||
capability_snapshot=state.capability_snapshot,
|
||
write_policy=state.write_policy,
|
||
wait_timeout_seconds=state.seat_timeout_seconds,
|
||
)
|
||
state.final_result = {
|
||
"content": response.content,
|
||
"agent_id": manager_id,
|
||
"partial_failure": state.partial_failure,
|
||
"degraded": state.partial_failure or has_gaps,
|
||
"quality_decision": quality_decision,
|
||
"controlled_gaps": list(state.controlled_gaps),
|
||
}
|
||
else:
|
||
state.final_result = {
|
||
"content": synthesis_input,
|
||
"agent_id": manager_id,
|
||
"note": "no_manager_thread",
|
||
"partial_failure": state.partial_failure,
|
||
"degraded": state.partial_failure or has_gaps,
|
||
"quality_decision": quality_decision,
|
||
"controlled_gaps": list(state.controlled_gaps),
|
||
}
|
||
|
||
await adapter._emit_critical(state.run_id, make_run_completed(manager_id, state.current_round + 1, {
|
||
"total_rounds": state.current_round,
|
||
"seats_completed": len([
|
||
a for a, o in state.seat_outcomes.items()
|
||
if isinstance(o, dict) and o.get("status") == "completed"
|
||
]),
|
||
"partial_failure": state.partial_failure,
|
||
}))
|
||
return state.final_result
|
||
|
||
|
||
class FinalizerExecutor(Executor):
|
||
"""Generates the final synthesis and ends the run."""
|
||
|
||
def __init__(self, adapter: Any, state: RoundtableState) -> None:
|
||
super().__init__(id="finalizer")
|
||
self._adapter = adapter
|
||
self._state = state
|
||
|
||
@handler(input=Any, workflow_output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
if self._state.is_completed("finalizer") and self._state.final_result is not None:
|
||
self._state.status = "completed"
|
||
await ctx.yield_output({"final_result": self._state.final_result, "replayed": True})
|
||
return
|
||
|
||
self._state.stage = "finalizing"
|
||
await generate_final_report(self._adapter, self._state)
|
||
self._state.status = "completed"
|
||
self._state.stage = "done"
|
||
self._state.mark_completed("finalizer")
|
||
await ctx.yield_output({"final_result": self._state.final_result})
|
||
|
||
|
||
class WaitingTerminalExecutor(Executor):
|
||
"""Terminal executor for the approval-waiting path."""
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__(id="waiting_terminal")
|
||
|
||
@handler(input=Any, workflow_output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
await ctx.yield_output({"status": "waiting_approval"})
|
||
|
||
|
||
class _SilentWorkflowCtx:
|
||
"""WorkflowContext stand-in for SeatExecutor invoked INSIDE the quality
|
||
loop. The embedded SeatExecutor reuses the outer executor's ctx when
|
||
called directly, which would otherwise publish its messages with the
|
||
outer node's source id and re-enter the graph. A silent ctx discards
|
||
those intermediate messages — the quality loop reads the delivery state
|
||
from ``turn_results`` / ``seat_outcomes`` instead."""
|
||
|
||
async def send_message(self, msg: Any) -> None:
|
||
return None
|
||
|
||
async def yield_output(self, msg: Any) -> None:
|
||
return None
|
||
|
||
|
||
class WaitingInputTerminalExecutor(Executor):
|
||
"""Terminal executor for the Stage-4 ask_user / waiting_input path.
|
||
|
||
Reached when a seat task's fallback policy requests user clarification
|
||
(``ask_user``) and the manager cannot resolve the gap. The run ends in
|
||
``waiting_input`` — never as a disguised success.
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__(id="waiting_input_terminal")
|
||
|
||
@handler(input=Any, workflow_output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
await ctx.yield_output({"status": "waiting_input"})
|
||
|
||
|
||
class QualitySeatTasksExecutor(Executor):
|
||
"""Stage 4 (R3-Q): run every seat task through the acceptance state machine.
|
||
|
||
Replaces the plain SeatExecutor fan-out for V2 structured dispatch. One
|
||
executor drives all tasks in bounded quality rounds::
|
||
|
||
delivering → submitted → manager_review →
|
||
accepted | rework(→delivering) | reassigned(→delivering) |
|
||
controlled_gap | ask_user(→ waiting_input)
|
||
|
||
Every delivery runs a deterministic quality check first; the manager model
|
||
then proposes one structured decision per task, and the server finalizes it
|
||
via :func:`quality_contracts.finalize_review_decision` — a delivery that
|
||
failed the deterministic checks can NEVER be accepted, reworks are capped
|
||
by the frozen contract, and an exhausted task ends as ``controlled_gap``
|
||
(or an explicit ``ask_user`` pause) instead of being disguised as success.
|
||
|
||
Task rows persist in ``maf_roundtable_seat_tasks``; reassignment only
|
||
changes the task's ``agent_id`` (a server-validated backup candidate) and
|
||
never creates a new task.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
adapter: Any,
|
||
state: RoundtableState,
|
||
*,
|
||
action_coordinator: ActionCoordinator | None = None,
|
||
max_rework_attempts: int = 2,
|
||
max_reassignments: int = 1,
|
||
min_delivery_chars: int = 40,
|
||
min_delivery_evidence: int = 1,
|
||
fallback_policy: str = "reassign_then_gap",
|
||
) -> None:
|
||
super().__init__(id="quality_seat_tasks")
|
||
self._adapter = adapter
|
||
self._state = state
|
||
self._action_coordinator = action_coordinator
|
||
self._max_rework_attempts = max_rework_attempts
|
||
self._max_reassignments = max_reassignments
|
||
self._min_delivery_chars = min_delivery_chars
|
||
self._min_delivery_evidence = min_delivery_evidence
|
||
self._fallback_policy = fallback_policy
|
||
self._tasks: list[dict[str, Any]] = []
|
||
|
||
@property
|
||
def _store(self) -> Any:
|
||
return self._adapter._service.store
|
||
|
||
@handler(input=Any, output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
if self._state.is_completed("quality_seat_tasks"):
|
||
await ctx.send_message({
|
||
"tasks": dict(self._state.seat_task_statuses),
|
||
"replayed": True,
|
||
})
|
||
return
|
||
|
||
self._state.stage = "experts"
|
||
tasks = await self._load_or_create_tasks()
|
||
self._tasks = tasks
|
||
base_round = max(self._state.current_round, 1)
|
||
# P1: duplicate detection is seeded from the persisted task history
|
||
# (SHA-256 digests of prior conclusions), so a restart cannot re-submit
|
||
# an already-rejected conclusion and pass it off as new content.
|
||
prior_conclusions: dict[str, set[str]] = {}
|
||
for task in tasks:
|
||
digests = {
|
||
str(h.get("conclusion_sha256"))
|
||
for h in (task.get("history") or [])
|
||
if isinstance(h, dict) and h.get("conclusion_sha256")
|
||
}
|
||
prior_conclusions[str(task.get("task_key") or "")] = digests
|
||
|
||
# Bounded quality loop: initial delivery + max_rework_attempts reworks
|
||
# per task budget, plus one delivery round per possible reassignment.
|
||
max_rounds = (self._max_rework_attempts + 1) * (self._max_reassignments + 1) + 1
|
||
for round_offset in range(max_rounds):
|
||
self._state.current_round = base_round + round_offset
|
||
active = [t for t in tasks if t["status"] not in TASK_TERMINAL_STATUSES]
|
||
if not active or self._state.status == "waiting_input":
|
||
break
|
||
|
||
submitted: list[dict[str, Any]] = []
|
||
for task in active:
|
||
outcome = await self._deliver(task, prior_conclusions)
|
||
if outcome is not None:
|
||
submitted.append(outcome)
|
||
|
||
if not submitted:
|
||
continue
|
||
|
||
reviews = await self._manager_review(submitted)
|
||
for task in submitted:
|
||
final_outcome = await self._apply_decision(task, reviews.get(task["agent_id"]))
|
||
if final_outcome == OUTCOME_ASK_USER:
|
||
self._state.status = "waiting_input"
|
||
self._state.stage = "waiting_input"
|
||
|
||
self._state.seat_task_statuses = {
|
||
t["task_key"]: {
|
||
"task_key": t["task_key"],
|
||
"status": t["status"],
|
||
"agent_id": t["agent_id"],
|
||
"original_agent_id": t["original_agent_id"],
|
||
"attempt_count": t["attempt_count"],
|
||
"rework_count": t["rework_count"],
|
||
"reassign_count": t["reassign_count"],
|
||
"required": bool(t["required"]),
|
||
}
|
||
for t in tasks
|
||
}
|
||
self._state.mark_completed("quality_seat_tasks")
|
||
await ctx.send_message({"tasks": dict(self._state.seat_task_statuses)})
|
||
|
||
# ── task init (atomic, recovery-safe) ───────────────────────────────────
|
||
|
||
def _expected_task_specs(self) -> list[dict[str, Any]]:
|
||
"""The server-computed expected task set from the dispatch assignments.
|
||
|
||
``task_key`` is stable per original assignment, so recovery always
|
||
derives the identical key set — existing rows are kept, rows that a
|
||
crashed initialization never created are filled back in.
|
||
"""
|
||
plan = self._state.dispatch_plan or {}
|
||
assignments = plan.get("assignments") or []
|
||
specs: list[dict[str, Any]] = []
|
||
for index, assignment in enumerate(assignments, start=1):
|
||
agent_id = str(assignment.get("agent_id") or "").strip()
|
||
if not agent_id:
|
||
continue
|
||
contract = build_task_contract(
|
||
task_goal=str(assignment.get("task_goal") or ""),
|
||
delivery_requirements=list(assignment.get("delivery_requirements") or []),
|
||
required=True,
|
||
max_rework_attempts=self._max_rework_attempts,
|
||
fallback_policy=self._fallback_policy, # type: ignore[arg-type]
|
||
)
|
||
specs.append({
|
||
"task_key": f"{index:02d}:{agent_id}",
|
||
"agent_id": agent_id,
|
||
"contract": contract.model_dump(),
|
||
"required": True,
|
||
})
|
||
return specs
|
||
|
||
async def _load_or_create_tasks(self) -> list[dict[str, Any]]:
|
||
"""Atomic get-or-create over the FULL expected task set (P0-4).
|
||
|
||
Never short-circuits on "some tasks already exist": a crash mid-init
|
||
must not permanently drop the remaining required seats. Existing rows
|
||
keep their frozen contracts; concurrent recoveries converge without
|
||
unique-key errors.
|
||
"""
|
||
return await self._store.seat_tasks.get_or_create_tasks(
|
||
session_id=self._state.session_id,
|
||
run_id=self._state.run_id,
|
||
expected=self._expected_task_specs(),
|
||
)
|
||
|
||
# ── delivery step ───────────────────────────────────────────────────────
|
||
|
||
async def _deliver(
|
||
self,
|
||
task: dict[str, Any],
|
||
prior_conclusions: dict[str, set[str]],
|
||
) -> dict[str, Any] | None:
|
||
"""Run one delivery via SeatExecutor (approvals / ledger / outcomes
|
||
reuse the existing seat path), then submit it with its deterministic
|
||
check result. Returns the submitted task dict or ``None`` if the seat
|
||
run hard-failed without any output."""
|
||
agent_id = task["agent_id"]
|
||
|
||
# Re-delivery must not replay the completed node.
|
||
node_id = f"seat_{agent_id}"
|
||
if node_id in self._state.completed_nodes:
|
||
self._state.completed_nodes.remove(node_id)
|
||
|
||
# Reassigned tasks: add the validated backup seat to the selected set
|
||
# and ensure its participant row + thread before it delivers.
|
||
if task["status"] == TASK_REASSIGNED:
|
||
if self._state.selected_seat_agent_ids is not None and agent_id not in self._state.selected_seat_agent_ids:
|
||
self._state.selected_seat_agent_ids.append(agent_id)
|
||
await _ensure_selected_seat_threads(self._adapter, self._state)
|
||
|
||
is_rework = task["attempt_count"] > 0
|
||
seat_exec = SeatExecutor(
|
||
self._adapter,
|
||
self._state,
|
||
agent_id,
|
||
action_coordinator=self._action_coordinator,
|
||
execution_stage="rework" if is_rework else None,
|
||
# P0-3: the delivery must not touch the fact ledger until the
|
||
# manager's final decision accepts it.
|
||
defer_fact_ledger_merge=True,
|
||
)
|
||
await seat_exec.handle({}, _SilentWorkflowCtx())
|
||
|
||
turn_row = next(
|
||
(
|
||
r for r in reversed(self._state.turn_results)
|
||
if r.get("agent_id") == agent_id
|
||
and r.get("turn_index") == self._state.current_round
|
||
),
|
||
None,
|
||
)
|
||
raw_content = str((turn_row or {}).get("content") or "")
|
||
structured_output = (turn_row or {}).get("structured_output")
|
||
schema_status = str((turn_row or {}).get("schema_status") or "ok")
|
||
delivery = delivery_from_seat_output(
|
||
structured_output=structured_output, raw_content=raw_content
|
||
)
|
||
prior = prior_conclusions.get(task["task_key"])
|
||
if prior is None:
|
||
prior = set()
|
||
prior_conclusions[task["task_key"]] = prior
|
||
# P1: evidence must be traceable to the fact ledger (recorded evidence
|
||
# or successful tool results). Self-made findings / invented refs fail
|
||
# the deterministic gate.
|
||
verified_refs = known_evidence_refs(self._state.fact_ledger)
|
||
check = check_delivery_deterministic(
|
||
delivery=delivery,
|
||
raw_content=raw_content,
|
||
schema_status=schema_status,
|
||
min_length=self._min_delivery_chars,
|
||
min_evidence=self._min_delivery_evidence,
|
||
prior_conclusion_digests=prior,
|
||
verified_evidence_refs=verified_refs,
|
||
)
|
||
digest = conclusion_digest(delivery.conclusion) if delivery.conclusion else None
|
||
if digest:
|
||
prior.add(digest)
|
||
|
||
task["attempt_count"] = int(task.get("attempt_count") or 0) + 1
|
||
history = list(task.get("history") or [])
|
||
history.append({
|
||
"attempt": task["attempt_count"],
|
||
"agent_id": agent_id,
|
||
"round": self._state.current_round,
|
||
"schema_status": schema_status,
|
||
"failure_codes": list(check["failure_codes"]),
|
||
# P1: persist the digest only — never the conclusion body — so
|
||
# duplicate detection survives restarts.
|
||
"conclusion_sha256": digest,
|
||
})
|
||
# P0-3: persisted delivery envelope. ``contract_delivery`` is the
|
||
# acceptance-facing view; ``validated_seat_output`` is the schema-
|
||
# validated raw SeatTurnOutput kept back until acceptance merges it
|
||
# into the fact ledger. Runtime objects / tool-call arguments are
|
||
# never written here.
|
||
task["delivery"] = {
|
||
"contract_delivery": delivery.model_dump(),
|
||
"validated_seat_output": structured_output
|
||
if isinstance(structured_output, dict)
|
||
else None,
|
||
}
|
||
task["deterministic_check"] = check
|
||
task["history"] = history
|
||
# In-memory only (never persisted): the turn row of this attempt, so
|
||
# an acceptance can merge exactly what was submitted.
|
||
task["_turn_row"] = turn_row
|
||
await self._store.seat_tasks.update_task(task["id"], {
|
||
"status": TASK_SUBMITTED,
|
||
"attempt_count": task["attempt_count"],
|
||
"delivery": task["delivery"],
|
||
"deterministic_check": check,
|
||
"history": history,
|
||
})
|
||
task["status"] = TASK_SUBMITTED
|
||
|
||
await self._adapter._emit_critical(self._state.run_id, make_seat_delivery_submitted(
|
||
agent_id,
|
||
self._state.current_round,
|
||
task_key=task["task_key"],
|
||
attempt=task["attempt_count"],
|
||
deterministic_passed=bool(check["passed"]),
|
||
failure_codes=list(check["failure_codes"]),
|
||
))
|
||
return task
|
||
|
||
# ── manager review step ─────────────────────────────────────────────────
|
||
|
||
async def _manager_review(self, tasks: list[dict[str, Any]]) -> dict[str, Any]:
|
||
"""One batched manager review call for the round's submissions.
|
||
|
||
Returns ``{agent_id: MafManagerReviewItem}``. Any failure (timeout,
|
||
runtime error, empty output, invalid JSON, schema failure) degrades to
|
||
an empty dict — every task then finalizes as an INVALID review
|
||
(``source=system``): a system rework while the cap allows it, or the
|
||
contract's exhaustion outcome afterwards. A delivery is never accepted
|
||
without a valid manager review, and a seat missing from a partially
|
||
valid review is treated the same way.
|
||
"""
|
||
manager_id = self._state.manager_agent_id
|
||
thread_id = self._state.thread_map.get(manager_id or "", "")
|
||
if not (manager_id and thread_id):
|
||
return {}
|
||
|
||
review_items: list[dict[str, Any]] = []
|
||
for task in tasks:
|
||
contract = task.get("contract") or {}
|
||
envelope = task.get("delivery") or {}
|
||
# The manager reviews the contract-facing delivery view; the raw
|
||
# validated seat output stays server-side until acceptance.
|
||
contract_delivery = envelope.get("contract_delivery") or envelope
|
||
review_items.append({
|
||
"agent_id": task["agent_id"],
|
||
"task_key": task["task_key"],
|
||
"attempt": task["attempt_count"],
|
||
"contract": {
|
||
"objective": contract.get("objective"),
|
||
"expected_deliverable": contract.get("expected_deliverable"),
|
||
"acceptance_criteria": list(contract.get("acceptance_criteria") or []),
|
||
"required": bool(contract.get("required", True)),
|
||
},
|
||
"delivery": contract_delivery,
|
||
"check": task.get("deterministic_check") or {},
|
||
})
|
||
prompt = build_manager_review_prompt(
|
||
task_text=self._state.task_text, review_items=review_items
|
||
)
|
||
try:
|
||
response = await asyncio.wait_for(
|
||
self._adapter.run_seat_turn(
|
||
session_id=self._state.session_id,
|
||
run_id=self._state.run_id,
|
||
agent_id=manager_id,
|
||
turn_index=self._state.current_round,
|
||
execution_stage="manager_review",
|
||
message=prompt,
|
||
thread_id=thread_id,
|
||
model_name=self._state.model_name,
|
||
reasoning_effort=self._state.reasoning_effort,
|
||
task_text=prompt,
|
||
stage_instruction="返回严格的总控验收 JSON(每席位一条 decision)",
|
||
fact_ledger=self._state.fact_ledger,
|
||
capability_snapshot=self._state.capability_snapshot,
|
||
write_policy=self._state.write_policy,
|
||
wait_timeout_seconds=self._state.seat_timeout_seconds,
|
||
),
|
||
timeout=self._state.seat_timeout_seconds,
|
||
)
|
||
except Exception: # noqa: BLE001 - degraded to deterministic finalization
|
||
return {}
|
||
if response.error:
|
||
return {}
|
||
model, _errors = parse_manager_review(response.content or "")
|
||
if model is None:
|
||
return {}
|
||
return {item.agent_id: item for item in model.decisions}
|
||
|
||
# ── decision application ────────────────────────────────────────────────
|
||
|
||
async def _apply_decision(
|
||
self,
|
||
task: dict[str, Any],
|
||
review_item: Any | None,
|
||
) -> str:
|
||
"""Finalize and persist one task's review decision; returns the outcome."""
|
||
contract = MafTaskContract.model_validate(task.get("contract") or {})
|
||
check = task.get("deterministic_check") or {"passed": False, "failure_codes": []}
|
||
final = finalize_review_decision(
|
||
contract=contract,
|
||
check_result=check,
|
||
review_item=review_item,
|
||
rework_count=int(task.get("rework_count") or 0),
|
||
reassign_count=int(task.get("reassign_count") or 0),
|
||
has_backup_seat=bool(self._backup_agents(task)),
|
||
max_reassignments=self._max_reassignments,
|
||
)
|
||
outcome = final["decision"]
|
||
|
||
await self._adapter._emit_critical(self._state.run_id, make_manager_review_decision(
|
||
task["agent_id"],
|
||
self._state.current_round,
|
||
task_key=task["task_key"],
|
||
decision=outcome,
|
||
reason=final["reason"],
|
||
source=final["source"],
|
||
attempt=task["attempt_count"],
|
||
))
|
||
|
||
if outcome == OUTCOME_ACCEPTED:
|
||
task["status"] = TASK_ACCEPTED
|
||
# P0-3: only an accepted delivery may enter the fact ledger and
|
||
# the accepted result set consumed by cross-review / the final
|
||
# synthesis. Reworked / reassigned / gapped attempts never do.
|
||
envelope = task.get("delivery") or {}
|
||
validated = envelope.get("validated_seat_output")
|
||
if isinstance(validated, dict):
|
||
merge_seat_output(
|
||
self._state.fact_ledger, validated, agent_id=task["agent_id"]
|
||
)
|
||
turn_row = task.get("_turn_row") or {}
|
||
self._state.accepted_task_results.append({
|
||
"task_key": task["task_key"],
|
||
"agent_id": task["agent_id"],
|
||
"attempt": task["attempt_count"],
|
||
"accepted": True,
|
||
"source": final["source"],
|
||
"turn_index": turn_row.get("turn_index", self._state.current_round),
|
||
"content": str(turn_row.get("content") or ""),
|
||
"execution_stage": turn_row.get("execution_stage"),
|
||
"schema_status": turn_row.get("schema_status"),
|
||
"structured_output": validated if isinstance(validated, dict) else None,
|
||
})
|
||
await self._store.seat_tasks.update_task(task["id"], {
|
||
"status": TASK_ACCEPTED,
|
||
"review_decision": final,
|
||
})
|
||
|
||
elif outcome == OUTCOME_REWORK:
|
||
task["rework_count"] = int(task.get("rework_count") or 0) + 1
|
||
task["status"] = TASK_REWORK
|
||
await self._store.seat_tasks.update_task(task["id"], {
|
||
"status": TASK_REWORK,
|
||
"rework_count": task["rework_count"],
|
||
"review_decision": final,
|
||
})
|
||
await self._adapter._emit_critical(self._state.run_id, make_seat_rework_assigned(
|
||
task["agent_id"],
|
||
self._state.current_round,
|
||
task_key=task["task_key"],
|
||
rework_count=task["rework_count"],
|
||
requirements=list(final.get("revision_requirements") or []),
|
||
))
|
||
|
||
elif outcome == OUTCOME_REASSIGNED:
|
||
from_agent_id = task["agent_id"]
|
||
backup = self._pick_backup(task)
|
||
task["reassign_count"] = int(task.get("reassign_count") or 0) + 1
|
||
task["agent_id"] = backup
|
||
task["status"] = TASK_REASSIGNED
|
||
await self._store.seat_tasks.update_task(task["id"], {
|
||
"agent_id": backup,
|
||
"status": TASK_REASSIGNED,
|
||
"reassign_count": task["reassign_count"],
|
||
"review_decision": final,
|
||
})
|
||
await self._adapter._emit_critical(self._state.run_id, make_seat_reassigned(
|
||
backup,
|
||
self._state.current_round,
|
||
task_key=task["task_key"],
|
||
from_agent_id=from_agent_id,
|
||
reassign_count=task["reassign_count"],
|
||
))
|
||
|
||
elif outcome == OUTCOME_CONTROLLED_GAP:
|
||
gap = make_controlled_gap(
|
||
task_key=task["task_key"],
|
||
agent_id=task["agent_id"],
|
||
objective=contract.objective,
|
||
reason=final["reason"],
|
||
failure_codes=list(check.get("failure_codes") or []),
|
||
attempts=task["attempt_count"],
|
||
)
|
||
task["gap"] = gap
|
||
task["status"] = TASK_CONTROLLED_GAP
|
||
self._state.controlled_gaps.append(gap)
|
||
await self._store.seat_tasks.update_task(task["id"], {
|
||
"status": TASK_CONTROLLED_GAP,
|
||
"gap": gap,
|
||
"review_decision": final,
|
||
})
|
||
|
||
else: # OUTCOME_ASK_USER
|
||
task["status"] = TASK_EXHAUSTED
|
||
await self._store.seat_tasks.update_task(task["id"], {
|
||
"status": TASK_EXHAUSTED,
|
||
"review_decision": final,
|
||
})
|
||
|
||
return outcome
|
||
|
||
# ── backup selection (server-validated) ─────────────────────────────────
|
||
|
||
def _backup_agents(self, task: dict[str, Any]) -> list[str]:
|
||
"""Ordered backup candidates for a task.
|
||
|
||
Candidates = server candidate seats minus every agent this task has
|
||
already used (original + rework owners) minus every agent that is the
|
||
current owner of ANY other task. A seat owns at most one task at a
|
||
time — a seat whose own task was accepted must not be double-booked as
|
||
a backup, because it would then hold two tasks and its deliveries
|
||
would collide on the (run, agent, turn, stage) idempotency key.
|
||
"""
|
||
candidates = list(self._state.candidate_seat_agent_ids or [])
|
||
used: set[str] = {
|
||
str(item.get("agent_id") or "")
|
||
for item in (task.get("history") or [])
|
||
if str(item.get("agent_id") or "")
|
||
}
|
||
used.add(str(task.get("agent_id") or ""))
|
||
for other in self._tasks:
|
||
if other.get("task_key") == task.get("task_key"):
|
||
continue
|
||
used.add(str(other.get("agent_id") or ""))
|
||
return [agent_id for agent_id in candidates if agent_id not in used]
|
||
|
||
def _pick_backup(self, task: dict[str, Any]) -> str:
|
||
backups = self._backup_agents(task)
|
||
if not backups:
|
||
raise RuntimeError(
|
||
f"MAF quality loop requested reassignment for task "
|
||
f"{task.get('task_key')} but no validated backup seat exists"
|
||
)
|
||
return backups[0]
|
||
|
||
|
||
class QualityGateExecutor(Executor):
|
||
"""Stage 4 (R3-Q): phase quality gate between seat tasks and cross-review.
|
||
|
||
All required seat tasks must have reached a terminal state (``accepted``
|
||
or ``controlled_gap``) before the phase may proceed. A required task left
|
||
non-terminal blocks the phase deterministically (hard run failure — the
|
||
quality loop guarantees terminal states, so a blocked gate is a safety
|
||
net, never a normal outcome). Controlled gaps do NOT block the phase:
|
||
they proceed as ``passed_with_gaps`` and are carried into the final
|
||
report's risk section.
|
||
"""
|
||
|
||
def __init__(self, adapter: Any, state: RoundtableState) -> None:
|
||
super().__init__(id="quality_gate")
|
||
self._adapter = adapter
|
||
self._state = state
|
||
|
||
@handler(input=Any, output=dict)
|
||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||
if self._state.is_completed("quality_gate"):
|
||
await ctx.send_message({"decision": "passed", "replayed": True})
|
||
return
|
||
|
||
# ask_user path: route to the waiting_input terminal, never to
|
||
# cross-review / finalizer.
|
||
if self._state.status == "waiting_input":
|
||
self._state.mark_completed("quality_gate")
|
||
await ctx.send_message({"decision": "waiting_input"})
|
||
return
|
||
|
||
statuses = self._state.seat_task_statuses
|
||
required = {k: v for k, v in statuses.items() if v.get("required")}
|
||
non_terminal = [
|
||
k for k, v in required.items() if v.get("status") not in TASK_TERMINAL_STATUSES
|
||
]
|
||
# P0-4 safety net: every dispatch assignment must be represented by a
|
||
# task row. A missing required task blocks the gate exactly like a
|
||
# non-terminal one — the gate must never pass on a dropped seat.
|
||
expected_keys: list[str] = []
|
||
plan = self._state.dispatch_plan or {}
|
||
for index, assignment in enumerate(plan.get("assignments") or [], start=1):
|
||
agent_id = str(assignment.get("agent_id") or "").strip()
|
||
if agent_id:
|
||
expected_keys.append(f"{index:02d}:{agent_id}")
|
||
missing = [k for k in expected_keys if k not in statuses]
|
||
blocked = sorted({*non_terminal, *missing})
|
||
accepted = sum(1 for v in statuses.values() if v.get("status") == TASK_ACCEPTED)
|
||
gap_count = sum(1 for v in statuses.values() if v.get("status") == TASK_CONTROLLED_GAP)
|
||
decision = "blocked" if blocked else ("passed_with_gaps" if gap_count else "passed")
|
||
|
||
await self._adapter._emit_critical(self._state.run_id, make_stage_quality_gate(
|
||
self._state.manager_agent_id,
|
||
self._state.current_round,
|
||
decision=decision,
|
||
total=len(statuses),
|
||
accepted=accepted,
|
||
controlled_gap=gap_count,
|
||
blocked_required=blocked,
|
||
))
|
||
|
||
if blocked:
|
||
raise RuntimeError(
|
||
f"MAF quality gate blocked: required seat tasks not terminal or missing: "
|
||
f"{', '.join(blocked)}"
|
||
)
|
||
|
||
self._state.mark_completed("quality_gate")
|
||
self._state.stage = "review"
|
||
await ctx.send_message({"decision": decision, "tasks": dict(statuses)})
|
||
|
||
|
||
# ── helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _agent_capabilities_from_snapshot(snapshot: dict[str, Any] | None, agent_id: str) -> dict[str, Any]:
|
||
if not snapshot:
|
||
return {}
|
||
agents = snapshot.get("agents")
|
||
if not isinstance(agents, list):
|
||
return {}
|
||
for a in agents:
|
||
if isinstance(a, dict) and a.get("agent_id") == agent_id:
|
||
return {
|
||
"model_name": a.get("model_name"),
|
||
"reasoning_effort": a.get("reasoning_effort"),
|
||
"thinking_enabled": a.get("thinking_enabled", True),
|
||
"subagent_enabled": a.get("subagent_enabled", False),
|
||
"excluded_tools": a.get("excluded_tools") or [],
|
||
"memory_recall_disabled": a.get("memory_recall_disabled"),
|
||
"memory_injection_enabled": a.get("memory_injection_enabled"),
|
||
"write_policy": a.get("write_policy"),
|
||
}
|
||
return {}
|
||
|
||
|
||
def _is_high_risk_tool(tool_name: str, snapshot: dict[str, Any] | None = None) -> bool:
|
||
side_effect, risk_level = classify_tool(tool_name, snapshot)
|
||
if side_effect in ("read", "skill"):
|
||
return False
|
||
risk_order = {"R0": 0, "R1": 1, "R2": 2, "R3": 3, "R4": 4}
|
||
return risk_order.get(risk_level, 3) >= 3
|