Files
董宏凯andCursor 1a080311bd fix(maf): harden phase-3 dispatch recovery and participant writes
Make ensure_participants concurrent-safe, restore clarification checkpoints without re-planning, and only create threads for validated seats.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 21:47:46 +08:00

1683 lines
74 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.fact_ledger import (
confirm_candidates,
empty_ledger,
merge_seat_output,
normalize_ledger,
record_action_transition,
record_tool_result,
)
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,
) -> 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)
)
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
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,
"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,
}
@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"),
)
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.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:
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,
) -> None:
super().__init__(id=f"seat_{agent_id}")
self._adapter = adapter
self._state = state
self._agent_id = agent_id
self._action_coordinator = action_coordinator
@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="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"):
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": "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"
# 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:
prompt = build_manager_synthesis_input(
task_text=self._state.task_text,
seat_results=[r for r in self._state.turn_results if r.get("agent_id") in review_seats],
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"
)
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, "")
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"]
synthesis_input = build_manager_synthesis_input(
task_text=state.task_text,
seat_results=usable or state.turn_results,
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]}"
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,
"quality_decision": (
(state.cross_review_result or {}).get("decision")
or ("controlled_gap" if state.partial_failure else "accept")
),
}
else:
state.final_result = {
"content": synthesis_input,
"agent_id": manager_id,
"note": "no_manager_thread",
"partial_failure": state.partial_failure,
"degraded": state.partial_failure,
"quality_decision": (
(state.cross_review_result or {}).get("decision")
or ("controlled_gap" if state.partial_failure else "accept")
),
}
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"})
# ── 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