feat(maf): add secure roundtable workflow runtime

This commit is contained in:
董宏凯 committed 2026-08-01 17:51:53 +08:00
1 parent e690475e6e
commit bd4131b4e2
52 files changed
+10315 -96

No files matched your search

+3
View File
@@ -81,6 +81,9 @@ skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
# MAF roundtable tests must be tracked (excepted from the broad tests/* ignore above)
!offline-backend-20260512/backend/tests/test_maf_*.py
!offline-backend-20260512/backend/tests/maf_test_helpers.py
deploy
.history
@@ -328,13 +328,41 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
maf_admin_diag = (
bool(getattr(maf_cfg, "admin_diagnostic_access", True)) if maf_cfg is not None else True
)
# P0-4: when MAF is switched on, its full safety stack must be in
# place (write_guard mounted + provider complete + bridge resolvable
# + MAF_ACTION_SECRET_KEY set). If any prerequisite is missing the
# feature is forced OFF (clearly logged) — never "MAF on, approval
# guard not mounted". Reads stay open for admin history review.
if maf_enabled:
from app.maf_roundtable.service import (
MafConfigurationError,
validate_maf_prerequisites,
)
try:
validate_maf_prerequisites()
except MafConfigurationError as cfg_exc:
logger.error(
"[maf-roundtable] enabled but prerequisites unmet — "
"forcing the feature OFF: %s",
cfg_exc,
)
maf_enabled = False
if app.state.maf_roundtable_store is not None:
app.state.maf_roundtable_service = MafRoundtableService(
app.state.maf_roundtable_store,
enabled=maf_enabled,
admin_diagnostic_access=maf_admin_diag,
config=maf_cfg,
agent_store=getattr(app.state, "agent_store", None),
app_config=config,
)
# Wire the WriteGuard bridge so the harness middleware can reach
# the service when intercepting external-write tool calls.
if app.state.maf_roundtable_service is not None:
from app.maf_roundtable.write_guard_bridge import register_write_guard_service
register_write_guard_service(app.state.maf_roundtable_service)
else:
app.state.maf_roundtable_service = None
except Exception: # noqa: BLE001 — experimental feature must never abort startup
@@ -198,12 +198,31 @@ async def delete_session(request: Request, session_id: str) -> dict[str, Any]:
async def create_run(request: Request, session_id: str, body: CreateRunRequest) -> RunResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
# Check MAF SDK availability BEFORE creating the run — so the user gets a
# clean 503 rather than a run that will immediately fail.
from app.maf_roundtable.dispatcher import MafSdkNotAvailableError, get_dispatcher
dispatcher = get_dispatcher(request.app)
try:
dispatcher.check_sdk_available()
except MafSdkNotAvailableError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
try:
run = await svc.create_run(
session_id=session_id, user_id=user_id, workflow_version=body.workflow_version, is_admin=is_admin
)
except (MafDisabledError, MafForbiddenError, MafNotFoundError, MafConflictError) as exc:
raise _map_service_error(exc) from exc
# Launch the workflow in the background. The run is already persisted as
# "pending"; the dispatcher transitions it to "running" then completes/fails it.
# Auto-dispatch can be disabled (e.g. in tests) via app.state.maf_auto_dispatch=False.
if getattr(request.app.state, "maf_auto_dispatch", True):
session = await svc.get_session(session_id=session_id, user_id=user_id, is_admin=is_admin)
await dispatcher.dispatch(session, run, user_id=user_id or "default")
return RunResponse(**run)
@@ -233,11 +252,43 @@ async def get_run(request: Request, run_id: str) -> RunResponse:
async def cancel_run(request: Request, run_id: str) -> RunResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
# Phase 1: atomically set run+session to cancelling (not yet cancelled).
try:
run = await svc.cancel_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
await svc.start_cancel(run_id=run_id, user_id=user_id, is_admin=is_admin)
except (MafDisabledError, MafForbiddenError, MafNotFoundError) as exc:
raise _map_service_error(exc) from exc
return RunResponse(**run)
# Phase 2: cascade cancel to the background task + DeerFlow sub-runs.
from app.maf_roundtable.dispatcher import get_dispatcher
dispatcher = get_dispatcher(request.app)
result = await dispatcher.cancel(run_id)
# Phase 3: finalize based on the structured cancel result.
# - all_cancelled → finalize to ``cancelled`` (terminal).
# - some sub-runs failed/still active → mark ``cancel_failed`` (non-terminal)
# so the failure is visible and retryable. A failed cancel must NEVER
# masquerade as cancelled.
try:
if result.get("all_cancelled"):
final_run = await svc.finalize_cancel(run_id=run_id, user_id=user_id, is_admin=is_admin)
else:
failed = result.get("failed_run_ids") or []
still_active = result.get("still_active_run_ids") or []
detail = []
if failed:
detail.append(f"{len(failed)} sub-run(s) failed to cancel")
if still_active:
detail.append(f"{len(still_active)} sub-run(s) had no DeerFlow run id")
reason = "; ".join(detail) or "cancel cascade incomplete"
final_run = await svc.mark_cancel_failed(
run_id=run_id, user_id=user_id, is_admin=is_admin, reason=reason
)
except (MafDisabledError, MafForbiddenError, MafNotFoundError) as exc:
raise _map_service_error(exc) from exc
return RunResponse(**final_run)
# ── events ─────────────────────────────────────────────────────────────────
@@ -310,4 +361,25 @@ async def decide_approval(request: Request, approval_id: str, body: ApprovalDeci
)
except (MafDisabledError, MafForbiddenError, MafNotFoundError, MafConflictError) as exc:
raise _map_service_error(exc) from exc
# P1: after a successful approval decision, resume the workflow from its
# checkpoint. Only the owner can resume (decide_approval already enforced
# owner-only write). The resume loads the checkpoint, restores
# RoundtableState.from_dict(), and executes the approved action (at-most-once).
run_id = approval.get("run_id")
if run_id and body.decision in ("approved", "rejected"):
from app.maf_roundtable.dispatcher import get_dispatcher
dispatcher = get_dispatcher(request.app)
try:
await dispatcher.resume(run_id, user_id=user_id or "default")
except Exception:
# Resume failure should not fail the approval response itself —
# the approval is already persisted. Log for observability.
import logging
logging.getLogger(__name__).exception(
"MAF resume failed for run %s after approval %s", run_id, approval_id
)
return ApprovalResponse(**approval)
@@ -0,0 +1,386 @@
"""Action Coordinator for the MAF roundtable experiment (Phase 2 P1).
Intercepts external-write tool calls **before** they reach the real external
system. The current problem (pre-fix) was that seats executed DeerFlow tools
first and the ApprovalGate only inspected ``tool_calls`` afterwards — too
late. The ActionCoordinator fixes this by:
1. **Classification**: reads per-tool risk metadata from the frozen
capability snapshot (``side_effect: read|write``, ``risk_level: R0–R4``).
Tools without metadata use a **safe default** (treated as write/R3 →
requires approval). Classification never relies on name-prefix heuristics
like ``create_`` / ``delete_``.
2. **Pre-execution interception (Phase-2)**: write tools are NO LONGER
excluded from the seat run — the model sees the full tool schema and can
propose writes. A harness-level ``WriteGuardMiddleware`` intercepts each
write tool call at ``wrap_tool_call``/``awrap_tool_call`` time (activated
per-run by a ``maf_run_id`` flag in the run context) and records a pending
action before the real tool ever runs. The AI's proposed call is turned
into an ``approval_required`` ToolMessage instead of the tool's real result.
3. **Post-turn capture**: after the seat turn, the seat executor reads the
run's ``tool_calls`` to surface the pending approval in workflow state so
the ApprovalGate can pause. Action creation itself happens inside the
WriteGuard interceptor (single transaction — see point 5).
For each intercepted write tool call, the coordinator creates — in a
**single logical transaction** (``store.create_pending_action_bundle``):
- an ``action`` row with idempotency key ``run_id:agent_id:turn_index:tool_call_id``
- the **sanitized** arguments (for audit / API visibility)
- the **encrypted** actual arguments (server-only, for restart recovery)
- an ``approval`` row (pending)
- an ``action.waiting_approval`` critical audit event
If the critical audit event write fails, the action stays **unexecuted**
— the external system is never called.
4. **Post-approval execution**: after the user approves:
- Only an ``approved`` ``action_id`` may re-enter the tool executor.
- Before execution, the action is atomically occupied (``approved → running``).
- A second entry for the same action returns the existing result or is
rejected — **never** a second write.
- Success → ``succeeded``, failure → ``failed``, network-unknown →
``unknown`` (``unknown`` prohibits auto-retry).
Encryption: uses ``cryptography.fernet.Fernet`` with a key derived from the
``MAF_ACTION_SECRET_KEY`` env var. There is **no default key** — the var is
required whenever the MAF feature is enabled (validated at startup). The
ciphertext is opaque to the store and is **never** returned from any API,
event, log, or frontend query.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import os
from typing import Any
from app.maf_roundtable.sanitize import sanitize, sanitize_payload
logger = logging.getLogger(__name__)
# ── Risk classification ───────────────────────────────────────────────────
# Tools whose side_effect is explicitly "read" or "skill" are never intercepted.
# Everything else (including "unknown" / missing metadata) is treated as a
# potential write and subject to the write policy.
_SAFE_SIDE_EFFECTS = frozenset({"read", "skill"})
# Default risk level for tools without metadata — safe default requires approval.
_DEFAULT_RISK_LEVEL = "R3"
_DEFAULT_SIDE_EFFECT = "write"
def classify_tool(
tool_name: str,
capability_snapshot: dict[str, Any] | None,
agent_id: str | None = None,
) -> tuple[str, str]:
"""Classify a tool's side-effect and risk level from the capability snapshot.
Returns ``(side_effect, risk_level)``. Tools without explicit metadata
use a **safe default**: ``("write", "R3")`` — meaning they are treated as
high-risk writes requiring approval. This is intentional: we never guess
risk from the tool name.
"""
if not capability_snapshot:
return (_DEFAULT_SIDE_EFFECT, _DEFAULT_RISK_LEVEL)
tools = capability_snapshot.get("tools")
if isinstance(tools, list):
for t in tools:
if isinstance(t, dict) and t.get("name") == tool_name:
side = t.get("side_effect") or _DEFAULT_SIDE_EFFECT
risk = t.get("risk_level") or _DEFAULT_RISK_LEVEL
return (side, risk)
agents = capability_snapshot.get("agents")
if isinstance(agents, list) and agent_id:
for a in agents:
if isinstance(a, dict) and a.get("agent_id") == agent_id:
agent_tools = a.get("tools")
if isinstance(agent_tools, list):
for t in agent_tools:
if isinstance(t, dict) and t.get("name") == tool_name:
side = t.get("side_effect") or _DEFAULT_SIDE_EFFECT
risk = t.get("risk_level") or _DEFAULT_RISK_LEVEL
return (side, risk)
return (_DEFAULT_SIDE_EFFECT, _DEFAULT_RISK_LEVEL)
def tool_needs_approval(
tool_name: str,
write_policy: str,
capability_snapshot: dict[str, Any] | None,
agent_id: str | None = None,
) -> bool:
"""Determine whether a tool call needs approval before execution.
- ``auto``: no approval needed (but the tool is still classified for audit).
- ``confirm_high_risk``: approval needed for R3+ tools.
- ``confirm_all_writes``: approval needed for any write tool.
"""
if write_policy == "auto":
return False
side_effect, risk_level = classify_tool(tool_name, capability_snapshot, agent_id)
if side_effect in _SAFE_SIDE_EFFECTS:
return False
if write_policy == "confirm_all_writes":
return True
# confirm_high_risk: R3 and above
risk_order = {"R0": 0, "R1": 1, "R2": 2, "R3": 3, "R4": 4}
risk_val = risk_order.get(risk_level, 3)
return risk_val >= 3
# ── Encryption ─────────────────────────────────────────────────────────────
class MafActionSecretKeyError(RuntimeError):
"""Raised when ``MAF_ACTION_SECRET_KEY`` is missing or empty.
There is no default key: the env var is required whenever the MAF feature
is enabled (validated at startup). Using a baked-in default would let a
deployment silently encrypt all action params with a publicly-known key.
"""
class ActionSecretUnavailableError(RuntimeError):
"""The approved action's parameters cannot be produced — execution MUST fail.
Raised (instead of returning ``None``) whenever the server-only secret is
missing, cannot be decrypted, or does not decrypt to a JSON object. Callers
must treat this as a hard execution failure: record the action ``failed``,
fail the parent run, and NEVER invoke the tool — they must NOT coerce the
failure into empty params (``params or {}``) and execute anyway.
Carries a machine-readable ``reason`` (``missing`` / ``decrypt_failed`` /
``invalid_type``) and a ``detail`` that is always safe to log/persist — it
never contains the ciphertext, the key, or raw exception bodies.
"""
def __init__(self, action_id: str, reason: str, detail: str) -> None:
self.action_id = action_id
self.reason = reason
self.detail = detail
super().__init__(f"action secret unavailable for {action_id}: {reason} — {detail}")
def _get_fernet_key() -> bytes:
"""Derive a Fernet key from the ``MAF_ACTION_SECRET_KEY`` env var.
Raises ``MafActionSecretKeyError`` if the var is missing or empty — there
is deliberately **no default key**. Callers must ensure the var is set
before the MAF feature is used (the service validates this at startup).
"""
raw = os.getenv("MAF_ACTION_SECRET_KEY", "")
if not raw or not raw.strip():
raise MafActionSecretKeyError(
"MAF_ACTION_SECRET_KEY environment variable is not set. "
"It is required to encrypt external-write action parameters. "
"Set it (e.g. a 32+ char random string) before enabling "
"maf_roundtable.enabled."
)
digest = hashlib.sha256(raw.encode()).digest()
return base64.urlsafe_b64encode(digest)
def encrypt_params(params: dict[str, Any]) -> str:
"""Encrypt tool-call arguments for server-only storage."""
import json
from cryptography.fernet import Fernet
plaintext = json.dumps(params, ensure_ascii=False).encode()
return Fernet(_get_fernet_key()).encrypt(plaintext).decode()
def decrypt_params(ciphertext: str) -> dict[str, Any]:
"""Decrypt tool-call arguments at execution time (server-only)."""
import json
from cryptography.fernet import Fernet
plaintext = Fernet(_get_fernet_key()).decrypt(ciphertext.encode()).decode()
return json.loads(plaintext)
# ── Action Coordinator ────────────────────────────────────────────────────
class ActionCoordinator:
"""Manages the intercept-approve-execute lifecycle for external writes.
Used by the DeerFlowAgentAdapter and WorkflowRunner to:
- Determine which tools to exclude before a seat turn.
- Capture intercepted write tool calls after a seat turn.
- Create actions + approvals + audit events in one logical transaction.
- Atomically occupy an approved action for execution (at-most-once).
"""
def __init__(self, service: Any, *, user_id: str = "") -> None:
self._service = service
self._store = service.store
self._user_id = user_id
# ── Post-turn: capture intercepted write tool calls ──────────────────
async def intercept_write_tool_calls(
self,
*,
run_id: str,
session_id: str,
agent_id: str,
turn_index: int,
tool_calls: list[dict[str, Any]],
capability_snapshot: dict[str, Any] | None,
write_policy: str,
) -> list[dict[str, Any]]:
"""Process the AI's tool_calls and create actions+approvals for writes.
For each tool call that needs approval, create the action + encrypted
params + approval + audit event in a **single transaction**
(``store.create_pending_action_bundle``). If any step fails, the whole
bundle rolls back — the DB never holds a half-finished action.
Idempotency: ``idempotency_key`` is DB-unique. A concurrent duplicate
submission re-reads and returns the already-stored action instead of
raising, so a retry never fails the run.
Returns a list of created (or pre-existing) action dicts.
"""
created: list[dict[str, Any]] = []
for tc in tool_calls:
tool_name = tc.get("name", "")
tool_call_id = tc.get("id", "")
raw_args = tc.get("args") or {}
if not tool_needs_approval(tool_name, write_policy, capability_snapshot, agent_id):
continue
_side_effect, risk_level = classify_tool(tool_name, capability_snapshot, agent_id)
idempotency_key = f"{run_id}:{agent_id}:{turn_index}:{tool_call_id or tool_name}"
# Sanitize args for audit visibility (never expose raw args).
sanitized_args = sanitize(raw_args)
if not isinstance(sanitized_args, dict):
sanitized_args = {"value": str(sanitized_args)}
# Encrypt actual args for server-only recovery.
encrypted_args = encrypt_params(
raw_args if isinstance(raw_args, dict) else {"value": str(raw_args)}
)
# Single-transaction bundle: action + secret + approval + event.
# The store retries internally (up to 8 attempts) on (run_id, seq)
# collisions, re-computing MAX(seq)+1 each time.
action = await self._store.create_pending_action_bundle(
session_id=session_id,
run_id=run_id,
agent_id=agent_id,
tool_name=tool_name,
logical_action_id=tool_call_id or None,
idempotency_key=idempotency_key,
risk_level=risk_level,
target_key=None,
turn_index=turn_index,
arguments_sanitized=sanitized_args,
arguments_encrypted=encrypted_args,
approval_summary=f"外部写操作需要审批: {tool_name}(第{turn_index}轮)",
approval_payload={
"tool_name": tool_name,
"risk_level": risk_level,
"turn_index": turn_index,
"arguments_redacted": sanitized_args,
},
audit_event_agent_id=agent_id,
audit_event_turn_index=turn_index,
)
created.append(action)
return created
# ── Post-approval: atomic occupy + execution ─────────────────────────
async def occupy_for_execution(self, action_id: str) -> str:
"""Atomically transition an action from ``approved`` to ``running``.
Returns the effective claim status (never a blanket bool):
``"claimed"`` (won — execute), ``"in_progress"`` (another executor is
running it), or a terminal status ``succeeded`` / ``failed`` /
``unknown`` / ``rejected`` (already finished elsewhere). Raises
``MafActionStateMachineError`` when the action was never properly
approved. This guarantees at-most-once execution under concurrent
``resume()`` / retries / restart.
"""
return await self._store.actions.claim_for_execution(action_id)
async def record_result(
self,
action_id: str,
*,
status: str,
result: dict[str, Any] | None = None,
external_object_id: str | None = None,
) -> None:
"""Record the outcome of an action execution.
``status`` must be ``succeeded``, ``failed``, or ``unknown``.
``unknown`` prohibits auto-retry — the action stays in ``unknown``
until a human reconciles it.
State-machine constrained: only a ``running`` action may transition
to a terminal status. A concurrent caller that lost the race (or a
replay after restart) finds the row already terminal and its UPDATE
is a no-op — it must NOT overwrite the real result.
"""
sanitized_result = sanitize_payload(result) if result else None
await self._store.actions.record_terminal_result(
action_id,
status=status,
result=sanitized_result,
external_object_id=external_object_id,
)
async def get_approved_action_params(self, action_id: str) -> dict[str, Any]:
"""Decrypt and return the actual execution parameters for an approved action.
Server-only: called only by the tool executor at execution time. The
decrypted params never reach API, events, logs, or frontend.
Raises ``ActionSecretUnavailableError`` (never returns ``None``) when
the params cannot be produced — missing secret row, undecryptable
ciphertext, or a non-object plaintext. The caller MUST treat this as a
hard failure (record ``failed``, fail the parent run, never execute the
tool); it must NOT fall back to empty params.
"""
ciphertext = await self._store.action_secrets.get_by_action(action_id)
if ciphertext is None:
raise ActionSecretUnavailableError(
action_id, "missing", "no encrypted action secret is stored for this action"
)
try:
params = decrypt_params(ciphertext)
except Exception as exc:
# Log a sanitized reason only — never the ciphertext, key, or raw body.
logger.error(
"Failed to decrypt action params for %s: %s",
action_id, sanitize(str(exc)),
)
raise ActionSecretUnavailableError(
action_id, "decrypt_failed", "encrypted action params could not be decrypted"
) from exc
if not isinstance(params, dict):
raise ActionSecretUnavailableError(
action_id, "invalid_type", "decrypted action params are not a JSON object"
)
return params
@@ -0,0 +1,168 @@
"""Build server-trusted, frozen MAF capability snapshots.
Clients must not supply tool metadata or full snapshots — only participant
``agent_id`` and optional per-seat ``excluded_tools`` (subtractive only).
"""
from __future__ import annotations
import logging
from typing import Any
from deerflow.config.agents_config import load_agent_config
from deerflow.config.app_config import AppConfig
from deerflow.tools.tool_metadata import resolve_tool_metadata
from deerflow.tools.tools import get_available_tools
logger = logging.getLogger(__name__)
class CapabilitySnapshotError(Exception):
"""Invalid participant / tool selection when building a trusted snapshot."""
class AgentNotAccessibleError(CapabilitySnapshotError):
def __init__(self, agent_id: str) -> None:
super().__init__(f"agent not accessible: {agent_id}")
self.agent_id = agent_id
class InvalidExcludedToolsError(CapabilitySnapshotError):
def __init__(self, agent_id: str, tools: list[str]) -> None:
super().__init__(f"invalid excluded_tools for {agent_id}: {tools}")
self.agent_id = agent_id
self.tools = tools
async def _assert_user_may_use_agent(
*,
agent_store: Any | None,
user_id: str,
agent_id: str,
) -> None:
"""Ensure the user may run this agent (visible in catalog)."""
if agent_store is not None:
visible = await agent_store.get_visible(agent_id, user_id)
if visible is None:
raise AgentNotAccessibleError(agent_id)
return
cfg = load_agent_config(agent_id)
if cfg is None:
raise AgentNotAccessibleError(agent_id)
def _config_extra_for_tool(app_config: AppConfig, tool_name: str) -> dict[str, Any] | None:
for tc in app_config.tools:
if tc.name == tool_name:
extra = dict(getattr(tc, "model_extra", None) or {})
return extra or None
return None
def _seat_tools_for_agent(
*,
app_config: AppConfig,
agent_id: str,
session_model_name: str | None,
session_reasoning_effort: str | None,
requested_excluded: list[str],
) -> tuple[list[str], list[str], list[dict[str, str]]]:
"""Resolve allowed names, effective excluded, and tool metadata for one seat."""
agent_config = load_agent_config(agent_id)
tool_groups = getattr(agent_config, "tool_groups", None) if agent_config else None
model_name = (
getattr(agent_config, "model", None) if agent_config else None
) or session_model_name
available = get_available_tools(
groups=tool_groups,
model_name=model_name,
excluded_tools=[],
app_config=app_config,
)
allowed_set = {t.name for t in available}
excluded_set = set(requested_excluded or [])
unknown_excluded = excluded_set - allowed_set
if unknown_excluded:
raise InvalidExcludedToolsError(agent_id, sorted(unknown_excluded))
effective_excluded = sorted(excluded_set & allowed_set)
effective_allowed = sorted(allowed_set - set(effective_excluded))
tools_meta: list[dict[str, str]] = []
for name in effective_allowed:
extra = _config_extra_for_tool(app_config, name)
tools_meta.append(resolve_tool_metadata(name, config_extra=extra))
return effective_allowed, effective_excluded, tools_meta
async def build_trusted_capability_snapshot(
*,
user_id: str,
participants: list[dict[str, Any]],
app_config: AppConfig,
agent_store: Any | None = None,
session_model_name: str | None = None,
session_reasoning_effort: str | None = None,
session_excluded_tools: list[str] | None = None,
) -> dict[str, Any]:
"""Build the frozen snapshot stored on the session and used by WriteGuard."""
agents_out: list[dict[str, Any]] = []
flat_tools: dict[str, dict[str, str]] = {}
for p in participants:
agent_id = str(p.get("agent_id") or "")
if not agent_id:
continue
await _assert_user_may_use_agent(
agent_store=agent_store, user_id=user_id, agent_id=agent_id
)
agent_config = load_agent_config(agent_id)
seat_excluded = list(p.get("excluded_tools") or [])
if session_excluded_tools:
seat_excluded = sorted(set(seat_excluded) | set(session_excluded_tools))
allowed, excluded, tools_meta = _seat_tools_for_agent(
app_config=app_config,
agent_id=agent_id,
session_model_name=session_model_name,
session_reasoning_effort=session_reasoning_effort,
requested_excluded=seat_excluded,
)
for meta in tools_meta:
flat_tools[meta["tool_name"]] = meta
agents_out.append(
{
"agent_id": agent_id,
"role": p.get("role", "seat"),
"model_name": getattr(agent_config, "model", None) if agent_config else session_model_name,
"reasoning_effort": session_reasoning_effort,
"thinking_enabled": True,
"subagent_enabled": bool(getattr(agent_config, "subagent_enabled", False))
if agent_config
else False,
"tool_groups": list(getattr(agent_config, "tool_groups", None) or [])
if agent_config
else None,
"allowed_tool_names": allowed,
"excluded_tools": excluded,
"tools": tools_meta,
"memory_recall_disabled": None,
"memory_injection_enabled": None,
}
)
return {
"version": 1,
"agents": agents_out,
"tools": list(flat_tools.values()),
}
def seat_snapshot_for_agent(full_snapshot: dict[str, Any], agent_id: str) -> dict[str, Any]:
"""Per-participant slice persisted on ``maf_roundtable_participants``."""
for a in full_snapshot.get("agents") or []:
if isinstance(a, dict) and a.get("agent_id") == agent_id:
return dict(a)
return {}
@@ -0,0 +1,160 @@
"""Build bounded, traceable seat input from MAF roundtable context.
Converts the shared roundtable state (task, previous turns, fact ledger,
stage instructions) into a single human message for a seat's DeerFlow thread.
Design constraints (MAF-02-06):
- Bounded: total output STRICTLY never exceeds ``max_context_chars``.
- Trimmable: each section is independently truncatable.
- Traceable: includes turn references so the seat can cite prior work.
- Never injects credentials, tokens, or raw binary content.
- All content is sanitized before inclusion.
"""
from __future__ import annotations
from typing import Any
from app.maf_roundtable.sanitize import sanitize
_DEFAULT_MAX_CONTEXT_CHARS = 12000
_DEFAULT_MAX_TURN_CHARS = 2000
_DEFAULT_MAX_FACT_CHARS = 3000
def build_seat_input(
*,
task_text: str,
stage_instruction: str,
role_description: str,
prior_turns: list[dict[str, Any]],
fact_ledger: dict[str, Any] | None = None,
max_context_chars: int = _DEFAULT_MAX_CONTEXT_CHARS,
max_turn_chars: int = _DEFAULT_MAX_TURN_CHARS,
max_fact_chars: int = _DEFAULT_MAX_FACT_CHARS,
) -> str:
"""Assemble the human message for a seat's DeerFlow run.
The total output length is STRICTLY <= max_context_chars. Truncation
happens per-section first, then a final hard cap ensures no overflow.
"""
sections: list[str] = []
sections.append(f"【任务】\n{task_text}")
if role_description:
sections.append(f"【你的角色】\n{role_description}")
if stage_instruction:
sections.append(f"【本阶段要求】\n{stage_instruction}")
if prior_turns:
turns_text = _format_prior_turns(prior_turns, max_turn_chars)
if turns_text:
sections.append(f"【已有讨论】\n{turns_text}")
if fact_ledger:
sanitized_ledger = sanitize(fact_ledger)
if isinstance(sanitized_ledger, dict):
facts_text = _format_fact_ledger(sanitized_ledger, max_fact_chars)
if facts_text:
sections.append(f"【共享事实账本】\n{facts_text}")
result = "\n\n".join(sections)
if len(result) > max_context_chars:
result = result[:max_context_chars]
return result
def build_manager_synthesis_input(
*,
task_text: str,
seat_results: list[dict[str, Any]],
fact_ledger: dict[str, Any] | None = None,
max_context_chars: int = _DEFAULT_MAX_CONTEXT_CHARS,
) -> str:
"""Build the manager's final synthesis prompt."""
sections: list[str] = []
sections.append(f"【原始任务】\n{task_text}")
sections.append("【各席位分析结果】")
for r in seat_results:
agent_id = r.get("agent_id", "unknown")
content = str(r.get("content", ""))
if len(content) > 3000:
content = content[:3000] + "..."
sections.append(f"--- {agent_id} ---\n{content}")
if fact_ledger:
sanitized_ledger = sanitize(fact_ledger)
if isinstance(sanitized_ledger, dict):
facts_text = _format_fact_ledger(sanitized_ledger, _DEFAULT_MAX_FACT_CHARS)
if facts_text:
sections.append(f"【共享事实账本】\n{facts_text}")
sections.append(
"【综合要求】\n"
"请基于以上各席位分析和事实账本,生成最终结论。要求:\n"
"1. 明确标注共识点和分歧点\n"
"2. 区分「建议动作」和「已实际执行的动作」\n"
"3. 引用具体证据来源\n"
"4. 列出失败或状态不确定的动作"
)
result = "\n\n".join(sections)
if len(result) > max_context_chars:
result = result[:max_context_chars]
return result
# ── internal helpers ──────────────────────────────────────────────────────────
def _format_prior_turns(turns: list[dict[str, Any]], max_per_turn: int) -> str:
lines: list[str] = []
for t in turns:
agent_id = t.get("agent_id", "unknown")
turn_idx = t.get("turn_index", "?")
summary = str(t.get("summary", ""))
if len(summary) > max_per_turn:
summary = summary[:max_per_turn] + "..."
lines.append(f"[轮次{turn_idx}] {agent_id}: {summary}")
return "\n".join(lines)
def _format_fact_ledger(ledger: dict[str, Any], max_chars: int) -> str:
parts: list[str] = []
facts = ledger.get("confirmed_facts")
if facts and isinstance(facts, list):
parts.append("已确认事实:")
for f in facts[:20]:
parts.append(f" - {f}")
tool_results = ledger.get("tool_results")
if tool_results and isinstance(tool_results, list):
parts.append("工具执行结果:")
for tr in tool_results[:10]:
if isinstance(tr, dict):
parts.append(f" - {tr.get('tool_name', '?')}: {tr.get('status', '?')}")
else:
parts.append(f" - {tr}")
conflicts = ledger.get("conflicts")
if conflicts and isinstance(conflicts, list):
parts.append("未解决冲突:")
for c in conflicts[:5]:
parts.append(f" - {c}")
pending = ledger.get("pending_actions")
if pending and isinstance(pending, list):
parts.append("等待确认动作:")
for p in pending[:5]:
parts.append(f" - {p}")
result = "\n".join(parts)
if len(result) > max_chars:
result = result[:max_chars]
return result
@@ -0,0 +1,513 @@
"""DeerFlow Agent Adapter for the MAF roundtable experiment (Phase 2).
Drives each MAF seat's actual execution through the existing DeerFlow Agent
Runtime, preserving tools / skills / MCP / sandbox / identity / middleware
semantics. The adapter does NOT wrap individual LangChain tools — it invokes
a full agent run per seat turn via ``DeerFlowRuntimeClient``.
Architecture:
- One DeerFlow thread per seat (bound at session start, reused across turns).
- In-process ``start_run`` via DeerFlowRuntimeClient (no fake Request).
- User context injected via ExecutionPrincipal (server-generated, never from body).
- StreamBridge subscription translates runtime SSE → MAF events in real time.
- Cancellation propagates from MAF run → DeerFlow run via RunManager.cancel.
- All seat runs persisted to ``maf_roundtable_seat_runs`` (survives restart).
- Critical audit events (tool.*, skill.*, approval.*, artifact.*) MUST persist;
failure stops external writes and marks the run as failed.
Boundary: lives in ``app.maf_roundtable`` (may import ``deerflow.*`` and
``app.gateway.*``); never imported by ``deerflow.*``.
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from dataclasses import dataclass, field
from typing import Any
from app.maf_roundtable.context_builder import build_seat_input
from app.maf_roundtable.event_normalizer import (
make_run_cancelled,
make_run_failed,
make_turn_completed,
make_turn_started,
normalize_stream_event,
)
from app.maf_roundtable.runtime_client import DeerFlowRuntimeClient, ExecutionPrincipal
from app.maf_roundtable.sanitize import sanitize
from deerflow.runtime.stream_bridge.base import END_SENTINEL, HEARTBEAT_SENTINEL
logger = logging.getLogger(__name__)
_IDLE_TIMEOUT_S = int(os.getenv("MAF_SEAT_IDLE_TIMEOUT_SECONDS", "1200") or "1200")
_IDLE_CHECK_INTERVAL_S = 10.0
_MAX_CONTEXT_CHARS = int(os.getenv("MAF_MAX_CONTEXT_CHARS", "12000") or "12000")
_CRITICAL_EVENT_TYPES = frozenset({
"tool.requested", "tool.started", "tool.completed", "tool.failed",
"skill.selected", "skill.started", "skill.completed", "skill.failed",
"artifact.created",
"action.waiting_approval", "action.approved", "action.rejected",
"run.completed", "run.failed", "run.cancelled",
})
class AuditEventError(RuntimeError):
"""Raised when a critical audit event fails to persist."""
@dataclass
class SeatResponse:
"""Standardized response from a seat turn (MAF-02-08)."""
agent_id: str
turn_index: int
content: str
tool_calls: list[dict[str, Any]] = field(default_factory=list)
usage: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None
error: str | None = None
latency_ms: int | None = None
class DeerFlowAgentAdapter:
"""Drives MAF seat execution through the DeerFlow Agent Runtime.
Usage::
client = DeerFlowRuntimeClient(app)
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
await adapter.ensure_threads(session_id, participants)
response = await adapter.run_seat_turn(...)
"""
def __init__(
self,
runtime_client: DeerFlowRuntimeClient,
*,
service: Any,
user_id: str,
) -> None:
self._client = runtime_client
self._service = service
self._user_id = user_id
self._seen_source_ids: set[str] = set()
# ── MAF-02-01: thread lifecycle ─────────────────────────────────────────
async def ensure_threads(
self, session_id: str, run_id: str, participants: list[dict[str, Any]]
) -> dict[str, str]:
"""Create and bind a DeerFlow thread for each participant that lacks one.
Thread creation is wrapped in set_current_user so thread_meta.user_id
matches the MAF session owner. Returns ``{agent_id: thread_id}``.
"""
principal = ExecutionPrincipal(user_id=self._user_id, session_id=session_id, run_id=run_id)
thread_map: dict[str, str] = {}
for p in participants:
agent_id = p.get("agent_id", "")
existing = p.get("thread_id")
if existing:
thread_map[agent_id] = existing
continue
thread_id = await self._client.create_thread(principal)
await self._service.store.participants.bind_thread(
session_id, agent_id, thread_id=thread_id
)
thread_map[agent_id] = thread_id
return thread_map
# ── MAF-02-02/03/04/05: seat turn execution ─────────────────────────────
async def run_seat_turn(
self,
*,
session_id: str,
run_id: str,
agent_id: str,
turn_index: int,
message: str,
thread_id: str,
model_name: str | None = None,
reasoning_effort: str | None = None,
thinking_enabled: bool = True,
subagent_enabled: bool = False,
excluded_tools: list[str] | None = None,
stage_instruction: str = "",
prior_turns: list[dict[str, Any]] | None = None,
fact_ledger: dict[str, Any] | None = None,
role_description: str = "",
task_text: str = "",
memory_recall_disabled: bool | None = None,
memory_injection_enabled: bool | None = None,
capability_snapshot: dict[str, Any] | None = None,
write_policy: str = "confirm_high_risk",
approved_tool: str | None = None,
) -> SeatResponse:
"""Execute one seat turn through the DeerFlow Agent Runtime."""
t0 = time.perf_counter()
seat_message = build_seat_input(
task_text=task_text or message,
stage_instruction=stage_instruction,
role_description=role_description,
prior_turns=prior_turns or [],
fact_ledger=fact_ledger,
max_context_chars=_MAX_CONTEXT_CHARS,
)
if not task_text:
seat_message = message
await self._emit_critical(run_id, make_turn_started(agent_id, turn_index, {"thread_id": thread_id}))
principal = ExecutionPrincipal(user_id=self._user_id, session_id=session_id, run_id=run_id)
seat_run = await self._service.store.seat_runs.create_seat_run(
maf_run_id=run_id, agent_id=agent_id, turn_index=turn_index, deerflow_thread_id=thread_id
)
seat_run_id = seat_run["id"]
try:
response = await self._execute_run(
principal=principal,
seat_run_id=seat_run_id,
run_id=run_id,
agent_id=agent_id,
turn_index=turn_index,
thread_id=thread_id,
message=seat_message,
model_name=model_name,
reasoning_effort=reasoning_effort,
thinking_enabled=thinking_enabled,
subagent_enabled=subagent_enabled,
excluded_tools=excluded_tools or [],
memory_recall_disabled=memory_recall_disabled,
memory_injection_enabled=memory_injection_enabled,
capability_snapshot=capability_snapshot,
write_policy=write_policy,
approved_tool=approved_tool,
)
except asyncio.CancelledError:
await self._service.store.seat_runs.mark_cancelled(seat_run_id)
await self._emit_critical(run_id, make_run_cancelled(agent_id, turn_index))
raise
except Exception as exc:
latency = int((time.perf_counter() - t0) * 1000)
# Sanitize before persisting / emitting — the raw exception body may
# embed params or credentials; only the redacted text is stored.
safe_error = str(sanitize(str(exc)))[:500]
await self._service.store.seat_runs.mark_failed(seat_run_id, error=safe_error)
await self._emit_critical(run_id, make_run_failed(agent_id, turn_index, error_code=type(exc).__name__, message=safe_error))
return SeatResponse(
agent_id=agent_id,
turn_index=turn_index,
content="",
error=safe_error,
latency_ms=latency,
)
await self._service.store.seat_runs.mark_completed(seat_run_id)
response.latency_ms = int((time.perf_counter() - t0) * 1000)
await self._emit_critical(run_id, make_turn_completed(
agent_id, turn_index,
content=response.content,
usage=response.usage,
metadata=response.metadata,
))
return response
async def _execute_run(
self,
*,
principal: ExecutionPrincipal,
seat_run_id: str,
run_id: str,
agent_id: str,
turn_index: int,
thread_id: str,
message: str,
model_name: str | None,
reasoning_effort: str | None,
thinking_enabled: bool,
subagent_enabled: bool,
excluded_tools: list[str],
memory_recall_disabled: bool | None,
memory_injection_enabled: bool | None,
capability_snapshot: dict[str, Any] | None = None,
write_policy: str = "confirm_high_risk",
approved_tool: str | None = None,
) -> SeatResponse:
record = await self._client.start_agent_run(
principal,
thread_id=thread_id,
agent_id=agent_id,
message=message,
model_name=model_name,
reasoning_effort=reasoning_effort,
thinking_enabled=thinking_enabled,
subagent_enabled=subagent_enabled,
excluded_tools=excluded_tools,
memory_recall_disabled=memory_recall_disabled,
memory_injection_enabled=memory_injection_enabled,
turn_index=turn_index,
capability_snapshot=capability_snapshot,
write_policy=write_policy,
approved_tool=approved_tool,
)
deerflow_run_id = getattr(record, "run_id", None)
if deerflow_run_id:
await self._service.store.seat_runs.mark_running(seat_run_id, deerflow_run_id=deerflow_run_id)
await self._await_with_event_translation(
record, deerflow_run_id, run_id=run_id, agent_id=agent_id, turn_index=turn_index
)
messages = await self._client.read_thread_messages(principal, thread_id)
content = self._latest_ai_text(messages)
tool_calls = self._tool_calls_since_last_human(messages)
usage = self._extract_usage(messages)
return SeatResponse(
agent_id=agent_id,
turn_index=turn_index,
content=content,
tool_calls=tool_calls,
usage=usage,
metadata={"thread_id": thread_id, "deerflow_run_id": deerflow_run_id},
)
# ── MAF-02-07: real-time event translation ──────────────────────────────
async def _await_with_event_translation(
self,
record: Any,
deerflow_run_id: str | None,
*,
run_id: str,
agent_id: str,
turn_index: int,
) -> None:
"""Await the DeerFlow run while translating StreamBridge events to MAF events.
P1.5 fix: ``if task in done: await task`` so exceptions propagate.
"""
task = getattr(record, "task", None)
if task is None:
return
bridge = getattr(self._client.app.state, "stream_bridge", None)
if bridge is None or deerflow_run_id is None:
await task
return
loop = asyncio.get_running_loop()
last_activity = loop.time()
stuck = asyncio.Event()
async def _watch() -> None:
nonlocal last_activity
async for ev in bridge.subscribe(deerflow_run_id, heartbeat_interval=_IDLE_CHECK_INTERVAL_S):
if ev is END_SENTINEL:
return
now = loop.time()
if ev is HEARTBEAT_SENTINEL:
if now - last_activity >= _IDLE_TIMEOUT_S:
stuck.set()
return
else:
last_activity = now
maf_events = normalize_stream_event(
ev.event, ev.data, agent_id=agent_id, turn_index=turn_index
)
for me in maf_events:
await self._emit(run_id, me)
watch = asyncio.create_task(_watch())
try:
done, _ = await asyncio.wait({task, watch}, return_when=asyncio.FIRST_COMPLETED)
if task in done:
await task
return
if not stuck.is_set():
await task
return
idle_elapsed = loop.time() - last_activity
logger.error(
"MAF seat '%s' stalled: no output for %.0fs on run %s",
agent_id, idle_elapsed, deerflow_run_id,
)
await self._client.cancel_run(deerflow_run_id)
try:
await asyncio.wait_for(asyncio.shield(task), timeout=10.0)
except (TimeoutError, asyncio.CancelledError, Exception):
logger.warning("DeerFlow task did not finish after cancel for run %s", deerflow_run_id)
raise RuntimeError(f"MAF seat '{agent_id}' stalled (no output for {_IDLE_TIMEOUT_S}s)")
finally:
if not watch.done():
watch.cancel()
try:
await watch
except BaseException:
pass
# ── MAF-02-09: cancellation ─────────────────────────────────────────────
async def cancel(self, maf_run_id: str) -> dict[str, Any]:
"""Cancel all active DeerFlow runs for a MAF run (two-phase cascade).
Phase 1: mark all active seat runs as ``cancelling``.
Phase 2: actually cancel each DeerFlow run, then mark the seat run
as ``cancelled`` (success) or ``cancel_failed`` (failure).
Returns a structured result so the caller (dispatcher → router) can
decide whether to finalize the parent run as ``cancelled`` or leave it
in a non-terminal ``cancelling`` / ``cancel_failed`` state. A failed
cancel must NEVER masquerade as cancelled.
Result shape::
{
"all_cancelled": bool,
"cancelled_run_ids": [...], # seat_run_ids that succeeded
"failed_run_ids": [...], # seat_run_ids where cancel failed
"still_active_run_ids": [...], # seat_run_ids with no deerflow id
}
"""
seat_infos = await self._service.store.seat_runs.cancel_all_active(maf_run_id)
cancelled_run_ids: list[str] = []
failed_run_ids: list[str] = []
still_active_run_ids: list[str] = []
for info in seat_infos:
seat_run_id = info["seat_run_id"]
deerflow_run_id = info.get("deerflow_run_id")
if not deerflow_run_id:
# No DeerFlow run to cancel — record as still-active/unknown.
await self._service.store.seat_runs.mark_cancel_unknown(
seat_run_id, error="no deerflow_run_id recorded"
)
still_active_run_ids.append(seat_run_id)
continue
if await self._client.cancel_run(deerflow_run_id):
await self._service.store.seat_runs.mark_cancelled(seat_run_id)
cancelled_run_ids.append(seat_run_id)
else:
await self._service.store.seat_runs.mark_cancel_failed(
seat_run_id, error="DeerFlow cancel_run returned False"
)
failed_run_ids.append(seat_run_id)
all_cancelled = not failed_run_ids and not still_active_run_ids and bool(seat_infos)
# If there were no active seat runs at all, treat as fully cancelled
# (nothing left to cancel).
if not seat_infos:
all_cancelled = True
return {
"all_cancelled": all_cancelled,
"cancelled_run_ids": cancelled_run_ids,
"failed_run_ids": failed_run_ids,
"still_active_run_ids": still_active_run_ids,
}
# ── P1.6: audit event persistence ───────────────────────────────────────
async def _emit_critical(self, run_id: str, event: dict[str, Any]) -> None:
"""Emit a critical audit event. Failure raises AuditEventError."""
try:
await self._service.append_event(
run_id=run_id,
user_id=self._user_id,
event_type=event["event_type"],
agent_id=event.get("agent_id"),
turn_index=event.get("turn_index"),
payload=event.get("payload"),
)
except Exception as exc:
logger.error("CRITICAL audit event %s failed to persist for run %s", event.get("event_type"), run_id, exc_info=True)
raise AuditEventError(
f"Critical audit event '{event.get('event_type')}' failed to persist: {exc}"
) from exc
async def _emit(self, run_id: str, event: dict[str, Any]) -> None:
"""Emit a non-critical event (agent.message.delta). Failure degrades gracefully.
Dedup: events with a source_event_id are deduped at the DB level via
the (run_id, source_event_id) unique constraint — durable across restarts.
The in-memory _seen_source_ids set is a fast-path cache only.
"""
source_id = event.get("source_event_id")
if source_id:
if source_id in self._seen_source_ids:
return
self._seen_source_ids.add(source_id)
event_type = event.get("event_type", "")
if event_type in _CRITICAL_EVENT_TYPES:
await self._emit_critical(run_id, event)
return
try:
await self._service.append_event(
run_id=run_id,
user_id=self._user_id,
event_type=event_type,
agent_id=event.get("agent_id"),
turn_index=event.get("turn_index"),
payload=event.get("payload"),
source_event_id=source_id,
)
except Exception:
logger.warning("non-critical event %s failed for run %s (degraded)", event_type, run_id, exc_info=True)
# ── internal helpers ────────────────────────────────────────────────────
def _latest_ai_text(self, messages: list[dict[str, Any]]) -> str:
for m in reversed(messages):
if not isinstance(m, dict):
continue
if (m.get("type") or m.get("role")) not in {"ai", "assistant"}:
continue
text = self._message_text(m)
if text.strip():
return text
return ""
def _message_text(self, message: dict[str, Any]) -> str:
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
return "\n".join(parts)
return ""
def _tool_calls_since_last_human(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
last_human = -1
for i, m in enumerate(messages):
if isinstance(m, dict) and (m.get("type") or m.get("role")) in {"human", "user"}:
last_human = i
out: list[dict[str, Any]] = []
for m in messages[last_human + 1:]:
if not isinstance(m, dict):
continue
if (m.get("type") or m.get("role")) not in {"ai", "assistant"}:
continue
for tc in m.get("tool_calls") or []:
if not isinstance(tc, dict):
continue
name = tc.get("name") or ""
if not name:
continue
out.append({"name": name, "args": tc.get("args") or {}, "id": tc.get("id") or ""})
return out
def _extract_usage(self, messages: list[dict[str, Any]]) -> dict[str, Any] | None:
for m in reversed(messages):
if not isinstance(m, dict):
continue
usage = m.get("usage_metadata") or m.get("usage")
if isinstance(usage, dict) and usage:
return usage
return None
@@ -0,0 +1,171 @@
"""MAF run dispatcher — launches WorkflowRunner after create_run succeeds.
The dispatcher is the bridge between the HTTP layer (router) and the workflow
execution layer (WorkflowRunner). It:
1. Checks MAF SDK availability BEFORE creating the run (so the user gets a
clean 503 rather than a run that will fail).
2. Launches ``WorkflowRunner.execute`` as a background asyncio task.
3. Tracks task handles for cancellation.
4. **Resume**: after an approval decision, the router calls
``dispatcher.resume(run_id, user_id)`` to restore the workflow from its
checkpoint.
5. **Cancel cascade**: ``dispatcher.cancel(run_id)`` cancels both the
background task AND the DeerFlow sub-runs. If the task handle is gone
(process restart), it rebuilds a runner from persisted ``seat_runs`` and
cascades cancel to DeerFlow.
6. Never silently degrades to sequential DeerFlow execution — if the SDK is
missing or the workflow fails, the run is marked ``failed``.
Usage (from the router)::
from app.maf_roundtable.dispatcher import get_dispatcher
dispatcher = get_dispatcher(request.app)
await dispatcher.dispatch(session, run, user_id=user_id)
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
logger = logging.getLogger(__name__)
class MafSdkNotAvailableError(RuntimeError):
"""Raised when the MAF SDK is not installed and a run is requested."""
class MafRunDispatcher:
"""Manages background MAF workflow execution tasks."""
def __init__(self, app: Any) -> None:
self._app = app
self._tasks: dict[str, asyncio.Task] = {}
def check_sdk_available(self) -> None:
"""Raise MafSdkNotAvailableError if the MAF SDK is not installed."""
try:
import agent_framework # noqa: F401
except ImportError as exc:
raise MafSdkNotAvailableError(
"agent_framework SDK is not installed; run 'uv sync --extra maf' to enable MAF workflows"
) from exc
async def dispatch(self, session: dict[str, Any], run: dict[str, Any], *, user_id: str) -> None:
"""Launch the workflow for a newly created run as a background task.
The task is tracked so it can be cancelled via ``cancel(run_id)``.
"""
from app.maf_roundtable.workflow_runner import WorkflowRunner
run_id = run["id"]
service = getattr(self._app.state, "maf_roundtable_service", None)
if service is None:
raise MafSdkNotAvailableError("MAF roundtable service not available")
runner = WorkflowRunner(self._app, service=service, user_id=user_id)
task = asyncio.create_task(self._execute(runner, session, run, run_id))
self._tasks[run_id] = task
task.add_done_callback(lambda t, rid=run_id: self._tasks.pop(rid, None))
async def _execute(self, runner: Any, session: dict[str, Any], run: dict[str, Any], run_id: str) -> None:
"""Execute the workflow, catching all exceptions (they're already persisted by the runner)."""
try:
await runner.execute(session, run)
except Exception:
# The runner already persists the failure. Log for observability.
logger.exception("MAF workflow execution failed for run %s", run_id)
async def resume(self, run_id: str, *, user_id: str) -> dict[str, Any] | None:
"""Resume a workflow from its checkpoint after an approval decision.
Called by the router after ``decide_approval`` succeeds. Loads the
runner (rebuilding if the process restarted), then calls
``runner.resume(run_id)`` which restores ``RoundtableState.from_dict()``
and executes the approved action (at-most-once).
"""
from app.maf_roundtable.workflow_runner import WorkflowRunner
service = getattr(self._app.state, "maf_roundtable_service", None)
if service is None:
raise MafSdkNotAvailableError("MAF roundtable service not available")
runner = WorkflowRunner(self._app, service=service, user_id=user_id)
return await runner.resume(run_id)
async def cancel(self, run_id: str) -> dict[str, Any]:
"""Cancel a running workflow AND cascade to DeerFlow sub-runs.
Returns a structured cancel result so the router can decide whether to
finalize the parent run as ``cancelled`` (all sub-runs confirmed
cancelled / already terminal) or leave it non-terminal (some sub-runs
failed to cancel — must not masquerade as cancelled).
Result shape::
{
"all_cancelled": bool,
"cancelled_run_ids": [...],
"failed_run_ids": [...],
"still_active_run_ids": [...],
}
"""
from app.maf_roundtable.workflow_runner import WorkflowRunner
_EMPTY = {"all_cancelled": False, "cancelled_run_ids": [], "failed_run_ids": [], "still_active_run_ids": []}
# P1c cancel order: the parent run is already in ``cancelling`` (the
# router sets it before calling here). We then (1) cascade-cancel the
# DeerFlow seat runs first and confirm/record the outcome, and only
# (2) cancel the local MAF asyncio task LAST. The router finalizes the
# parent run as ``cancelled`` only when every sub-run confirmed
# cancelled — a failed cascade must never masquerade as cancelled.
service = getattr(self._app.state, "maf_roundtable_service", None)
if service is None:
return _EMPTY
run = await service.store.runs.get_run(run_id)
if run is None:
return _EMPTY
session = await service.store.sessions.get_session(run["session_id"], user_id=None)
if session is None:
return _EMPTY
user_id = session.get("user_id", "default")
runner = WorkflowRunner(self._app, service=service, user_id=user_id)
# Step 1: cascade cancel to DeerFlow sub-runs (works even after a
# process restart — the runner reads persisted seat_runs).
try:
result = await runner.cancel(run_id)
except Exception: # noqa: BLE001
logger.exception("MAF cancel cascade failed for run %s", run_id)
result = dict(_EMPTY)
# Step 2: cancel the local MAF asyncio task LAST (after the external
# sub-runs have been addressed), so we never drop the orchestrator
# before its sub-runs are cancelled/recorded.
task = self._tasks.get(run_id)
if task is not None and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
return result
_dispatchers: dict[int, MafRunDispatcher] = {}
def get_dispatcher(app: Any) -> MafRunDispatcher:
"""Get or create the dispatcher for an app instance (keyed by id)."""
app_id = id(app)
if app_id not in _dispatchers:
_dispatchers[app_id] = MafRunDispatcher(app)
return _dispatchers[app_id]
@@ -0,0 +1,286 @@
"""Translate DeerFlow runtime SSE events into MAF roundtable event types.
Maps raw StreamBridge chunks (LangGraph Platform-aligned: ``metadata``,
``values``, ``updates``, ``messages``, ``error``, ``end``) into the durable
MAF event taxonomy persisted to ``maf_roundtable_events``.
Design constraints:
- Pure functions only (no I/O, no state).
- Never fabricates events: only emits what the runtime actually produced.
- Skill events come ONLY from structured tool/skill metadata, never from
guessing based on AI text content.
- All payloads are sanitized (credentials/tokens/cookies stripped).
- Supports source_event_id for dedup (messages + updates may overlap).
"""
from __future__ import annotations
from typing import Any
from app.maf_roundtable.sanitize import sanitize_payload
def normalize_stream_event(
sse_event: str,
data: Any,
*,
agent_id: str,
turn_index: int,
) -> list[dict[str, Any]]:
"""Convert a single DeerFlow SSE frame into zero or more MAF event dicts.
Each returned dict has:
``{event_type, agent_id, turn_index, payload, source_event_id?}``.
"""
if sse_event == "metadata":
return [_make("run.started", agent_id, turn_index, {"run_id": _get(data, "run_id"), "thread_id": _get(data, "thread_id")})]
if sse_event == "messages":
return _normalize_messages(data, agent_id, turn_index)
if sse_event == "updates":
return _normalize_updates(data, agent_id, turn_index)
if sse_event == "error":
return [_make("run.failed", agent_id, turn_index, {"message": _get(data, "message", ""), "name": _get(data, "name", "")})]
# Structured skill lifecycle events (emitted by DeerFlow skill middleware,
# never guessed from AI text content).
if sse_event in ("invoke_skill", "skill_search_step"):
skill_name = _get(data, "skill_name", "") or ""
if sse_event == "invoke_skill":
return [_make("skill.started", agent_id, turn_index, {"skill_name": skill_name, "message": _get(data, "message", "")})]
return [_make("skill.selected", agent_id, turn_index, {"skill_name": skill_name, "message": _get(data, "message", "")})]
if sse_event == "skill_result":
return [_make("skill.completed", agent_id, turn_index, {"skill_name": _get(data, "skill_name", ""), "message": _get(data, "message", "")})]
if sse_event == "skill_error":
return [_make("skill.failed", agent_id, turn_index, {"skill_name": _get(data, "skill_name", ""), "error": _get(data, "reason", "") or _get(data, "message", "")})]
if sse_event == "artifact_created":
return [_make("artifact.created", agent_id, turn_index, {"artifact_type": _get(data, "artifact_type", ""), "artifact_id": _get(data, "artifact_id", ""), "title": _get(data, "title", "")})]
return []
def make_turn_started(agent_id: str, turn_index: int, payload: dict[str, Any] | None = None) -> dict[str, Any]:
return _make("agent.turn_started", agent_id, turn_index, payload or {})
def make_turn_completed(
agent_id: str,
turn_index: int,
*,
content: str,
usage: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {"content": content}
if usage:
payload["usage"] = usage
if metadata:
payload["metadata"] = metadata
return _make("agent.message.completed", agent_id, turn_index, payload)
def make_run_completed(agent_id: str | None, turn_index: int, payload: dict[str, Any] | None = None) -> dict[str, Any]:
return _make("run.completed", agent_id or "", turn_index, payload or {})
def make_run_failed(agent_id: str | None, turn_index: int, *, error_code: str, message: str) -> dict[str, Any]:
return _make("run.failed", agent_id or "", turn_index, {"error_code": error_code, "message": message})
def make_run_cancelled(agent_id: str | None, turn_index: int) -> dict[str, Any]:
return _make("run.cancelled", agent_id or "", turn_index, {})
# ── skill lifecycle events ─────────────────────────────────────────────────
def make_skill_selected(agent_id: str, turn_index: int, *, skill_name: str, skill_id: str = "") -> dict[str, Any]:
return _make("skill.selected", agent_id, turn_index, {"skill_name": skill_name, "skill_id": skill_id})
def make_skill_started(agent_id: str, turn_index: int, *, skill_name: str, skill_id: str = "") -> dict[str, Any]:
return _make("skill.started", agent_id, turn_index, {"skill_name": skill_name, "skill_id": skill_id})
def make_skill_completed(agent_id: str, turn_index: int, *, skill_name: str, result_preview: str = "") -> dict[str, Any]:
return _make("skill.completed", agent_id, turn_index, {"skill_name": skill_name, "result_preview": _truncate(result_preview, 500)})
def make_skill_failed(agent_id: str, turn_index: int, *, skill_name: str, error: str = "") -> dict[str, Any]:
return _make("skill.failed", agent_id, turn_index, {"skill_name": skill_name, "error": _truncate(error, 500)})
# ── artifact events ────────────────────────────────────────────────────────
def make_artifact_created(agent_id: str, turn_index: int, *, artifact_type: str, artifact_id: str = "", title: str = "") -> dict[str, Any]:
return _make("artifact.created", agent_id, turn_index, {"artifact_type": artifact_type, "artifact_id": artifact_id, "title": title})
# ── approval lifecycle events ──────────────────────────────────────────────
def make_action_waiting_approval(agent_id: str, turn_index: int, *, action_id: str, approval_id: str, tool_name: str, summary: str = "") -> dict[str, Any]:
return _make("action.waiting_approval", agent_id, turn_index, {"action_id": action_id, "approval_id": approval_id, "tool_name": tool_name, "summary": _truncate(summary, 500)})
def make_action_approved(agent_id: str, turn_index: int, *, action_id: str, approval_id: str, decided_by: str = "") -> dict[str, Any]:
return _make("action.approved", agent_id, turn_index, {"action_id": action_id, "approval_id": approval_id, "decided_by": decided_by})
def make_action_rejected(agent_id: str, turn_index: int, *, action_id: str, approval_id: str, decided_by: str = "") -> dict[str, Any]:
return _make("action.rejected", agent_id, turn_index, {"action_id": action_id, "approval_id": approval_id, "decided_by": decided_by})
# ── workflow checkpoint events ─────────────────────────────────────────────
def make_workflow_checkpointed(agent_id: str | None, turn_index: int, *, checkpoint_id: str, reason: str = "") -> dict[str, Any]:
return _make("workflow.checkpointed", agent_id or "", turn_index, {"checkpoint_id": checkpoint_id, "reason": reason})
# ── internal helpers ──────────────────────────────────────────────────────────
def _make(event_type: str, agent_id: str, turn_index: int, payload: dict[str, Any], source_event_id: str | None = None) -> dict[str, Any]:
ev: dict[str, Any] = {
"event_type": event_type,
"agent_id": agent_id,
"turn_index": turn_index,
"payload": sanitize_payload(payload),
}
if source_event_id:
ev["source_event_id"] = source_event_id
return ev
def _get(data: Any, key: str, default: Any = None) -> Any:
if isinstance(data, dict):
return data.get(key, default)
return default
def _normalize_messages(data: Any, agent_id: str, turn_index: int) -> list[dict[str, Any]]:
"""Handle ``messages`` SSE event (streaming token deltas).
Format: ``[message_id, {type, content, ...}]`` (messages-tuple mode).
"""
events: list[dict[str, Any]] = []
if not isinstance(data, (list, tuple)) or len(data) < 2:
return events
msg_id = str(data[0]) if data[0] else None
msg = data[1] if len(data) > 1 else None
if not isinstance(msg, dict):
return events
msg_type = msg.get("type") or msg.get("role") or ""
content = msg.get("content")
if msg_type in ("ai", "assistant"):
tool_calls = msg.get("tool_calls")
if tool_calls:
for tc in tool_calls:
if not isinstance(tc, dict):
continue
tc_id = tc.get("id", "")
events.append(_make("tool.requested", agent_id, turn_index, {
"tool_name": tc.get("name", ""),
"tool_call_id": tc_id,
"args": _summarize_args(tc.get("args")),
}, source_event_id=f"{msg_id}:{tc_id}" if msg_id and tc_id else None))
if content:
text = _extract_text(content)
if text:
events.append(_make("agent.message.delta", agent_id, turn_index, {"text": text}, source_event_id=msg_id))
elif msg_type == "tool":
tc_id = msg.get("tool_call_id", "")
status = msg.get("status", "completed")
event_type = "tool.completed" if status != "error" else "tool.failed"
events.append(_make(event_type, agent_id, turn_index, {
"tool_name": msg.get("name", ""),
"tool_call_id": tc_id,
"content_preview": _truncate(_extract_text(content), 500),
"status": status,
}, source_event_id=f"tool:{tc_id}" if tc_id else None))
return events
def _normalize_updates(data: Any, agent_id: str, turn_index: int) -> list[dict[str, Any]]:
"""Handle ``updates`` SSE event (node completion with full output).
Only emits tool lifecycle events from structured data. Does NOT guess
skill execution from text content.
"""
events: list[dict[str, Any]] = []
if not isinstance(data, dict):
return events
for node_name, node_output in data.items():
if not isinstance(node_output, dict):
continue
messages = node_output.get("messages")
if not isinstance(messages, list):
continue
for msg in messages:
if not isinstance(msg, dict):
continue
msg_type = msg.get("type") or msg.get("role") or ""
if msg_type in ("ai", "assistant"):
tool_calls = msg.get("tool_calls")
if tool_calls:
for tc in tool_calls:
if not isinstance(tc, dict):
continue
tc_id = tc.get("id", "")
events.append(_make("tool.started", agent_id, turn_index, {
"tool_name": tc.get("name", ""),
"tool_call_id": tc_id,
"node": node_name,
}, source_event_id=f"upd:{tc_id}" if tc_id else None))
elif msg_type == "tool":
tc_id = msg.get("tool_call_id", "")
status = msg.get("status", "completed")
event_type = "tool.completed" if status != "error" else "tool.failed"
events.append(_make(event_type, agent_id, turn_index, {
"tool_name": msg.get("name", ""),
"tool_call_id": tc_id,
"content_preview": _truncate(_extract_text(msg.get("content")), 500),
"status": status,
"node": node_name,
}, source_event_id=f"upd:tool:{tc_id}" if tc_id else None))
return events
def _extract_text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for p in content:
if isinstance(p, dict) and p.get("type") == "text":
parts.append(p.get("text", ""))
elif isinstance(p, str):
parts.append(p)
return "\n".join(parts)
return ""
def _truncate(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
return text[:max_len] + "..."
def _summarize_args(args: Any) -> dict[str, Any] | None:
if not isinstance(args, dict):
return None
from app.maf_roundtable.sanitize import sanitize
result = sanitize(args)
return result if isinstance(result, dict) else None
@@ -0,0 +1,333 @@
"""Controlled DeerFlow runtime invocation client for MAF roundtable.
All invocations require an explicit ``ExecutionPrincipal`` — the server-generated
identity derived ONLY from the authenticated MAF session owner. No raw tokens,
cookies, or credentials ever pass into events, checkpoints, or prompts.
Two invocation modes (the adapter uses mode 2):
1. Direct checkpointer access for thread creation and message reads.
2. In-process ``start_run`` via a typed internal request that carries the
principal's user identity (never ``user=None``).
Security invariants:
- ExecutionPrincipal is constructed ONLY from the MAF session's user_id.
- The internal request object explicitly carries the principal's user so
downstream code sees a real identity, not a None placeholder.
- Agent access is enforced server-side (enforce_access=True).
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from langgraph.checkpoint.base import empty_checkpoint
from deerflow.runtime.user_context import reset_current_user, set_current_user
logger = logging.getLogger(__name__)
def _serialize_tool_result(result: Any) -> Any:
"""Convert a tool result to a JSON-safe dict/list/str/int/float/bool/None."""
if result is None:
return None
if isinstance(result, (str, int, float, bool)):
return result
if isinstance(result, (list, tuple)):
return [_serialize_tool_result(v) for v in result]
if isinstance(result, dict):
return {str(k): _serialize_tool_result(v) for k, v in result.items()}
return str(result)[:1000]
@dataclass(frozen=True)
class ExecutionPrincipal:
"""Server-generated execution identity for a MAF seat run.
Constructed ONLY from the authenticated MAF session owner. Never from
request body, query params, or client-supplied data.
"""
user_id: str
session_id: str
run_id: str
def __post_init__(self) -> None:
if not self.user_id or not self.user_id.strip():
raise ValueError("ExecutionPrincipal requires a non-empty user_id")
class _PrincipalUser:
"""Minimal CurrentUser protocol implementation backed by ExecutionPrincipal."""
def __init__(self, principal: ExecutionPrincipal) -> None:
self.id = principal.user_id
class _InternalState:
"""Typed state container for the internal run request.
Carries the principal's user identity explicitly — never None — so
downstream access checks and run metadata attribution work correctly.
"""
def __init__(self, principal: ExecutionPrincipal) -> None:
self.user = _PrincipalUser(principal)
self.auth = None
@dataclass
class _InternalRunRequest:
"""Typed internal request for in-process agent run invocation.
Replaces the former ``SimpleNamespace(app=..., state=SimpleNamespace(user=None))``
pattern. The state explicitly carries the principal's user identity.
"""
app: Any
state: _InternalState
@classmethod
def from_principal(cls, app: Any, principal: ExecutionPrincipal) -> _InternalRunRequest:
return cls(app=app, state=_InternalState(principal))
class DeerFlowRuntimeClient:
"""Controlled entry point for invoking the DeerFlow Agent Runtime from MAF.
All calls require an ExecutionPrincipal. The client:
- Sets the user ContextVar before each invocation.
- Creates threads with proper user attribution.
- Invokes start_run with server-generated identity.
- Never passes raw tokens or credentials downstream.
"""
def __init__(self, app: Any) -> None:
self._app = app
@property
def app(self) -> Any:
return self._app
async def create_thread(self, principal: ExecutionPrincipal) -> str:
"""Create a DeerFlow thread owned by the principal's user.
The user ContextVar is set so thread_store.create records the correct
user_id in thread_meta.
"""
token = set_current_user(_PrincipalUser(principal))
try:
checkpointer = self._app.state.checkpointer
thread_store = self._app.state.thread_store
thread_id = str(uuid.uuid4())
await thread_store.create(
thread_id,
assistant_id=None,
metadata={
"thread_type": "maf_roundtable",
"system": True,
"maf_session_id": principal.session_id,
"maf_run_id": principal.run_id,
},
)
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
await checkpointer.aput(
config,
empty_checkpoint(),
{"step": -1, "source": "input", "writes": None, "parents": {}, "created_at": datetime.now(UTC).isoformat()},
{},
)
return thread_id
finally:
reset_current_user(token)
async def start_agent_run(
self,
principal: ExecutionPrincipal,
*,
thread_id: str,
agent_id: str,
message: str,
model_name: str | None = None,
reasoning_effort: str | None = None,
thinking_enabled: bool = True,
subagent_enabled: bool = False,
excluded_tools: list[str] | None = None,
memory_recall_disabled: bool | None = None,
memory_injection_enabled: bool | None = None,
turn_index: int = 0,
capability_snapshot: dict[str, Any] | None = None,
write_policy: str = "confirm_high_risk",
approved_tool: str | None = None,
approved_action_id: str | None = None,
) -> Any:
"""Start a DeerFlow agent run under the principal's identity.
Returns the RunRecord (with .task and .run_id). The caller must await
the task and handle exceptions.
WriteGuard activation: when ``write_policy != "auto"`` the run context
carries ``maf_run_id`` (the activation flag the WriteGuard middleware
checks) plus the per-run data the interceptor needs to record a
pending action. ``excluded_tools`` here MUST only contain tools the
user explicitly disabled — write tools are no longer excluded (the
middleware intercepts them instead, keeping them visible to the model).
"""
from app.gateway.routers.thread_runs import RunCreateRequest
from app.gateway.services import RUN_SCOPE_BACKGROUND_CHILD, start_run
context: dict[str, Any] = {
"agent_name": agent_id,
"model_name": model_name or "",
"mode": "pro",
"reasoning_effort": reasoning_effort or "medium",
"thinking_enabled": thinking_enabled,
"is_plan_mode": False,
"subagent_enabled": subagent_enabled,
"thread_id": thread_id,
}
if memory_recall_disabled is not None:
context["memory_recall_disabled"] = memory_recall_disabled
if memory_injection_enabled is not None:
context["memory_injection_enabled"] = memory_injection_enabled
# WriteGuard activation + per-run data (server-generated identity only).
# ``maf_run_id`` is the flag the WriteGuard middleware checks to decide
# whether to intercept; the rest is the data the interceptor reads.
context["maf_run_id"] = principal.run_id
context["maf_session_id"] = principal.session_id
context["maf_user_id"] = principal.user_id
context["agent_id"] = agent_id
context["turn_index"] = turn_index
context["write_policy"] = write_policy
if capability_snapshot is not None:
context["capability_snapshot"] = capability_snapshot
# Post-approval deterministic execution: when set, the WriteGuard
# middleware passes this single tool through without interception.
# ``maf_approved_action_id`` binds the passthrough to a specific
# approved action — the middleware validates both tool name AND
# action_id so a model calling another tool with the same name is
# still intercepted (defense-in-depth when combined with direct
# tool execution that bypasses the LLM).
if approved_tool:
context["maf_approved_tool"] = approved_tool
if approved_action_id:
context["maf_approved_action_id"] = approved_action_id
body = RunCreateRequest(
assistant_id="lead_agent",
input={"messages": [{"type": "human", "content": [{"type": "text", "text": message}], "additional_kwargs": {}}]},
metadata={"run_scope": RUN_SCOPE_BACKGROUND_CHILD, "maf_run_id": principal.run_id},
config={"recursion_limit": 1000},
context=context,
excluded_tools=excluded_tools or [],
on_disconnect="continue",
multitask_strategy="reject",
)
# Typed internal request carrying the principal's identity — never user=None.
request = _InternalRunRequest.from_principal(self._app, principal)
token = set_current_user(_PrincipalUser(principal))
try:
record = await start_run(
body,
thread_id,
request,
run_scope=RUN_SCOPE_BACKGROUND_CHILD,
)
return record
finally:
reset_current_user(token)
async def read_thread_messages(self, principal: ExecutionPrincipal, thread_id: str) -> list[dict[str, Any]]:
"""Read the latest checkpoint messages from a thread."""
from deerflow.runtime.serialization import serialize_channel_values
token = set_current_user(_PrincipalUser(principal))
try:
checkpointer = self._app.state.checkpointer
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ct = await checkpointer.aget_tuple(config)
if ct is None:
return []
checkpoint = getattr(ct, "checkpoint", {}) or {}
values = serialize_channel_values(checkpoint.get("channel_values", {}) or {})
return values.get("messages") or []
finally:
reset_current_user(token)
async def execute_tool_directly(
self,
*,
thread_id: str,
agent_id: str,
tool_name: str,
params: dict[str, Any],
principal: ExecutionPrincipal,
turn_index: int,
capability_snapshot: dict[str, Any] | None,
write_policy: str,
approved_action_id: str,
action_record: dict[str, Any] | None,
model_name: str | None = None,
excluded_tools: list[str] | None = None,
) -> dict[str, Any]:
"""Execute a DeerFlow tool directly without LLM involvement.
For post-approval deterministic execution this delegates to the harness
``execute_approved_tool``, which runs the call through the **real**
LangGraph ``ToolNode`` / ``ToolRuntime`` / middleware chain (state
recovered from the thread checkpoint), not a faked runtime. No raw
params enter any prompt, checkpoint, or model context.
Returns ``{"status": "succeeded", "result": ...}`` or
``{"status": "failed", "error": "..."}``. A security rejection
(unauthorized / disabled / nonexistent tool, context mismatch) is a
``failed`` outcome and the tool executes 0 times.
"""
from deerflow.config import get_app_config
from deerflow.tools.approved_tool_executor import execute_approved_tool
app_config = get_app_config()
checkpointer = getattr(self._app.state, "checkpointer", None)
store = getattr(self._app.state, "store", None)
return await execute_approved_tool(
app_config=app_config,
checkpointer=checkpointer,
store=store,
thread_id=thread_id,
user_id=principal.user_id,
session_id=principal.session_id,
run_id=principal.run_id,
agent_id=agent_id,
turn_index=turn_index,
model_name=model_name,
excluded_tools=excluded_tools,
capability_snapshot=capability_snapshot,
write_policy=write_policy,
tool_name=tool_name,
params=params,
approved_action_id=approved_action_id,
action_record=action_record,
)
async def cancel_run(self, deerflow_run_id: str) -> bool:
"""Cancel a DeerFlow run by its run_id."""
run_manager = getattr(self._app.state, "run_manager", None)
if run_manager is None:
return False
try:
await run_manager.cancel(deerflow_run_id, action="interrupt")
return True
except Exception as exc: # noqa: BLE001
# Log only the exception type — never the raw message/traceback,
# which may carry params or credentials.
logger.warning(
"cancel DeerFlow run %s failed (%s)", deerflow_run_id, type(exc).__name__
)
return False
@@ -0,0 +1,103 @@
"""Recursive sanitization for MAF roundtable events, payloads, and logs.
Strips credentials, tokens, cookies, passwords, secrets, and API keys from
nested data structures before they reach the event store, context builder,
or log output. Applied uniformly to tool arguments, tool results, fact
ledger entries, and event payloads.
Design:
- Pure functions, no I/O.
- Recursive: handles nested dicts, lists, and strings.
- Key-based: redacts values whose key matches a sensitive pattern.
- Value-based: redacts string values that look like tokens/keys.
- Never raises: malformed input passes through unchanged (best-effort).
"""
from __future__ import annotations
import re
from typing import Any
_SENSITIVE_KEYS = frozenset({
"token", "access_token", "refresh_token", "id_token",
"authorization", "auth", "auth_token", "authtoken",
"cookie", "cookies", "set-cookie",
"password", "passwd", "pwd",
"secret", "secret_key", "client_secret",
"api_key", "apikey", "api-key",
"private_key", "private-key",
"credential", "credentials",
"session_id", "sessionid",
"x-api-key", "x-auth-token",
})
_KEY_PATTERN = re.compile(
r"(token|authorization|cookie|password|passwd|secret|api[_-]?key|private[_-]?key|credential|bearer)",
re.IGNORECASE,
)
_VALUE_PATTERNS = [
re.compile(r"^Bearer\s+\S+", re.IGNORECASE),
re.compile(r"^(sk|pk|ak|rk)[_-][A-Za-z0-9]{16,}$"),
re.compile(r"^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"),
re.compile(r"^[A-Za-z0-9+/]{40,}={0,2}$"),
]
_INLINE_SECRET_PATTERN = re.compile(
r"(token|password|passwd|secret|api[_-]?key|authorization|cookie)\s*[=:]\s*\S+",
re.IGNORECASE,
)
_REDACTED = "***REDACTED***"
_MAX_DEPTH = 12
def sanitize(data: Any, *, _depth: int = 0) -> Any:
"""Recursively sanitize sensitive fields from a data structure."""
if _depth > _MAX_DEPTH:
return data
if isinstance(data, dict):
return _sanitize_dict(data, _depth)
if isinstance(data, (list, tuple)):
return [sanitize(item, _depth=_depth + 1) for item in data]
if isinstance(data, str):
return _sanitize_value_str(data)
return data
def sanitize_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None:
"""Sanitize an event payload dict. Returns None if input is None."""
if payload is None:
return None
result = sanitize(payload)
return result if isinstance(result, dict) else payload
def _sanitize_dict(d: dict[str, Any], depth: int) -> dict[str, Any]:
out: dict[str, Any] = {}
for key, value in d.items():
key_lower = str(key).lower().replace("-", "_")
if key_lower in _SENSITIVE_KEYS or _KEY_PATTERN.search(key_lower):
out[key] = _REDACTED
elif isinstance(value, dict):
out[key] = _sanitize_dict(value, depth + 1)
elif isinstance(value, (list, tuple)):
out[key] = [sanitize(item, _depth=depth + 1) for item in value]
elif isinstance(value, str):
out[key] = _sanitize_value_str(value)
else:
out[key] = value
return out
def _sanitize_value_str(s: str) -> str:
# Never skip sanitization based on length — long strings (error traces,
# log dumps, seat-run errors) may embed credentials deep inside.
for pat in _VALUE_PATTERNS:
if pat.match(s):
return _REDACTED
if _INLINE_SECRET_PATTERN.search(s):
return _INLINE_SECRET_PATTERN.sub(
lambda m: m.group(0).split("=")[0].split(":")[0] + "=***REDACTED***", s
)
return s
@@ -30,8 +30,8 @@ class ParticipantInput(BaseModel):
display_name: str = Field(default="", max_length=255)
role: MafParticipantRole = "seat"
sort_order: int = 0
# Frozen at creation — the agent's tools / skills snapshot.
capability_snapshot: dict[str, Any] | None = None
# Subtractive only: tools removed from the server-resolved allow-list for this seat.
excluded_tools: list[str] = Field(default_factory=list)
class CreateSessionRequest(BaseModel):
@@ -48,8 +48,8 @@ class CreateSessionRequest(BaseModel):
# Optional: omitted → MafRoundtableConfig.default_write_policy.
write_policy: MafWritePolicy | None = None
participants: list[ParticipantInput] = Field(default_factory=list)
# Frozen at creation — the full session capability snapshot (agents + tools + skills).
capability_snapshot: dict[str, Any] | None = None
# Subtractive session-wide tool exclusions (applied per seat when building snapshot).
excluded_tools: list[str] = Field(default_factory=list)
# Fields that must not be explicitly set to null on PUT (→ 422).
@@ -30,11 +30,15 @@ reserved / not-yet-executing state) rather than pretending to be ``running``.
from __future__ import annotations
import logging
from typing import Any
from app.maf_roundtable.sanitize import sanitize, sanitize_payload
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
from deerflow.persistence.maf_roundtable.store import MafRoundtableStore
logger = logging.getLogger(__name__)
class MafDisabledError(RuntimeError):
"""Raised when a write is attempted while the feature flag is off."""
@@ -64,11 +68,67 @@ class MafForbiddenError(PermissionError):
self.ident = ident
class MafConfigurationError(RuntimeError):
"""MAF is enabled but a required safety prerequisite is missing.
Raised by :func:`validate_maf_prerequisites` on the startup path so the
feature can never come up in a half-safe state ("MAF on, approval guard not
mounted"). The gateway lifespan catches this and forces the feature OFF
(clearly logged) rather than aborting the whole (experimental, opt-in)
service.
"""
def validate_maf_prerequisites() -> None:
"""Fail loudly if MAF is enabled without its full safety stack in place.
Checks, in order:
1. ``write_guard.enabled`` is true (the approval guard is mounted);
2. ``write_guard.provider`` is complete (``policy_use`` + ``interceptor_use``);
3. the bridge factories actually resolve (the app-layer guard is importable);
4. ``MAF_ACTION_SECRET_KEY`` is set (action params are encrypted with it).
Raises ``MafConfigurationError`` on the first unmet condition. Callers on
the startup path treat that as "force the feature off"; direct unit-test
construction of :class:`MafRoundtableService` does NOT call this.
"""
import os
from deerflow.config.write_guard_config import get_write_guard_config
from deerflow.reflection import resolve_variable
wg = get_write_guard_config()
if not wg.enabled:
raise MafConfigurationError(
"maf_roundtable.enabled=true requires write_guard.enabled=true "
"(the approval guard must be mounted)"
)
provider = wg.provider
if provider is None or not provider.policy_use or not provider.interceptor_use:
raise MafConfigurationError(
"maf_roundtable.enabled=true requires a complete write_guard.provider "
"(policy_use + interceptor_use)"
)
try:
resolve_variable(provider.policy_use)
resolve_variable(provider.interceptor_use)
except Exception as exc: # noqa: BLE001
raise MafConfigurationError(
f"write_guard bridge factories are not resolvable: {exc}"
) from exc
if not (os.getenv("MAF_ACTION_SECRET_KEY") or "").strip():
raise MafConfigurationError(
"maf_roundtable.enabled=true requires the MAF_ACTION_SECRET_KEY env var"
)
# Session statuses that may be edited (config changes).
_EDITABLE_STATUSES = {"draft", "completed", "failed", "cancelled"}
# Session statuses that mean a run is in flight (no config edits). ``starting``
# is the transient CAS-claim state between "claimed" and "run row created".
_RUNNING_STATUSES = {"starting", "running", "waiting_approval", "waiting_input"}
# ``cancel_failed`` blocks edits because the cancel cascade is incomplete and
# the may still have active sub-runs.
_RUNNING_STATUSES = {"starting", "running", "waiting_approval", "waiting_input", "cancelling", "cancel_failed"}
class MafRoundtableService:
@@ -79,14 +139,37 @@ class MafRoundtableService:
enabled: bool,
admin_diagnostic_access: bool,
config: MafRoundtableConfig | None = None,
agent_store: Any | None = None,
app_config: Any | None = None,
) -> None:
self._store = store
self._enabled = enabled
self._admin_diagnostic_access = admin_diagnostic_access
self._agent_store = agent_store
self._app_config = app_config
# Defaults source for create_session; falls back to the pydantic
# defaults when no config is injected (keeps unit tests lightweight).
self._config = config or MafRoundtableConfig()
# Startup validation: if the MAF feature is enabled, the
# ``MAF_ACTION_SECRET_KEY`` env var MUST be set — external-write
# action params are encrypted with it and there is no default key.
# A missing key forces the feature off (write paths then return 503)
# rather than running with a publicly-known key. This never aborts
# Gateway startup (the feature is experimental / opt-in).
if enabled:
import os
key = os.getenv("MAF_ACTION_SECRET_KEY", "")
if not key or not key.strip():
logger.error(
"[maf-roundtable] MAF_ACTION_SECRET_KEY is not set — "
"disabling the feature (write paths will return 503). "
"Set a 32+ char random string before enabling "
"maf_roundtable.enabled."
)
enabled = False
self._enabled = enabled
@property
def store(self) -> MafRoundtableStore:
return self._store
@@ -132,8 +215,52 @@ class MafRoundtableService:
) -> dict[str, Any]:
self._require_enabled()
cfg = self._config
# Config-driven defaults: an omitted field falls back to the deployed
# MafRoundtableConfig value; an explicit request value always wins.
from app.maf_roundtable.capability_snapshot import (
AgentNotAccessibleError,
InvalidExcludedToolsError,
build_trusted_capability_snapshot,
seat_snapshot_for_agent,
)
from deerflow.config import get_app_config
app_config = self._app_config or get_app_config()
raw_participants = list(body.get("participants") or [])
# Strip any client-supplied snapshot / tool metadata — never trusted.
participants: list[dict[str, Any]] = []
for p in raw_participants:
participants.append(
{
"agent_id": p.get("agent_id"),
"display_name": p.get("display_name", ""),
"role": p.get("role", "seat"),
"sort_order": p.get("sort_order", 0),
"thread_id": p.get("thread_id"),
"excluded_tools": p.get("excluded_tools") or [],
}
)
try:
trusted_snapshot = await build_trusted_capability_snapshot(
user_id=user_id,
participants=participants,
app_config=app_config,
agent_store=self._agent_store,
session_model_name=body.get("model_name"),
session_reasoning_effort=body.get("reasoning_effort"),
session_excluded_tools=body.get("excluded_tools"),
)
except AgentNotAccessibleError as exc:
raise MafForbiddenError("agent", exc.agent_id) from exc
except InvalidExcludedToolsError as exc:
raise MafConflictError(
f"excluded_tools not allowed for agent {exc.agent_id}: {exc.tools}"
) from exc
for p in participants:
aid = p.get("agent_id")
if aid:
p["capability_snapshot"] = seat_snapshot_for_agent(trusted_snapshot, str(aid))
session_data: dict[str, Any] = {
"user_id": user_id,
"title": body.get("title", ""),
@@ -146,10 +273,9 @@ class MafRoundtableService:
"manager_agent_id": body.get("manager_agent_id"),
"max_rounds": body.get("max_rounds") if body.get("max_rounds") is not None else cfg.default_max_rounds,
"write_policy": body.get("write_policy") or cfg.default_write_policy,
"capability_snapshot": body.get("capability_snapshot"),
"capability_snapshot": trusted_snapshot,
}
session = await self._store.sessions.create_session(session_data)
participants = body.get("participants") or []
if participants:
await self._store.participants.replace_participants(session["id"], participants)
return await self.get_session(session_id=session["id"], user_id=user_id, is_admin=is_admin)
@@ -287,6 +413,76 @@ class MafRoundtableService:
raise MafForbiddenError("run", run_id)
raise MafNotFoundError("run", run_id)
async def start_cancel(self, *, run_id: str, user_id: str | None, is_admin: bool = False) -> dict[str, Any]:
"""Phase 1 of two-phase cancel: atomically set run+session to ``cancelling``.
The caller (router) then calls ``dispatcher.cancel(run_id)`` to
cascade to DeerFlow, then calls ``cancel_run`` to finalize to
``cancelled``.
"""
self._require_enabled()
if not user_id:
raise MafNotFoundError("run", run_id)
result = await self._store.start_cancel_atomically(run_id=run_id, owner_user_id=user_id)
if result is not None:
return result
# Classify the miss.
run = await self._store.runs.get_run(run_id)
if run is None:
raise MafNotFoundError("run", run_id)
if is_admin:
raise MafForbiddenError("run", run_id)
raise MafNotFoundError("run", run_id)
async def finalize_cancel(self, *, run_id: str, user_id: str | None, is_admin: bool = False) -> dict[str, Any]:
"""Phase 2 of two-phase cancel: finalize ``cancelling`` → ``cancelled``."""
return await self.cancel_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
async def mark_cancel_failed(
self,
*,
run_id: str,
user_id: str | None,
is_admin: bool = False,
reason: str = "",
) -> dict[str, Any]:
"""Mark a cancelling run as ``cancel_failed`` (non-terminal).
Used when the dispatcher's cancel cascade could not confirm all
DeerFlow sub-runs were cancelled. The run AND session both enter
``cancel_failed`` atomically so their statuses never diverge.
The user can retry cancel later — ``cancel_run_atomically``
accepts ``cancel_failed`` as a valid input state.
A failed cancel must NEVER masquerade as ``cancelled``.
"""
self._require_enabled()
if not user_id:
raise MafNotFoundError("run", run_id)
scope = self._write_scope_user(user_id, is_admin)
run = await self._store.runs.get_run(run_id)
if run is None:
raise MafNotFoundError("run", run_id)
# Owner (or admin) check.
session = await self._store.sessions.get_session(run["session_id"], user_id=scope)
if session is None:
if is_admin and await self._store.sessions.get_session(run["session_id"], user_id=None) is not None:
raise MafForbiddenError("run", run_id)
raise MafNotFoundError("run", run_id)
safe_reason = str(sanitize(reason))[:500] if reason else "one or more sub-runs could not be cancelled"
updated = await self._store.mark_cancel_failed_atomically(
run_id=run_id,
owner_user_id=user_id,
error_code="CancelCascadeIncomplete",
error_message=safe_reason,
)
if updated is None:
raise MafNotFoundError("run", run_id)
return updated
# ── events ─────────────────────────────────────────────────────────────
async def append_event(
@@ -298,6 +494,7 @@ class MafRoundtableService:
agent_id: str | None = None,
turn_index: int | None = None,
payload: dict[str, Any] | None = None,
source_event_id: str | None = None,
is_admin: bool = False,
) -> dict[str, Any]:
"""Append an event to a run's stream (internal / executor only).
@@ -324,7 +521,8 @@ class MafRoundtableService:
event_type=event_type,
agent_id=agent_id,
turn_index=turn_index,
payload=payload,
payload=sanitize_payload(payload),
source_event_id=source_event_id,
)
async def list_events(
@@ -0,0 +1,235 @@
"""Deterministic post-approval tool executor for the MAF roundtable.
After a human approves an external-write action, this executor calls the
approved DeerFlow tool **directly** with the **decrypted original params** —
no model free-will, no re-decision, no raw params in any prompt or checkpoint.
It is invoked by ``WorkflowRunner.resume()`` instead of re-running an LLM seat
turn (which would let the model pick different arguments or a different tool).
Design — direct tool invocation
-------------------------------
1. **Atomic occupy** (``approved → running``): only the caller whose
conditional UPDATE affects one row wins the right to execute. Concurrent
``resume()`` or post-restart replay finds the row already ``running`` or
terminal and bails out — at-most-once execution is guaranteed at the DB
level, not by locking.
2. **Direct tool call**: decrypts approved params (server-only), looks up the
tool in DeerFlow's registry, builds a minimal ToolRuntime from the thread's
persisted state (sandbox/thread_data), and calls ``tool.func()`` or
``tool.ainvoke()`` directly. The LLM is never involved.
3. **Authority**: the approved ``action_id`` is carried in the runtime context
so the WriteGuard middleware can validate both tool name AND action_id as
defense-in-depth (though the LLM is bypassed, so this is purely a secondary
check).
4. **Outcome recording**: ``succeeded`` / ``failed`` / ``unknown`` are
recorded via the state-machine-constrained ``record_terminal_result``
(only ``running → terminal``). ``unknown`` prohibits auto-retry.
Security: the decrypted params are read server-side from the encrypted secret
and never leave this process. They do NOT appear in prompts, checkpoints,
events, or API responses.
"""
from __future__ import annotations
import logging
from typing import Any
from app.maf_roundtable.action_coordinator import ActionCoordinator
from app.maf_roundtable.sanitize import sanitize
logger = logging.getLogger(__name__)
class ToolExecutor:
"""Executes an approved external-write action deterministically.
Constructed per-resume from the ``WorkflowRunner``; takes the already-built
adapter + coordinator + runtime client so it reuses identity / sandbox /
middleware wiring.
"""
def __init__(
self,
*,
adapter: Any,
action_coordinator: ActionCoordinator,
user_id: str,
) -> None:
self._adapter = adapter
self._coordinator = action_coordinator
self._user_id = user_id
async def execute(
self,
*,
action_id: str,
session_id: str,
run_id: str,
agent_id: str,
thread_id: str,
approved_tool: str,
capability_snapshot: dict[str, Any] | None = None,
write_policy: str = "confirm_high_risk",
model_name: str | None = None,
excluded_tools: list[str] | None = None,
) -> dict[str, Any]:
"""Execute the approved tool deterministically and record the outcome.
Returns::
{"status": "succeeded"|"failed"|"skipped"|"unknown", ...}
``skipped`` means another caller already occupied/finished the action
(at-most-once) — the caller should return the existing result.
The approved params are recovered **server-side** from the encrypted
action secret *after* the atomic occupy. If the secret is missing or
cannot be decrypted, ``ActionSecretUnavailableError`` is raised by the
coordinator and turned into a terminal ``failed`` outcome here — the
tool is NEVER called with empty params (no ``params or {}`` fallback),
so the external side effect happens exactly zero times.
The actual call is delegated to the harness ``execute_approved_tool``
(real ``ToolNode`` / ``ToolRuntime`` / middleware chain), rebuilt from
the seat's frozen capability snapshot / model / ``excluded_tools``. The
stored action record is passed so the harness can verify run/session/
agent/tool consistency before executing.
"""
# 1. Atomic occupy — only the winner ("claimed") proceeds to execute.
# A lost claim reflects the action's ACTUAL state (in_progress /
# terminal) rather than a blanket "skip", so the caller can keep the
# parent run running while another executor finishes it.
from deerflow.persistence.maf_roundtable.actions import (
MafActionStateMachineError,
)
try:
claim = await self._coordinator.occupy_for_execution(action_id)
except MafActionStateMachineError as exc:
logger.error(
"ToolExecutor: action %s cannot be claimed: %s", action_id, exc,
)
return {"status": "failed", "error": f"action state machine error: {exc}"}
if claim != "claimed":
existing = await self._coordinator._store.actions.get_action(action_id) # noqa: SLF001
if claim == "in_progress":
logger.info(
"ToolExecutor: action %s already running elsewhere (in_progress)",
action_id,
)
return {"status": "in_progress", "action": existing}
logger.info(
"ToolExecutor: action %s already terminal (%s) — at-most-once",
action_id, claim,
)
return {"status": claim, "action": existing}
if not approved_tool:
await self._coordinator.record_result(
action_id, status="failed",
result={"error": "no approved tool name — cannot execute"},
)
return {"status": "failed", "error": "no approved tool name"}
# 2. Recover the approved params from the server-only encrypted secret.
# A missing/undecryptable/invalid secret is a HARD failure: record
# the action failed and never invoke the tool (execution count == 0).
from app.maf_roundtable.action_coordinator import ActionSecretUnavailableError
try:
params = await self._coordinator.get_approved_action_params(action_id)
except ActionSecretUnavailableError as exc:
safe_msg = str(sanitize(exc.detail))[:500]
logger.error(
"ToolExecutor: action %s params unavailable (%s): %s",
action_id, exc.reason, safe_msg,
)
await self._coordinator.record_result(
action_id, status="failed",
result={"error": f"action secret unavailable: {exc.reason}"},
)
return {"status": "failed", "error": safe_msg}
# 3. Read the stored action record (authoritative turn_index + the
# run/session/agent/tool the harness will verify against).
action_record = await self._coordinator._store.actions.get_action(action_id) # noqa: SLF001
turn_index = int((action_record or {}).get("turn_index") or 0)
# 4. Execute via the real DeerFlow tool path — no LLM, no prompt
# injection. The raw params are read from the encrypted secret and
# passed straight to the tool; they never enter any model context.
from app.maf_roundtable.runtime_client import ExecutionPrincipal
principal = ExecutionPrincipal(
user_id=self._user_id,
session_id=session_id,
run_id=run_id,
)
client = self._adapter._client # noqa: SLF001 — DeerFlowRuntimeClient
try:
outcome = await client.execute_tool_directly(
thread_id=thread_id,
agent_id=agent_id,
tool_name=approved_tool,
params=params,
principal=principal,
turn_index=turn_index,
capability_snapshot=capability_snapshot,
write_policy=write_policy,
model_name=model_name,
excluded_tools=excluded_tools,
approved_action_id=action_id,
action_record=action_record,
)
except Exception as exc:
safe_msg = str(sanitize(str(exc)))[:500]
logger.error(
"ToolExecutor: direct tool call '%s' raised: %s",
approved_tool, safe_msg,
)
await self._coordinator.record_result(
action_id, status="failed", result={"error": safe_msg},
)
return {"status": "failed", "error": safe_msg}
# 5. Record terminal result (state-machine constrained: only
# running → terminal; a lost race is a no-op).
outcome_status = outcome.get("status")
if outcome_status == "succeeded":
await self._coordinator.record_result(
action_id, status="succeeded",
result={"tool_result": outcome.get("result")},
)
return {"status": "succeeded", "tool_result": outcome.get("result")}
elif outcome_status == "unknown":
# Inconclusive external result (timeout / disconnect / gateway):
# record ``unknown`` — never auto-retry, a human must reconcile.
error = outcome.get("error", "inconclusive external result")
await self._coordinator.record_result(
action_id, status="unknown", result={"error": error},
)
return {"status": "unknown", "error": error}
else:
error = outcome.get("error", "tool execution failed")
await self._coordinator.record_result(
action_id, status="failed", result={"error": error},
)
return {"status": "failed", "error": error}
async def record_unknown(self, action_id: str, *, reason: str) -> None:
"""Record an ``unknown`` outcome (network timeout / inconclusive).
``unknown`` prohibits auto-retry — the action stays in ``unknown``
until a human reconciles it.
"""
logger.warning(
"ToolExecutor: action %s outcome unknown — %s (no auto-retry)",
action_id, reason,
)
await self._coordinator.record_result(
action_id, status="unknown", result={"reason": reason},
)
@@ -0,0 +1,606 @@
"""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.
Executors delegate to ``DeerFlowAgentAdapter.run_seat_turn()`` for actual
model/tool execution and emit MAF events via the adapter.
Nodes:
- CoordinatorExecutor: decides which seat speaks next, whether to continue.
- SeatExecutor (one per seat): runs one seat turn via DeerFlowAgentAdapter.
- ApprovalGateExecutor: pauses for external-write approval before proceeding.
- FinalizerExecutor: generates the final synthesis and ends the run.
"""
from __future__ import annotations
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
logger = logging.getLogger(__name__)
class WriteGuardConfigurationError(RuntimeError):
"""Raised when WriteGuard should have intercepted a write tool but didn't.
Indicates write_guard is misconfigured (not enabled or provider missing)
while a MAF run requires approval. The run is failed — we never fall back
to creating generic post-hoc approval records.
"""
# ── 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,
) -> 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.current_round = 0
self.turn_results: list[dict[str, Any]] = []
self.fact_ledger: dict[str, Any] = {
"confirmed_facts": [],
"tool_results": [],
"conflicts": [],
"pending_actions": [],
}
self.final_result: dict[str, Any] | None = None
self.status = "running"
self.pending_approvals: list[dict[str, Any]] = []
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,
"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,
}
@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"),
)
state.current_round = d.get("current_round", 0)
state.turn_results = d.get("turn_results", [])
state.fact_ledger = d.get("fact_ledger", state.fact_ledger)
state.final_result = d.get("final_result")
state.status = d.get("status", "running")
state.pending_approvals = d.get("pending_approvals", []) or d.get("pending_approval") and [d["pending_approval"]] or []
return state
# ── Coordinator executor ────────────────────────────────────────────────────
class CoordinatorExecutor(Executor):
"""Decides which seat speaks next, whether to continue or finish."""
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:
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
if self._state.mode == "structured":
if self._state.current_round == 1:
await ctx.send_message({"decision": "parallel_seats", "agents": self._state.seat_agent_ids})
return
await ctx.send_message({"decision": "done", "reason": "structured_complete"})
return
# group_chat: round-robin speaker selection
remaining = [
a for a in self._state.seat_agent_ids
if not any(
r.get("agent_id") == a and r.get("turn_index", 0) >= self._state.current_round - 1
for r in self._state.turn_results
)
]
if not remaining:
await ctx.send_message({"decision": "done", "reason": "all_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": "round_robin", "round": self._state.current_round},
})
await ctx.send_message({"decision": "next_speaker", "agent_id": next_agent})
# ── Seat executor (one instance per seat) ───────────────────────────────────
class SeatExecutor(Executor):
"""Runs one seat turn via DeerFlowAgentAdapter.
Each instance is bound to a fixed ``agent_id`` — the MAF workflow routes
to the correct seat executor by edge, not by guessing from input.
Phase-2 fix: write tools are NO LONGER excluded from the seat run. The
WriteGuard middleware (harness layer) intercepts them at execution time
via ``wrap_tool_call``/``awrap_tool_call`` when the run context carries a
``maf_run_id`` flag. The model therefore SEES the write-tool schema and
can propose the call, but the real tool never executes before approval.
``excluded_tools`` now only contains tools the user explicitly disabled
(from the capability snapshot). The post-turn capture is retained solely
to surface the intercepted calls in the run state / pending approval.
"""
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:
thread_id = self._state.thread_map.get(self._agent_id, "")
if not thread_id:
await ctx.send_message({"error": f"no thread for agent {self._agent_id}", "agent_id": self._agent_id})
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
]
# Read ALL capabilities from the frozen snapshot — never default-disable.
caps = _agent_capabilities_from_snapshot(self._state.capability_snapshot, self._agent_id)
# Phase-2: excluded_tools contains ONLY user-disabled tools. Write tools
# that need approval are no longer excluded — the WriteGuard middleware
# intercepts them at execution time so the model still sees the schema.
excluded = list(caps.get("excluded_tools", []))
response = 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,
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=f"第{self._state.current_round}轮分析",
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,
)
# Phase-2: the WriteGuard middleware already created pending actions for
# intercepted write tools during the run (inside DeerFlow). We now read
# the waiting_approval actions that match THIS agent + THIS turn to
# surface the pending approval in the workflow state so the ApprovalGate
# can pause. No action creation here.
if response.tool_calls and self._state.write_policy != "auto":
pending_calls = [
tc for tc in response.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 pending_calls:
all_actions = await self._store.actions.list_actions(
run_id=self._state.run_id, status="waiting_approval"
)
# P1-5/P1b: candidate actions are scoped to this run + agent +
# THIS turn (never an earlier turn's waiting actions).
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
]
# Every pending tool call must match EXACTLY one action, by
# tool_call_id (logical_action_id); fall back to tool_name only
# when the call carries no id. Any count/ID mismatch is a hard
# failure — we never guess which action a call maps to.
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"
)
matched.append(hits[0])
self._state.pending_approvals = [
{
"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,
}
for a in matched
]
self._state.turn_results.append({
"agent_id": self._agent_id,
"turn_index": self._state.current_round,
"content": response.content,
"tool_calls": response.tool_calls,
"error": response.error,
"usage": response.usage,
})
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 [])
)
self._state.fact_ledger["tool_results"].append({
"tool_name": tc_name,
"status": "waiting_approval" if is_intercepted else "completed",
"agent_id": self._agent_id,
})
# SDK/API errors must fail the run, never silently complete.
if response.error:
raise RuntimeError(
f"seat {self._agent_id} turn {self._state.current_round} failed: {response.error}"
)
await ctx.send_message({"agent_id": self._agent_id, "content": response.content, "error": response.error})
@property
def _store(self) -> Any:
return self._adapter._service.store
def _pending_write_calls(self, tool_calls: list[dict[str, Any]]) -> list[str]:
"""Return the names of write tool calls that the policy requires approval for."""
names: list[str] = []
for tc in tool_calls:
name = tc.get("name", "")
if name and tool_needs_approval(
name, self._state.write_policy, self._state.capability_snapshot, self._agent_id
):
names.append(name)
return names
# ── Approval gate executor ──────────────────────────────────────────────────
class ApprovalGateExecutor(Executor):
"""Pauses for external-write approval before proceeding.
When the write policy requires approval and a seat produced external write
tool calls, this executor:
1. Reads the pending approvals already created by WriteGuard (zero or more).
2. If none exist but the policy requires approval for detected write tools:
raises ``WriteGuardConfigurationError`` — the WriteGuard middleware
should have intercepted and recorded them. We NEVER fall back to
creating generic post-hoc actions.
3. Emits ``action.waiting_approval`` events.
4. Sets the run status to ``waiting_approval``.
On resume (after user approves), the workflow re-enters and this executor
checks the approval statuses. When all are decided, it routes to the
finalizer.
"""
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:
# Check if we're resuming from a previous approval pause.
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 = []
await ctx.send_message({"decision": "approved"})
return
# Still pending — stay in waiting state.
await ctx.send_message({"decision": "waiting_approval"})
return
# First entry: check if any seat produced write tool calls needing approval.
needs_approval = self._check_needs_approval()
if not needs_approval:
await ctx.send_message({"decision": "approved", "policy": self._state.write_policy})
return
# P0-4: WriteGuard should already have created pending actions for every
# tool that needs approval. If we detect write tools that need approval
# but NO pending_approvals were set by the SeatExecutor, the WriteGuard
# middleware is misconfigured — fail the run rather than silently
# bypassing approval.
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."
)
# Update run status to waiting_approval.
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"}
)
await ctx.send_message({"decision": "waiting_approval"})
def _check_needs_approval(self) -> bool:
"""Check if the write policy requires approval for any pending tool calls.
P1: uses metadata-driven classification from the capability snapshot,
not name-prefix heuristics. Tools without metadata use a safe default
(requires approval).
"""
if self._state.write_policy == "auto":
return False
for result in self._state.turn_results:
for tc in result.get("tool_calls") or []:
name = tc.get("name", "")
if not name:
continue
if tool_needs_approval(name, self._state.write_policy, self._state.capability_snapshot):
return True
return False
# ── Finalizer executor ──────────────────────────────────────────────────────
async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[str, Any]:
"""Run the manager synthesis (the MAF finalizer path) and set ``state.final_result``.
Shared by the in-graph ``FinalizerExecutor`` and ``WorkflowRunner.resume`` so
that a run which paused for approval still gets a real synthesized report
after its approved actions execute — resume must re-enter the finalizer
path, not merely flip ``state.status`` to ``completed``.
"""
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, "")
synthesis_input = build_manager_synthesis_input(
task_text=state.task_text,
seat_results=state.turn_results,
fact_ledger=state.fact_ledger,
)
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,
message=synthesis_input,
thread_id=thread_id,
model_name=state.model_name,
reasoning_effort=state.reasoning_effort,
task_text=synthesis_input,
stage_instruction="综合所有席位分析,生成最终结论",
)
state.final_result = {"content": response.content, "agent_id": manager_id}
else:
state.final_result = {"content": synthesis_input, "agent_id": manager_id, "note": "no_manager_thread"}
await adapter._emit_critical(state.run_id, make_run_completed(manager_id, state.current_round + 1, {
"total_rounds": state.current_round,
"seats_completed": len(state.turn_results),
}))
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:
await generate_final_report(self._adapter, self._state)
self._state.status = "completed"
await ctx.yield_output({"final_result": self._state.final_result})
# ── Waiting terminal executor ───────────────────────────────────────────────
class WaitingTerminalExecutor(Executor):
"""Terminal executor for the approval-waiting path.
When the approval gate routes here, the workflow stops. The run stays in
``waiting_approval`` until the user decides, at which point the service
resumes the workflow from its checkpoint.
"""
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 _excluded_tools_from_snapshot(snapshot: dict[str, Any] | None, agent_id: str) -> list[str]:
"""Extract excluded_tools from the frozen capability snapshot for an agent."""
caps = _agent_capabilities_from_snapshot(snapshot, agent_id)
return caps.get("excluded_tools", [])
def _agent_capabilities_from_snapshot(snapshot: dict[str, Any] | None, agent_id: str) -> dict[str, Any]:
"""Extract ALL capabilities from the frozen snapshot for an agent.
Returns a dict with keys: model_name, reasoning_effort, thinking_enabled,
subagent_enabled, excluded_tools, memory_recall_disabled,
memory_injection_enabled, write_policy. Missing keys default to
permissive values (tools/skills/memory NOT disabled by default).
"""
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:
"""Determine if a tool is high-risk using capability snapshot metadata.
P1 fix: no longer guesses risk from name prefixes (``create_`` /
``delete_`` etc.). Instead, reads the per-tool ``side_effect`` and
``risk_level`` from the frozen capability snapshot. Tools without
metadata use a **safe default** (R3 write → high-risk → requires approval).
"""
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
@@ -0,0 +1,139 @@
"""MAF WorkflowBuilder graph construction for the roundtable experiment.
Creates real MAF Workflow graphs using the official SDK's WorkflowBuilder.
The SDK is imported lazily so the Gateway starts without it when the feature
flag is off.
Two topologies:
- ``structured``: coordinator → parallel seats → approval gate → finalizer
- ``group_chat``: coordinator loop (dynamic speaker selection) → finalizer
Each node is a real MAF ``Executor`` subclass instance (from
``workflow_executors.py``). The builder uses the SDK's actual API:
``WorkflowBuilder(start_executor=...)``, ``add_edge``, ``add_fan_out_edges``,
``add_fan_in_edges``, ``add_switch_case_edge_group``, ``build()``.
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def build_structured_workflow(
*,
coordinator: Any,
seat_executors: list[Any],
approval_gate: Any,
finalizer: Any,
waiting_terminal: Any,
) -> Any:
"""Build a structured (fixed-topology) MAF Workflow.
Topology: coordinator → parallel seat executors → approval gate → finalizer.
The approval gate conditionally routes to waiting_terminal (pause) or finalizer.
"""
from agent_framework import Case, Default, WorkflowBuilder
builder = WorkflowBuilder(
start_executor=coordinator,
name="maf-structured-roundtable",
output_from=[finalizer, waiting_terminal],
)
# coordinator → seats → approval gate. Handle 0/1/≥2 seats since the SDK's
# FanOut/FanIn edge groups require at least two members.
if len(seat_executors) >= 2:
builder.add_fan_out_edges(coordinator, seat_executors)
builder.add_fan_in_edges(seat_executors, approval_gate)
elif len(seat_executors) == 1:
builder.add_edge(coordinator, seat_executors[0])
builder.add_edge(seat_executors[0], approval_gate)
else:
# No seats — coordinator feeds the approval gate directly.
builder.add_edge(coordinator, approval_gate)
# approval gate → conditional: approved → finalizer, waiting → waiting_terminal
builder.add_switch_case_edge_group(approval_gate, [
Case(condition=lambda out: isinstance(out, dict) and out.get("decision") == "waiting_approval", target=waiting_terminal),
Default(target=finalizer),
])
return builder.build()
def build_group_chat_workflow(
*,
coordinator: Any,
seat_executors: list[Any],
approval_gate: Any,
finalizer: Any,
waiting_terminal: Any,
seat_agent_ids: list[str],
) -> Any:
"""Build a dynamic group-chat MAF Workflow.
The coordinator selects the next speaker each round via switch-case edges.
Loop terminates on: coordinator signals completion, max_rounds reached.
"""
from agent_framework import Case, Default, WorkflowBuilder
builder = WorkflowBuilder(
start_executor=coordinator,
name="maf-groupchat-roundtable",
output_from=[finalizer, waiting_terminal],
)
# coordinator → switch-case: route to the selected seat, or to approval gate
cases = []
for i, agent_id in enumerate(seat_agent_ids):
cases.append(Case(
condition=lambda out, aid=agent_id: isinstance(out, dict) and out.get("agent_id") == aid,
target=seat_executors[i],
))
# Default: coordinator said "done" → go to approval gate
cases.append(Default(target=approval_gate))
builder.add_switch_case_edge_group(coordinator, cases)
# each seat → back to coordinator (loop)
for seat_exec in seat_executors:
builder.add_edge(seat_exec, coordinator)
# approval gate → conditional: waiting → waiting_terminal, else → finalizer
builder.add_switch_case_edge_group(approval_gate, [
Case(condition=lambda out: isinstance(out, dict) and out.get("decision") == "waiting_approval", target=waiting_terminal),
Default(target=finalizer),
])
return builder.build()
def create_workflow(
*,
mode: str,
coordinator: Any,
seat_executors: list[Any],
approval_gate: Any,
finalizer: Any,
waiting_terminal: Any,
seat_agent_ids: list[str],
) -> Any:
"""Factory: create the appropriate workflow for the given mode."""
if mode == "group_chat":
return build_group_chat_workflow(
coordinator=coordinator,
seat_executors=seat_executors,
approval_gate=approval_gate,
finalizer=finalizer,
waiting_terminal=waiting_terminal,
seat_agent_ids=seat_agent_ids,
)
return build_structured_workflow(
coordinator=coordinator,
seat_executors=seat_executors,
approval_gate=approval_gate,
finalizer=finalizer,
waiting_terminal=waiting_terminal,
)
@@ -0,0 +1,483 @@
"""MAF Workflow runner — orchestrates workflow execution for a MAF run.
Called after ``create_run`` to actually execute the workflow. Translates all
MAF Workflow events into ``maf_roundtable_events`` (the single event stream
the frontend reads). Manages run status lifecycle and checkpoint persistence.
There is NO sequential fallback. If the MAF SDK is missing or the workflow
fails, the run is marked ``failed`` with a sanitized error message. The runner
never pretends a workflow executed when it did not.
Usage::
runner = WorkflowRunner(app, service=svc, user_id="u1")
await runner.execute(session, run)
"""
from __future__ import annotations
import logging
import time
from datetime import UTC, datetime
from typing import Any
from app.maf_roundtable.action_coordinator import ActionCoordinator
from app.maf_roundtable.deerflow_agent_adapter import DeerFlowAgentAdapter
from app.maf_roundtable.runtime_client import DeerFlowRuntimeClient
from app.maf_roundtable.sanitize import sanitize
from app.maf_roundtable.workflow_executors import (
ApprovalGateExecutor,
CoordinatorExecutor,
FinalizerExecutor,
RoundtableState,
SeatExecutor,
WaitingTerminalExecutor,
_agent_capabilities_from_snapshot,
generate_final_report,
)
from app.maf_roundtable.workflow_factory import create_workflow
logger = logging.getLogger(__name__)
class MafSdkMissingError(RuntimeError):
"""Raised when the MAF SDK (agent_framework) is not installed."""
class WorkflowRunner:
"""Executes a MAF workflow for a given session/run pair."""
def __init__(self, app: Any, *, service: Any, user_id: str) -> None:
self._app = app
self._service = service
self._user_id = user_id
self._client = DeerFlowRuntimeClient(app)
self._adapter = DeerFlowAgentAdapter(self._client, service=service, user_id=user_id)
self._action_coordinator = ActionCoordinator(service, user_id=user_id)
@property
def adapter(self) -> DeerFlowAgentAdapter:
return self._adapter
@property
def action_coordinator(self) -> ActionCoordinator:
return self._action_coordinator
async def execute(self, session: dict[str, Any], run: dict[str, Any]) -> None:
"""Execute the full workflow for a session/run.
Transitions run status: pending → running → completed/failed/cancelled.
Persists checkpoint (RoundtableState) after each major step so the
workflow can resume after approval or restart.
"""
run_id = run["id"]
session_id = session["id"]
t0 = time.perf_counter()
# Verify the MAF SDK is available before touching anything.
try:
import agent_framework # noqa: F401
except ImportError as exc:
await self._fail_run(run_id, session_id, t0, "MafSdkMissing", "agent_framework SDK is not installed")
raise MafSdkMissingError("agent_framework SDK is not installed") from exc
await self._service.store.runs.update_run(run_id, {
"status": "running",
"started_at": datetime.now(UTC),
})
try:
participants = await self._service.store.participants.list_participants(session_id)
thread_map = await self._adapter.ensure_threads(session_id, run_id, participants)
seat_agents = [p["agent_id"] for p in participants if p.get("role") != "manager"]
manager_id = session.get("manager_agent_id")
state = RoundtableState(
session_id=session_id,
run_id=run_id,
task_text=session.get("task_text", ""),
mode=session.get("mode", "structured"),
model_name=session.get("model_name"),
reasoning_effort=session.get("reasoning_effort"),
max_rounds=session.get("max_rounds", 8),
write_policy=session.get("write_policy", "confirm_high_risk"),
manager_agent_id=manager_id,
seat_agent_ids=seat_agents,
thread_map=thread_map,
capability_snapshot=session.get("capability_snapshot"),
)
# Build executor instances — one per seat, each bound to a fixed agent_id.
coordinator = CoordinatorExecutor(self._adapter, state)
seat_executors = [
SeatExecutor(self._adapter, state, agent_id, action_coordinator=self._action_coordinator)
for agent_id in seat_agents
]
approval_gate = ApprovalGateExecutor(self._adapter, state, self._service)
finalizer = FinalizerExecutor(self._adapter, state)
waiting_terminal = WaitingTerminalExecutor()
workflow = create_workflow(
mode=state.mode,
coordinator=coordinator,
seat_executors=seat_executors,
approval_gate=approval_gate,
finalizer=finalizer,
waiting_terminal=waiting_terminal,
seat_agent_ids=seat_agents,
)
await self._run_workflow(workflow, state)
# Persist final state. waiting_approval is NOT a terminal state —
# it must NOT write finished_at. Only completed/failed/cancelled do.
latency = int((time.perf_counter() - t0) * 1000)
final_status = state.status
token_usage = _aggregate_token_usage(state.turn_results)
update_fields: dict[str, Any] = {
"status": final_status,
"latency_ms": latency,
"maf_checkpoint": state.to_dict(),
"token_usage": token_usage,
}
# Only write finished_at for terminal statuses.
if final_status in ("completed", "failed", "cancelled"):
update_fields["finished_at"] = datetime.now(UTC)
await self._service.store.runs.update_run(run_id, update_fields)
session_fields: dict[str, Any] = {"status": final_status}
if final_status == "completed":
session_fields["final_result"] = state.final_result
if final_status != "waiting_approval":
session_fields["fact_ledger"] = state.fact_ledger
await self._service.store.sessions.update_session(
session_id, user_id=self._user_id, fields=session_fields
)
except MafSdkMissingError:
raise
except Exception as exc:
await self._fail_run(run_id, session_id, t0, type(exc).__name__, str(exc))
async def _run_workflow(self, workflow: Any, state: RoundtableState) -> None:
"""Execute the workflow graph via the MAF SDK's streaming API.
Consumes ``WorkflowEvent``s and translates them into MAF roundtable
events. Persists the RoundtableState checkpoint after each superstep.
"""
from app.maf_roundtable.event_normalizer import make_workflow_checkpointed
async for event in workflow.run(state.task_text, stream=True, include_status_events=True):
event_type = getattr(event, "type", "")
executor_id = getattr(event, "executor_id", None)
if event_type == "superstep_completed":
# Persist checkpoint after each superstep.
iteration = getattr(event, "iteration", None)
await self._service.store.runs.update_run(state.run_id, {
"maf_checkpoint": state.to_dict(),
})
await self._adapter._emit_critical(state.run_id, make_workflow_checkpointed(
executor_id, state.current_round,
checkpoint_id=f"superstep-{iteration}",
reason="superstep_completed",
))
elif event_type == "executor_failed":
details = getattr(event, "details", None)
error_msg = str(details) if details else "executor failed"
raise RuntimeError(f"MAF executor '{executor_id}' failed: {error_msg}")
# Check if the workflow ended in a waiting_approval state.
if state.status == "waiting_approval" or state.pending_approvals:
state.status = "waiting_approval"
async def _fail_run(self, run_id: str, session_id: str, t0: float, error_code: str, error_message: str) -> None:
"""Mark a run as failed with a sanitized error message."""
latency = int((time.perf_counter() - t0) * 1000)
safe_message = str(sanitize(error_message))[:500]
logger.error("MAF workflow failed for run %s: %s", run_id, safe_message, exc_info=True)
await self._service.store.runs.update_run(run_id, {
"status": "failed",
"finished_at": datetime.now(UTC),
"error_code": error_code,
"error_message_safe": safe_message,
"latency_ms": latency,
})
await self._service.store.sessions.update_session(session_id, user_id=self._user_id, fields={
"status": "failed",
})
async def cancel(self, run_id: str) -> dict[str, Any]:
"""Cancel a running workflow (cascades to all active seat runs).
Returns the structured cancel result from the adapter so the caller
can decide whether to finalize the parent run as ``cancelled`` or
leave it non-terminal when some sub-runs failed to cancel.
"""
return await self._adapter.cancel(run_id)
async def resume(self, run_id: str) -> dict[str, Any] | None:
"""Resume a workflow from its persisted checkpoint after an approval decision.
P1: ``RoundtableState.from_dict()`` is actually called here. Flow:
1. Load ``maf_checkpoint_json`` from the run row.
2. Restore ``RoundtableState`` via ``from_dict()``.
3. Check the pending approvals' status:
- ``approved``: execute the approved action via ToolExecutor
(at-most-once, deterministic, no LLM).
- ``rejected`` / ``expired``: write ``action.rejected``, skip.
- ``pending``: do NOT start a second Workflow — return immediately.
4. After all actions are executed, determine the parent run status:
- All succeeded → ``completed``.
- Any failed → ``failed`` (P1-8).
- Any unknown → ``waiting_input`` (not completed; human reconcile needed).
- All skipped (already executed) → ``completed``.
Only the owner can resume (enforced by the router before calling
``dispatcher.resume``).
"""
t0 = time.perf_counter()
run = await self._service.store.runs.get_run(run_id)
if run is None:
return None
if run["status"] != "waiting_approval":
return run
checkpoint = run.get("maf_checkpoint")
if not checkpoint:
logger.warning("Cannot resume run %s: no checkpoint", run_id)
return run
state = RoundtableState.from_dict(checkpoint)
session_id = state.session_id
if not state.pending_approvals:
logger.warning("Cannot resume run %s: no pending_approvals in checkpoint", run_id)
return run
# Check if any approval is still pending — if so, don't resume yet.
any_pending = False
for pa in state.pending_approvals:
approval_id = pa.get("approval_id", "")
approval = await self._service.store.approvals.get_approval(approval_id)
if approval and approval.get("status") == "pending":
any_pending = True
if any_pending:
return run
# P0-3 run-level CAS: only ONE concurrent resume may execute the
# approved actions and write the parent run's final state. The winner
# moves the run waiting_approval → running; every other concurrent
# caller loses the CAS and returns the current run WITHOUT writing a
# terminal status (so it can never race a completed/failed write).
claimed_run = await self._service.store.runs.claim_for_resume(run_id)
if not claimed_run:
logger.info("resume(%s): lost run-level CAS — another resume is executing", run_id)
return await self._service.store.runs.get_run(run_id)
# Execute each approved action (skip rejected/expired).
action_outcomes: list[dict[str, Any]] = []
for pa in state.pending_approvals:
approval_id = pa.get("approval_id", "")
action_id = pa.get("action_id", "")
approval = await self._service.store.approvals.get_approval(approval_id)
if approval is None:
action_outcomes.append({"action_id": action_id, "status": "failed",
"error": "approval not found"})
continue
approval_status = approval.get("status", "pending")
if approval_status in ("rejected", "expired"):
from app.maf_roundtable.event_normalizer import make_action_rejected
# Conditional transition (waiting_approval → rejected) — never
# overwrite a terminal state via a generic update.
await self._service.store.actions.mark_rejected(action_id)
await self._adapter._emit_critical(run_id, make_action_rejected(
pa.get("agent_id", ""),
state.current_round,
action_id=action_id,
approval_id=approval_id,
decided_by=approval.get("decided_by", ""),
))
action_outcomes.append({"action_id": action_id, "status": "rejected"})
continue
if approval_status != "approved":
action_outcomes.append({"action_id": action_id, "status": "failed",
"error": f"unexpected approval status: {approval_status}"})
continue
# Approved — execute deterministically via ToolExecutor.
agent_id = pa.get("agent_id", "")
thread_id = state.thread_map.get(agent_id, "")
tool_name = pa.get("tool_name", "")
# Conditional transition (waiting_approval → approved). The
# authoritative at-most-once gate is the ToolExecutor's
# approved → running claim below; this just advances the state
# machine without ever clobbering a terminal status.
await self._service.store.actions.mark_approved(action_id)
from app.maf_roundtable.event_normalizer import make_action_approved
await self._adapter._emit_critical(run_id, make_action_approved(
agent_id,
state.current_round,
action_id=action_id,
approval_id=approval_id,
decided_by=approval.get("decided_by", ""),
))
# Params are recovered server-side inside ToolExecutor (after the
# atomic occupy). A missing/undecryptable secret fails the action
# there — it is never coerced into empty params, so the tool is
# never called (execution count == 0) and the parent run fails.
if thread_id and tool_name:
from app.maf_roundtable.tool_executor import ToolExecutor
executor = ToolExecutor(
adapter=self._adapter,
action_coordinator=self._action_coordinator,
user_id=self._user_id,
)
try:
outcome = await executor.execute(
action_id=action_id,
session_id=session_id,
run_id=run_id,
agent_id=agent_id,
thread_id=thread_id,
approved_tool=tool_name,
capability_snapshot=state.capability_snapshot,
write_policy=state.write_policy,
model_name=state.model_name,
excluded_tools=list(
_agent_capabilities_from_snapshot(
state.capability_snapshot, agent_id
).get("excluded_tools")
or []
),
)
except Exception as exc:
safe_msg = str(sanitize(str(exc)))[:500]
logger.error("ToolExecutor for action %s failed: %s", action_id, safe_msg)
outcome = {"status": "failed", "error": safe_msg}
else:
outcome = {"status": "failed", "error": f"no thread_id or tool_name for action {action_id}"}
action_outcomes.append({
"action_id": action_id,
"tool_name": tool_name,
"status": outcome.get("status"),
"error": outcome.get("error"),
})
state.fact_ledger["pending_actions"].append({
"action_id": action_id,
"tool_name": tool_name,
"outcome": outcome.get("status"),
})
# P0-3: determine parent run status from action outcomes.
statuses = {o.get("status") for o in action_outcomes}
# While any action is still in_progress (another executor running it),
# the parent run stays running — never write finished_at, never finalize.
if "in_progress" in statuses:
state.status = "running"
await self._service.store.runs.update_run(run_id, {
"status": "running",
"maf_checkpoint": state.to_dict(),
})
logger.info("resume(%s): action(s) still in_progress — run stays running", run_id)
return await self._service.store.runs.get_run(run_id)
state.pending_approvals = []
has_unknown = "unknown" in statuses
has_failed = "failed" in statuses
all_resolved = statuses <= {"succeeded", "skipped", "rejected"}
if has_unknown:
state.status = "waiting_input" # human reconciliation needed
elif has_failed:
state.status = "failed"
elif all_resolved:
# Re-enter the MAF finalizer path to synthesize the real report —
# do NOT just flip the status to completed.
try:
await generate_final_report(self._adapter, state)
state.status = "completed"
except Exception as exc: # noqa: BLE001
safe_msg = str(sanitize(str(exc)))[:500]
logger.error("resume(%s): finalizer failed: %s", run_id, safe_msg)
state.status = "failed"
action_outcomes.append({"action_id": "", "status": "failed",
"error": f"finalizer failed: {safe_msg}"})
else:
state.status = "failed"
# Persist final state.
latency = int((time.perf_counter() - t0) * 1000)
token_usage = _aggregate_token_usage(state.turn_results)
update_fields: dict[str, Any] = {
"status": state.status,
"latency_ms": latency,
"maf_checkpoint": state.to_dict(),
"token_usage": token_usage,
}
if state.status in ("completed", "failed", "cancelled"):
update_fields["finished_at"] = datetime.now(UTC)
if state.status == "failed":
errors = [o.get("error") for o in action_outcomes if o.get("error")]
update_fields["error_code"] = "ActionExecutionFailed"
update_fields["error_message_safe"] = str(sanitize("; ".join(errors)))[:500]
await self._service.store.runs.update_run(run_id, update_fields)
session_fields: dict[str, Any] = {"status": state.status}
if state.status == "completed":
session_fields["final_result"] = state.final_result
session_fields["fact_ledger"] = state.fact_ledger
await self._service.store.sessions.update_session(
session_id, user_id=self._user_id, fields=session_fields
)
return await self._service.store.runs.get_run(run_id)
def _aggregate_token_usage(turn_results: list[dict[str, Any]]) -> dict[str, Any]:
"""Aggregate token usage across all seat turns into a run-level summary."""
total_input = 0
total_output = 0
total_total = 0
per_seat: list[dict[str, Any]] = []
for turn in turn_results:
usage = turn.get("usage")
if not isinstance(usage, dict):
continue
inp = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0) or 0
out = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0) or 0
tot = usage.get("total_tokens", 0) or (inp + out)
total_input += inp
total_output += out
total_total += tot
per_seat.append({
"agent_id": turn.get("agent_id", ""),
"turn_index": turn.get("turn_index", 0),
"input_tokens": inp,
"output_tokens": out,
"total_tokens": tot,
})
return {
"total_input_tokens": total_input,
"total_output_tokens": total_output,
"total_tokens": total_total,
"per_seat": per_seat,
}
@@ -0,0 +1,210 @@
"""Application-layer bridge between the harness ``WriteGuardMiddleware`` and
the MAF ``ActionCoordinator``.
The harness middleware (``deerflow.agents.middlewares.write_guard_middleware``)
is constructed once at agent-build time and cannot hold per-run state. It
resolves two factories — ``make_policy`` / ``make_interceptor`` — by dotted
path (``resolve_variable``). These factories return lightweight singleton
objects that, at interception time, read the MAF service + per-run data from
the run context.
Wiring
------
``register_write_guard_service(service)`` is called from ``deps.py`` once the
``MafRoundtableService`` is built, stashing a module-level reference. The
factories use it to build an ``ActionCoordinator`` on demand.
Per-run data (``session_id`` / ``run_id`` / ``agent_id`` / ``turn_index`` /
``write_policy`` / ``capability_snapshot``) is injected into the DeerFlow run
context by ``runtime_client.start_agent_run`` (see Task 1.4) and read back
here from the ``run_context`` dict the middleware forwards.
"""
from __future__ import annotations
import logging
from typing import Any
from app.maf_roundtable.action_coordinator import (
ActionCoordinator,
tool_needs_approval,
)
logger = logging.getLogger(__name__)
# Module-level reference to the MAF service, set by ``register_write_guard_service``.
# Resolved lazily so importing this module never requires the service to exist.
_maf_service: Any = None
def register_write_guard_service(service: Any) -> None:
"""Stash the MAF service so the WriteGuard factories can reach it.
Called once from ``app.gateway.deps`` after ``MafRoundtableService`` is built.
"""
global _maf_service
_maf_service = service
def _get_service() -> Any:
if _maf_service is None: # pragma: no cover - defensive; wiring is enforced at startup
raise RuntimeError(
"WriteGuard bridge used before register_write_guard_service() — "
"MAF service is not wired."
)
return _maf_service
def _coordinator_for_context(run_context: dict) -> ActionCoordinator:
"""Build an ActionCoordinator bound to the run's user identity.
The user_id is sourced ONLY from the server-injected run context (set by
``runtime_client`` from the authenticated ``ExecutionPrincipal``), never
from tool arguments.
"""
service = _get_service()
user_id = str(run_context.get("maf_user_id") or run_context.get("user_id") or "")
return ActionCoordinator(service, user_id=user_id)
# ── Policy ─────────────────────────────────────────────────────────────────
class MafWriteGuardPolicy:
"""Decides whether a tool call must be intercepted for approval.
Reads the per-run ``write_policy`` and ``capability_snapshot`` from the
run context. Uses the same metadata-driven classification as the rest of
the ActionCoordinator (never name-prefix heuristics). Tools without
metadata use the safe default (treated as a write requiring approval).
"""
def should_intercept(self, tool_name: str, args: Any, run_context: dict | None = None) -> bool: # noqa: ANN401
"""Decide interception from the run context (read vs write aware).
Reads / skills (``side_effect`` of ``read`` / ``skill``) are NEVER
intercepted, so they pass straight through — including on the sync
path. Writes are gated by the active write policy via the same
metadata-driven ``tool_needs_approval`` classification used everywhere
else (never name-prefix heuristics). When no run context is available
we defer to the interceptor (legacy behaviour).
"""
if not tool_name:
return False
if not run_context:
# Cannot classify without context — let the interceptor decide.
return True
write_policy = str(run_context.get("write_policy") or "confirm_high_risk")
capability_snapshot = run_context.get("capability_snapshot")
agent_id = str(run_context.get("agent_id") or "")
return tool_needs_approval(tool_name, write_policy, capability_snapshot, agent_id)
# ── Interceptor ────────────────────────────────────────────────────────────
class MafWriteGuardInterceptor:
"""Records an intercepted write tool call as a pending action + approval.
Implements the harness ``WriteGuardInterceptor`` Protocol. Uses the
single-transaction ``store.create_pending_action_bundle`` (Task 2) so an
action + secret + approval + audit event are written atomically — a
partial action never reaches the database.
"""
async def intercept(
self,
*,
tool_name: str,
args: Any, # noqa: ANN401
tool_call_id: str,
run_context: dict,
) -> dict[str, Any]:
write_policy = str(run_context.get("write_policy") or "confirm_high_risk")
capability_snapshot = run_context.get("capability_snapshot")
agent_id = str(run_context.get("agent_id") or "")
run_id = str(run_context.get("maf_run_id") or "")
session_id = str(run_context.get("maf_session_id") or "")
turn_index = int(run_context.get("turn_index") or 0)
# Apply the real policy now that we have the run context.
if not tool_needs_approval(tool_name, write_policy, capability_snapshot, agent_id):
return {"status": "allowed", "tool_name": tool_name}
coordinator = _coordinator_for_context(run_context)
raw_args = args if isinstance(args, dict) else {"value": str(args)}
created = await coordinator.intercept_write_tool_calls(
run_id=run_id,
session_id=session_id,
agent_id=agent_id,
turn_index=turn_index,
tool_calls=[{"name": tool_name, "id": tool_call_id, "args": raw_args}],
capability_snapshot=capability_snapshot,
write_policy=write_policy,
)
if not created:
return {"status": "allowed", "tool_name": tool_name}
action = created[0]
return {
"status": "approval_required",
"action_id": action.get("id", ""),
"approval_id": action.get("approval_id", ""),
"tool_name": tool_name,
}
async def validate_approved_action(
self,
*,
tool_name: str,
run_context: dict,
) -> bool:
"""Validate a post-approval deterministic execution against the store.
Field presence is NOT trusted: this queries the bound action and confirms
it is ``running`` (the executor already occupied it), belongs to the
current ``run_id`` / ``session_id`` / ``agent_id``, and its ``tool_name``
matches. Returns True only when every check holds; otherwise the
middleware fails closed and the tool does not run.
"""
action_id = str(run_context.get("maf_approved_action_id") or "")
if not action_id:
return False
approved_tool = str(run_context.get("maf_approved_tool") or "")
if approved_tool and tool_name != approved_tool:
return False
run_id = str(run_context.get("maf_run_id") or "")
session_id = str(run_context.get("maf_session_id") or "")
agent_id = str(run_context.get("agent_id") or "")
service = _get_service()
action = await service.store.actions.get_action(action_id)
if action is None:
return False
if action.get("status") != "running":
return False
if run_id and str(action.get("run_id") or "") != run_id:
return False
if session_id and str(action.get("session_id") or "") != session_id:
return False
if agent_id and str(action.get("agent_id") or "") != agent_id:
return False
if str(action.get("tool_name") or "") != tool_name:
return False
return True
# ── Factories resolved by the harness via resolve_variable ──────────────────
def make_policy(**_: Any) -> MafWriteGuardPolicy:
"""Factory for the harness ``resolve_variable`` resolution."""
return MafWriteGuardPolicy()
def make_interceptor(**_: Any) -> MafWriteGuardInterceptor:
"""Factory for the harness ``resolve_variable`` resolution."""
return MafWriteGuardInterceptor()
@@ -563,3 +563,15 @@ maf_roundtable:
default_write_policy: confirm_high_risk # auto | confirm_high_risk | confirm_all_writes
default_max_rounds: 8 # 群聊模式轮次硬上限
admin_diagnostic_access: true # 管理员是否可跨用户读取所有 MAF 会商用于诊断(仅读,不含写)
# ── WriteGuard 中间件配置 ────────────────────────────────────────────────────
# 与 maf_roundtable 联动:MAF 会商运行时,WriteGuard 中间件拦截外部写工具调用,
# 记录为待审批动作。仅在 write_guard.enabled=true 且 maf_roundtable.enabled=true
# 时生效;关闭时不影响任何已存在功能。
# policy_use / interceptor_use 指向 app.maf_roundtable 的具体实现(通过
# resolve_variable 解析),保证 harness 层不导入 app.*。
write_guard:
enabled: false
provider:
policy_use: app.maf_roundtable.write_guard_bridge:make_policy
interceptor_use: app.maf_roundtable.write_guard_bridge:make_interceptor
@@ -21,14 +21,38 @@ _MISSING_TOOL_CALL_ID = "missing_tool_call_id"
class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
"""Convert tool exceptions into error ToolMessages so the run can continue."""
def _maf_safe_mode(self, request: ToolCallRequest) -> bool:
runtime = getattr(request, "runtime", None)
ctx = getattr(runtime, "context", None) if runtime is not None else None
if isinstance(ctx, dict):
return bool(ctx.get("maf_safe_error_mode"))
return False
def _build_error_message(self, request: ToolCallRequest, exc: Exception) -> ToolMessage:
tool_name = str(request.tool_call.get("name") or "unknown_tool")
tool_call_id = str(request.tool_call.get("id") or _MISSING_TOOL_CALL_ID)
detail = str(exc).strip() or exc.__class__.__name__
if len(detail) > 500:
detail = detail[:497] + "..."
if self._maf_safe_mode(request):
logger.error(
"Tool execution failed (maf safe mode): name=%s id=%s type=%s",
tool_name,
tool_call_id,
type(exc).__name__,
)
content = "Error: tool execution failed."
else:
detail = str(exc).strip() or exc.__class__.__name__
if len(detail) > 500:
detail = detail[:497] + "..."
content = (
f"Error: Tool '{tool_name}' failed with {exc.__class__.__name__}: {detail}. "
"Continue with available context, or choose an alternative tool."
)
logger.exception(
"Tool execution failed (sync): name=%s id=%s",
request.tool_call.get("name"),
request.tool_call.get("id"),
)
content = f"Error: Tool '{tool_name}' failed with {exc.__class__.__name__}: {detail}. Continue with available context, or choose an alternative tool."
return ToolMessage(
content=content,
tool_call_id=tool_call_id,
@@ -47,8 +71,20 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
except GraphBubbleUp:
# Preserve LangGraph control-flow signals (interrupt/pause/resume).
raise
except Exception as exc:
logger.exception("Tool execution failed (sync): name=%s id=%s", request.tool_call.get("name"), request.tool_call.get("id"))
except Exception as exc: # noqa: BLE001
if self._maf_safe_mode(request):
logger.error(
"Tool execution failed (maf safe mode, sync): name=%s id=%s type=%s",
request.tool_call.get("name"),
request.tool_call.get("id"),
type(exc).__name__,
)
else:
logger.exception(
"Tool execution failed (sync): name=%s id=%s",
request.tool_call.get("name"),
request.tool_call.get("id"),
)
return self._build_error_message(request, exc)
@override
@@ -62,8 +98,20 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
except GraphBubbleUp:
# Preserve LangGraph control-flow signals (interrupt/pause/resume).
raise
except Exception as exc:
logger.exception("Tool execution failed (async): name=%s id=%s", request.tool_call.get("name"), request.tool_call.get("id"))
except Exception as exc: # noqa: BLE001
if self._maf_safe_mode(request):
logger.error(
"Tool execution failed (maf safe mode, async): name=%s id=%s type=%s",
request.tool_call.get("name"),
request.tool_call.get("id"),
type(exc).__name__,
)
else:
logger.exception(
"Tool execution failed (async): name=%s id=%s",
request.tool_call.get("name"),
request.tool_call.get("id"),
)
return self._build_error_message(request, exc)
@@ -127,6 +175,23 @@ def _build_runtime_middlewares(
provider = provider_cls(**provider_kwargs)
middlewares.append(GuardrailMiddleware(provider, fail_closed=guardrails_config.fail_closed, passport=guardrails_config.passport))
# WriteGuard middleware (if configured) — intercepts external-write tool
# calls for human approval. Activated per-run via a ``maf_run_id`` flag in
# the run context, so legacy runs are unaffected. The policy/interceptor
# factories live in the application layer and are resolved by dotted path
# so the harness never imports ``app.*``.
write_guard_config = app_config.write_guard
if write_guard_config.enabled and write_guard_config.provider:
from deerflow.agents.middlewares.write_guard_middleware import WriteGuardMiddleware
from deerflow.reflection import resolve_variable
policy_factory = resolve_variable(write_guard_config.provider.policy_use)
interceptor_factory = resolve_variable(write_guard_config.provider.interceptor_use)
factory_kwargs = dict(write_guard_config.provider.config) if write_guard_config.provider.config else {}
policy = policy_factory(**factory_kwargs)
interceptor = interceptor_factory(**factory_kwargs)
middlewares.append(WriteGuardMiddleware(policy=policy, interceptor=interceptor))
from deerflow.agents.middlewares.sandbox_audit_middleware import SandboxAuditMiddleware
middlewares.append(SandboxAuditMiddleware())
@@ -0,0 +1,318 @@
"""WriteGuardMiddleware — intercepts external-write tool calls for human approval.
This middleware keeps write tools **visible** to the model (the schema is not
removed from the tool list), but short-circuits their execution so the real
tool never runs until a human approves. The model sees a structured
``ToolMessage`` describing the pending approval instead of the tool's real
result.
Activation model (zero-impact on legacy runs)
---------------------------------------------
The middleware is always present in the chain when configured, but it only
acts when the run context carries a truthy ``maf_run_id`` flag. Every other
run — legacy single-agent chats, subagents, ephemeral runs — calls the
``handler`` unchanged. This mirrors how ``TaskExternalContextMiddleware``
reads a run-context flag to switch on per-run behaviour.
Injection
---------
The application layer supplies a ``WriteGuardPolicy`` (decides *whether* to
intercept a given call) and a ``WriteGuardInterceptor`` (records the
action/approval/audit bundle). Both are injected via constructor so the
harness never imports ``app.*``; resolution happens in
``_build_runtime_middlewares`` through ``resolve_variable``.
Note: unlike ``GuardrailMiddleware`` (which is fail-closed on provider error),
this middleware is **fail-open by default for legacy runs** — but inside an
active MAF run a failure to record an approval aborts the call with an error
ToolMessage rather than letting the write proceed.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Awaitable, Callable
from typing import override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage
from langgraph.errors import GraphBubbleUp
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
from deerflow.config.write_guard_config import WriteGuardInterceptor, WriteGuardPolicy
logger = logging.getLogger(__name__)
class WriteGuardMiddleware(AgentMiddleware[AgentState]):
"""Intercept write tool calls for approval when running inside a MAF seat run.
The middleware reads ``request.runtime.context`` (the shared run context)
to detect an active MAF run (``maf_run_id`` truthy). When inactive it is a
pure pass-through. When active, each tool call is offered to the injected
``WriteGuardPolicy``; if it says ``should_intercept`` is True, the
``WriteGuardInterceptor`` records the pending action and the middleware
returns an ``approval_required`` ToolMessage **without** calling
``handler`` — so the real tool executes exactly zero times before approval.
"""
def __init__(
self,
policy: WriteGuardPolicy,
interceptor: WriteGuardInterceptor,
) -> None:
self.policy = policy
self.interceptor = interceptor
# ── context helpers ──────────────────────────────────────────────────
@staticmethod
def _run_context(request: ToolCallRequest) -> dict:
"""Return the run context dict from the runtime, or {} if absent.
``request.runtime`` is a ``ToolRuntime`` whose ``.context`` attribute
holds the shared run-context dict (the same one ``Runtime.context``
exposes to graph nodes / other middlewares). Outside a graph it may
be None.
"""
runtime = getattr(request, "runtime", None)
if runtime is None:
return {}
ctx = getattr(runtime, "context", None)
if isinstance(ctx, dict):
return ctx
# Some context objects are Mapping-like but not plain dicts.
try:
return dict(ctx) # type: ignore[arg-type]
except Exception:
return {}
@staticmethod
def _tool_name(request: ToolCallRequest) -> str:
return str(request.tool_call.get("name", ""))
@staticmethod
def _tool_call_id(request: ToolCallRequest) -> str:
return str(request.tool_call.get("id", ""))
@staticmethod
def _tool_args(request: ToolCallRequest) -> object:
return request.tool_call.get("args", {})
def _build_approval_message(self, request: ToolCallRequest, outcome: dict) -> ToolMessage:
"""Build the ToolMessage the model sees in place of the real tool result.
The content is a structured JSON envelope so a frontend / coordinator
can parse the ``action_id`` to drive the approval UI. The real tool's
return value is never produced.
"""
tool_name = self._tool_name(request)
tool_call_id = self._tool_call_id(request)
envelope = {
"status": outcome.get("status", "approval_required"),
"action_id": outcome.get("action_id", ""),
"approval_id": outcome.get("approval_id", ""),
"tool_name": tool_name,
"message": (
f"外部写操作 '{tool_name}' 已记录,等待人工审批后执行。"
"本次调用未执行实际写入。"
),
}
return ToolMessage(
content=json.dumps(envelope, ensure_ascii=False),
tool_call_id=tool_call_id,
name=tool_name,
status="error",
)
# ── interception core (shared by sync/async) ─────────────────────────
def _is_active(self, request: ToolCallRequest) -> bool:
"""True only when running inside an active MAF seat run."""
return bool(self._run_context(request).get("maf_run_id"))
def _approved_passthrough(self, request: ToolCallRequest) -> bool:
"""True when this run is a deterministic, post-approval tool execution.
The ToolExecutor sets ``maf_approved_tool`` + ``maf_approved_action_id``
in the run context for defense-in-depth validation. The middleware
checks:
1. ``maf_approved_tool`` matches the tool name (required).
2. ``maf_approved_action_id`` is present (required when tool is set).
3. The action is in ``approved`` status and belongs to the current
run / session / agent (verified through the interceptor).
A model calling another tool with the same name but different
action_id is still intercepted. When combined with the direct tool
executor (P0-2) which bypasses the LLM entirely, this is defense-in-
depth — the real enforcement is that the LLM never sees the approved
params and never decides whether to call the tool.
"""
ctx = self._run_context(request)
approved_tool = ctx.get("maf_approved_tool")
if not approved_tool:
return False
if self._tool_name(request) != approved_tool:
return False
approved_action_id = ctx.get("maf_approved_action_id")
if not approved_action_id:
# When no action_id is bound, the passthrough is too permissive.
# In production the ToolExecutor always sets this; the absence
# means the tool was flagged by some other mechanism without
# proper action binding — block to be safe.
logger.warning(
"WriteGuard: approved tool '%s' has no maf_approved_action_id "
"in run context — blocking passthrough",
approved_tool,
)
return False
return True
async def _validate_approved_async(self, request: ToolCallRequest) -> bool:
"""Confirm a bound approved action is genuinely executable (async path).
Field presence (``maf_approved_tool`` / ``maf_approved_action_id``) is
necessary but NOT sufficient: the interceptor queries the stored action
and verifies status==``running`` + run/session/agent ownership + tool
match. Fails closed (False) when the interceptor cannot confirm.
"""
run_context = self._run_context(request)
validator = getattr(self.interceptor, "validate_approved_action", None)
if validator is None:
# No validator available — cannot confirm the action is legitimate.
logger.warning(
"WriteGuard: approved tool '%s' bound but interceptor lacks "
"validate_approved_action — blocking (fail-closed)",
self._tool_name(request),
)
return False
try:
return bool(await validator(tool_name=self._tool_name(request), run_context=run_context))
except Exception:
logger.exception(
"WriteGuard: validate_approved_action failed for '%s' — blocking",
self._tool_name(request),
)
return False
async def _intercept_async(self, request: ToolCallRequest) -> ToolMessage | None:
"""Record an interception. Returns the approval ToolMessage, or None to allow."""
tool_name = self._tool_name(request)
args = self._tool_args(request)
run_context = self._run_context(request)
if not self.policy.should_intercept(tool_name, args, run_context):
return None
try:
outcome = await self.interceptor.intercept(
tool_name=tool_name,
args=args,
tool_call_id=self._tool_call_id(request),
run_context=run_context,
)
except GraphBubbleUp:
raise
except Exception:
# Recording the approval failed. We must NOT let the write
# proceed silently — fail the call so the run surfaces the error
# rather than executing an unapproved write.
logger.exception("WriteGuard interceptor failed for tool '%s'", tool_name)
return ToolMessage(
content=json.dumps(
{"status": "intercept_failed", "tool_name": tool_name,
"message": f"记录写操作审批失败,{tool_name} 未执行。"},
ensure_ascii=False,
),
tool_call_id=self._tool_call_id(request),
name=tool_name,
status="error",
)
# If the interceptor decided no approval is needed (e.g. a read tool
# under a write policy, or ``auto`` policy), allow the call through.
if outcome.get("status") == "allowed":
return None
return self._build_approval_message(request, outcome)
# ── AgentMiddleware overrides ────────────────────────────────────────
@override
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
if not self._is_active(request):
return handler(request)
# Post-approval deterministic execution: the approved tool passes through.
if self._approved_passthrough(request):
return handler(request)
# The interceptor is async; recording synchronously is not supported.
# Reads are never intercepted (should_intercept False) so they pass
# straight through on the sync path. Writes that cannot be safely
# async-intercepted fail closed here rather than running unapproved.
tool_name = self._tool_name(request)
run_context = self._run_context(request)
if self.policy.should_intercept(tool_name, self._tool_args(request), run_context):
# Cannot await here; return an immediate error so the write does
# not proceed. The async path records the action properly.
logger.warning(
"WriteGuard: sync write call '%s' blocked (use async tool path)", tool_name
)
return ToolMessage(
content=json.dumps(
{"status": "intercept_blocked_sync", "tool_name": tool_name,
"message": f"{tool_name} 写操作需走异步审批路径,未执行。"},
ensure_ascii=False,
),
tool_call_id=self._tool_call_id(request),
name=tool_name,
status="error",
)
return handler(request)
@override
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
) -> ToolMessage | Command:
if not self._is_active(request):
return await handler(request)
# Post-approval deterministic execution: the approved tool passes
# through only after the bound action is validated against the store
# (status running + run/session/agent ownership + tool match). Field
# presence alone is not trusted.
if self._approved_passthrough(request):
if await self._validate_approved_async(request):
return await handler(request)
tool_name = self._tool_name(request)
logger.warning(
"WriteGuard: approved execution of '%s' failed action validation — blocking",
tool_name,
)
return ToolMessage(
content=json.dumps(
{"status": "approval_invalid", "tool_name": tool_name,
"message": f"{tool_name} 的已审批动作未通过校验,未执行。"},
ensure_ascii=False,
),
tool_call_id=self._tool_call_id(request),
name=tool_name,
status="error",
)
try:
approval_msg = await self._intercept_async(request)
except GraphBubbleUp:
# Preserve interrupt/pause/resume control-flow signals.
raise
if approval_msg is not None:
logger.info(
"WriteGuard intercepted '%s' (call_id=%s) — awaiting approval",
self._tool_name(request), self._tool_call_id(request),
)
return approval_msg
return await handler(request)
@@ -41,6 +41,7 @@ from deerflow.config.token_usage_config import TokenUsageConfig
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict
from deerflow.config.vision_config import VisionConfig
from deerflow.config.write_guard_config import WriteGuardConfig, load_write_guard_config_from_dict
from deerflow.config.y_log_config import YLogConfig
load_dotenv()
@@ -131,6 +132,7 @@ class AppConfig(BaseModel):
acp_agents: dict[str, ACPAgentConfig] = Field(default_factory=dict, description="ACP-compatible agent configuration")
subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration")
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
write_guard: WriteGuardConfig = Field(default_factory=WriteGuardConfig, description="WriteGuard middleware configuration (intercepts external-write tools for approval)")
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration")
excluded_tools: list[str] = Field(default_factory=list, description="Tool names to exclude from the main agent (e.g. ['task', 'ask_clarification'])")
skill_stop: SkillStopConfig = Field(default_factory=SkillStopConfig, description="Skill stop middleware configuration")
@@ -228,6 +230,10 @@ class AppConfig(BaseModel):
if "guardrails" in config_data:
load_guardrails_config_from_dict(config_data["guardrails"])
# Load write_guard config if present
if "write_guard" in config_data:
load_write_guard_config_from_dict(config_data["write_guard"])
# Load circuit_breaker config if present
if "circuit_breaker" in config_data:
config_data["circuit_breaker"] = config_data["circuit_breaker"]
@@ -0,0 +1,151 @@
"""Configuration for the WriteGuard middleware.
The WriteGuard middleware intercepts external-write tool calls so they can be
recorded for human approval **before** the underlying tool executes. It is
activated per-run via a ``maf_run_id`` flag in the run context, so legacy
single-agent runs are completely unaffected (the middleware no-ops).
This module is harness-only: it defines the config schema and the
``WriteGuardPolicy`` / ``WriteGuardInterceptor`` Protocols. The concrete
implementations live in the application layer (``app.maf_roundtable``) and are
resolved at runtime via ``resolve_variable`` from the ``use`` dotted path.
"""
from __future__ import annotations
from typing import Any, Protocol, runtime_checkable
from pydantic import BaseModel, Field
class WriteGuardProviderConfig(BaseModel):
"""Configuration for the application-layer WriteGuard implementations.
``policy_use`` resolves to a callable/class implementing
``WriteGuardPolicy``; ``interceptor_use`` resolves to one implementing
``WriteGuardInterceptor``. Both are resolved lazily by the harness via
``deerflow.reflection.resolve_variable`` so the harness never imports
``app.*``.
"""
policy_use: str = Field(
description="Dotted path to the WriteGuardPolicy factory, e.g. 'app.maf_roundtable.write_guard_bridge:make_policy'"
)
interceptor_use: str = Field(
description="Dotted path to the WriteGuardInterceptor factory, e.g. 'app.maf_roundtable.write_guard_bridge:make_interceptor'"
)
config: dict = Field(default_factory=dict, description="Implementation-specific settings passed as kwargs")
class WriteGuardConfig(BaseModel):
"""Configuration for the WriteGuard middleware.
When ``enabled`` and a ``provider`` is configured, the harness appends a
``WriteGuardMiddleware`` to the runtime middleware chain. The middleware
itself only acts when the run context carries a ``maf_run_id`` flag —
every other run is a pass-through.
"""
enabled: bool = Field(default=False, description="Enable the WriteGuard middleware in the chain")
provider: WriteGuardProviderConfig | None = Field(
default=None, description="Application-layer policy/interceptor factories"
)
_write_guard_config: WriteGuardConfig | None = None
def get_write_guard_config() -> WriteGuardConfig:
"""Get the write-guard config, returning defaults if not loaded."""
global _write_guard_config
if _write_guard_config is None:
_write_guard_config = WriteGuardConfig()
return _write_guard_config
def load_write_guard_config_from_dict(data: dict) -> WriteGuardConfig:
"""Load write-guard config from a dict (called during AppConfig loading)."""
global _write_guard_config
_write_guard_config = WriteGuardConfig.model_validate(data)
return _write_guard_config
def reset_write_guard_config() -> None:
"""Reset the cached config instance. Used in tests to prevent singleton leaks."""
global _write_guard_config
_write_guard_config = None
# ── Protocols (harness ↔ application contract) ─────────────────────────────
@runtime_checkable
class WriteGuardPolicy(Protocol):
"""Decides whether a given tool call must be intercepted for approval.
Implementations are constructed by the application layer and passed into
the harness ``WriteGuardMiddleware``. They must NOT import deerflow.*
(the harness owns this Protocol definition).
"""
def should_intercept(self, tool_name: str, args: Any, run_context: dict | None = None) -> bool: # noqa: ANN401
"""Return True if the tool call must be recorded for approval before execution.
``run_context`` (the shared run-context dict) lets the policy distinguish
reads from writes using the frozen capability snapshot + write policy, so
read tools are never intercepted (they pass straight through, including
on the sync path) while writes are gated by the active write policy.
"""
...
@runtime_checkable
class WriteGuardInterceptor(Protocol):
"""Records an intercepted write tool call (action + approval + audit event).
The interceptor is responsible for the full create-pending-action flow
(see ``store.create_pending_action_bundle``). It receives the run context
dict (the same one ``request.runtime.context`` exposes) so it can resolve
the current ``session_id`` / ``run_id`` / ``agent_id`` / ``turn_index``
and the write policy. It returns a dict describing the interception
outcome so the middleware can build the ToolMessage the model sees in
place of the real tool result.
Expected return shape::
{
"status": "approval_required" | "allowed",
"action_id": "<id when approval_required>",
"approval_id": "<id when approval_required>",
"tool_name": "<name>",
}
"""
async def intercept(
self,
*,
tool_name: str,
args: Any, # noqa: ANN401
tool_call_id: str,
run_context: dict,
) -> dict[str, Any]:
"""Record the tool call for approval. Never executes the real tool."""
...
async def validate_approved_action(
self,
*,
tool_name: str,
run_context: dict,
) -> bool:
"""Validate a post-approval deterministic execution before it runs.
Called on the async path when the run context binds ``maf_approved_tool``
+ ``maf_approved_action_id``. Must query the stored action and confirm:
its status is ``running`` (the executor already occupied it), it belongs
to the current ``run_id`` / ``session_id`` / ``agent_id``, and its
``tool_name`` matches. Returns True only when all hold — the middleware
fails closed (blocks the call) otherwise. Field-existence alone is NOT
sufficient.
"""
...
@@ -13,47 +13,57 @@ started (Phase 2+).
Tables (see the implementation plan §8):
- ``maf_roundtable_sessions`` — one MAF roundtable session (task + mode + capability snapshot)
- ``maf_roundtable_participants`` — manager + seat agents bound to a session
- ``maf_roundtable_runs`` — one execution run of a session
- ``maf_roundtable_events`` — monotonic per-run event stream (``(run_id, seq)`` unique)
- ``maf_roundtable_actions`` — external write / tool actions (idempotency-keyed)
- ``maf_roundtable_approvals`` — pending human-approval requests
- ``maf_roundtable_sessions`` — one MAF roundtable session (task + mode + capability snapshot)
- ``maf_roundtable_participants`` — manager + seat agents bound to a session
- ``maf_roundtable_runs`` — one execution run of a session
- ``maf_roundtable_events`` — monotonic per-run event stream (``(run_id, seq)`` unique)
- ``maf_roundtable_actions`` — external write / tool actions (idempotency-keyed)
- ``maf_roundtable_approvals`` — pending human-approval requests
- ``maf_roundtable_seat_runs`` — MAF↔DeerFlow run mapping (survives restart)
- ``maf_roundtable_action_secrets`` — encrypted actual execution params (server-only)
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.action_secrets import MafActionSecretRepository
from deerflow.persistence.maf_roundtable.actions import MafActionRepository
from deerflow.persistence.maf_roundtable.approvals import MafApprovalRepository
from deerflow.persistence.maf_roundtable.events import MafEventRepository
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableActionRow,
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
MafRoundtableEventRow,
MafRoundtableParticipantRow,
MafRoundtableRunRow,
MafRoundtableSeatRunRow,
MafRoundtableSessionRow,
)
from deerflow.persistence.maf_roundtable.participants import MafParticipantRepository
from deerflow.persistence.maf_roundtable.runs import MafRunRepository
from deerflow.persistence.maf_roundtable.seat_runs import MafSeatRunRepository
from deerflow.persistence.maf_roundtable.sessions import MafSessionRepository
from deerflow.persistence.maf_roundtable.store import MafRoundtableStore
__all__ = [
"MafActionRepository",
"MafActionSecretRepository",
"MafApprovalRepository",
"MafEventRepository",
"MafParticipantRepository",
"MafRoundtableActionRow",
"MafRoundtableActionSecretRow",
"MafRoundtableApprovalRow",
"MafRoundtableEventRow",
"MafRoundtableParticipantRow",
"MafRoundtableRunRow",
"MafRoundtableSeatRunRow",
"MafRoundtableSessionRow",
"MafRoundtableStore",
"MafRunRepository",
"MafSeatRunRepository",
"MafSessionRepository",
"make_maf_roundtable_store",
]
@@ -0,0 +1,65 @@
"""Action secrets repository — encrypted actual execution parameters.
Stores the **real** (un-redacted) tool-call arguments for a protected action
so that after approval or a service restart the system can recover and
execute the approved action with the original parameters. This repository is
**server-only**: it is never read by any API endpoint, event serializer, log
formatter, or frontend-facing query. The ActionCoordinator encrypts before
writing and decrypts only at execution time.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.model import MafRoundtableActionSecretRow
def _now() -> datetime:
return datetime.now(UTC)
class MafActionSecretRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def create(self, *, action_id: str, arguments_encrypted: str) -> dict[str, Any]:
now = _now()
row = MafRoundtableActionSecretRow(
id=uuid4().hex,
action_id=action_id,
arguments_encrypted=arguments_encrypted,
created_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
return {"id": row.id, "action_id": action_id}
async def get_by_action(self, action_id: str) -> str | None:
"""Return the encrypted arguments ciphertext for an action, or None."""
async with self._sf() as session:
stmt = (
select(MafRoundtableActionSecretRow)
.where(MafRoundtableActionSecretRow.action_id == action_id)
.limit(1)
)
row = (await session.execute(stmt)).scalars().first()
return row.arguments_encrypted if row is not None else None
async def delete_by_action(self, action_id: str) -> None:
async with self._sf() as session:
stmt = (
select(MafRoundtableActionSecretRow)
.where(MafRoundtableActionSecretRow.action_id == action_id)
.limit(1)
)
row = (await session.execute(stmt)).scalars().first()
if row is not None:
await session.delete(row)
await session.commit()
@@ -14,7 +14,7 @@ from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.model import MafRoundtableActionRow
@@ -24,7 +24,8 @@ def _now() -> datetime:
return datetime.now(UTC)
def _to_dict(row: MafRoundtableActionRow) -> dict[str, Any]:
def action_to_dict(row: MafRoundtableActionRow) -> dict[str, Any]:
"""Serialise an action ORM row to a plain dict (public — reused by the store facade)."""
return {
"id": row.id,
"session_id": row.session_id,
@@ -32,6 +33,7 @@ def _to_dict(row: MafRoundtableActionRow) -> dict[str, Any]:
"agent_id": row.agent_id,
"tool_name": row.tool_name,
"logical_action_id": row.logical_action_id,
"turn_index": row.turn_index,
"idempotency_key": row.idempotency_key,
"target_key": row.target_key,
"risk_level": row.risk_level,
@@ -48,6 +50,25 @@ def _to_dict(row: MafRoundtableActionRow) -> dict[str, Any]:
}
# Back-compat alias for internal callers that used the old private name.
_to_dict = action_to_dict
class MafActionStateMachineError(RuntimeError):
"""An action was claimed or transitioned from an unexpected state.
Raised when a claim loses the ``approved → running`` CAS and the row is in
a state that is neither ``running`` (in progress elsewhere) nor a recognized
terminal (``succeeded`` / ``failed`` / ``unknown`` / ``rejected``) — e.g.
still ``waiting_approval`` / ``pending`` — meaning the caller tried to
execute an action that was never properly approved.
"""
# Terminal statuses a failed claim may reflect back to the caller.
_TERMINAL_CLAIM_STATUSES = ("succeeded", "failed", "unknown", "rejected")
class MafActionRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@@ -65,6 +86,7 @@ class MafActionRepository:
agent_id=data.get("agent_id"),
tool_name=str(data["tool_name"]),
logical_action_id=data.get("logical_action_id"),
turn_index=data.get("turn_index"),
idempotency_key=str(data["idempotency_key"]),
target_key=data.get("target_key"),
risk_level=str(data.get("risk_level") or "R0"),
@@ -135,3 +157,131 @@ class MafActionRepository:
row.updated_at = _now()
await session.commit()
return _to_dict(row)
async def claim_for_execution(self, action_id: str) -> str:
"""Atomically transition an action from ``approved`` to ``running``.
Single conditional UPDATE — only the caller whose UPDATE affects
exactly one row wins the right to execute the external write. This
guarantees at-most-once execution even under concurrent ``resume()``
calls or retries after a restart.
Returns the effective claim status (callers must NOT treat a lost claim
as a blanket "skip"):
- ``"claimed"`` — won the CAS; the caller may execute.
- ``"in_progress"`` — another executor already moved it to ``running``.
- ``"succeeded"`` / ``"failed"`` / ``"unknown"`` / ``"rejected"`` — the
action is already in that terminal state.
Raises ``MafActionStateMachineError`` when the row is missing or in any
other state (e.g. still ``waiting_approval`` / ``pending``) — i.e. it was
never properly approved and must not be executed.
"""
now = _now()
async with self._sf() as session:
result = await session.execute(
update(MafRoundtableActionRow)
.where(
MafRoundtableActionRow.id == action_id,
MafRoundtableActionRow.status == "approved",
)
.values(status="running", started_at=now, updated_at=now)
)
await session.commit()
if result.rowcount == 1: # type: ignore[union-attr]
return "claimed"
# Lost the CAS (or never approved) — report the ACTUAL state.
current = await self.get_action(action_id)
if current is None:
raise MafActionStateMachineError(f"action {action_id} not found during claim")
status = current.get("status")
if status == "running":
return "in_progress"
if status in _TERMINAL_CLAIM_STATUSES:
return str(status)
raise MafActionStateMachineError(
f"action {action_id} cannot be claimed from state '{status}'"
)
async def mark_approved(self, action_id: str) -> bool:
"""Conditionally transition ``waiting_approval → approved``.
Returns True only if this call performed the transition (``rowcount==1``).
A concurrent decider or an already-terminal action yields False — the
caller must NOT overwrite a terminal state via a generic update.
"""
now = _now()
async with self._sf() as session:
result = await session.execute(
update(MafRoundtableActionRow)
.where(
MafRoundtableActionRow.id == action_id,
MafRoundtableActionRow.status == "waiting_approval",
)
.values(status="approved", updated_at=now)
)
await session.commit()
return result.rowcount == 1 # type: ignore[union-attr]
async def mark_rejected(self, action_id: str) -> bool:
"""Conditionally transition ``waiting_approval → rejected`` (+ finished_at).
Returns True only if this call performed the transition. Terminal states
are never overwritten.
"""
now = _now()
async with self._sf() as session:
result = await session.execute(
update(MafRoundtableActionRow)
.where(
MafRoundtableActionRow.id == action_id,
MafRoundtableActionRow.status == "waiting_approval",
)
.values(status="rejected", finished_at=now, updated_at=now)
)
await session.commit()
return result.rowcount == 1 # type: ignore[union-attr]
async def record_terminal_result(
self,
action_id: str,
*,
status: str,
result: dict[str, Any] | None = None,
external_object_id: str | None = None,
) -> dict[str, Any] | None:
"""Record the terminal outcome of an action execution.
State-machine constrained: only a ``running`` action may transition
to a terminal status (``succeeded`` / ``failed`` / ``unknown``).
A concurrent caller that lost the race (or a replay after restart)
finds the row already terminal and its UPDATE is a no-op
(``rowcount == 0``) — it must NOT overwrite the real result.
"""
if status not in ("succeeded", "failed", "unknown"):
raise ValueError(f"invalid action result status: {status}")
now = _now()
async with self._sf() as session:
result_q = await session.execute(
update(MafRoundtableActionRow)
.where(
MafRoundtableActionRow.id == action_id,
MafRoundtableActionRow.status == "running",
)
.values(
status=status,
result_redacted_json=result,
external_object_id=external_object_id,
finished_at=now,
updated_at=now,
)
)
await session.commit()
# Use rowcount instead of .returning() for cross-DB compatibility
# (SQLite, PostgreSQL, MySQL).
if result_q.rowcount != 1: # type: ignore[union-attr]
# Lost the race or not in running state — do not overwrite.
return None
return await self.get_action(action_id)
@@ -46,6 +46,7 @@ def _to_dict(row: MafRoundtableEventRow) -> dict[str, Any]:
"event_type": row.event_type,
"agent_id": row.agent_id,
"turn_index": row.turn_index,
"source_event_id": row.source_event_id,
"payload": row.payload_json,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
}
@@ -69,6 +70,7 @@ class MafEventRepository:
agent_id: str | None = None,
turn_index: int | None = None,
payload: dict[str, Any] | None = None,
source_event_id: str | None = None,
) -> dict[str, Any]:
"""Append one event with a monotonic per-run seq.
@@ -77,7 +79,19 @@ class MafEventRepository:
``last_event_seq`` cache is bumped forward (max wins) so pollers can
cheaply check ``runs.last_event_seq`` without scanning the events
table.
Durable dedup: when ``source_event_id`` is given, the
``(run_id, source_event_id)`` unique constraint guarantees at-most-once
persistence. A duplicate insert returns the already-stored row instead
of raising — this survives process restarts (unlike an in-memory seen
set), so a replayed runtime stream never double-writes an event.
"""
# Fast-path dedup: if this source_event_id already landed, return it.
if source_event_id:
existing = await self._get_by_source(run_id, source_event_id)
if existing is not None:
return existing
last_exc: Exception | None = None
for _ in range(_SEQ_INSERT_RETRIES):
async with self._sf() as session:
@@ -97,6 +111,7 @@ class MafEventRepository:
event_type=event_type,
agent_id=agent_id,
turn_index=turn_index,
source_event_id=source_event_id,
payload_json=payload,
created_at=_now(),
)
@@ -104,8 +119,14 @@ class MafEventRepository:
try:
await session.commit()
except IntegrityError as exc: # noqa: PERF203 - retry path
last_exc = exc
await session.rollback()
# A source_event_id collision means a concurrent writer
# already persisted this exact event — return its row.
if source_event_id:
dup = await self._get_by_source(run_id, source_event_id)
if dup is not None:
return dup
last_exc = exc
continue
if self._run_repo is not None:
# best-effort cache bump; never let a cache failure abort
@@ -121,6 +142,15 @@ class MafEventRepository:
orig=last_exc,
)
async def _get_by_source(self, run_id: str, source_event_id: str) -> dict[str, Any] | None:
stmt = select(MafRoundtableEventRow).where(
MafRoundtableEventRow.run_id == run_id,
MafRoundtableEventRow.source_event_id == source_event_id,
)
async with self._sf() as session:
row = (await session.execute(stmt)).scalars().first()
return _to_dict(row) if row is not None else None
async def append_events(
self, *, session_id: str, run_id: str, events: list[dict[str, Any]]
) -> list[dict[str, Any]]:
@@ -138,6 +168,7 @@ class MafEventRepository:
agent_id=ev.get("agent_id"),
turn_index=ev.get("turn_index"),
payload=ev.get("payload"),
source_event_id=ev.get("source_event_id"),
)
)
return out
@@ -175,6 +175,13 @@ class MafRoundtableEventRow(Base):
__tablename__ = "maf_roundtable_events"
__table_args__ = (
UniqueConstraint("run_id", "seq", name="uq_maf_events_run_seq"),
# Dedup key for at-most-once event persistence. The authoritative
# dedup is this DB constraint (survives process restarts) — not the
# in-process ``_seen_source_ids`` set, which is only a fast-path cache.
# ``source_event_id`` is nullable; rows with NULL are not deduped
# (SQL treats NULLs as distinct in a UNIQUE constraint on sqlite /
# postgres / mysql), so events without a source id stay append-only.
UniqueConstraint("run_id", "source_event_id", name="uq_maf_events_run_source"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
@@ -194,6 +201,10 @@ class MafRoundtableEventRow(Base):
event_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
turn_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Upstream identity of the source runtime event (e.g. LangGraph message id
# / tool_call id). Used by the ``(run_id, source_event_id)`` unique
# constraint for durable at-most-once dedup across restarts.
source_event_id: Mapped[str | None] = mapped_column(String(191), nullable=True, index=True)
payload_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, index=True)
@@ -227,6 +238,11 @@ class MafRoundtableActionRow(Base):
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
tool_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
logical_action_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
# The seat turn that produced this action. Persisted (not only embedded in
# the idempotency_key) so pending-approval matching can key on
# run_id + agent_id + turn_index + logical_action_id precisely — never
# mixing a seat's earlier-turn waiting actions into the current approval.
turn_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
idempotency_key: Mapped[str] = mapped_column(String(191), nullable=False, unique=True)
target_key: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
# R0 | R1 | R2 | R3 | R4
@@ -280,11 +296,84 @@ class MafRoundtableApprovalRow(Base):
)
# ── 7. seat runs (Phase 2: MAF↔DeerFlow run mapping) ─────────────────────
class MafRoundtableSeatRunRow(Base):
"""Persistent mapping between a MAF workflow seat turn and its DeerFlow run.
Replaces the in-memory ``_active_runs`` dict so that after a service restart
the system can still locate and cancel outstanding DeerFlow runs, and the
MAF checkpoint can reference which seat turns completed.
Unique constraint: ``(maf_run_id, agent_id, turn_index)`` — one DeerFlow run
per seat per turn.
"""
__tablename__ = "maf_roundtable_seat_runs"
__table_args__ = (
UniqueConstraint("maf_run_id", "agent_id", "turn_index", name="uq_maf_seat_runs_run_agent_turn"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
maf_run_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_runs.id", ondelete="CASCADE"), nullable=False, index=True
)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
turn_index: Mapped[int] = mapped_column(Integer, nullable=False)
deerflow_run_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
deerflow_thread_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
sandbox_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
workflow_checkpoint_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
# pending | running | completed | failed | cancelled
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
started_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
error: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 8. action secrets (encrypted actual execution params, server-only) ────
class MafRoundtableActionSecretRow(Base):
"""Encrypted actual execution parameters for a protected action.
Stores the **real** (un-redacted) tool-call arguments so that after
approval or a service restart the system can recover and execute the
approved action with the original parameters. This table is **never**
joined in any read query exposed to the API, events, logs, or frontend —
it is server-only. The ActionCoordinator encrypts the payload before
writing and decrypts only at execution time.
One row per action (unique ``action_id``). The encryption key is derived
from the deployment's secret key; the ciphertext is opaque to the store.
"""
__tablename__ = "maf_roundtable_action_secrets"
__table_args__ = (
UniqueConstraint("action_id", name="uq_maf_action_secrets_action"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
action_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_actions.id", ondelete="CASCADE"),
nullable=False, index=True,
)
arguments_encrypted: Mapped[str] = mapped_column(PortableLongText, nullable=False)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
__all__ = [
"MafRoundtableActionRow",
"MafRoundtableActionSecretRow",
"MafRoundtableApprovalRow",
"MafRoundtableEventRow",
"MafRoundtableParticipantRow",
"MafRoundtableRunRow",
"MafRoundtableSeatRunRow",
"MafRoundtableSessionRow",
]
@@ -120,3 +120,25 @@ class MafParticipantRepository:
row.updated_at = _now()
await session.commit()
return _to_dict(row)
async def bind_thread(
self, session_id: str, agent_id: str, *, thread_id: str, sandbox_id: str | None = None
) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = (
select(MafRoundtableParticipantRow)
.where(
MafRoundtableParticipantRow.session_id == session_id,
MafRoundtableParticipantRow.agent_id == agent_id,
)
.limit(1)
)
row = (await session.execute(stmt)).scalars().first()
if row is None:
return None
row.thread_id = thread_id
if sandbox_id is not None:
row.sandbox_id = sandbox_id
row.updated_at = _now()
await session.commit()
return _to_dict(row)
@@ -118,6 +118,27 @@ class MafRunRepository:
)
await session.commit()
async def claim_for_resume(self, run_id: str) -> bool:
"""Run-level CAS gating post-approval execution: ``waiting_approval → running``.
After every approval is decided, exactly one concurrent ``resume()`` may
proceed to execute the approved actions and write the parent run's final
state. The winner (``rowcount == 1``) executes; every other concurrent
caller gets False and must return the current run WITHOUT writing a
terminal status (so it can never race a ``completed``/``failed`` write).
"""
async with self._sf() as session:
result = await session.execute(
update(MafRoundtableRunRow)
.where(
MafRoundtableRunRow.id == run_id,
MafRoundtableRunRow.status == "waiting_approval",
)
.values(status="running", started_at=_now(), updated_at=_now())
)
await session.commit()
return result.rowcount == 1 # type: ignore[union-attr]
async def bump_last_event_seq(self, run_id: str, seq: int) -> None:
"""Denormalized cache update — only bumps forward (max wins)."""
async with self._sf() as session:
@@ -0,0 +1,195 @@
"""Seat run repository — persistent MAF↔DeerFlow run mapping.
Replaces the in-memory ``_active_runs`` dict. After a service restart the
system can still locate and cancel outstanding DeerFlow runs via the
persisted ``deerflow_run_id``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.model import MafRoundtableSeatRunRow
def _now() -> datetime:
return datetime.now(UTC)
def _to_dict(row: MafRoundtableSeatRunRow) -> dict[str, Any]:
return {
"id": row.id,
"maf_run_id": row.maf_run_id,
"agent_id": row.agent_id,
"turn_index": row.turn_index,
"deerflow_run_id": row.deerflow_run_id,
"deerflow_thread_id": row.deerflow_thread_id,
"sandbox_id": row.sandbox_id,
"workflow_checkpoint_id": row.workflow_checkpoint_id,
"status": row.status,
"started_at": row.started_at.isoformat() if isinstance(row.started_at, datetime) else row.started_at,
"finished_at": row.finished_at.isoformat() if isinstance(row.finished_at, datetime) else row.finished_at,
"error": row.error,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
class MafSeatRunRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def create_seat_run(
self,
*,
maf_run_id: str,
agent_id: str,
turn_index: int,
deerflow_thread_id: str | None = None,
) -> dict[str, Any]:
now = _now()
row = MafRoundtableSeatRunRow(
id=uuid4().hex,
maf_run_id=maf_run_id,
agent_id=agent_id,
turn_index=turn_index,
deerflow_thread_id=deerflow_thread_id,
status="pending",
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
return _to_dict(row)
async def mark_running(
self, seat_run_id: str, *, deerflow_run_id: str, sandbox_id: str | None = None
) -> None:
async with self._sf() as session:
await session.execute(
update(MafRoundtableSeatRunRow)
.where(MafRoundtableSeatRunRow.id == seat_run_id)
.values(
status="running",
deerflow_run_id=deerflow_run_id,
sandbox_id=sandbox_id,
started_at=_now(),
updated_at=_now(),
)
)
await session.commit()
async def mark_completed(self, seat_run_id: str, *, checkpoint_id: str | None = None) -> None:
async with self._sf() as session:
await session.execute(
update(MafRoundtableSeatRunRow)
.where(MafRoundtableSeatRunRow.id == seat_run_id)
.values(
status="completed",
workflow_checkpoint_id=checkpoint_id,
finished_at=_now(),
updated_at=_now(),
)
)
await session.commit()
async def mark_failed(self, seat_run_id: str, *, error: str | None = None) -> None:
async with self._sf() as session:
await session.execute(
update(MafRoundtableSeatRunRow)
.where(MafRoundtableSeatRunRow.id == seat_run_id)
.values(status="failed", error=error, finished_at=_now(), updated_at=_now())
)
await session.commit()
async def mark_cancelled(self, seat_run_id: str) -> None:
async with self._sf() as session:
await session.execute(
update(MafRoundtableSeatRunRow)
.where(MafRoundtableSeatRunRow.id == seat_run_id)
.values(status="cancelled", finished_at=_now(), updated_at=_now())
)
await session.commit()
async def mark_cancelling(self, seat_run_id: str) -> None:
"""Mark a seat run as cancelling (cancel requested, not yet confirmed)."""
async with self._sf() as session:
await session.execute(
update(MafRoundtableSeatRunRow)
.where(MafRoundtableSeatRunRow.id == seat_run_id)
.values(status="cancelling", updated_at=_now())
)
await session.commit()
async def mark_cancel_failed(self, seat_run_id: str, *, error: str | None = None) -> None:
"""Mark a seat run as cancel_failed (DeerFlow cancel did not succeed)."""
async with self._sf() as session:
await session.execute(
update(MafRoundtableSeatRunRow)
.where(MafRoundtableSeatRunRow.id == seat_run_id)
.values(status="cancel_failed", error=error, updated_at=_now())
)
await session.commit()
async def mark_cancel_unknown(self, seat_run_id: str, *, error: str | None = None) -> None:
"""Mark a seat run's cancel outcome as unknown (e.g. no DeerFlow run id)."""
async with self._sf() as session:
await session.execute(
update(MafRoundtableSeatRunRow)
.where(MafRoundtableSeatRunRow.id == seat_run_id)
.values(status="cancel_unknown", error=error, updated_at=_now())
)
await session.commit()
async def get_active_by_maf_run(self, maf_run_id: str) -> list[dict[str, Any]]:
stmt = (
select(MafRoundtableSeatRunRow)
.where(
MafRoundtableSeatRunRow.maf_run_id == maf_run_id,
MafRoundtableSeatRunRow.status.in_(("pending", "running")),
)
)
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
return [_to_dict(r) for r in rows]
async def get_by_maf_run(self, maf_run_id: str) -> list[dict[str, Any]]:
stmt = (
select(MafRoundtableSeatRunRow)
.where(MafRoundtableSeatRunRow.maf_run_id == maf_run_id)
.order_by(MafRoundtableSeatRunRow.turn_index.asc())
)
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
return [_to_dict(r) for r in rows]
async def cancel_all_active(self, maf_run_id: str) -> list[dict[str, Any]]:
"""Mark all active seat runs as cancelling and return their info.
Two-phase cancel: first mark as ``cancelling``, then the caller
actually cancels the DeerFlow runs and calls ``mark_cancelled`` or
``mark_cancel_failed`` per seat run. Returns list of
``{seat_run_id, deerflow_run_id}`` dicts.
"""
stmt = (
select(MafRoundtableSeatRunRow)
.where(
MafRoundtableSeatRunRow.maf_run_id == maf_run_id,
MafRoundtableSeatRunRow.status.in_(("pending", "running")),
)
)
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
result = []
for row in rows:
row.status = "cancelling"
row.updated_at = _now()
result.append({"seat_run_id": row.id, "deerflow_run_id": row.deerflow_run_id})
await session.commit()
return result
@@ -18,21 +18,31 @@ from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select, update
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.actions import MafActionRepository
from deerflow.persistence.maf_roundtable.action_secrets import MafActionSecretRepository
from deerflow.persistence.maf_roundtable.actions import MafActionRepository, action_to_dict
from deerflow.persistence.maf_roundtable.approvals import MafApprovalRepository
from deerflow.persistence.maf_roundtable.events import MafEventRepository
from deerflow.persistence.maf_roundtable.model import MafRoundtableRunRow, MafRoundtableSessionRow
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableActionRow,
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
MafRoundtableEventRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
from deerflow.persistence.maf_roundtable.participants import MafParticipantRepository
from deerflow.persistence.maf_roundtable.runs import MafRunRepository, run_to_dict
from deerflow.persistence.maf_roundtable.seat_runs import MafSeatRunRepository
from deerflow.persistence.maf_roundtable.sessions import MafSessionRepository
# Session statuses that mean a run is in flight — the CAS claim refuses these.
_ACTIVE_SESSION_STATUSES = ("starting", "running", "waiting_approval", "waiting_input")
_ACTIVE_SESSION_STATUSES = ("starting", "running", "waiting_approval", "waiting_input", "cancelling", "cancel_failed")
# Run statuses that are still in flight (cancellable).
_ACTIVE_RUN_STATUSES = ("pending", "running", "waiting_approval", "waiting_input")
_ACTIVE_RUN_STATUSES = ("pending", "running", "waiting_approval", "waiting_input", "cancelling", "cancel_failed")
# Run statuses that are terminal (cannot be re-cancelled).
_TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled")
@@ -57,6 +67,8 @@ class MafRoundtableStore:
self.events = MafEventRepository(session_factory, run_repo=self.runs)
self.actions = MafActionRepository(session_factory)
self.approvals = MafApprovalRepository(session_factory)
self.seat_runs = MafSeatRunRepository(session_factory)
self.action_secrets = MafActionSecretRepository(session_factory)
@property
def session_factory(self) -> async_sessionmaker[AsyncSession]:
@@ -160,13 +172,15 @@ class MafRoundtableStore:
if run_row.status in _ACTIVE_RUN_STATUSES:
# Normal cancel: run + session → cancelled.
# Also handles cancel_failed → cancelled retry.
run_row.status = "cancelled"
run_row.finished_at = now
run_row.updated_at = now
session_row.status = "cancelled"
session_row.updated_at = now
elif run_row.status == "cancelled":
# Already cancelled — repair session if still active.
elif run_row.status == "cancelled" or run_row.status == "cancel_failed":
# Already cancelled / previously failed cancel — repair
# session if still active.
if session_row.status in _ACTIVE_SESSION_STATUSES:
session_row.status = "cancelled"
session_row.updated_at = now
@@ -180,9 +194,302 @@ class MafRoundtableStore:
await db.flush()
return run_to_dict(run_row)
async def start_cancel_atomically(
self,
*,
run_id: str,
owner_user_id: str,
) -> dict[str, Any] | None:
"""Atomically transition a run to ``cancelling`` (not yet ``cancelled``).
Phase 1 of the two-phase cancel cascade:
1. Router calls this → run+session enter ``cancelling``.
2. Router calls ``dispatcher.cancel(run_id)`` which cascades to
DeerFlow sub-runs.
3. After the cascade, ``cancel_run_atomically`` finalizes to
``cancelled``.
Returns the updated run dict, or None if the run is missing,
wrong-owner, or already terminal.
"""
now = _now()
async with self._sf() as db:
async with db.begin():
stmt = (
select(MafRoundtableRunRow, MafRoundtableSessionRow)
.join(
MafRoundtableSessionRow,
MafRoundtableRunRow.session_id == MafRoundtableSessionRow.id,
)
.where(
MafRoundtableRunRow.id == run_id,
MafRoundtableSessionRow.user_id == owner_user_id,
)
.with_for_update()
)
row = (await db.execute(stmt)).first()
if row is None:
return None
run_row, session_row = row
# Transition ANY active (or cancel_failed retry) run to cancelling.
# Includes: pending (not yet executing), running, waiting_*,
# cancelling (no-op, return current), cancel_failed (retry).
if run_row.status in _ACTIVE_RUN_STATUSES and run_row.status != "cancelling":
run_row.status = "cancelling"
run_row.updated_at = now
session_row.status = "cancelling"
session_row.updated_at = now
await db.flush()
return run_to_dict(run_row)
# Already cancelling or terminal — return current state.
return run_to_dict(run_row)
async def mark_cancel_failed_atomically(
self,
*,
run_id: str,
owner_user_id: str,
error_code: str | None = None,
error_message: str | None = None,
) -> dict[str, Any] | None:
"""Atomically mark run+session as ``cancel_failed``.
Unlike ``mark_cancel_failed`` in service.py (which only updates run),
this writes run AND session in a single transaction so their statuses
never diverge. ``cancel_failed`` is a non-terminal state: the user
can retry cancel later.
"""
now = _now()
async with self._sf() as db:
async with db.begin():
stmt = (
select(MafRoundtableRunRow, MafRoundtableSessionRow)
.join(
MafRoundtableSessionRow,
MafRoundtableRunRow.session_id == MafRoundtableSessionRow.id,
)
.where(
MafRoundtableRunRow.id == run_id,
MafRoundtableSessionRow.user_id == owner_user_id,
)
.with_for_update()
)
row = (await db.execute(stmt)).first()
if row is None:
return None
run_row, session_row = row
if run_row.status not in ("cancelling", "cancel_failed"):
return run_to_dict(run_row)
run_row.status = "cancel_failed"
run_row.updated_at = now
if error_code:
run_row.error_code = error_code
if error_message:
run_row.error_message_safe = error_message
session_row.status = "cancel_failed"
session_row.updated_at = now
await db.flush()
return run_to_dict(run_row)
async def create_pending_action_bundle(
self,
*,
session_id: str,
run_id: str,
agent_id: str,
tool_name: str,
logical_action_id: str | None,
idempotency_key: str,
risk_level: str,
target_key: str | None,
turn_index: int | None = None,
arguments_sanitized: dict[str, Any] | None,
arguments_encrypted: str,
approval_summary: str,
approval_payload: dict[str, Any] | None,
audit_event_agent_id: str,
audit_event_turn_index: int,
) -> dict[str, Any]:
"""Create an action + secret + approval + audit event in ONE transaction.
All four writes share a single ``AsyncSession`` + ``begin()`` so that
any failure rolls everything back — the database never holds a
half-finished action (action without secret / approval / event).
Idempotency: ``idempotency_key`` is DB-unique. A concurrent duplicate
submission loses the INSERT (``IntegrityError``); the method then
re-reads and returns the already-stored action instead of raising,
so a retry never fails the run.
**P1-6 concurrent retry**: when multiple parallel seat turns create
action bundles concurrently, the ``MAX(seq)+1`` approach may collide
on the ``(run_id, seq)`` unique constraint. This method retries up to
``_MAX_BUNDLE_RETRIES`` times, re-computing ``next_seq`` from the
latest committed state inside a fresh transaction each time.
Returns the stored action dict (with ``approval_id`` linked). The
returned dict carries a ``"_idempotent_replay"`` flag (True when an
existing action was returned rather than newly created).
"""
_MAX_BUNDLE_RETRIES = 8
# Idempotency fast-path: check if an identical action already exists
# (concurrent retry / replay after restart). This check is outside the
# retry loop because idempotency key duplicates are independent of seq
# collisions.
async with self._sf() as db:
existing = await db.execute(
select(MafRoundtableActionRow).where(
MafRoundtableActionRow.idempotency_key == idempotency_key
).limit(1)
)
existing_row = existing.scalars().first()
if existing_row is not None:
d = action_to_dict(existing_row)
d["_idempotent_replay"] = True
return d
now = _now()
action_id = uuid4().hex
last_error: Exception | None = None
for attempt in range(1, _MAX_BUNDLE_RETRIES + 1):
async with self._sf() as db:
async with db.begin():
# Re-check idempotency inside transaction (concurrent writer
# may have committed between attempts).
existing = await db.execute(
select(MafRoundtableActionRow).where(
MafRoundtableActionRow.idempotency_key == idempotency_key
).limit(1)
)
existing_row2 = existing.scalars().first()
if existing_row2 is not None:
d = action_to_dict(existing_row2)
d["_idempotent_replay"] = True
return d
# 1. action row.
action_row = MafRoundtableActionRow(
id=action_id,
session_id=session_id,
run_id=run_id,
agent_id=agent_id,
tool_name=tool_name,
logical_action_id=logical_action_id,
idempotency_key=idempotency_key,
target_key=target_key,
turn_index=turn_index,
risk_level=risk_level,
status="waiting_approval",
arguments_redacted_json=arguments_sanitized,
created_at=now,
updated_at=now,
)
db.add(action_row)
try:
await db.flush()
except IntegrityError:
last_error = IntegrityError("action row insert conflict", orig=None, params={})
await db.rollback()
# Re-read — another writer may have created the action.
async with self._sf() as db2:
existing3 = await db2.execute(
select(MafRoundtableActionRow).where(
MafRoundtableActionRow.idempotency_key == idempotency_key
).limit(1)
)
er = existing3.scalars().first()
if er is not None:
d = action_to_dict(er)
d["_idempotent_replay"] = True
return d
continue
# 2. encrypted secret (server-only).
db.add(MafRoundtableActionSecretRow(
id=uuid4().hex,
action_id=action_id,
arguments_encrypted=arguments_encrypted,
created_at=now,
))
await db.flush()
# 3. approval row (pending).
approval_id = uuid4().hex
db.add(MafRoundtableApprovalRow(
id=approval_id,
session_id=session_id,
run_id=run_id,
action_id=action_id,
requested_by_agent_id=agent_id,
status="pending",
summary=approval_summary,
payload_redacted_json=approval_payload,
created_at=now,
updated_at=now,
))
await db.flush()
# 4. link approval back to action.
action_row.approval_id = approval_id
action_row.updated_at = now
await db.flush()
# 5. critical audit event — action.waiting_approval.
# Recompute seq fresh inside each attempt/transaction.
max_seq = (
await db.execute(
select(func.max(MafRoundtableEventRow.seq)).where(
MafRoundtableEventRow.run_id == run_id
)
)
).scalar_one()
next_seq = int(max_seq or 0) + 1
db.add(MafRoundtableEventRow(
id=uuid4().hex,
session_id=session_id,
run_id=run_id,
seq=next_seq,
event_type="action.waiting_approval",
agent_id=audit_event_agent_id,
turn_index=audit_event_turn_index,
payload_json={
"action_id": action_id,
"approval_id": approval_id,
"tool_name": tool_name,
"risk_level": risk_level,
},
created_at=now,
))
try:
await db.flush()
except IntegrityError:
# (run_id, seq) collision — retry with fresh seq.
last_error = IntegrityError("event seq collision", orig=None, params={})
await db.rollback()
continue
result = action_to_dict(action_row)
result["approval_id"] = approval_id
result["_idempotent_replay"] = False
return result
raise RuntimeError(
f"create_pending_action_bundle failed after {_MAX_BUNDLE_RETRIES} attempts "
f"for idempotency_key={idempotency_key}"
) from last_error
__all__ = [
"MafActionRepository",
"MafActionSecretRepository",
"MafApprovalRepository",
"MafEventRepository",
"MafParticipantRepository",
@@ -0,0 +1,482 @@
"""Server-side executor for a single, human-approved tool call (MAF roundtable).
This is the harness-layer "approved tool executor". After a human approves an
external-write action, the MAF app layer hands the server-constructed tool call
to :func:`execute_approved_tool`, which runs it through the **real** LangGraph
tool-execution path — the same path a normal DeerFlow tool call uses — without
any LLM involvement:
- a real ``ToolNode`` (so ``_inject_tool_args`` runs internally: ``InjectedState``
/ ``InjectedStore`` / ``ToolRuntime`` are populated by the framework, the
caller-provided args are stripped-then-rebuilt so nothing is forged);
- a real ``ToolRuntime`` (``state`` recovered from the thread checkpoint's
``channel_values`` — sandbox / thread_data / etc. — plus ``config`` / ``store``
/ ``stream_writer``), NOT a duck-typed ``SimpleNamespace``;
- the same ``wrap_tool_call`` / ``awrap_tool_call`` middleware chain the lead
agent uses (``build_lead_runtime_middlewares``), composed first-outermost
exactly like ``langchain.agents.factory.create_agent``;
- unified tool-error handling (``handle_tool_errors=True``).
The tool list is rebuilt from the seat's frozen capability snapshot / agent
config / model / ``excluded_tools`` — never the global default registry — and
four security gates run BEFORE execution:
1. the action's ``run_id`` / ``session_id`` / ``agent_id`` / ``tool_name`` must
match the current execution context;
2. the tool must not be disabled (``excluded_tools``);
3. the tool must exist in the rebuilt allow-list (i.e. be permitted by the
capability snapshot);
4. a trusted MAF context (``maf_run_id`` / ``maf_approved_tool`` /
``maf_approved_action_id`` / …) is injected so ``WriteGuardMiddleware`` can
validate the approved binding and pass the call through.
Boundary: this module lives in ``deerflow.*`` and imports only ``deerflow.*`` +
``langgraph`` / ``langchain`` — never ``app.*``. The MAF app layer calls it,
passing all secret-derived params and the stored action record as plain data.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any, TypedDict
from langchain_core.messages import AIMessage, ToolMessage
from langgraph.errors import GraphBubbleUp
from langgraph.types import Command
from deerflow.config.app_config import AppConfig
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.tools.tools import get_available_tools
logger = logging.getLogger(__name__)
class MafApprovedToolContext(TypedDict, total=False):
"""Typed run context for post-approval ToolNode / ToolRuntime injection.
Declared so LangGraph ``Runtime[MafApprovedToolContext]`` serializes cleanly
(bare ``Runtime`` defaults ``context`` to ``None`` and warns on dict values).
"""
thread_id: str
run_id: str
agent_id: str
user_id: str
turn_index: int
maf_run_id: str
maf_session_id: str
maf_user_id: str
capability_snapshot: dict[str, Any]
write_policy: str
maf_approved_tool: str
maf_approved_action_id: str
maf_safe_error_mode: bool
class _ExecutorUser:
"""Minimal ``CurrentUser`` shim (structural ``.id``) for path resolution."""
def __init__(self, user_id: str) -> None:
self.id = user_id
class ApprovedToolRejection(Exception):
"""A security gate refused the approved tool call (never executed).
``reason`` is a stable machine-readable code; ``detail`` is safe to log /
persist (never contains params or credentials).
"""
def __init__(self, reason: str, detail: str) -> None:
self.reason = reason
self.detail = detail
super().__init__(f"approved tool rejected: {reason} — {detail}")
_INCONCLUSIVE_ERROR_MARKERS = (
"timeout", "timedout", "deadline",
"connection", "connect", "disconnect", "reset", "refused", "broken",
"gateway", "badgateway", "serviceunavailable", "unavailable",
"protocol", "remoteprotocol", "eof", "network", "socket",
"temporarily", "tryagain", "econnreset", "etimedout",
)
def _is_inconclusive_text(text: str) -> bool:
"""True when an error string denotes an unknowable external result."""
lowered = text.lower()
return any(marker in lowered for marker in _INCONCLUSIVE_ERROR_MARKERS)
def _is_inconclusive_error(exc: BaseException) -> bool:
"""True when an exception means the external result is unknowable.
Classified by exception type name + message keywords (no hard dependency on
httpx/aiohttp). Timeouts, dropped/reset connections, and 502/503-gateway
errors leave the external write's fate undetermined → ``unknown``.
"""
return _is_inconclusive_text(type(exc).__name__) or _is_inconclusive_text(str(exc))
# ── middleware-chain composition (mirrors langchain create_agent) ──────────
def _overrides_hook(middleware: Any, hook: str) -> bool:
"""True if ``middleware`` overrides ``hook`` (vs. the no-op base default)."""
from langchain.agents.middleware import AgentMiddleware
base = getattr(AgentMiddleware, hook, None)
impl = getattr(type(middleware), hook, None)
return impl is not None and impl is not base
def _compose_two_async(outer: Any, inner: Any) -> Any:
"""Compose two async ``wrap_tool_call`` wrappers; ``outer`` runs first."""
async def composed(request: Any, handler: Any) -> Any:
async def call_inner(req: Any) -> Any:
return await inner(req, handler)
return await outer(request, call_inner)
return composed
def compose_async_tool_call_wrappers(wrappers: list[Any]) -> Any | None:
"""Fold async wrappers into one chain, **first = outermost**.
Replicates ``langchain.agents.factory._chain_async_tool_call_wrappers``
without importing the private helper, so the approved executor reuses the
exact same middleware ordering the lead agent gets.
"""
if not wrappers:
return None
result = wrappers[-1]
for wrapper in reversed(wrappers[:-1]):
result = _compose_two_async(wrapper, result)
return result
def _build_tool_call_middleware_chain(app_config: AppConfig) -> Any | None:
"""Build the composed async ``wrap_tool_call`` chain from the real middlewares.
Uses ``build_lead_runtime_middlewares`` (the same instances the lead agent
runs — WriteGuard / ToolErrorHandling / SandboxAudit / ToolMetrics /
Guardrail when enabled), filtered to those overriding ``awrap_tool_call``
(falling back to a sync ``wrap_tool_call`` adapter), composed first-outermost.
"""
from deerflow.agents.middlewares.tool_error_handling_middleware import (
build_lead_runtime_middlewares,
)
middlewares = build_lead_runtime_middlewares(app_config=app_config)
wrappers: list[Any] = []
for mw in middlewares:
if _overrides_hook(mw, "awrap_tool_call"):
wrappers.append(mw.awrap_tool_call)
elif _overrides_hook(mw, "wrap_tool_call"):
# A sync-only wrap_tool_call cannot drive the async approved path
# safely; DeerFlow's tool middlewares all implement awrap_tool_call,
# so this is defensive. Skip rather than risk a broken chain.
logger.warning(
"execute_approved_tool: skipping sync-only tool middleware %s",
type(mw).__name__,
)
return compose_async_tool_call_wrappers(wrappers)
# ── security gates ────────────────────────────────────────────────────────
_SAFE_SUCCESS_KEYS = frozenset({"executed", "external_object_id", "safe_summary", "command", "has_update"})
_ERROR_CODE_INCONCLUSIVE = "inconclusive_external_result"
_ERROR_CODE_FAILED = "tool_execution_failed"
_ERROR_CODE_REJECTED = "approved_action_rejected"
def _seat_excluded_tools(
capability_snapshot: dict[str, Any] | None,
agent_id: str,
fallback: list[str] | None,
) -> list[str]:
if not capability_snapshot:
return list(fallback or [])
for a in capability_snapshot.get("agents") or []:
if isinstance(a, dict) and a.get("agent_id") == agent_id:
return list(a.get("excluded_tools") or [])
return list(fallback or [])
def _frozen_allowed_tool_names(
capability_snapshot: dict[str, Any] | None,
agent_id: str,
) -> frozenset[str] | None:
"""Return the frozen allow-list for a seat, or None if snapshot has no list."""
if not capability_snapshot:
return None
for a in capability_snapshot.get("agents") or []:
if isinstance(a, dict) and a.get("agent_id") == agent_id:
names = a.get("allowed_tool_names")
if isinstance(names, list):
return frozenset(str(n) for n in names)
return None
def _check_action_consistency(
*,
action_record: dict[str, Any] | None,
approved_action_id: str,
run_id: str,
session_id: str,
agent_id: str,
tool_name: str,
turn_index: int,
) -> None:
"""Gate 1: the stored action must belong to this exact execution context."""
if not action_record:
raise ApprovedToolRejection(
"missing_action", "no action record supplied for approved execution"
)
required: dict[str, Any] = {
"id": approved_action_id,
"status": "running",
"run_id": run_id,
"session_id": session_id,
"agent_id": agent_id,
"tool_name": tool_name,
"turn_index": turn_index,
}
for field, expected in required.items():
actual = action_record.get(field)
if actual != expected:
raise ApprovedToolRejection(
_ERROR_CODE_REJECTED,
f"action {field} mismatch or missing (expected {expected!r}, got {actual!r})",
)
# ── the executor ──────────────────────────────────────────────────────────
async def execute_approved_tool(
*,
app_config: AppConfig,
checkpointer: Any,
store: Any,
thread_id: str,
# identity / trusted context
user_id: str,
session_id: str,
run_id: str,
agent_id: str,
turn_index: int,
# frozen per-seat tool selection
model_name: str | None,
excluded_tools: list[str] | None,
capability_snapshot: dict[str, Any] | None,
write_policy: str,
# the approved call (params already decrypted server-side by the caller)
tool_name: str,
params: dict[str, Any],
approved_action_id: str,
action_record: dict[str, Any] | None,
tool_call_id: str | None = None,
) -> dict[str, Any]:
"""Execute one approved tool call via the real LangGraph tool path.
Returns ``{"status": "succeeded", "result": ...}`` or
``{"status": "failed", "error": ...}``. A security rejection is a
``failed`` outcome (so the parent run fails) and the tool executes 0 times.
The LLM is never involved.
"""
from deerflow.config.agents_config import load_agent_config
excluded = _seat_excluded_tools(capability_snapshot, agent_id, excluded_tools)
# Gate 1 — action/context consistency (before building anything).
try:
_check_action_consistency(
action_record=action_record,
approved_action_id=approved_action_id,
run_id=run_id,
session_id=session_id,
agent_id=agent_id,
tool_name=tool_name,
turn_index=turn_index,
)
except ApprovedToolRejection as exc:
logger.error(
"execute_approved_tool rejected (%s): %s", exc.reason, exc.detail
)
return {"status": "failed", "error": f"{exc.reason}: {exc.detail}"}
# Rebuild the tool list exactly as the seat's agent run would: the agent's
# configured tool_groups + model, minus disabled tools. Never the global
# default registry.
agent_config = load_agent_config(agent_id)
tool_groups = getattr(agent_config, "tool_groups", None) if agent_config else None
effective_model = model_name or (getattr(agent_config, "model", None) if agent_config else None)
all_tools = get_available_tools(
groups=tool_groups,
model_name=effective_model,
excluded_tools=excluded,
app_config=app_config,
)
tool_map = {t.name: t for t in all_tools}
frozen_allowed = _frozen_allowed_tool_names(capability_snapshot, agent_id)
if frozen_allowed is not None:
tool_map = {name: tool for name, tool in tool_map.items() if name in frozen_allowed}
# Gate 2 — disabled tool.
if tool_name in excluded:
logger.error("execute_approved_tool: tool '%s' is disabled", tool_name)
return {"status": "failed", "error": f"tool '{tool_name}' is disabled for this seat"}
# Gate 3 — allowed / exists in the rebuilt capability allow-list.
if tool_name not in tool_map:
logger.error("execute_approved_tool: tool '%s' not in seat allow-list", tool_name)
return {
"status": "failed",
"error": f"tool '{tool_name}' is not permitted by this seat's capability snapshot",
}
# Recover real runtime state from the thread checkpoint.
channel_values: dict[str, Any] = {}
config_dict: dict[str, Any] = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
if checkpointer is not None:
try:
ct = await checkpointer.aget_tuple(config_dict)
checkpoint = getattr(ct, "checkpoint", None) if ct is not None else None
if isinstance(checkpoint, dict):
raw = checkpoint.get("channel_values", {}) or {}
if isinstance(raw, dict):
channel_values = dict(raw)
except Exception:
logger.warning(
"execute_approved_tool: could not read checkpoint for thread %s; "
"continuing with empty state",
thread_id,
exc_info=True,
)
# Build the trusted run context. These keys come ONLY from the server — the
# decrypted params never enter this dict or any model context.
call_id = tool_call_id or uuid.uuid4().hex
run_context: MafApprovedToolContext = {
"thread_id": thread_id,
"run_id": run_id,
"agent_id": agent_id,
"user_id": user_id,
"turn_index": turn_index,
"maf_run_id": run_id,
"maf_session_id": session_id,
"maf_user_id": user_id,
"capability_snapshot": capability_snapshot or {},
"write_policy": write_policy or "",
"maf_approved_tool": tool_name,
"maf_approved_action_id": approved_action_id,
"maf_safe_error_mode": True,
}
# Compose the real middleware chain (WriteGuard sees the approved binding
# and passes the call through; ToolErrorHandling/etc. behave as usual).
awrap = _build_tool_call_middleware_chain(app_config)
from langgraph.prebuilt import ToolNode
from langgraph.runtime import Runtime
collected_writes: list[Any] = []
def _stream_writer(payload: Any) -> None:
collected_writes.append(payload)
tool_node = ToolNode(
list(tool_map.values()),
awrap_tool_call=awrap,
handle_tool_errors=True,
)
# Server-constructed single tool call — no LLM. Carried on a synthetic
# AIMessage so the ToolNode parses it exactly like a graph tool turn.
tool_call = {"name": tool_name, "args": dict(params), "id": call_id, "type": "tool_call"}
ai_msg = AIMessage(content="", tool_calls=[tool_call])
input_state: dict[str, Any] = dict(channel_values)
input_state["messages"] = [ai_msg]
# Context lives only on typed Runtime — do not also set config["context"].
runtime = Runtime[MafApprovedToolContext](
context=run_context, store=store, stream_writer=_stream_writer
)
config_dict["configurable"]["__pregel_runtime"] = runtime
token = set_current_user(_ExecutorUser(user_id))
try:
result = await tool_node.ainvoke(input_state, config_dict)
except GraphBubbleUp:
# Preserve interrupt / pause / resume control-flow signals.
raise
except Exception as exc: # noqa: BLE001 — map any failure to a safe outcome
# Never leak raw exception text (it may embed params / credentials):
# surface only the exception type. Inconclusive failures (timeout /
# disconnect / gateway) become ``unknown`` and are NEVER auto-retried —
# the external write may or may not have happened.
if _is_inconclusive_error(exc):
logger.error(
"execute_approved_tool: tool '%s' inconclusive (%s) — recording unknown",
tool_name,
type(exc).__name__,
)
return {"status": "unknown", "error": _ERROR_CODE_INCONCLUSIVE}
logger.error(
"execute_approved_tool: tool '%s' raised %s",
tool_name,
type(exc).__name__,
)
return {"status": "failed", "error": _ERROR_CODE_FAILED}
finally:
reset_current_user(token)
return _interpret_tool_node_result(result, tool_name)
def _safe_success_payload(content: Any) -> dict[str, Any]:
if isinstance(content, dict):
safe = {k: content[k] for k in _SAFE_SUCCESS_KEYS if k in content}
if safe:
return safe
return {"executed": True}
def _interpret_tool_node_result(result: Any, tool_name: str) -> dict[str, Any]:
"""Translate the ToolNode output (ToolMessage / Command) into an outcome dict."""
messages: list[Any] = []
if isinstance(result, dict):
messages = result.get("messages", []) or []
elif isinstance(result, list):
messages = result
output = messages[0] if messages else result
if isinstance(output, Command):
update = getattr(output, "update", None)
return {
"status": "succeeded",
"result": {"command": type(output).__name__, "has_update": update is not None},
}
if isinstance(output, ToolMessage):
status = getattr(output, "status", "success")
content = output.content
if status == "error":
text = str(content)
if _is_inconclusive_text(text):
return {"status": "unknown", "error": _ERROR_CODE_INCONCLUSIVE}
return {"status": "failed", "error": _ERROR_CODE_FAILED}
return {"status": "succeeded", "result": _safe_success_payload(content)}
return {"status": "succeeded", "result": {"executed": True}}
@@ -0,0 +1,61 @@
"""Server-trusted tool side-effect / risk metadata for MAF WriteGuard.
Harness-only — never imports ``app.*``. Used when building frozen capability
snapshots and when classifying tools at runtime. Client-supplied metadata is
never trusted; only deployment config (``ToolConfig`` extras) and this registry
apply.
Tools without explicit metadata default to ``write`` / ``R3`` (requires approval
under ``confirm_high_risk``).
"""
from __future__ import annotations
from typing import Any
_DEFAULT_SIDE_EFFECT = "write"
_DEFAULT_RISK_LEVEL = "R3"
# Built-in tools whose side effects are known server-side (not name-prefix guessing
# for arbitrary tools — only this curated allow-list).
_BUILTIN_METADATA: dict[str, tuple[str, str]] = {
"memory": ("read", "R0"),
"ask_clarification": ("read", "R0"),
"view_image": ("read", "R0"),
"present_file": ("read", "R0"),
"web_search": ("read", "R1"),
"web_fetch": ("read", "R1"),
"read_file": ("read", "R0"),
"grep": ("read", "R0"),
"glob": ("read", "R0"),
"ls": ("read", "R0"),
"invoke_skill": ("skill", "R1"),
"skill_search": ("skill", "R1"),
"task": ("read", "R2"),
"bash": ("write", "R3"),
"write_file": ("write", "R3"),
"edit_file": ("write", "R3"),
}
def resolve_tool_metadata(
tool_name: str,
*,
config_extra: dict[str, Any] | None = None,
) -> dict[str, str]:
"""Return ``{name, tool_name, side_effect, risk_level}`` for one tool."""
side = _DEFAULT_SIDE_EFFECT
risk = _DEFAULT_RISK_LEVEL
if config_extra:
if config_extra.get("side_effect"):
side = str(config_extra["side_effect"])
if config_extra.get("risk_level"):
risk = str(config_extra["risk_level"])
elif tool_name in _BUILTIN_METADATA:
side, risk = _BUILTIN_METADATA[tool_name]
return {
"name": tool_name,
"tool_name": tool_name,
"side_effect": side,
"risk_level": risk,
}
@@ -29,6 +29,7 @@ dependencies = [
[project.optional-dependencies]
postgres = ["deerflow-harness[postgres]"]
mysql = ["deerflow-harness[mysql]"]
maf = ["agent-framework-core>=1.13.0,<2", "cryptography>=42.0"]
[dependency-groups]
dev = [
@@ -0,0 +1,205 @@
"""Phase 2 migration for the MAF roundtable experiment.
Idempotent migration that brings a Phase-1 deployment up to Phase-2 schema:
1. Create ``maf_roundtable_seat_runs`` (MAF↔DeerFlow run mapping, survives restart).
2. Add ``source_event_id`` column to ``maf_roundtable_events`` (durable event dedup).
3. Create the ``(run_id, source_event_id)`` unique index on ``maf_roundtable_events``.
4. Create ``maf_roundtable_action_secrets`` (encrypted actual execution params, server-only).
5. Add ``turn_index`` column to ``maf_roundtable_actions`` (Phase-1 DBs may lack it).
Supports SQLite, PostgreSQL, and MySQL. Each step checks whether the object
already exists before creating it, so running the script multiple times has no
side effect. Old event rows keep ``source_event_id = NULL`` (SQL treats NULLs as
distinct in a UNIQUE constraint, so they are never deduped).
**Deployment order**: run this migration BEFORE enabling the
``maf_roundtable.enabled`` feature flag.
Usage (from ``offline-backend-20260512/backend/``)::
# Preview (no writes)
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_phase2.py --dry-run
# Execute
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_phase2.py
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from typing import Any
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("migrate_maf_roundtable_phase2")
async def _migrate(dry_run: bool) -> int:
from deerflow.config import get_app_config
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
config = get_app_config()
await init_engine_from_config(config.database)
sf = get_session_factory()
if sf is None:
logger.error("Database backend is 'memory' — nothing to migrate.")
return 0
engine = sf.kw["bind"] # the async engine
# Run introspection + DDL on the sync connection (works across all dialects).
async with engine.begin() as conn:
await conn.run_sync(_do_migrate_sync, dry_run=dry_run, logger=logger, changes_ref=[0])
await close_engine()
logger.info("Migration complete (dry_run=%s).", dry_run)
return 0
def _do_migrate_sync(
sync_conn: Any,
*,
dry_run: bool,
logger: logging.Logger,
changes_ref: list[int],
) -> None:
"""Run all migration steps on a sync connection (called via run_sync)."""
from sqlalchemy import inspect, text
insp = inspect(sync_conn)
dialect_name = sync_conn.dialect.name
def _exec(sql: str) -> None:
if dry_run:
logger.info(" [dry-run] would execute: %s", sql.strip()[:300])
return
sync_conn.execute(text(sql))
changes_ref[0] += 1
# ── 1. maf_roundtable_seat_runs ──────────────────────────────────────
if "maf_roundtable_seat_runs" not in insp.get_table_names():
logger.info("Creating table: maf_roundtable_seat_runs")
for stmt in _DDL_SEAT_RUNS:
_exec(stmt)
else:
logger.info("Table maf_roundtable_seat_runs already exists — skip.")
# ── 2. source_event_id column on maf_roundtable_events ──────────────
if "maf_roundtable_events" in insp.get_table_names():
event_cols = {c["name"] for c in insp.get_columns("maf_roundtable_events")}
if "source_event_id" not in event_cols:
logger.info("Adding column source_event_id to maf_roundtable_events")
_exec(_ddl_add_source_event_id(dialect_name))
else:
logger.info("Column source_event_id already exists on maf_roundtable_events — skip.")
else:
logger.info("Table maf_roundtable_events not present — skip source_event_id migration.")
# ── 3. (run_id, source_event_id) unique index ───────────────────────
index_found = False
if "maf_roundtable_events" in insp.get_table_names():
for idx in insp.get_indexes("maf_roundtable_events"):
if idx.get("name") == "uq_maf_events_run_source":
index_found = True
break
if not index_found and "maf_roundtable_events" in insp.get_table_names():
logger.info("Creating unique index uq_maf_events_run_source on maf_roundtable_events")
_exec(_DDL_EVENTS_SOURCE_INDEX)
else:
logger.info("Index uq_maf_events_run_source already exists or events table absent — skip.")
# ── 4. maf_roundtable_action_secrets ─────────────────────────────────
if "maf_roundtable_action_secrets" not in insp.get_table_names():
logger.info("Creating table: maf_roundtable_action_secrets")
for stmt in _DDL_ACTION_SECRETS:
_exec(stmt)
else:
logger.info("Table maf_roundtable_action_secrets already exists — skip.")
# ── 5. turn_index on maf_roundtable_actions (Phase-1 deployments) ───
action_cols = (
{c["name"] for c in insp.get_columns("maf_roundtable_actions")}
if "maf_roundtable_actions" in insp.get_table_names()
else set()
)
if "maf_roundtable_actions" in insp.get_table_names() and "turn_index" not in action_cols:
logger.info("Adding column turn_index to maf_roundtable_actions")
_exec(_ddl_add_action_turn_index(dialect_name))
elif "turn_index" in action_cols:
logger.info("Column turn_index already exists on maf_roundtable_actions — skip.")
logger.info("Migration steps: %d applied.", changes_ref[0])
# ── DDL templates (portable across sqlite / postgres / mysql) ──────────────
# Each DDL is a LIST of single SQL statements so ``text()`` receives one
# statement at a time — multi-statement strings fail on MySQL and several
# SQLite builds because ``text()`` does not split on ``;``.
_DDL_SEAT_RUNS: list[str] = [
"""
CREATE TABLE maf_roundtable_seat_runs (
id VARCHAR(64) NOT NULL PRIMARY KEY,
maf_run_id VARCHAR(64) NOT NULL,
agent_id VARCHAR(64) NOT NULL,
turn_index INTEGER NOT NULL,
deerflow_run_id VARCHAR(64),
deerflow_thread_id VARCHAR(64),
sandbox_id VARCHAR(64),
workflow_checkpoint_id VARCHAR(128),
status VARCHAR(32) DEFAULT 'pending' NOT NULL,
started_at DATETIME,
finished_at DATETIME,
error TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (maf_run_id) REFERENCES maf_roundtable_runs (id) ON DELETE CASCADE
)
""",
"CREATE INDEX ix_maf_seat_runs_maf_run_id ON maf_roundtable_seat_runs (maf_run_id)",
"CREATE INDEX ix_maf_seat_runs_agent_id ON maf_roundtable_seat_runs (agent_id)",
"CREATE INDEX ix_maf_seat_runs_status ON maf_roundtable_seat_runs (status)",
"CREATE UNIQUE INDEX uq_maf_seat_runs_run_agent_turn ON maf_roundtable_seat_runs (maf_run_id, agent_id, turn_index)",
]
_DDL_EVENTS_SOURCE_INDEX = "CREATE UNIQUE INDEX uq_maf_events_run_source ON maf_roundtable_events (run_id, source_event_id)"
_DDL_ACTION_SECRETS: list[str] = [
"""
CREATE TABLE maf_roundtable_action_secrets (
id VARCHAR(64) NOT NULL PRIMARY KEY,
action_id VARCHAR(64) NOT NULL,
arguments_encrypted TEXT NOT NULL,
created_at DATETIME NOT NULL,
FOREIGN KEY (action_id) REFERENCES maf_roundtable_actions (id) ON DELETE CASCADE
)
""",
"CREATE INDEX ix_maf_action_secrets_action_id ON maf_roundtable_action_secrets (action_id)",
"CREATE UNIQUE INDEX uq_maf_action_secrets_action ON maf_roundtable_action_secrets (action_id)",
]
def _ddl_add_source_event_id(dialect: str) -> str:
"""ALTER TABLE to add source_event_id column (dialect-specific NULL handling)."""
col_type = "VARCHAR(191)"
return f"ALTER TABLE maf_roundtable_events ADD COLUMN source_event_id {col_type} NULL"
def _ddl_add_action_turn_index(dialect: str) -> str:
if dialect == "mysql":
return "ALTER TABLE maf_roundtable_actions ADD COLUMN turn_index INT NULL"
return "ALTER TABLE maf_roundtable_actions ADD COLUMN turn_index INTEGER NULL"
def main() -> int:
parser = argparse.ArgumentParser(description="Phase 2 migration for MAF roundtable tables")
parser.add_argument("--dry-run", action="store_true", help="Preview only; do not write to the database")
args = parser.parse_args()
return asyncio.run(_migrate(args.dry_run))
if __name__ == "__main__":
sys.exit(main())
@@ -191,6 +191,13 @@ CALL _add_column_if_missing('task_buttons', 'append_auth', 'TINYINT(1) NOT NULL
CALL _add_column_if_missing('task_buttons', 'auth_token_param', "VARCHAR(64) NULL DEFAULT 'token'");
CALL _add_column_if_missing('task_buttons', 'auth_name_param', "VARCHAR(64) NULL DEFAULT 'username'");
-- ---- maf_roundtable_actions.turn_index --------------------------------------
-- MAF 会商外部写动作所属的席位轮次。持久化该列(而非仅藏在 idempotency_key 里),
-- 使待审批动作可精确按 run_id + agent_id + turn_index + logical_action_id 匹配,
-- 不会把同一席位历史轮次的 waiting action 混进当前审批。
-- ORM: MafRoundtableActionRow.turn_index = Integer, nullable。
CALL _add_column_if_missing('maf_roundtable_actions', 'turn_index', 'INT NULL');
-- ---- 执行后自检:确认本批次涉及的列都已存在 ---------------------------------
SELECT table_name, column_name
FROM information_schema.columns
@@ -199,5 +206,6 @@ SELECT table_name, column_name
OR (table_name = 'agents' AND column_name IN ('square_id'))
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id')
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id')
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param')))
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param'))
OR (table_name = 'maf_roundtable_actions' AND column_name = 'turn_index'))
ORDER BY table_name, column_name;
@@ -0,0 +1,33 @@
"""Shared helpers for MAF roundtable unit tests."""
from __future__ import annotations
from typing import Any
from app.maf_roundtable.service import MafRoundtableService
from deerflow.config import get_app_config
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
class FakeAgentStore:
"""Treat every agent id as visible to any user (tests only)."""
async def get_visible(self, agent_id: str, user_id: str) -> dict[str, Any]:
return {"id": agent_id, "user_id": user_id}
def make_maf_service(
store,
*,
enabled: bool = True,
admin_diagnostic_access: bool = True,
config: MafRoundtableConfig | None = None,
) -> MafRoundtableService:
return MafRoundtableService(
store,
enabled=enabled,
admin_diagnostic_access=admin_diagnostic_access,
config=config,
agent_store=FakeAgentStore(),
app_config=get_app_config(),
)
@@ -0,0 +1,438 @@
"""Tests for the MAF DeerFlow Agent Adapter (Phase 2 rework).
Tests: event normalization, context building, sanitization, cancellation,
audit event reliability, seat run persistence, and user isolation.
Uses in-memory SQLite + mocks for the DeerFlow runtime (no real model calls).
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.context_builder import build_manager_synthesis_input, build_seat_input
from app.maf_roundtable.deerflow_agent_adapter import (
AuditEventError,
DeerFlowAgentAdapter,
SeatResponse,
)
from app.maf_roundtable.event_normalizer import (
make_run_completed,
make_turn_completed,
make_turn_started,
normalize_stream_event,
)
from app.maf_roundtable.runtime_client import DeerFlowRuntimeClient, ExecutionPrincipal
from app.maf_roundtable.sanitize import sanitize, sanitize_payload
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import Base
@pytest_asyncio.fixture
async def store():
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
s = make_maf_roundtable_store(sf)
assert s is not None
yield s
await engine.dispose()
def _svc(store, enabled: bool = True):
from maf_test_helpers import make_maf_service
return make_maf_service(store, enabled=enabled)
def _mock_client() -> DeerFlowRuntimeClient:
app = MagicMock()
app.state.checkpointer = AsyncMock()
app.state.thread_store = AsyncMock()
app.state.stream_bridge = None
app.state.run_manager = AsyncMock()
return DeerFlowRuntimeClient(app)
# ── event_normalizer tests ────────────────────────────────────────────────────
class TestEventNormalizer:
def test_metadata_event(self):
events = normalize_stream_event("metadata", {"run_id": "r1", "thread_id": "t1"}, agent_id="a1", turn_index=0)
assert len(events) == 1
assert events[0]["event_type"] == "run.started"
def test_error_event(self):
events = normalize_stream_event("error", {"message": "boom", "name": "TimeoutError"}, agent_id="a1", turn_index=1)
assert len(events) == 1
assert events[0]["event_type"] == "run.failed"
def test_messages_ai_with_tool_calls(self):
data = ["msg-1", {"type": "ai", "content": "", "tool_calls": [{"name": "search", "id": "tc1", "args": {"q": "test"}}]}]
events = normalize_stream_event("messages", data, agent_id="a1", turn_index=1)
assert any(e["event_type"] == "tool.requested" for e in events)
def test_messages_tool_result(self):
data = ["msg-1", {"type": "tool", "name": "search", "tool_call_id": "tc1", "content": "results...", "status": "completed"}]
events = normalize_stream_event("messages", data, agent_id="a1", turn_index=1)
assert events[0]["event_type"] == "tool.completed"
def test_no_skill_guessing_from_text(self):
data = {"agent_node": {"messages": [{"type": "ai", "content": "I will use the skill to analyze this"}]}}
events = normalize_stream_event("updates", data, agent_id="a1", turn_index=1)
assert not any("skill" in e["event_type"] for e in events)
def test_source_event_id_for_dedup(self):
data = ["msg-1", {"type": "tool", "name": "search", "tool_call_id": "tc1", "content": "ok", "status": "completed"}]
events = normalize_stream_event("messages", data, agent_id="a1", turn_index=1)
assert events[0].get("source_event_id") == "tool:tc1"
def test_unknown_event_ignored(self):
events = normalize_stream_event("checkpoints", {"foo": "bar"}, agent_id="a1", turn_index=0)
assert events == []
# ── sanitization tests ────────────────────────────────────────────────────────
class TestSanitization:
def test_sensitive_keys_redacted(self):
data = {"token": "abc123", "name": "safe", "nested": {"password": "secret", "ok": "yes"}}
result = sanitize(data)
assert result["token"] == "***REDACTED***"
assert result["name"] == "safe"
assert result["nested"]["password"] == "***REDACTED***"
assert result["nested"]["ok"] == "yes"
def test_bearer_token_redacted(self):
data = {"header": "Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig"}
result = sanitize(data)
assert result["header"] == "***REDACTED***"
def test_api_key_pattern_redacted(self):
data = {"key": "sk-abcdefghijklmnopqrstuvwxyz123456"}
result = sanitize(data)
assert result["key"] == "***REDACTED***"
def test_normal_values_untouched(self):
data = {"message": "hello world", "count": 42, "items": ["a", "b"]}
result = sanitize(data)
assert result == data
def test_sanitize_payload_none(self):
assert sanitize_payload(None) is None
def test_event_payload_sanitized(self):
events = normalize_stream_event(
"messages",
["m1", {"type": "ai", "content": "", "tool_calls": [{"name": "api_call", "id": "tc1", "args": {"api_key": "secret123", "url": "http://x"}}]}],
agent_id="a1",
turn_index=1,
)
tool_ev = next(e for e in events if e["event_type"] == "tool.requested")
assert tool_ev["payload"]["args"]["api_key"] == "***REDACTED***"
assert tool_ev["payload"]["args"]["url"] == "http://x"
# ── context_builder tests ─────────────────────────────────────────────────────
class TestContextBuilder:
def test_strict_truncation(self):
result = build_seat_input(
task_text="x" * 20000,
stage_instruction="",
role_description="",
prior_turns=[],
max_context_chars=100,
)
assert len(result) <= 100
def test_no_overflow_after_truncation(self):
result = build_seat_input(
task_text="task",
stage_instruction="stage",
role_description="role",
prior_turns=[{"agent_id": "a", "turn_index": 1, "summary": "y" * 5000}],
fact_ledger={"confirmed_facts": ["f" * 5000]},
max_context_chars=200,
)
assert len(result) <= 200
def test_fact_ledger_sanitized(self):
ledger = {"confirmed_facts": ["server uses token=abc123"]}
result = build_seat_input(
task_text="check",
stage_instruction="",
role_description="",
prior_turns=[],
fact_ledger=ledger,
)
assert "abc123" not in result or "token" not in result.lower()
def test_manager_synthesis(self):
result = build_manager_synthesis_input(
task_text="evaluate",
seat_results=[{"agent_id": "risk", "content": "high risk"}],
)
assert "risk" in result
assert "建议动作" in result
# ── ExecutionPrincipal tests ──────────────────────────────────────────────────
class TestExecutionPrincipal:
def test_requires_user_id(self):
with pytest.raises(ValueError):
ExecutionPrincipal(user_id="", session_id="s1", run_id="r1")
def test_valid_principal(self):
p = ExecutionPrincipal(user_id="u1", session_id="s1", run_id="r1")
assert p.user_id == "u1"
def test_satisfies_current_user_protocol(self):
from app.maf_roundtable.runtime_client import _PrincipalUser
from deerflow.runtime.user_context import CurrentUser
p = ExecutionPrincipal(user_id="test-user", session_id="s", run_id="r")
user = _PrincipalUser(p)
assert isinstance(user, CurrentUser)
assert user.id == "test-user"
# ── adapter unit tests ────────────────────────────────────────────────────────
class TestDeerFlowAgentAdapter:
@pytest.mark.asyncio
async def test_ensure_threads_creates_and_binds(self, store):
svc = _svc(store)
session = await svc.create_session(
user_id="u1",
body={"task_text": "test", "participants": [{"agent_id": "a1"}, {"agent_id": "a2"}]},
)
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
client._app.state.checkpointer.aput = AsyncMock()
client._app.state.thread_store.create = AsyncMock()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
thread_map = await adapter.ensure_threads(session["id"], run["id"], session["participants"])
assert len(thread_map) == 2
assert thread_map["a1"] != thread_map["a2"]
updated = await svc.get_session(session_id=session["id"], user_id="u1")
for p in updated["participants"]:
assert p["thread_id"] is not None
@pytest.mark.asyncio
async def test_ensure_threads_reuses_existing(self, store):
svc = _svc(store)
session = await svc.create_session(
user_id="u1",
body={"task_text": "test", "participants": [{"agent_id": "a1", "thread_id": "existing-t1"}]},
)
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
thread_map = await adapter.ensure_threads(session["id"], run["id"], session["participants"])
assert thread_map["a1"] == "existing-t1"
@pytest.mark.asyncio
async def test_cancel_cascades_via_seat_runs(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
seat_run_id = active[0]["id"]
await svc.store.seat_runs.mark_running(seat_run_id, deerflow_run_id="df-run-1")
client = _mock_client()
client._app.state.run_manager.cancel = AsyncMock()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
result = await adapter.cancel(run["id"])
# Structured cancel result (Task 4): all_cancelled when every active
# sub-run was confirmed cancelled.
assert result["all_cancelled"] is True
assert result["cancelled_run_ids"] == [seat_run_id]
assert result["failed_run_ids"] == []
client._app.state.run_manager.cancel.assert_called_once_with("df-run-1", action="interrupt")
remaining = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
assert len(remaining) == 0
@pytest.mark.asyncio
async def test_critical_event_failure_raises(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
original_append = svc.append_event
async def _failing_append(**kwargs):
raise RuntimeError("DB connection lost")
svc.append_event = _failing_append
with pytest.raises(AuditEventError):
await adapter._emit_critical(run["id"], make_turn_started("a1", 0))
svc.append_event = original_append
@pytest.mark.asyncio
async def test_noncritical_event_degrades_gracefully(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
async def _failing_append(**kwargs):
raise RuntimeError("DB connection lost")
svc.append_event = _failing_append
await adapter._emit(run["id"], {"event_type": "agent.message.delta", "agent_id": "a1", "turn_index": 0, "payload": {"text": "hi"}})
@pytest.mark.asyncio
async def test_seat_response_dataclass(self):
r = SeatResponse(agent_id="a1", turn_index=1, content="hello", tool_calls=[{"name": "search"}])
assert r.agent_id == "a1"
assert r.error is None
@pytest.mark.asyncio
async def test_latest_ai_text_extraction(self, store):
svc = _svc(store)
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
messages = [
{"type": "human", "content": "hello"},
{"type": "ai", "content": "", "tool_calls": [{"name": "search", "args": {}, "id": "1"}]},
{"type": "ai", "content": "The answer is 42."},
]
assert adapter._latest_ai_text(messages) == "The answer is 42."
@pytest.mark.asyncio
async def test_tool_calls_since_last_human(self, store):
svc = _svc(store)
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
messages = [
{"type": "human", "content": "old"},
{"type": "ai", "content": "", "tool_calls": [{"name": "old_tool", "args": {}, "id": "0"}]},
{"type": "human", "content": "new"},
{"type": "ai", "content": "", "tool_calls": [{"name": "search", "args": {"q": "x"}, "id": "1"}]},
]
calls = adapter._tool_calls_since_last_human(messages)
assert len(calls) == 1
assert calls[0]["name"] == "search"
# ── seat_runs persistence tests ───────────────────────────────────────────────
class TestSeatRunPersistence:
@pytest.mark.asyncio
async def test_create_and_lifecycle(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
sr = await svc.store.seat_runs.create_seat_run(
maf_run_id=run["id"], agent_id="expert-a", turn_index=1, deerflow_thread_id="t1"
)
assert sr["status"] == "pending"
await svc.store.seat_runs.mark_running(sr["id"], deerflow_run_id="df-1")
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
assert len(active) == 1
assert active[0]["deerflow_run_id"] == "df-1"
await svc.store.seat_runs.mark_completed(sr["id"], checkpoint_id="cp-1")
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
assert len(active) == 0
@pytest.mark.asyncio
async def test_cancel_all_active(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
sr1 = await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
sr2 = await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a2", turn_index=1)
await svc.store.seat_runs.mark_running(sr1["id"], deerflow_run_id="df-1")
await svc.store.seat_runs.mark_running(sr2["id"], deerflow_run_id="df-2")
cancelled = await svc.store.seat_runs.cancel_all_active(run["id"])
# Two-phase cancel: returns {seat_run_id, deerflow_run_id} dicts and
# leaves rows in 'cancelling' (caller finalizes to cancelled).
assert {c["deerflow_run_id"] for c in cancelled} == {"df-1", "df-2"}
assert {c["seat_run_id"] for c in cancelled} == {sr1["id"], sr2["id"]}
# 'cancelling' is not an active status, so active count drops to 0.
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
assert len(active) == 0
# Rows are mid-cancel, not yet finalized.
all_runs = await svc.store.seat_runs.get_by_maf_run(run["id"])
assert {r["status"] for r in all_runs} == {"cancelling"}
@pytest.mark.asyncio
async def test_unique_constraint(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
with pytest.raises(Exception):
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
# ── event flow integration tests ──────────────────────────────────────────────
class TestAdapterEventFlow:
@pytest.mark.asyncio
async def test_full_event_sequence_persisted(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "analyze"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
await adapter._emit_critical(run["id"], make_turn_started("expert", 1))
await adapter._emit(run["id"], {"event_type": "tool.requested", "agent_id": "expert", "turn_index": 1, "payload": {"tool_name": "search"}})
await adapter._emit(run["id"], {"event_type": "tool.completed", "agent_id": "expert", "turn_index": 1, "payload": {"tool_name": "search", "status": "succeeded"}})
await adapter._emit_critical(run["id"], make_turn_completed("expert", 1, content="done"))
await adapter._emit_critical(run["id"], make_run_completed("expert", 1))
events, latest = await svc.list_events(run_id=run["id"], user_id="u1")
assert len(events) == 5
assert latest == 5
types = [e["event_type"] for e in events]
assert types == ["agent.turn_started", "tool.requested", "tool.completed", "agent.message.completed", "run.completed"]
@pytest.mark.asyncio
async def test_dedup_via_source_event_id(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
ev = {"event_type": "tool.completed", "agent_id": "a1", "turn_index": 1, "payload": {"tool_name": "x"}, "source_event_id": "tool:tc1"}
await adapter._emit(run["id"], ev)
await adapter._emit(run["id"], ev)
events, _ = await svc.list_events(run_id=run["id"], user_id="u1")
assert len(events) == 1
@@ -0,0 +1,669 @@
"""P0-1 acceptance tests for the harness approved-tool executor.
These exercise ``deerflow.tools.approved_tool_executor.execute_approved_tool``
directly on the real LangGraph ToolNode / ToolRuntime / middleware path with
controlled tools (no LLM, no DeerFlow runtime).
"""
from __future__ import annotations
from typing import Any
import pytest
from langchain_core.tools import StructuredTool
from langgraph.prebuilt import ToolRuntime
from deerflow.agents.middlewares.tool_error_handling_middleware import (
build_lead_runtime_middlewares,
)
from deerflow.agents.middlewares.write_guard_middleware import (
WriteGuardMiddleware,
)
from deerflow.config.write_guard_config import WriteGuardConfig
from deerflow.tools import approved_tool_executor
from deerflow.tools.approved_tool_executor import execute_approved_tool
# ?? fakes ?????????????????????????????????????????????????????????????????
class _FakeCheckpointTuple:
def __init__(self, channel_values: dict[str, Any]) -> None:
self.checkpoint = {"channel_values": channel_values}
class _FakeCheckpointer:
def __init__(self, channel_values: dict[str, Any]) -> None:
self._cv = channel_values
async def aget_tuple(self, config: dict[str, Any]) -> _FakeCheckpointTuple:
return _FakeCheckpointTuple(self._cv)
CHANNEL_VALUES: dict[str, Any] = {
"thread_data": {"marker": "THREAD_MARKER_42"},
"sandbox": {"sandbox_id": "local"},
"messages": [],
}
@pytest.fixture
def app_config():
from deerflow.config import get_app_config
return get_app_config()
def _snapshot(action_tool: str) -> dict[str, Any]:
meta = {"name": action_tool, "tool_name": action_tool, "side_effect": "write", "risk_level": "R3"}
return {
"version": 1,
"agents": [
{
"agent_id": "a1",
"allowed_tool_names": [action_tool],
"excluded_tools": [],
"tools": [meta],
}
],
"tools": [meta],
}
def _ctx(action_tool: str = "sync_write") -> dict[str, Any]:
"""Common trusted execution context + matching action record."""
return {
"thread_id": "thread-1",
"user_id": "u1",
"session_id": "s1",
"run_id": "r1",
"agent_id": "a1",
"turn_index": 2,
"model_name": None,
"excluded_tools": [],
"capability_snapshot": _snapshot(action_tool),
"write_policy": "confirm_high_risk",
"approved_action_id": "action-1",
"action_record": {
"id": "action-1",
"status": "running",
"run_id": "r1",
"session_id": "s1",
"agent_id": "a1",
"tool_name": action_tool,
"turn_index": 2,
},
}
# ?? 1. sync StructuredTool.func runs once ??succeeded ?????????????????????
@pytest.mark.asyncio
async def test_sync_tool_runs_once_succeeded(app_config, monkeypatch):
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return f"sync-ok:{payload}"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("sync_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="sync_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "succeeded", result
assert calls["n"] == 1, "sync tool must run exactly once"
# ?? 2. async tool runs normally ???????????????????????????????????????????
@pytest.mark.asyncio
async def test_async_tool_runs(app_config, monkeypatch):
calls = {"n": 0}
async def _fn(payload: str) -> str:
calls["n"] += 1
return f"async-ok:{payload}"
tool = StructuredTool.from_function(coroutine=_fn, name="async_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("async_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="async_write", params={"payload": "Y"}, **ctx,
)
assert result["status"] == "succeeded", result
assert calls["n"] == 1
# ?? 3. tool needing ToolRuntime gets the REAL runtime + checkpoint state ??
@pytest.mark.asyncio
async def test_tool_receives_real_tool_runtime_and_state(app_config, monkeypatch):
seen: dict[str, Any] = {}
def _fn(payload: str, runtime: ToolRuntime[dict[str, Any], dict[str, Any]]) -> str:
# Typed ToolRuntime context avoids PydanticSerializationUnexpectedValue
# (bare ToolRuntime defaults context to None).
seen["runtime_type"] = type(runtime).__name__
seen["is_real_toolruntime"] = isinstance(runtime, ToolRuntime)
seen["marker"] = runtime.state.get("thread_data", {}).get("marker")
seen["context_run_id"] = runtime.context.get("maf_run_id")
seen["approved_tool"] = runtime.context.get("maf_approved_tool")
seen["context_action_id"] = runtime.context.get("maf_approved_action_id")
return "rt-ok"
tool = StructuredTool.from_function(func=_fn, name="rt_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("rt_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="rt_write", params={"payload": "Z"}, **ctx,
)
assert result["status"] == "succeeded", result
# A real ToolRuntime, not a duck-typed SimpleNamespace.
assert seen["is_real_toolruntime"] is True
assert seen["runtime_type"] != "SimpleNamespace"
# Real checkpoint state flowed through (sandbox / thread_data recovered).
assert seen["marker"] == "THREAD_MARKER_42"
# Trusted MAF context injected by the server (via Runtime on __pregel_runtime).
assert seen["context_run_id"] == "r1"
assert seen["approved_tool"] == "rt_write"
assert seen["context_action_id"] == "action-1"
# ?? 4. unauthorized / disabled / nonexistent ??0 executions ???????????????
@pytest.mark.asyncio
async def test_nonexistent_tool_executes_zero_times(app_config, monkeypatch):
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return "ok"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("ghost_tool") # action says ghost_tool; not in the allow-list
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="ghost_tool", params={"payload": "X"}, **ctx,
)
assert result["status"] == "failed"
assert calls["n"] == 0
@pytest.mark.asyncio
async def test_disabled_tool_executes_zero_times(app_config, monkeypatch):
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return "ok"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("sync_write")
ctx["capability_snapshot"]["agents"][0]["excluded_tools"] = ["sync_write"]
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="sync_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "failed"
assert calls["n"] == 0
@pytest.mark.asyncio
async def test_context_mismatch_executes_zero_times(app_config, monkeypatch):
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return "ok"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("sync_write")
# The stored action belongs to a DIFFERENT run ??must be refused.
ctx["action_record"] = {
"id": "action-1",
"status": "running",
"run_id": "OTHER_RUN",
"session_id": "s1",
"agent_id": "a1",
"tool_name": "sync_write",
"turn_index": 2,
}
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="sync_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "failed"
assert calls["n"] == 0
# ?? 5. LLM is never involved ??????????????????????????????????????????????
@pytest.mark.asyncio
async def test_llm_never_involved(app_config, monkeypatch):
llm_calls = {"n": 0}
def _boom(*args: Any, **kwargs: Any) -> Any:
llm_calls["n"] += 1
raise AssertionError("model factory must never be called by the approved executor")
# Guard every plausible model-construction entry point.
import deerflow.models.factory as factory
monkeypatch.setattr(factory, "create_chat_model", _boom)
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return "ok"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("sync_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="sync_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "succeeded", result
assert calls["n"] == 1
assert llm_calls["n"] == 0, "the LLM must never be involved"
# ?? 6. inconclusive external errors ??unknown (no auto-retry) ???????????????
@pytest.mark.asyncio
async def test_timeout_error_records_unknown(app_config, monkeypatch):
"""P1c: a timeout (external result unknowable) is recorded ``unknown`` ?? never ``failed`` and never auto-retried."""
async def _fn(payload: str) -> str:
raise TimeoutError("upstream timed out")
tool = StructuredTool.from_function(coroutine=_fn, name="timeout_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("timeout_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="timeout_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "unknown", result
@pytest.mark.asyncio
async def test_connection_error_records_unknown(app_config, monkeypatch):
"""P1c: a dropped/reset connection is likewise inconclusive ??``unknown``."""
async def _fn(payload: str) -> str:
raise ConnectionResetError("connection reset by peer")
tool = StructuredTool.from_function(coroutine=_fn, name="conn_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("conn_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="conn_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "unknown", result
@pytest.mark.asyncio
async def test_ordinary_error_records_failed(app_config, monkeypatch):
"""P1c: a deterministic validation error (external write definitely did NOT
happen) is ``failed``, not ``unknown``."""
async def _fn(payload: str) -> str:
raise ValueError("invalid payload value")
tool = StructuredTool.from_function(coroutine=_fn, name="bad_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("bad_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="bad_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "failed", result
assert result.get("error") == "tool_execution_failed"
@pytest.mark.asyncio
async def test_tool_error_never_leaks_secrets(app_config, monkeypatch, caplog):
secret = "TOP_SECRET_123"
def _fn(payload: str) -> str:
raise ValueError(f"token={secret}")
tool = StructuredTool.from_function(func=_fn, name="bad_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("bad_write")
with caplog.at_level("ERROR"):
result = await execute_approved_tool(
app_config=app_config,
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
store=None,
tool_name="bad_write",
params={"payload": "X"},
**ctx,
)
assert result["status"] == "failed"
assert secret not in str(result)
assert secret not in caplog.text
@pytest.mark.asyncio
async def test_frozen_allow_list_blocks_extra_registry_tool(app_config, monkeypatch):
calls = {"n": 0}
def _allowed(payload: str) -> str:
calls["n"] += 1
return "ok"
def _blocked(payload: str) -> str:
calls["n"] += 1
return "no"
allowed = StructuredTool.from_function(func=_allowed, name="tool_a", description="d")
blocked = StructuredTool.from_function(func=_blocked, name="tool_b", description="d")
monkeypatch.setattr(
approved_tool_executor, "get_available_tools", lambda **kw: [allowed, blocked]
)
snap = _snapshot("tool_a")
snap["agents"][0]["allowed_tool_names"] = ["tool_a"]
ctx = _ctx("tool_b")
ctx["capability_snapshot"] = snap
ctx["action_record"]["tool_name"] = "tool_b"
ctx["approved_action_id"] = "action-1"
result = await execute_approved_tool(
app_config=app_config,
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
store=None,
tool_name="tool_b",
params={"payload": "X"},
**ctx,
)
assert result["status"] == "failed"
assert calls["n"] == 0
@pytest.mark.asyncio
async def test_action_not_running_executes_zero_times(app_config, monkeypatch):
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return "ok"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("sync_write")
ctx["action_record"]["status"] = "approved"
result = await execute_approved_tool(
app_config=app_config,
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
store=None,
tool_name="sync_write",
params={"payload": "X"},
**ctx,
)
assert result["status"] == "failed"
assert calls["n"] == 0
def _enabled_write_guard_app_config(app_config):
enabled_guard = WriteGuardConfig.model_validate(
{
"enabled": True,
"provider": {
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
"config": {},
},
}
)
return app_config.model_copy(update={"write_guard": enabled_guard})
async def _writeguard_store_fixture(tmp_path, monkeypatch):
"""Create a real MAF store + service with WriteGuard bridge registered.
Uses monkeypatch so ``write_guard_bridge._maf_service`` is restored when the
test ends (avoids leaking a disposed sqlite service into later tests).
"""
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import deerflow.persistence.maf_roundtable.model # noqa: F401
from app.maf_roundtable import write_guard_bridge
from app.maf_roundtable.write_guard_bridge import register_write_guard_service
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wg.db'}")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
svc = make_maf_service(store)
# Capture the pre-test service *before* registration. Registering first
# would make monkeypatch restore this disposable sqlite-backed service at
# teardown and leak it into the next test.
monkeypatch.setattr(write_guard_bridge, "_maf_service", None)
register_write_guard_service(svc)
return engine, store, svc
@pytest.mark.asyncio
async def test_approved_tool_executor_with_writeguard_enabled(app_config, monkeypatch, tmp_path):
"""P1: real middleware chain with WriteGuard enabled passes approved execution."""
from app.maf_roundtable.write_guard_bridge import MafWriteGuardInterceptor
enabled_app_config = _enabled_write_guard_app_config(app_config)
middlewares = build_lead_runtime_middlewares(app_config=enabled_app_config)
assert any(isinstance(item, WriteGuardMiddleware) for item in middlewares)
engine, store, svc = await _writeguard_store_fixture(tmp_path, monkeypatch)
validate_spy_calls = {"n": 0}
original_validate = MafWriteGuardInterceptor.validate_approved_action
async def _spy_validate(self, *, tool_name: str, run_context: dict) -> bool:
validate_spy_calls["n"] += 1
return await original_validate(self, tool_name=tool_name, run_context=run_context)
monkeypatch.setattr(MafWriteGuardInterceptor, "validate_approved_action", _spy_validate)
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
action = await store.actions.create_action(
{
"session_id": session["id"],
"run_id": run["id"],
"agent_id": "a1",
"tool_name": "sync_write",
"idempotency_key": "wg-test-1",
"risk_level": "R3",
"status": "running",
"turn_index": 2,
}
)
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return "wg-ok"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("sync_write")
ctx["session_id"] = session["id"]
ctx["run_id"] = run["id"]
ctx["approved_action_id"] = action["id"]
ctx["action_record"] = {
"id": action["id"],
"status": "running",
"run_id": run["id"],
"session_id": session["id"],
"agent_id": "a1",
"tool_name": "sync_write",
"turn_index": 2,
}
try:
result = await execute_approved_tool(
app_config=enabled_app_config,
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
store=None,
tool_name="sync_write",
params={"payload": "X"},
**ctx,
)
finally:
await engine.dispose()
assert result["status"] == "succeeded", result
assert calls["n"] == 1
assert validate_spy_calls["n"] == 1
assert result["result"] == {"executed": True}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"db_mismatch",
[
"status",
"run_id",
"session_id",
"agent_id",
"tool_name",
],
)
async def test_writeguard_rejects_db_mismatched_action(
app_config, monkeypatch, tmp_path, db_mismatch
):
"""Second-line defense: Guard re-reads the DB action and rejects mismatches.
``action_record`` passed to the executor stays consistent so the first-layer
field check passes; only the DB row is wrong.
"""
from app.maf_roundtable.write_guard_bridge import MafWriteGuardInterceptor
enabled_app_config = _enabled_write_guard_app_config(app_config)
middlewares = build_lead_runtime_middlewares(app_config=enabled_app_config)
assert any(isinstance(item, WriteGuardMiddleware) for item in middlewares)
engine, store, svc = await _writeguard_store_fixture(tmp_path, monkeypatch)
validate_spy_calls = {"n": 0}
original_validate = MafWriteGuardInterceptor.validate_approved_action
async def _spy_validate(self, *, tool_name: str, run_context: dict) -> bool:
validate_spy_calls["n"] += 1
return await original_validate(self, tool_name=tool_name, run_context=run_context)
monkeypatch.setattr(MafWriteGuardInterceptor, "validate_approved_action", _spy_validate)
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
other = await svc.create_session(user_id="u1", body={"task_text": "other"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
db_fields = {
"session_id": session["id"],
"run_id": run["id"],
"agent_id": "a1",
"tool_name": "sync_write",
"idempotency_key": f"wg-mismatch-{db_mismatch}",
"risk_level": "R3",
"status": "running",
"turn_index": 2,
}
if db_mismatch == "status":
db_fields["status"] = "approved"
elif db_mismatch == "run_id":
# No second create_run (session allows only one active run) ? fake id is enough.
db_fields["run_id"] = "other-run-id"
elif db_mismatch == "session_id":
db_fields["session_id"] = other["id"]
elif db_mismatch == "agent_id":
db_fields["agent_id"] = "other-agent"
elif db_mismatch == "tool_name":
db_fields["tool_name"] = "other_tool"
action = await store.actions.create_action(db_fields)
tool_calls = {"n": 0}
def _fn(payload: str) -> str:
tool_calls["n"] += 1
return "should-not-run"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
# Executor-facing record looks correct ? Guard must catch the DB mismatch.
ctx = _ctx("sync_write")
ctx["session_id"] = session["id"]
ctx["run_id"] = run["id"]
ctx["approved_action_id"] = action["id"]
ctx["action_record"] = {
"id": action["id"],
"status": "running",
"run_id": run["id"],
"session_id": session["id"],
"agent_id": "a1",
"tool_name": "sync_write",
"turn_index": 2,
}
try:
result = await execute_approved_tool(
app_config=enabled_app_config,
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
store=None,
tool_name="sync_write",
params={"payload": "X"},
**ctx,
)
finally:
await engine.dispose()
assert tool_calls["n"] == 0
assert validate_spy_calls["n"] == 1
assert result["status"] == "failed"
@@ -0,0 +1,110 @@
"""Acceptance tests for the structured cancel cascade (Task 4).
Verifies that when DeerFlow cancel fails, the parent run is NOT marked
``cancelled`` —it stays non-terminal (``cancel_failed``) so the failure is
visible and retryable. A failed cancel must never masquerade as cancelled.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.deerflow_agent_adapter import DeerFlowAgentAdapter
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import Base
@pytest_asyncio.fixture
async def store():
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
s = make_maf_roundtable_store(sf)
assert s is not None
yield s
await engine.dispose()
@pytest.fixture
def svc(store):
from maf_test_helpers import make_maf_service
return make_maf_service(store, enabled=True, admin_diagnostic_access=True)
def _mock_client(*, cancel_succeeds: bool) -> MagicMock:
client = MagicMock()
client._app.state.run_manager.cancel = AsyncMock(return_value=None)
client.cancel_run = AsyncMock(return_value=cancel_succeeds)
return client
@pytest.mark.asyncio
async def test_cancel_all_succeeds_marks_cancelled(svc, store):
"""When every sub-run cancels, all_cancelled is True."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
await svc.store.seat_runs.mark_running(active[0]["id"], deerflow_run_id="df-1")
adapter = DeerFlowAgentAdapter(_mock_client(cancel_succeeds=True), service=svc, user_id="u1")
result = await adapter.cancel(run["id"])
assert result["all_cancelled"] is True
assert result["failed_run_ids"] == []
assert result["cancelled_run_ids"] == [active[0]["id"]]
@pytest.mark.asyncio
async def test_cancel_failure_keeps_run_nonterminal(svc, store):
"""When DeerFlow cancel fails, the parent run must NOT be marked cancelled.
The adapter returns all_cancelled=False; the router would then mark the
run ``cancel_failed`` (non-terminal). This test verifies the adapter-level
signal that drives that decision.
"""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
await svc.store.seat_runs.mark_running(active[0]["id"], deerflow_run_id="df-2")
adapter = DeerFlowAgentAdapter(_mock_client(cancel_succeeds=False), service=svc, user_id="u1")
result = await adapter.cancel(run["id"])
assert result["all_cancelled"] is False
assert result["failed_run_ids"] == [active[0]["id"]]
assert result["cancelled_run_ids"] == []
@pytest.mark.asyncio
async def test_mark_cancel_failed_service_method(svc, store):
"""The service ``mark_cancel_failed`` transitions a cancelling run to
``cancel_failed`` (non-terminal), recording a sanitized reason."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
# Put the run into cancelling via start_cancel.
await svc.start_cancel(run_id=run["id"], user_id="u1")
updated = await svc.mark_cancel_failed(
run_id=run["id"], user_id="u1", reason="1 sub-run failed to cancel"
)
assert updated["status"] == "cancel_failed"
assert updated["error_code"] == "CancelCascadeIncomplete"
assert "failed to cancel" in updated["error_message_safe"]
@pytest.mark.asyncio
async def test_cancel_no_active_runs_is_all_cancelled(svc, store):
"""With no active seat runs, cancel is trivially all_cancelled (nothing to do)."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
adapter = DeerFlowAgentAdapter(_mock_client(cancel_succeeds=True), service=svc, user_id="u1")
result = await adapter.cancel(run["id"])
assert result["all_cancelled"] is True
assert result["cancelled_run_ids"] == []
@@ -0,0 +1,88 @@
"""P0-1: trusted capability snapshot builder tests."""
from __future__ import annotations
import pytest
from maf_test_helpers import FakeAgentStore
from app.maf_roundtable.capability_snapshot import (
AgentNotAccessibleError,
build_trusted_capability_snapshot,
)
from deerflow.config import get_app_config
from deerflow.tools.tool_metadata import resolve_tool_metadata
@pytest.mark.asyncio
async def test_client_forged_side_effect_not_stored(monkeypatch):
app_config = get_app_config()
def _fake_build_seat(*, app_config, agent_id, session_model_name, session_reasoning_effort, requested_excluded):
meta = resolve_tool_metadata("write_file")
return (["write_file"], [], [meta])
monkeypatch.setattr(
"app.maf_roundtable.capability_snapshot._seat_tools_for_agent",
_fake_build_seat,
)
snap = await build_trusted_capability_snapshot(
user_id="u1",
participants=[
{
"agent_id": "seat-a",
"role": "seat",
"excluded_tools": [],
# Client forgery ??must be ignored (field not read).
"capability_snapshot": {
"tools": [{"name": "write_file", "side_effect": "read", "risk_level": "R0"}],
},
}
],
app_config=app_config,
agent_store=FakeAgentStore(),
)
tool = snap["agents"][0]["tools"][0]
assert tool["side_effect"] == "write"
assert tool["risk_level"] == "R3"
@pytest.mark.asyncio
async def test_frozen_allow_list_excludes_registry_only_tool(monkeypatch):
app_config = get_app_config()
def _fake_build_seat(*, app_config, agent_id, session_model_name, session_reasoning_effort, requested_excluded):
return (["tool_a"], [], [resolve_tool_metadata("tool_a")])
monkeypatch.setattr(
"app.maf_roundtable.capability_snapshot._seat_tools_for_agent",
_fake_build_seat,
)
snap = await build_trusted_capability_snapshot(
user_id="u1",
participants=[{"agent_id": "seat-a", "role": "seat"}],
app_config=app_config,
agent_store=FakeAgentStore(),
)
assert snap["agents"][0]["allowed_tool_names"] == ["tool_a"]
assert "tool_b" not in snap["agents"][0]["allowed_tool_names"]
@pytest.mark.asyncio
async def test_inaccessible_agent_rejected():
app_config = get_app_config()
class _DenyStore:
async def get_visible(self, agent_id: str, user_id: str):
return None
with pytest.raises(AgentNotAccessibleError):
await build_trusted_capability_snapshot(
user_id="u1",
participants=[{"agent_id": "missing-agent", "role": "seat"}],
app_config=app_config,
agent_store=_DenyStore(),
)
@@ -0,0 +1,231 @@
"""Acceptance tests for the Phase-2 migration script (Task 5).
Verifies:
1. The migration creates ``maf_roundtable_seat_runs`` + ``source_event_id``
column + the unique index + ``maf_roundtable_action_secrets`` on SQLite.
2. Running the migration twice is idempotent (no error, no duplicate objects).
3. Phase-1 data (an existing event row) remains readable after migration.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
import pytest
from sqlalchemy import inspect as sa_inspect
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import Base
def _load_migration_module():
"""Load scripts/migrate_maf_roundtable_phase2.py as a module."""
here = Path(__file__).resolve().parent.parent
script = here / "scripts" / "migrate_maf_roundtable_phase2.py"
spec = importlib.util.spec_from_file_location("migrate_maf_roundtable_phase2", script)
assert spec and spec.loader, "could not load migration script"
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.mark.asyncio
async def test_migration_creates_phase2_objects_and_is_idempotent():
"""Run the migration on a fresh Phase-1 schema, then re-run it; both must
succeed and produce the expected Phase-2 objects."""
mod = _load_migration_module()
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
# Build the Phase-1 schema (everything except the Phase-2 additions).
# We create the base tables the migration expects to already exist.
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Drop the Phase-2 tables if create_all created them, so we test creation.
await conn.execute(text("DROP TABLE IF EXISTS maf_roundtable_seat_runs"))
await conn.execute(text("DROP TABLE IF EXISTS maf_roundtable_action_secrets"))
# Seed a Phase-1 event row so we can verify it survives migration.
# Use the ORM (not raw SQL) so column Python-defaults apply.
from datetime import datetime
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableEventRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
sf = async_sessionmaker(engine, expire_on_commit=False)
now = datetime(2026, 1, 1)
async with sf() as session:
session.add(MafRoundtableSessionRow(
id="s1", user_id="u1", title="t", task_text="t",
mode="structured", status="draft", write_policy="confirm_high_risk",
max_rounds=8, created_at=now, updated_at=now,
))
session.add(MafRoundtableRunRow(
id="r1", session_id="s1", status="pending",
created_at=now, updated_at=now,
))
session.add(MafRoundtableEventRow(
id="e1", session_id="s1", run_id="r1", seq=1,
event_type="run.started", created_at=now,
))
await session.commit()
# Run the migration's sync step directly on a connection (dry_run=False).
async with engine.begin() as conn:
await conn.run_sync(mod._do_migrate_sync, dry_run=False, logger=mod.logger, changes_ref=[0])
# Verify all Phase-2 objects exist.
def _check(sync_conn):
insp = sa_inspect(sync_conn)
tables = set(insp.get_table_names())
assert "maf_roundtable_seat_runs" in tables
assert "maf_roundtable_action_secrets" in tables
event_cols = {c["name"] for c in insp.get_columns("maf_roundtable_events")}
assert "source_event_id" in event_cols
# Unique indexes.
idx_names: set[str] = set()
for t in insp.get_table_names():
for idx in insp.get_indexes(t):
idx_names.add(idx.get("name"))
assert "uq_maf_events_run_source" in idx_names
assert "uq_maf_seat_runs_run_agent_turn" in idx_names
assert "uq_maf_action_secrets_action" in idx_names
async with engine.begin() as conn:
await conn.run_sync(_check)
# Phase-1 data still readable + the new column is nullable.
async with sf() as session:
rows = (await session.execute(text("SELECT id, source_event_id FROM maf_roundtable_events"))).all()
assert len(rows) == 1
assert rows[0][0] == "e1"
assert rows[0][1] is None # old row keeps NULL
# Re-run the migration ??must be idempotent (no error).
async with engine.begin() as conn:
await conn.run_sync(mod._do_migrate_sync, dry_run=False, logger=mod.logger, changes_ref=[0])
# Dry-run must also not throw and must not write.
async with engine.begin() as conn:
await conn.run_sync(mod._do_migrate_sync, dry_run=True, logger=mod.logger, changes_ref=[0])
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_adds_turn_index_to_legacy_actions_table():
"""Phase-1 actions table without turn_index is upgraded idempotently."""
mod = _load_migration_module()
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.execute(
text(
"""
CREATE TABLE maf_roundtable_actions (
id VARCHAR(64) NOT NULL PRIMARY KEY,
session_id VARCHAR(64) NOT NULL,
run_id VARCHAR(64),
agent_id VARCHAR(64),
tool_name VARCHAR(128) NOT NULL,
logical_action_id VARCHAR(128),
idempotency_key VARCHAR(191) NOT NULL UNIQUE,
target_key VARCHAR(255),
risk_level VARCHAR(8) NOT NULL,
status VARCHAR(32) NOT NULL,
arguments_redacted_json TEXT,
result_redacted_json TEXT,
external_object_id VARCHAR(255),
approval_id VARCHAR(64),
attempt_count INTEGER NOT NULL,
started_at DATETIME,
finished_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)
"""
)
)
async with engine.begin() as conn:
await conn.run_sync(mod._do_migrate_sync, dry_run=False, logger=mod.logger, changes_ref=[0])
def _has_turn_index(sync_conn):
insp = sa_inspect(sync_conn)
cols = {c["name"] for c in insp.get_columns("maf_roundtable_actions")}
assert "turn_index" in cols
async with engine.begin() as conn:
await conn.run_sync(_has_turn_index)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
assert store is not None
from datetime import datetime
from deerflow.persistence.maf_roundtable.model import MafRoundtableSessionRow
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all, tables=[MafRoundtableSessionRow.__table__])
now = datetime(2026, 1, 1)
async with sf() as session:
session.add(
MafRoundtableSessionRow(
id="s1",
user_id="u1",
title="t",
task_text="t",
mode="structured",
status="draft",
write_policy="confirm_high_risk",
max_rounds=8,
created_at=now,
updated_at=now,
)
)
await session.commit()
row = await store.actions.create_action(
{
"session_id": "s1",
"tool_name": "write_file",
"idempotency_key": "legacy-1",
"turn_index": 1,
"risk_level": "R3",
"status": "pending",
}
)
assert row["turn_index"] == 1
async with engine.begin() as conn:
await conn.run_sync(mod._do_migrate_sync, dry_run=False, logger=mod.logger, changes_ref=[0])
await engine.dispose()
def test_ddl_add_action_turn_index_by_dialect():
mod = _load_migration_module()
assert "INTEGER NULL" in mod._ddl_add_action_turn_index("sqlite")
assert "INTEGER NULL" in mod._ddl_add_action_turn_index("postgresql")
mysql = mod._ddl_add_action_turn_index("mysql")
assert "INT NULL" in mysql
assert "INTEGER" not in mysql.replace("INT NULL", "")
def test_ddl_lists_are_single_statement():
"""The DDL constants are lists of single SQL statements (no ``;`` joining)."""
mod = _load_migration_module()
for stmt in mod._DDL_SEAT_RUNS + mod._DDL_ACTION_SECRETS:
# Each statement must not contain a statement separator mid-string
# (trailing/leading whitespace stripped). A CREATE TABLE followed by
# a semicolon + another CREATE is the bug we fixed.
stripped = stmt.strip().rstrip(";").strip()
assert stripped.count(";") == 0, f"DDL statement contains ';': {stmt[:80]!r}"
# The events index is a single statement string.
assert mod._DDL_EVENTS_SOURCE_INDEX.count(";") <= 1
@@ -0,0 +1,188 @@
"""Regression test: legacy single-agent runs are unaffected by WriteGuard.
The WriteGuard middleware is registered unconditionally (when configured) but
must be a pure pass-through when the run context has no ``maf_run_id`` flag.
This test proves a write tool call in a legacy run executes normally (0
interceptions, real handler invoked), so the new middleware introduces zero
behaviour change for non-MAF runs.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import MagicMock
import pytest
from deerflow.agents.middlewares.write_guard_middleware import WriteGuardMiddleware
class _StubPolicy:
def should_intercept(self, tool_name: str, args: Any, run_context: Any = None) -> bool: # noqa: ANN401
return True # would intercept — but only if active
class _StubInterceptor:
async def intercept(self, *, tool_name: str, args: Any, tool_call_id: str, run_context: dict) -> dict: # noqa: ANN401
raise AssertionError("interceptor must NOT be called in a legacy run")
async def validate_approved_action(self, *, tool_name: str, run_context: dict) -> bool:
# Legacy stub: no real store. The approved-passthrough tests supply
# their own validating interceptor; this default fails closed.
return False
class _ValidatingStubInterceptor:
"""Approved-passthrough interceptor whose action validation is configurable."""
def __init__(self, *, valid: bool) -> None:
self._valid = valid
self.validated_with: dict | None = None
async def intercept(self, *, tool_name: str, args: Any, tool_call_id: str, run_context: dict) -> dict: # noqa: ANN401
return {"status": "approval_required", "action_id": "a?", "tool_name": tool_name}
async def validate_approved_action(self, *, tool_name: str, run_context: dict) -> bool:
self.validated_with = {"tool_name": tool_name, "run_context": run_context}
return self._valid
def _request(run_context: dict | None = None):
runtime = MagicMock()
runtime.context = run_context or {}
req = MagicMock()
req.tool_call = {"name": "write_anything", "args": {"x": 1}, "id": "c1"}
req.runtime = runtime
return req
@pytest.mark.asyncio
async def test_legacy_run_write_tool_not_intercepted():
"""No ``maf_run_id`` → middleware passes through, real handler runs."""
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=_StubInterceptor())
called = {"n": 0}
async def handler(request):
called["n"] += 1
return MagicMock(content="ok")
# No maf_run_id in context = legacy run.
result = await mw.awrap_tool_call(_request({}), handler)
assert called["n"] == 1
assert result.content == "ok"
@pytest.mark.asyncio
async def test_maf_run_write_tool_intercepted():
"""With ``maf_run_id`` set, the same policy DOES intercept (sanity check)."""
called = {"n": 0}
async def handler(request):
called["n"] += 1
return MagicMock(content="ok")
# Active MAF run → interceptor would be called, but ours raises to prove it.
# Use a real interceptor here to avoid the assertion.
class _Real:
async def intercept(self, *, tool_name, args, tool_call_id, run_context):
return {"status": "approval_required", "action_id": "a1", "approval_id": "p1"}
mw2 = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=_Real())
result = await mw2.awrap_tool_call(_request({"maf_run_id": "r1"}), handler)
assert called["n"] == 0 # handler NOT called
envelope = json.loads(result.content)
assert envelope["status"] == "approval_required"
@pytest.mark.asyncio
async def test_approved_passthrough_executes():
"""An approved-tool run (maf_approved_tool + maf_approved_action_id bound and
the action validates) lets that one tool through (P0-4 action-id security)."""
interceptor = _ValidatingStubInterceptor(valid=True)
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=interceptor)
called = {"n": 0}
async def handler(request):
called["n"] += 1
return MagicMock(content="approved-exec")
ctx = {
"maf_run_id": "r1",
"maf_session_id": "s1",
"agent_id": "seat-1",
"maf_approved_tool": "write_anything",
"maf_approved_action_id": "action-1",
}
result = await mw.awrap_tool_call(_request(ctx), handler)
assert called["n"] == 1
assert result.content == "approved-exec"
# The async path actually validated the bound action (not just field presence).
assert interceptor.validated_with is not None
assert interceptor.validated_with["tool_name"] == "write_anything"
@pytest.mark.asyncio
async def test_approved_passthrough_fails_closed_when_validation_false():
"""P0-4: if the bound action does NOT validate (status/ownership/tool mismatch),
the approved passthrough fails closed — the tool does not run."""
interceptor = _ValidatingStubInterceptor(valid=False)
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=interceptor)
called = {"n": 0}
async def handler(request):
called["n"] += 1
return MagicMock(content="should-not-run")
ctx = {
"maf_run_id": "r1",
"maf_approved_tool": "write_anything",
"maf_approved_action_id": "action-bad",
}
result = await mw.awrap_tool_call(_request(ctx), handler)
assert called["n"] == 0, "invalid approved action must fail closed"
envelope = json.loads(result.content)
assert envelope["status"] == "approval_invalid"
@pytest.mark.asyncio
async def test_approved_passthrough_requires_action_id():
"""P0-4: maf_approved_tool WITHOUT maf_approved_action_id no longer passes
through — action-id binding is mandatory."""
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=_ValidatingStubInterceptor(valid=True))
called = {"n": 0}
async def handler(request):
called["n"] += 1
return MagicMock(content="ok")
ctx = {"maf_run_id": "r1", "maf_approved_tool": "write_anything"} # no action id
result = await mw.awrap_tool_call(_request(ctx), handler)
assert called["n"] == 0
envelope = json.loads(result.content)
assert envelope["status"] in ("approval_required", "intercept_failed")
@pytest.mark.asyncio
async def test_approved_passthrough_only_for_named_tool():
"""When maf_approved_tool is set but the call is a DIFFERENT tool, it is
still intercepted (only the approved tool passes through)."""
class _Real:
async def intercept(self, *, tool_name, args, tool_call_id, run_context):
return {"status": "approval_required", "action_id": "a2"}
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=_Real())
called = {"n": 0}
async def handler(request):
called["n"] += 1
return MagicMock(content="ok")
ctx = {"maf_run_id": "r1", "maf_approved_tool": "update_task"}
# The request is for a different tool → not allowed through.
req = _request(ctx)
req.tool_call = {"name": "delete_thing", "args": {}, "id": "c2"}
result = await mw.awrap_tool_call(req, handler)
assert called["n"] == 0 # intercepted, not executed
envelope = json.loads(result.content)
assert envelope["status"] == "approval_required"
File diff suppressed because it is too large. Load diff
@@ -1,14 +1,14 @@
"""P1 admin write-boundary + P2 config-default tests for the MAF roundtable.
P1 — admin diagnostic access is **read-only**:
P1 —admin diagnostic access is **read-only**:
- An admin with ``admin_diagnostic_access=True`` can *read* another user's
session / run / action / approval (cross-user diagnostics).
- The same admin **cannot** *write* another user's resources: update / delete
session, start / cancel run, append event, decide approval all raise
``MafForbiddenError`` (→ HTTP 403), never silently succeeding.
``MafForbiddenError`` (→HTTP 403), never silently succeeding.
- Normal users remain fully isolated from each other.
P2 — config-driven create defaults:
P2 —config-driven create defaults:
- Omitted ``mode`` / ``write_policy`` / ``max_rounds`` fall back to the deployed
``MafRoundtableConfig`` values.
- An explicit request value overrides the config default.
@@ -18,13 +18,10 @@ from __future__ import annotations
import pytest
import pytest_asyncio
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.service import (
MafForbiddenError,
MafNotFoundError,
MafRoundtableService,
)
from app.maf_roundtable.service import MafForbiddenError, MafNotFoundError
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
@@ -43,7 +40,7 @@ async def store(tmp_path):
def _svc(store, *, enabled=True, admin_diagnostic_access=True, config=None):
return MafRoundtableService(
return make_maf_service(
store,
enabled=enabled,
admin_diagnostic_access=admin_diagnostic_access,
@@ -165,7 +162,7 @@ async def test_admin_diagnostic_off_cannot_even_read_cross_user(store):
async def test_normal_users_remain_fully_isolated(store):
svc = _svc(store, admin_diagnostic_access=True)
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
# bob is a non-admin → isolated on both read and write.
# bob is a non-admin →isolated on both read and write.
with pytest.raises(MafNotFoundError):
await svc.get_session(session_id=s["id"], user_id="bob")
with pytest.raises(MafNotFoundError):
@@ -1,16 +1,16 @@
"""Tests for the MAF roundtable persistence + service layer (阶段一骨架).
"""Tests for the MAF roundtable persistence + service layer (?????).
Covers the Phase 1 exit criteria:
- Session CRUD with participants + capability snapshot.
- Run lifecycle (create / get / list / cancel).
- Monotonic per-run event seq under concurrent appends (MAF-01-06).
- Event 断线补拉 (list_events after_seq=N).
- Event ???? (list_events after_seq=N).
- Action idempotency-key uniqueness.
- Approval decision transition + "already decided" guard.
- User isolation (MAF-01-07) — non-owner gets None / 404, not 403.
- User isolation (MAF-01-07) ??non-owner gets None / 404, not 403.
- Admin diagnostic access bypass.
- Feature flag (MAF-01-04) — writes raise MafDisabledError when off; reads open.
- Run gating — running session rejects config edits.
- Feature flag (MAF-01-04) ??writes raise MafDisabledError when off; reads open.
- Run gating ??running session rejects config edits.
- capability_snapshot frozen at creation.
- MAF tables have no FK to legacy roundtable_* tables.
"""
@@ -21,12 +21,12 @@ import asyncio
import pytest
import pytest_asyncio
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.service import (
MafConflictError,
MafNotFoundError,
MafRoundtableService,
)
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
@@ -45,7 +45,7 @@ async def store(tmp_path):
def _svc(store, *, enabled=True, admin_diagnostic_access=True):
return MafRoundtableService(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
return make_maf_service(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
@pytest.mark.asyncio
@@ -54,23 +54,26 @@ async def test_create_and_get_session_round_trips(store):
created = await svc.create_session(
user_id="u1",
body={
"title": "舆情会商",
"task_text": "分析暑期文旅舆情",
"title": "????",
"task_text": "????????",
"mode": "structured",
"model_name": "glm-5",
"max_rounds": 5,
"write_policy": "confirm_high_risk",
"capability_snapshot": {"agents": ["a1"]},
"participants": [
{"agent_id": "mgr", "display_name": "主持人", "role": "manager", "sort_order": 0},
{"agent_id": "seat-a", "display_name": "席A", "role": "seat", "sort_order": 1},
{"agent_id": "mgr", "display_name": "host", "role": "manager", "sort_order": 0},
{"agent_id": "seat-a", "display_name": "?A", "role": "seat", "sort_order": 1},
],
},
)
assert created["user_id"] == "u1"
assert created["status"] == "draft"
assert created["mode"] == "structured"
assert created["capability_snapshot"] == {"agents": ["a1"]}
snap = created["capability_snapshot"]
assert snap is not None
assert snap.get("version") == 1
assert isinstance(snap.get("agents"), list)
assert len(snap["agents"]) == 2
assert len(created["participants"]) == 2
assert created["participants"][0]["agent_id"] == "mgr"
@@ -102,9 +105,9 @@ async def test_update_session_partial_and_participants_replace(store):
updated = await svc.update_session(
session_id=s["id"],
user_id="u1",
fields={"title": "新标题", "participants": [{"agent_id": "a2", "role": "seat"}]},
fields={"title": "new-title", "participants": [{"agent_id": "a2", "role": "seat"}]},
)
assert updated["title"] == "新标题"
assert updated["title"] == "new-title"
assert updated["task_text"] == "orig"
assert {p["agent_id"] for p in updated["participants"]} == {"a2"}
@@ -112,11 +115,12 @@ async def test_update_session_partial_and_participants_replace(store):
@pytest.mark.asyncio
async def test_update_session_freezes_capability_snapshot(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t", "capability_snapshot": {"v": 1}})
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
original = s["capability_snapshot"]
updated = await svc.update_session(
session_id=s["id"], user_id="u1", fields={"capability_snapshot": {"v": 999}}
)
assert updated["capability_snapshot"] == {"v": 1}
assert updated["capability_snapshot"] == original
@pytest.mark.asyncio
@@ -130,7 +134,7 @@ async def test_delete_session_owner_scoped(store):
await svc.get_session(session_id=s["id"], user_id="u1")
# ── run lifecycle ──────────────────────────────────────────────────────────
# ?? run lifecycle ??????????????????????????????????????????????????????????
@pytest.mark.asyncio
@@ -175,7 +179,7 @@ async def test_cancel_run_marks_session_cancelled(store):
assert refreshed["status"] == "cancelled"
# ── events: monotonic seq + concurrent appends (MAF-01-06) ────────────────
# ?? events: monotonic seq + concurrent appends (MAF-01-06) ????????????????
@pytest.mark.asyncio
@@ -193,8 +197,8 @@ async def test_append_event_monotonic_seq(store):
@pytest.mark.asyncio
async def test_concurrent_appends_produce_dense_seq_set(store):
"""Many parallel appends must produce a dense 1..N seq set — no gaps, no
collisions — exercising the MAX+1 retry on the (run_id, seq) unique
"""Many parallel appends must produce a dense 1..N seq set ??no gaps, no
collisions ??exercising the MAX+1 retry on the (run_id, seq) unique
constraint (MAF-01-06)."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
@@ -218,7 +222,7 @@ async def test_concurrent_appends_produce_dense_seq_set(store):
@pytest.mark.asyncio
async def test_list_events_after_seq_pull(store):
"""断线补拉: events with seq > after_seq in ascending order."""
"""????: events with seq > after_seq in ascending order."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
@@ -229,7 +233,7 @@ async def test_list_events_after_seq_pull(store):
assert latest == 5
# ── actions: idempotency key ──────────────────────────────────────────────
# ?? actions: idempotency key ??????????????????????????????????????????????
@pytest.mark.asyncio
@@ -264,7 +268,7 @@ async def test_action_idempotency_key_unique(store):
assert found["tool_name"] == "update_task"
# ── approvals: decision transition ────────────────────────────────────────
# ?? approvals: decision transition ????????????????????????????????????????
@pytest.mark.asyncio
@@ -273,7 +277,7 @@ async def test_approval_decision_and_already_decided_guard(store):
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
appr = await store.approvals.create_approval(
{"session_id": s["id"], "run_id": run["id"], "summary": "高风险写入", "status": "pending"}
{"session_id": s["id"], "run_id": run["id"], "summary": "approve write", "status": "pending"}
)
decided = await svc.decide_approval(approval_id=appr["id"], decision="approved", user_id="u1")
assert decided["status"] == "approved"
@@ -1,13 +1,13 @@
"""P1 concurrency + single-transaction launch tests for MAF roundtable.
Covers:
- **Concurrent run launch**: ``asyncio.gather`` of ≥8 ``create_run`` calls must
- **Concurrent run launch**: ``asyncio.gather`` of ≥ ``create_run`` calls must
yield exactly one active run; the rest get ``MafConflictError``.
- **Single-transaction atomicity**: flush / pre-commit failures roll back fully —
session stays at its prior status, no orphan run, no residual ``starting``.
- **Success shape**: session ``running``, unique run ``pending``.
- **Cancel then re-launch** works.
- **Concurrent approval decision**: parallel approve + reject → exactly one winner.
- **Concurrent approval decision**: parallel approve + reject →exactly one winner.
"""
from __future__ import annotations
@@ -21,7 +21,6 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from app.maf_roundtable.service import (
MafConflictError,
MafRoundtableService,
)
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
@@ -40,7 +39,9 @@ async def store(tmp_path):
def _svc(store, *, enabled=True, admin_diagnostic_access=True):
return MafRoundtableService(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
from maf_test_helpers import make_maf_service
return make_maf_service(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
# ── P1: concurrent run launch (single-tx CAS) ─────────────────────────────
@@ -48,7 +49,7 @@ def _svc(store, *, enabled=True, admin_diagnostic_access=True):
@pytest.mark.asyncio
async def test_concurrent_create_run_only_one_wins(store):
"""8 parallel ``create_run`` calls → exactly one active run, 7 conflicts."""
"""8 parallel ``create_run`` calls →exactly one active run, 7 conflicts."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
@@ -217,7 +218,7 @@ async def test_approval_decision_cannot_be_overwritten(store):
@pytest.mark.asyncio
async def test_cancel_run_flush_failure_rolls_back_run_and_session(store):
"""If the cancel transaction's flush fails, run stays pending and session
stays running — nothing is half-committed."""
stays running —nothing is half-committed."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
@@ -275,7 +276,7 @@ async def test_cancel_terminal_run_does_not_change_run(store):
@pytest.mark.asyncio
async def test_concurrent_cancel_same_run_consistent(store):
"""Two concurrent cancels of the same run → run and session both cancelled,
"""Two concurrent cancels of the same run →run and session both cancelled,
no exceptions, no inconsistent state."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
@@ -1,7 +1,7 @@
"""P2 deployment-config enum / range validation for MafRoundtableConfig.
Config and API schemas share ``MafMode`` / ``MafWritePolicy`` from
``deerflow.config.maf_roundtable_config`` — invalid deployment values must fail
``deerflow.config.maf_roundtable_config`` —invalid deployment values must fail
at load time, not silently become runtime bugs.
"""
@@ -9,7 +9,7 @@ Covers the full HTTP status code matrix:
- **503 Service Unavailable**: feature disabled, mock events disabled
Also verifies that identity comes from ``request.state.user`` (stamped by auth
middleware), not from body parameters — a client cannot forge ``user_id`` in the
middleware), not from body parameters —a client cannot forge ``user_id`` in the
request body to impersonate another user.
"""
@@ -19,10 +19,10 @@ import pytest
import pytest_asyncio
from fastapi import FastAPI, Request
from httpx import ASGITransport, AsyncClient
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers.maf_roundtable import router as maf_router
from app.maf_roundtable import MafRoundtableService
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
@@ -48,14 +48,15 @@ async def app_and_client(tmp_path):
store = make_maf_roundtable_store(sf)
cfg = MafRoundtableConfig(enabled=True, admin_diagnostic_access=True)
svc = MafRoundtableService(store, enabled=True, admin_diagnostic_access=True, config=cfg)
svc = make_maf_service(store, enabled=True, admin_diagnostic_access=True, config=cfg)
app = FastAPI()
app.state.maf_roundtable_service = svc
app.state.maf_auto_dispatch = False
app.include_router(maf_router)
# Fake auth middleware: reads X-Test-User / X-Test-Role headers and stamps
# request.state.user. No header → 401 (unauthenticated).
# request.state.user. No header →401 (unauthenticated).
@app.middleware("http")
async def fake_auth(request: Request, call_next):
user_id = request.headers.get("X-Test-User")
@@ -99,7 +100,7 @@ async def test_403_admin_cannot_update_other_user_session(app_and_client):
assert resp.status_code == 200
session_id = resp.json()["id"]
# admin tries to update it → 403
# admin tries to update it →403
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"title": "hijack"},
@@ -146,7 +147,7 @@ async def test_404_other_user_session_not_leaked(app_and_client):
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
# bob (non-admin) tries to read alice's session → 404 (not 403, so existence is not leaked)
# bob (non-admin) tries to read alice's session →404 (not 403, so existence is not leaked)
resp = await client.get(
f"/api/maf-roundtable/sessions/{session_id}",
headers={"X-Test-User": "bob"},
@@ -173,7 +174,7 @@ async def test_409_second_run_launch_conflicts(app_and_client):
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
# Second run launch → 409
# Second run launch →409
resp = await client.post(
f"/api/maf-roundtable/sessions/{session_id}/runs",
json={},
@@ -208,7 +209,7 @@ async def test_409_approval_already_decided(app_and_client):
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
# Second decision → 409
# Second decision →409
resp = await client.post(
f"/api/maf-roundtable/approvals/{appr['id']}/decision",
json={"decision": "rejected"},
@@ -268,10 +269,11 @@ async def test_503_feature_disabled(tmp_path):
store = make_maf_roundtable_store(sf)
cfg = MafRoundtableConfig(enabled=False)
svc = MafRoundtableService(store, enabled=False, admin_diagnostic_access=True, config=cfg)
svc = make_maf_service(store, enabled=False, admin_diagnostic_access=True, config=cfg)
app = FastAPI()
app.state.maf_roundtable_service = svc
app.state.maf_auto_dispatch = False
app.include_router(maf_router)
@app.middleware("http")
@@ -289,7 +291,7 @@ async def test_503_feature_disabled(tmp_path):
@pytest.mark.asyncio
async def test_404_post_events_endpoint_removed(app_and_client):
"""POST /runs/{id}/events is deliberately absent — no public mock write path."""
"""POST /runs/{id}/events is deliberately absent —no public mock write path."""
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
@@ -3,7 +3,7 @@
These tests focus on the security boundary: a non-owner cannot read or mutate
another user's session / run / action / approval, and the six MAF tables have
no foreign-key relationship to the legacy ``roundtable_*`` tables (the
experiment is fully isolated — see implementation plan §2.3 "不改原有的工程定义").
experiment is fully isolated —see implementation plan §2.3 "不改原有的工程定义).
"""
from __future__ import annotations
@@ -13,15 +13,17 @@ import pytest_asyncio
from sqlalchemy import inspect
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.service import MafNotFoundError, MafRoundtableService
from app.maf_roundtable.service import MafNotFoundError
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableActionRow,
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
MafRoundtableEventRow,
MafRoundtableParticipantRow,
MafRoundtableRunRow,
MafRoundtableSeatRunRow,
MafRoundtableSessionRow,
)
@@ -39,7 +41,9 @@ async def store(tmp_path):
def _svc(store, *, enabled=True, admin_diagnostic_access=True):
return MafRoundtableService(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
from maf_test_helpers import make_maf_service
return make_maf_service(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
# ── user isolation: non-owner cannot read ────────────────────────────────
@@ -146,7 +150,7 @@ async def test_admin_diagnostic_access_off_keeps_owner_scope(store):
@pytest.mark.asyncio
async def test_maf_tables_isolated_from_legacy_roundtable(store):
"""The six MAF tables must exist and none of their FKs may target a legacy
roundtable_* table (MAF-01-02 / 退出标准: MAF 表和旧 roundtable 表完全无关联外键)."""
roundtable_* table (MAF-01-02 / 退出标净 MAF 表和旀roundtable 表完全无关联外键)."""
_, engine = store
async with engine.connect() as conn:
table_names = await conn.run_sync(lambda sync_conn: set(inspect(sync_conn).get_table_names()))
@@ -159,6 +163,8 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
"maf_roundtable_events",
"maf_roundtable_actions",
"maf_roundtable_approvals",
"maf_roundtable_seat_runs",
"maf_roundtable_action_secrets",
}
# Every FK defined on an MAF model must reference another maf_roundtable_* table.
@@ -168,7 +174,9 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
MafRoundtableRunRow,
MafRoundtableEventRow,
MafRoundtableActionRow,
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
MafRoundtableSeatRunRow,
]
for model in maf_models:
for fk in model.__table__.foreign_keys:
@@ -197,7 +205,7 @@ async def test_disabled_feature_keeps_reads_open(store):
svc_on = _svc(store[0], enabled=True)
s = await svc_on.create_session(user_id="u1", body={"task_text": "t"})
svc_off = _svc(store[0], enabled=False)
# Reads do not raise — admin can review history while the feature is off.
# Reads do not raise —admin can review history while the feature is off.
fetched = await svc_off.get_session(session_id=s["id"], user_id="u1")
assert fetched["id"] == s["id"]
# But a write through the disabled service still raises.
@@ -0,0 +1,955 @@
"""Acceptance tests for the WriteGuard middleware + MAF bridge (Task 1).
Verifies the core security invariants:
1. Write tools stay visible to the model (NOT excluded) but the real tool
executes **0 times** before approval —the middleware intercepts and
returns an ``approval_required`` ToolMessage instead.
2. After approval, the ToolExecutor runs the tool **exactly once** with the
approved original params (model deviation is ignored).
3. Two concurrent ``resume()``/occupy calls →only one executor wins.
4. The single-transaction action bundle rolls back if any step fails.
5. ``auto`` policy →write tools pass through (no interception).
6. Legacy runs (no ``maf_run_id`` in context) →middleware is a no-op.
These tests exercise the middleware + interceptor in isolation (no LLM, no
DeerFlow runtime) so the security properties are deterministic and fast.
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import Awaitable, Callable
from typing import Any
from unittest.mock import MagicMock
import pytest
import pytest_asyncio
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.action_coordinator import ActionCoordinator
from app.maf_roundtable.write_guard_bridge import (
MafWriteGuardInterceptor,
MafWriteGuardPolicy,
make_interceptor,
make_policy,
register_write_guard_service,
)
from deerflow.agents.middlewares.write_guard_middleware import WriteGuardMiddleware
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import Base
@pytest_asyncio.fixture
async def store():
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
s = make_maf_roundtable_store(sf)
assert s is not None
yield s
await engine.dispose()
@pytest.fixture
def svc(store):
return make_maf_service(store, enabled=True, admin_diagnostic_access=True)
@pytest.fixture(autouse=True)
def _wire_bridge(svc):
register_write_guard_service(svc)
def _capability_snapshot(*, write_policy: str = "confirm_all_writes") -> dict[str, Any]:
"""A snapshot with one read tool and one write tool."""
return {
"tools": [
{"name": "read_data", "side_effect": "read", "risk_level": "R0"},
{"name": "update_task", "side_effect": "write", "risk_level": "R3"},
],
}
def _runtime_context(*, active: bool = True, write_policy: str = "confirm_all_writes") -> dict[str, Any]:
"""A run context mimicking what runtime_client.start_agent_run injects."""
ctx: dict[str, Any] = {
"agent_id": "seat-1",
"turn_index": 1,
"write_policy": write_policy,
"capability_snapshot": _capability_snapshot(write_policy=write_policy),
}
if active:
ctx["maf_run_id"] = "run-1"
ctx["maf_session_id"] = "sess-1"
ctx["maf_user_id"] = "u1"
return ctx
def _tool_call_request(
name: str,
args: dict[str, Any] | None = None,
*,
run_context: dict[str, Any] | None = None,
):
"""Build a ToolCallRequest-like object with the fields the middleware reads."""
runtime = MagicMock()
runtime.context = run_context or {}
req = MagicMock()
req.tool_call = {"name": name, "args": args or {}, "id": f"call-{name}"}
req.runtime = runtime
return req
def _real_tool_handler(call_counter: dict[str, int]) -> Callable[[Any], Awaitable[Any]]:
"""An async handler that records each invocation —proves the real tool ran."""
async def handler(request):
name = request.tool_call["name"]
call_counter[name] = call_counter.get(name, 0) + 1
msg = MagicMock()
msg.content = f"executed {name}"
return msg
return handler
# ── 1. Real tool executes 0 times before approval ──────────────────────────
@pytest.mark.asyncio
async def test_write_tool_never_executes_before_approval(svc, store):
"""The real tool handler is NOT called for a write tool awaiting approval.
The middleware returns an approval_required ToolMessage and short-circuits.
"""
call_counter: dict[str, int] = {}
policy = MafWriteGuardPolicy()
interceptor = MafWriteGuardInterceptor()
mw = WriteGuardMiddleware(policy=policy, interceptor=interceptor)
# Need a session + run for the bundle to reference.
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
await svc.create_run(session_id=session["id"], user_id="u1")
ctx = _runtime_context()
ctx["maf_session_id"] = session["id"]
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _real_tool_handler(call_counter)
result = await mw.awrap_tool_call(request, handler)
# Real tool never ran.
assert call_counter.get("update_task", 0) == 0
# An approval_required message was returned.
envelope = json.loads(result.content)
assert envelope["status"] == "approval_required"
assert envelope["action_id"]
assert envelope["tool_name"] == "update_task"
@pytest.mark.asyncio
async def test_read_tool_passes_through(svc, store):
"""A read tool is NOT intercepted —it executes normally even in an active run."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
request = _tool_call_request("read_data", {"q": "x"}, run_context=_runtime_context())
await mw.awrap_tool_call(request, _real_tool_handler(call_counter))
assert call_counter.get("read_data", 0) == 1
# ── 5. auto policy →no interception ────────────────────────────────────────
@pytest.mark.asyncio
async def test_auto_policy_write_tool_executes(svc, store):
"""Under ``auto`` policy, a write tool passes through without interception."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context(write_policy="auto")
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
await mw.awrap_tool_call(request, _real_tool_handler(call_counter))
assert call_counter.get("update_task", 0) == 1
# ── 6. Legacy runs (no maf_run_id) →no-op ──────────────────────────────────
@pytest.mark.asyncio
async def test_legacy_run_noop():
"""Without ``maf_run_id`` in the context, the middleware passes through."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
request = _tool_call_request("update_task", {"title": "A"}, run_context=_runtime_context(active=False))
await mw.awrap_tool_call(request, _real_tool_handler(call_counter))
assert call_counter.get("update_task", 0) == 1
# ── 3. At-most-once: concurrent occupy →only one winner ────────────────────
@pytest.mark.asyncio
async def test_concurrent_occupy_only_one_winner(svc, store):
"""Two concurrent claim_for_execution calls →exactly one returns "claimed";
the loser reflects the real state (in_progress), not a blanket skip (P0-3)."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
# Create an action in 'approved' state directly.
action = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "k1",
"risk_level": "R3", "status": "approved",
})
coord = ActionCoordinator(svc, user_id="u1")
# Two concurrent occupies.
r1, r2 = await asyncio.gather(
coord.occupy_for_execution(action["id"]),
coord.occupy_for_execution(action["id"]),
)
claimed = [r for r in (r1, r2) if r == "claimed"]
assert len(claimed) == 1, f"expected exactly one 'claimed', got {(r1, r2)}"
# The loser sees the action is already being executed (real state), so the
# parent run can stay running instead of treating it as a no-op skip.
losers = [r for r in (r1, r2) if r != "claimed"]
assert losers == ["in_progress"], f"loser must reflect in_progress, got {losers}"
# ── 3b. record_terminal_result is state-machine constrained ─────────────────
@pytest.mark.asyncio
async def test_record_result_only_from_running(svc, store):
"""record_terminal_result only transitions running →terminal."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
# Action in 'running' state.
action = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "k2",
"risk_level": "R3", "status": "running",
})
# First record →succeeds, returns the updated row.
res1 = await store.actions.record_terminal_result(action["id"], status="succeeded", result={"ok": True})
assert res1 is not None
assert res1["status"] == "succeeded"
# Second record (already terminal) →no-op, returns None, does NOT overwrite.
res2 = await store.actions.record_terminal_result(action["id"], status="failed", result={"ok": False})
assert res2 is None
final = await store.actions.get_action(action["id"])
assert final["status"] == "succeeded" # not overwritten
# ── 2. ToolExecutor runs once with approved params (direct tool execution) ──
@pytest.mark.asyncio
async def test_tool_executor_runs_once_with_approved_params(svc, store):
"""The ToolExecutor calls execute_tool_directly exactly once with the
approved params, and records the terminal result. The LLM is never involved.
P0-2 fix: raw params never enter a prompt —they go directly to the tool
function via execute_tool_directly(). The adapter.run_seat_turn() is NOT
called (the LLM is bypassed).
"""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
# Seed an approved action with encrypted params {title: "A"}.
from app.maf_roundtable.action_coordinator import encrypt_params
coord = ActionCoordinator(svc, user_id="u1")
enc = encrypt_params({"title": "A"})
action = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "k3",
"risk_level": "R3", "status": "approved",
})
await store.action_secrets.create(action_id=action["id"], arguments_encrypted=enc)
# Mock client.execute_tool_directly —this is what ToolExecutor now calls.
original_params_received = None
exec_count = {"n": 0}
async def _mock_execute_tool(*, thread_id, agent_id, tool_name, params, principal, **kwargs):
nonlocal original_params_received
original_params_received = params
exec_count["n"] += 1
return {"status": "succeeded", "result": {"output": f"called {tool_name}"}}
mock_client = MagicMock()
mock_client.execute_tool_directly = _mock_execute_tool
mock_adapter = MagicMock()
mock_adapter._client = mock_client
from app.maf_roundtable.tool_executor import ToolExecutor
executor = ToolExecutor(adapter=mock_adapter, action_coordinator=coord, user_id="u1")
# P0-2: no approved_params kwarg —the executor recovers the params itself
# from the encrypted secret stored above.
outcome = await executor.execute(
action_id=action["id"], session_id=session["id"], run_id=run["id"],
agent_id="a1", thread_id="t1",
approved_tool="update_task",
)
assert outcome["status"] == "succeeded"
# The tool received the exact approved params (title=A, not something the
# LLM modified).
assert original_params_received == {"title": "A"}
# The action is now terminal.
final = await store.actions.get_action(action["id"])
assert final["status"] == "succeeded"
# A second execution attempt reflects the terminal state (P0-3: no blanket
# "skipped") and —critically —does NOT re-execute the tool (at-most-once).
outcome2 = await executor.execute(
action_id=action["id"], session_id=session["id"], run_id=run["id"],
agent_id="a1", thread_id="t1",
approved_tool="update_task",
)
assert outcome2["status"] == "succeeded" # reflects the real terminal status
assert outcome2.get("action") is not None
assert exec_count["n"] == 1, "tool must execute exactly once (at-most-once)"
# ── 2b. P0-2: secret unavailable ⇀hard fail, zero tool executions ─────────
@pytest.mark.asyncio
async def test_executor_missing_secret_fails_without_calling_tool(svc, store):
"""P0-2: an approved action whose secret row is missing must FAIL with the
tool executing **0 times** —never fall back to empty params."""
from app.maf_roundtable.action_coordinator import encrypt_params
from app.maf_roundtable.tool_executor import ToolExecutor
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
coord = ActionCoordinator(svc, user_id="u1")
action = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "k-missing",
"risk_level": "R3", "status": "approved",
})
# Seed then DELETE the secret so the row is gone.
await store.action_secrets.create(
action_id=action["id"], arguments_encrypted=encrypt_params({"title": "A"})
)
await store.action_secrets.delete_by_action(action["id"])
assert await store.action_secrets.get_by_action(action["id"]) is None
calls = {"n": 0}
async def _boom(*, thread_id, agent_id, tool_name, params, principal):
calls["n"] += 1
return {"status": "succeeded", "result": {}}
mock_client = MagicMock()
mock_client.execute_tool_directly = _boom
mock_adapter = MagicMock()
mock_adapter._client = mock_client
executor = ToolExecutor(adapter=mock_adapter, action_coordinator=coord, user_id="u1")
outcome = await executor.execute(
action_id=action["id"], session_id=session["id"], run_id=run["id"],
agent_id="a1", thread_id="t1", approved_tool="update_task",
)
assert calls["n"] == 0, "tool must NOT run when the secret is missing"
assert outcome["status"] == "failed"
final = await store.actions.get_action(action["id"])
assert final["status"] == "failed"
assert final["status"] != "succeeded"
@pytest.mark.asyncio
async def test_executor_corrupt_secret_fails_without_calling_tool(svc, store):
"""P0-2: an undecryptable secret (wrong key / garbage) must FAIL with the
tool executing **0 times** —not run with empty params."""
from app.maf_roundtable.tool_executor import ToolExecutor
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
coord = ActionCoordinator(svc, user_id="u1")
action = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "k-corrupt",
"risk_level": "R3", "status": "approved",
})
# Garbage ciphertext that Fernet cannot decrypt.
await store.action_secrets.create(
action_id=action["id"], arguments_encrypted="not-a-valid-fernet-token"
)
calls = {"n": 0}
async def _boom(*, thread_id, agent_id, tool_name, params, principal):
calls["n"] += 1
return {"status": "succeeded", "result": {}}
mock_client = MagicMock()
mock_client.execute_tool_directly = _boom
mock_adapter = MagicMock()
mock_adapter._client = mock_client
executor = ToolExecutor(adapter=mock_adapter, action_coordinator=coord, user_id="u1")
outcome = await executor.execute(
action_id=action["id"], session_id=session["id"], run_id=run["id"],
agent_id="a1", thread_id="t1", approved_tool="update_task",
)
assert calls["n"] == 0, "tool must NOT run when the secret cannot be decrypted"
assert outcome["status"] == "failed"
final = await store.actions.get_action(action["id"])
assert final["status"] == "failed"
@pytest.mark.asyncio
async def test_get_approved_action_params_raises_with_reasons(svc, store):
"""P0-2: get_approved_action_params raises ActionSecretUnavailableError
(never returns None) with a distinct reason per failure mode."""
from app.maf_roundtable.action_coordinator import (
ActionSecretUnavailableError,
encrypt_params,
)
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
coord = ActionCoordinator(svc, user_id="u1")
# missing
a_missing = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "r-missing",
"risk_level": "R3", "status": "approved",
})
with pytest.raises(ActionSecretUnavailableError) as ei:
await coord.get_approved_action_params(a_missing["id"])
assert ei.value.reason == "missing"
# decrypt_failed
a_corrupt = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "r-corrupt",
"risk_level": "R3", "status": "approved",
})
await store.action_secrets.create(action_id=a_corrupt["id"], arguments_encrypted="garbage")
with pytest.raises(ActionSecretUnavailableError) as ei:
await coord.get_approved_action_params(a_corrupt["id"])
assert ei.value.reason == "decrypt_failed"
# invalid_type —valid Fernet token whose plaintext is a JSON list, not object
from cryptography.fernet import Fernet
from app.maf_roundtable.action_coordinator import _get_fernet_key
list_token = Fernet(_get_fernet_key()).encrypt(b"[1, 2, 3]").decode()
a_list = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "r-list",
"risk_level": "R3", "status": "approved",
})
await store.action_secrets.create(action_id=a_list["id"], arguments_encrypted=list_token)
with pytest.raises(ActionSecretUnavailableError) as ei:
await coord.get_approved_action_params(a_list["id"])
assert ei.value.reason == "invalid_type"
# happy path still returns the dict
a_ok = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "r-ok",
"risk_level": "R3", "status": "approved",
})
await store.action_secrets.create(
action_id=a_ok["id"], arguments_encrypted=encrypt_params({"title": "A"})
)
assert await coord.get_approved_action_params(a_ok["id"]) == {"title": "A"}
# ── 4. Single-transaction bundle atomicity + idempotency ──────────────────
@pytest.mark.asyncio
async def test_action_bundle_writes_all_four_atomically(svc, store):
"""A successful bundle writes action + secret + approval + audit event
together —none can exist without the others (single transaction)."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
coord = ActionCoordinator(svc, user_id="u1")
created = await coord.intercept_write_tool_calls(
run_id=run["id"], session_id=session["id"], agent_id="a1", turn_index=1,
tool_calls=[{"name": "update_task", "id": "c1", "args": {"title": "A"}}],
capability_snapshot=_capability_snapshot(), write_policy="confirm_all_writes",
)
assert len(created) == 1
action_id = created[0]["id"]
# All four rows exist (atomic success).
action = await store.actions.get_action(action_id)
assert action is not None and action["status"] == "waiting_approval"
assert action["approval_id"], "approval must be linked"
secret = await store.action_secrets.get_by_action(action_id)
assert secret is not None, "encrypted secret must exist"
approval = await store.approvals.get_approval(action["approval_id"])
assert approval is not None and approval["status"] == "pending"
events = await store.events.list_events(run_id=run["id"])
assert any(e["event_type"] == "action.waiting_approval" for e in events), \
"audit event must exist"
@pytest.mark.asyncio
async def test_action_bundle_idempotent_on_duplicate(svc, store):
"""A duplicate (same idempotency_key) submission re-reads and returns the
existing action instead of creating a second one —retries never fail the run."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
coord = ActionCoordinator(svc, user_id="u1")
args = [dict(run_id=run["id"], session_id=session["id"], agent_id="a1", turn_index=1,
tool_calls=[{"name": "update_task", "id": "c1", "args": {"title": "A"}}],
capability_snapshot=_capability_snapshot(), write_policy="confirm_all_writes")]
first = await coord.intercept_write_tool_calls(**args[0])
second = await coord.intercept_write_tool_calls(**args[0])
assert first[0]["id"] == second[0]["id"], "duplicate must return the same action id"
actions = await store.actions.list_actions(run_id=run["id"])
assert len(actions) == 1, f"expected 1 action, got {len(actions)}"
# ── Factory resolution ──────────────────────────────────────────────────────
def test_factories_return_singletons():
"""make_policy/make_interceptor return the bridge objects."""
p = make_policy()
i = make_interceptor()
assert isinstance(p, MafWriteGuardPolicy)
assert isinstance(i, MafWriteGuardInterceptor)
# ── P0-3: Strengthened WriteGuard passthrough ───────────────────────────────
@pytest.mark.asyncio
async def test_approved_passthrough_requires_action_id(svc, store):
"""When maf_approved_tool is set but maf_approved_action_id is missing,
the WriteGuard blocks the passthrough —tool name matching alone is
insufficient (P0-3 fix)."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context()
ctx["maf_approved_tool"] = "update_task"
# Deliberately omit maf_approved_action_id —should be blocked.
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _real_tool_handler(call_counter)
result = await mw.awrap_tool_call(request, handler)
# Real tool should NOT execute —the passthrough requires action_id.
assert call_counter.get("update_task", 0) == 0
# Should return an approval_required or error message, not pass through.
envelope = json.loads(result.content)
assert envelope["status"] in ("approval_required", "intercept_failed")
@pytest.mark.asyncio
async def test_approved_passthrough_allows_with_action_id(svc, store):
"""When both maf_approved_tool and maf_approved_action_id are set AND the
bound action validates (status running + run/session/agent + tool match),
the WriteGuard allows the passthrough (defense-in-depth)."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
await svc.create_run(session_id=session["id"], user_id="u1")
# Seed the bound action in the state the executor leaves it (running) with
# ownership matching the run context —validate_approved_action queries it.
await store.actions.create_action({
"id": "action-1", "session_id": session["id"], "run_id": "run-1",
"agent_id": "seat-1", "tool_name": "update_task",
"idempotency_key": "pt-allow", "risk_level": "R3", "status": "running",
})
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context()
ctx["maf_session_id"] = session["id"]
ctx["maf_approved_tool"] = "update_task"
ctx["maf_approved_action_id"] = "action-1"
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _real_tool_handler(call_counter)
await mw.awrap_tool_call(request, handler)
# Real tool should execute —passthrough is active and validated.
assert call_counter.get("update_task", 0) == 1
@pytest.mark.asyncio
async def test_approved_passthrough_blocks_when_action_not_running(svc, store):
"""P0-4: field presence is not enough —if the bound action is NOT in
``running`` status, the async passthrough fails closed (tool does not run)."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
await svc.create_run(session_id=session["id"], user_id="u1")
# Action exists but is still waiting_approval (not occupied/running).
await store.actions.create_action({
"id": "action-2", "session_id": session["id"], "run_id": "run-1",
"agent_id": "seat-1", "tool_name": "update_task",
"idempotency_key": "pt-notrunning", "risk_level": "R3", "status": "waiting_approval",
})
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context()
ctx["maf_session_id"] = session["id"]
ctx["maf_approved_tool"] = "update_task"
ctx["maf_approved_action_id"] = "action-2"
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _real_tool_handler(call_counter)
result = await mw.awrap_tool_call(request, handler)
assert call_counter.get("update_task", 0) == 0, "must fail closed when action is not running"
envelope = json.loads(result.content)
assert envelope["status"] == "approval_invalid"
@pytest.mark.asyncio
async def test_approved_passthrough_blocks_when_action_ownership_mismatches(svc, store):
"""P0-4: a running action belonging to a DIFFERENT run must not validate."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
await svc.create_run(session_id=session["id"], user_id="u1")
await store.actions.create_action({
"id": "action-3", "session_id": session["id"], "run_id": "OTHER-RUN",
"agent_id": "seat-1", "tool_name": "update_task",
"idempotency_key": "pt-ownership", "risk_level": "R3", "status": "running",
})
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context() # maf_run_id = "run-1"
ctx["maf_session_id"] = session["id"]
ctx["maf_approved_tool"] = "update_task"
ctx["maf_approved_action_id"] = "action-3"
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _real_tool_handler(call_counter)
result = await mw.awrap_tool_call(request, handler)
assert call_counter.get("update_task", 0) == 0
envelope = json.loads(result.content)
assert envelope["status"] == "approval_invalid"
@pytest.mark.asyncio
async def test_approved_passthrough_blocks_different_tool(svc, store):
"""When maf_approved_tool is 'update_task' but the LLM calls 'delete_task',
the passthrough blocks —different tool name (defense-in-depth)."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
await svc.create_run(session_id=session["id"], user_id="u1")
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context()
ctx["maf_session_id"] = session["id"]
ctx["maf_approved_tool"] = "update_task"
ctx["maf_approved_action_id"] = "action-1"
request = _tool_call_request("delete_task", {"id": "x"}, run_context=ctx)
handler = _real_tool_handler(call_counter)
result = await mw.awrap_tool_call(request, handler)
assert call_counter.get("delete_task", 0) == 0
envelope = json.loads(result.content)
assert envelope["status"] == "approval_required"
# ── P0-4: sync path distinguishes read (pass) vs write (fail-closed) ────────
def _sync_tool_handler(call_counter: dict[str, int]):
"""A sync handler that records each invocation —proves the real tool ran."""
def handler(request):
name = request.tool_call["name"]
call_counter[name] = call_counter.get(name, 0) + 1
msg = MagicMock()
msg.content = f"executed {name}"
return msg
return handler
def test_sync_read_tool_passes_through(svc, store):
"""P0-4: on the SYNC path a read tool is never intercepted —it runs."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context() # confirm_all_writes; read_data is side_effect=read
request = _tool_call_request("read_data", {"q": "x"}, run_context=ctx)
handler = _sync_tool_handler(call_counter)
mw.wrap_tool_call(request, handler)
assert call_counter.get("read_data", 0) == 1, "read tools must pass the sync path"
def test_sync_write_tool_fails_closed(svc, store):
"""P0-4: on the SYNC path a write tool that cannot be async-intercepted
fails closed (blocked), it does NOT run unapproved."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context() # confirm_all_writes; update_task is side_effect=write
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _sync_tool_handler(call_counter)
result = mw.wrap_tool_call(request, handler)
assert call_counter.get("update_task", 0) == 0, "write tools must fail closed on the sync path"
envelope = json.loads(result.content)
assert envelope["status"] == "intercept_blocked_sync"
def test_sync_approved_passthrough_runs(svc, store):
"""P0-4: an approved deterministic execution (maf_approved_tool bound)
passes the sync path without being blocked as a write."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context()
ctx["maf_approved_tool"] = "update_task"
ctx["maf_approved_action_id"] = "action-sync"
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _sync_tool_handler(call_counter)
mw.wrap_tool_call(request, handler)
assert call_counter.get("update_task", 0) == 1, "approved passthrough must run on the sync path"
# ── P0-4: startup prerequisite validation ───────────────────────────────────
def test_validate_maf_prerequisites_requires_write_guard(monkeypatch):
"""validate_maf_prerequisites raises when write_guard is disabled."""
from app.maf_roundtable.service import (
MafConfigurationError,
validate_maf_prerequisites,
)
from deerflow.config.write_guard_config import (
load_write_guard_config_from_dict,
reset_write_guard_config,
)
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "x" * 32)
try:
load_write_guard_config_from_dict({"enabled": False})
with pytest.raises(MafConfigurationError):
validate_maf_prerequisites()
finally:
reset_write_guard_config()
def test_validate_maf_prerequisites_passes_when_all_met(monkeypatch):
"""validate_maf_prerequisites passes when write_guard + provider + key present."""
from app.maf_roundtable.service import validate_maf_prerequisites
from deerflow.config.write_guard_config import (
load_write_guard_config_from_dict,
reset_write_guard_config,
)
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "x" * 32)
try:
load_write_guard_config_from_dict({
"enabled": True,
"provider": {
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
},
})
validate_maf_prerequisites() # must not raise
finally:
reset_write_guard_config()
def test_validate_maf_prerequisites_requires_secret_key(monkeypatch):
"""validate_maf_prerequisites raises when MAF_ACTION_SECRET_KEY is missing."""
from app.maf_roundtable.service import (
MafConfigurationError,
validate_maf_prerequisites,
)
from deerflow.config.write_guard_config import (
load_write_guard_config_from_dict,
reset_write_guard_config,
)
monkeypatch.delenv("MAF_ACTION_SECRET_KEY", raising=False)
try:
load_write_guard_config_from_dict({
"enabled": True,
"provider": {
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
},
})
with pytest.raises(MafConfigurationError):
validate_maf_prerequisites()
finally:
reset_write_guard_config()
# ── P1b: SeatExecutor precise pending-approval matching ─────────────────────
class _FakeSeatResponse:
def __init__(self, tool_calls, content="ok", error=None, usage=None):
self.tool_calls = tool_calls
self.content = content
self.error = error
self.usage = usage or {}
class _FakeSeatAdapter:
"""Minimal adapter: returns a canned seat response, exposes a real store."""
def __init__(self, store, response):
from types import SimpleNamespace
self._service = SimpleNamespace(store=store)
self._response = response
async def run_seat_turn(self, **kwargs):
return self._response
async def _emit_critical(self, *args, **kwargs):
pass
class _FakeSeatCtx:
def __init__(self):
self.sent = []
async def send_message(self, msg):
self.sent.append(msg)
def _seat_state(session_id, run_id):
from app.maf_roundtable.workflow_executors import RoundtableState
state = RoundtableState(
session_id=session_id, run_id=run_id, task_text="t", mode="structured",
model_name=None, reasoning_effort=None, max_rounds=8,
write_policy="confirm_all_writes", manager_agent_id=None,
seat_agent_ids=["seat-1"], thread_map={"seat-1": "thread-x"},
capability_snapshot=_capability_snapshot(),
)
state.current_round = 1
return state
@pytest.mark.asyncio
async def test_seat_matching_scopes_by_turn_index(svc, store):
"""P1b: a stale waiting action from an EARLIER turn is NOT mixed into the
current turn's pending approval —matching keys on turn_index + tool_call_id."""
from app.maf_roundtable.workflow_executors import SeatExecutor
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
# Stale action from turn 0 (same agent/run, different turn + call id).
stale = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1",
"tool_name": "update_task", "turn_index": 0, "logical_action_id": "old-call",
"idempotency_key": "stale", "risk_level": "R3", "status": "waiting_approval",
})
# Current action from turn 1 matching the response's tool_call id.
current = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1",
"tool_name": "update_task", "turn_index": 1, "logical_action_id": "call-1",
"idempotency_key": "current", "risk_level": "R3", "status": "waiting_approval",
})
state = _seat_state(session["id"], run["id"])
response = _FakeSeatResponse(tool_calls=[{"name": "update_task", "id": "call-1", "args": {}}])
executor = SeatExecutor(_FakeSeatAdapter(store, response), state, "seat-1",
action_coordinator=ActionCoordinator(svc, user_id="u1"))
await executor.handle("msg", _FakeSeatCtx())
assert len(state.pending_approvals) == 1
assert state.pending_approvals[0]["action_id"] == current["id"]
assert state.pending_approvals[0]["action_id"] != stale["id"]
assert state.pending_approvals[0]["turn_index"] == 1
@pytest.mark.asyncio
async def test_seat_matching_count_mismatch_fails(svc, store):
"""P1b: a pending tool call that does not match EXACTLY one waiting action
(by tool_call_id) fails the turn —the executor never guesses."""
from app.maf_roundtable.workflow_executors import SeatExecutor
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
# One waiting action for turn 1, but its logical_action_id != the response's.
await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1",
"tool_name": "update_task", "turn_index": 1, "logical_action_id": "other-call",
"idempotency_key": "mismatch", "risk_level": "R3", "status": "waiting_approval",
})
state = _seat_state(session["id"], run["id"])
response = _FakeSeatResponse(tool_calls=[{"name": "update_task", "id": "call-1", "args": {}}])
executor = SeatExecutor(_FakeSeatAdapter(store, response), state, "seat-1",
action_coordinator=ActionCoordinator(svc, user_id="u1"))
with pytest.raises(RuntimeError, match="expected exactly 1"):
await executor.handle("msg", _FakeSeatCtx())
# ── P0-1: WriteGuard middleware loads from config ────────────────────────────
def test_write_guard_middleware_in_chain_when_enabled():
"""When write_guard.enabled is True and provider is configured,
build_lead_runtime_middlewares includes WriteGuardMiddleware."""
from deerflow.agents.middlewares.tool_error_handling_middleware import (
_build_runtime_middlewares,
)
from deerflow.config.write_guard_config import (
WriteGuardConfig,
WriteGuardProviderConfig,
load_write_guard_config_from_dict,
reset_write_guard_config,
)
try:
load_write_guard_config_from_dict({
"enabled": True,
"provider": {
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
},
})
# Build a minimal AppConfig with write_guard enabled.
# Use model_construct to skip full validation (we only need
# write_guard and sandbox fields for this middleware test).
from deerflow.config import get_app_config
cfg = get_app_config().__class__.model_construct(
sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"},
write_guard=WriteGuardConfig(
enabled=True,
provider=WriteGuardProviderConfig(
policy_use="app.maf_roundtable.write_guard_bridge:make_policy",
interceptor_use="app.maf_roundtable.write_guard_bridge:make_interceptor",
),
),
)
middlewares = _build_runtime_middlewares(
app_config=cfg,
include_uploads=False,
include_dangling_tool_call_patch=False,
)
from deerflow.agents.middlewares.write_guard_middleware import WriteGuardMiddleware
assert any(isinstance(mw, WriteGuardMiddleware) for mw in middlewares), (
"WriteGuardMiddleware should be in the chain when write_guard is enabled"
)
finally:
reset_write_guard_config()