From bd4131b4e2febf329304a857737f1bdfe50a56f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 1 Aug 2026 17:51:53 +0800 Subject: [PATCH] feat(maf): add secure roundtable workflow runtime --- .gitignore | 3 + .../backend/app/gateway/deps.py | 28 + .../app/gateway/routers/maf_roundtable.py | 76 +- .../app/maf_roundtable/action_coordinator.py | 386 ++++++ .../app/maf_roundtable/capability_snapshot.py | 168 +++ .../app/maf_roundtable/context_builder.py | 160 +++ .../maf_roundtable/deerflow_agent_adapter.py | 513 +++++++ .../backend/app/maf_roundtable/dispatcher.py | 171 +++ .../app/maf_roundtable/event_normalizer.py | 286 ++++ .../app/maf_roundtable/runtime_client.py | 333 +++++ .../backend/app/maf_roundtable/sanitize.py | 103 ++ .../backend/app/maf_roundtable/schemas.py | 8 +- .../backend/app/maf_roundtable/service.py | 212 ++- .../app/maf_roundtable/tool_executor.py | 235 ++++ .../app/maf_roundtable/workflow_executors.py | 606 +++++++++ .../app/maf_roundtable/workflow_factory.py | 139 ++ .../app/maf_roundtable/workflow_runner.py | 483 +++++++ .../app/maf_roundtable/write_guard_bridge.py | 210 +++ offline-backend-20260512/backend/config.yaml | 12 + .../tool_error_handling_middleware.py | 81 +- .../middlewares/write_guard_middleware.py | 318 +++++ .../harness/deerflow/config/app_config.py | 6 + .../deerflow/config/write_guard_config.py | 151 +++ .../persistence/maf_roundtable/__init__.py | 22 +- .../maf_roundtable/action_secrets.py | 65 + .../persistence/maf_roundtable/actions.py | 154 ++- .../persistence/maf_roundtable/events.py | 33 +- .../persistence/maf_roundtable/model.py | 89 ++ .../maf_roundtable/participants.py | 22 + .../persistence/maf_roundtable/runs.py | 21 + .../persistence/maf_roundtable/seat_runs.py | 195 +++ .../persistence/maf_roundtable/store.py | 321 ++++- .../deerflow/tools/approved_tool_executor.py | 482 +++++++ .../harness/deerflow/tools/tool_metadata.py | 61 + .../backend/pyproject.toml | 1 + .../scripts/migrate_maf_roundtable_phase2.py | 205 +++ .../scripts/sql/column_additions_mysql.sql | 10 +- .../backend/tests/maf_test_helpers.py | 33 + .../backend/tests/test_maf_agent_adapter.py | 438 ++++++ .../tests/test_maf_approved_tool_executor.py | 669 +++++++++ .../backend/tests/test_maf_cancel_cascade.py | 110 ++ .../tests/test_maf_capability_snapshot.py | 88 ++ .../tests/test_maf_migration_phase2.py | 231 ++++ .../tests/test_maf_old_deerflow_regression.py | 188 +++ .../tests/test_maf_roundtable_acceptance.py | 1202 +++++++++++++++++ .../test_maf_roundtable_admin_boundary.py | 17 +- .../backend/tests/test_maf_roundtable_api.py | 54 +- .../tests/test_maf_roundtable_concurrency.py | 15 +- .../tests/test_maf_roundtable_config.py | 2 +- .../backend/tests/test_maf_roundtable_http.py | 22 +- .../tests/test_maf_roundtable_isolation.py | 18 +- .../backend/tests/test_maf_write_guard.py | 955 +++++++++++++ 52 files changed, 10315 insertions(+), 96 deletions(-) create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/action_coordinator.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/capability_snapshot.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/context_builder.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/dispatcher.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/event_normalizer.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/runtime_client.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/sanitize.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/tool_executor.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/workflow_factory.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/write_guard_bridge.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/write_guard_middleware.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/config/write_guard_config.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/action_secrets.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/seat_runs.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/tools/approved_tool_executor.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/tools/tool_metadata.py create mode 100644 offline-backend-20260512/backend/scripts/migrate_maf_roundtable_phase2.py create mode 100644 offline-backend-20260512/backend/tests/maf_test_helpers.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_agent_adapter.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_approved_tool_executor.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_cancel_cascade.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_capability_snapshot.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_migration_phase2.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_old_deerflow_regression.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_roundtable_acceptance.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_write_guard.py diff --git a/.gitignore b/.gitignore index 7f1164a1..15f1539b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index dad31e0b..2163863c 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -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 diff --git a/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py index e30c4418..d6647d23 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py +++ b/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py @@ -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) diff --git a/offline-backend-20260512/backend/app/maf_roundtable/action_coordinator.py b/offline-backend-20260512/backend/app/maf_roundtable/action_coordinator.py new file mode 100644 index 00000000..9e635b20 --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/action_coordinator.py @@ -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 diff --git a/offline-backend-20260512/backend/app/maf_roundtable/capability_snapshot.py b/offline-backend-20260512/backend/app/maf_roundtable/capability_snapshot.py new file mode 100644 index 00000000..a555872f --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/capability_snapshot.py @@ -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 {} diff --git a/offline-backend-20260512/backend/app/maf_roundtable/context_builder.py b/offline-backend-20260512/backend/app/maf_roundtable/context_builder.py new file mode 100644 index 00000000..45dfac3f --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/context_builder.py @@ -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 diff --git a/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py b/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py new file mode 100644 index 00000000..1fe680c6 --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py @@ -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 diff --git a/offline-backend-20260512/backend/app/maf_roundtable/dispatcher.py b/offline-backend-20260512/backend/app/maf_roundtable/dispatcher.py new file mode 100644 index 00000000..352c779e --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/dispatcher.py @@ -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] diff --git a/offline-backend-20260512/backend/app/maf_roundtable/event_normalizer.py b/offline-backend-20260512/backend/app/maf_roundtable/event_normalizer.py new file mode 100644 index 00000000..dcd73cda --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/event_normalizer.py @@ -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 diff --git a/offline-backend-20260512/backend/app/maf_roundtable/runtime_client.py b/offline-backend-20260512/backend/app/maf_roundtable/runtime_client.py new file mode 100644 index 00000000..124d033c --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/runtime_client.py @@ -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 diff --git a/offline-backend-20260512/backend/app/maf_roundtable/sanitize.py b/offline-backend-20260512/backend/app/maf_roundtable/sanitize.py new file mode 100644 index 00000000..0f0c081d --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/sanitize.py @@ -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 diff --git a/offline-backend-20260512/backend/app/maf_roundtable/schemas.py b/offline-backend-20260512/backend/app/maf_roundtable/schemas.py index 36688c7f..081b1739 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/schemas.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/schemas.py @@ -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). diff --git a/offline-backend-20260512/backend/app/maf_roundtable/service.py b/offline-backend-20260512/backend/app/maf_roundtable/service.py index 8640b075..06fcecd2 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/service.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/service.py @@ -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( diff --git a/offline-backend-20260512/backend/app/maf_roundtable/tool_executor.py b/offline-backend-20260512/backend/app/maf_roundtable/tool_executor.py new file mode 100644 index 00000000..f3841881 --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/tool_executor.py @@ -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}, + ) diff --git a/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py b/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py new file mode 100644 index 00000000..eff80215 --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py @@ -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 ''}) 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 diff --git a/offline-backend-20260512/backend/app/maf_roundtable/workflow_factory.py b/offline-backend-20260512/backend/app/maf_roundtable/workflow_factory.py new file mode 100644 index 00000000..65d3d83b --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/workflow_factory.py @@ -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, + ) diff --git a/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py b/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py new file mode 100644 index 00000000..4ce0e42e --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py @@ -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, + } diff --git a/offline-backend-20260512/backend/app/maf_roundtable/write_guard_bridge.py b/offline-backend-20260512/backend/app/maf_roundtable/write_guard_bridge.py new file mode 100644 index 00000000..f41dede5 --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/write_guard_bridge.py @@ -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() diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 89904597..59832ee9 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py index 8d151de4..0184f3c8 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py @@ -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()) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/write_guard_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/write_guard_middleware.py new file mode 100644 index 00000000..024c912c --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/write_guard_middleware.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py index 2dcca884..d434a9da 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py @@ -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"] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/write_guard_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/write_guard_config.py new file mode 100644 index 00000000..c0a3d2dd --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/write_guard_config.py @@ -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": "", + "approval_id": "", + "tool_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. + """ + ... diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py index 38db7bf8..a6df168b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py @@ -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", ] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/action_secrets.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/action_secrets.py new file mode 100644 index 00000000..b10e4b76 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/action_secrets.py @@ -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() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/actions.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/actions.py index 035436ea..7baf6985 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/actions.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/actions.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/events.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/events.py index 43b771a0..f65841ce 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/events.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/events.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py index 11966dd0..69395e14 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py @@ -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", ] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/participants.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/participants.py index af4a902d..bfd86c0c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/participants.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/participants.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/runs.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/runs.py index 858f40d7..91bcbfde 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/runs.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/runs.py @@ -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: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/seat_runs.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/seat_runs.py new file mode 100644 index 00000000..a596d63b --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/seat_runs.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py index 61e15bdc..740580fe 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py @@ -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", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/approved_tool_executor.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/approved_tool_executor.py new file mode 100644 index 00000000..72171652 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/approved_tool_executor.py @@ -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}} diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/tool_metadata.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tool_metadata.py new file mode 100644 index 00000000..e188da9d --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tool_metadata.py @@ -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, + } diff --git a/offline-backend-20260512/backend/pyproject.toml b/offline-backend-20260512/backend/pyproject.toml index 45a21bbb..3ac5b74c 100644 --- a/offline-backend-20260512/backend/pyproject.toml +++ b/offline-backend-20260512/backend/pyproject.toml @@ -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 = [ diff --git a/offline-backend-20260512/backend/scripts/migrate_maf_roundtable_phase2.py b/offline-backend-20260512/backend/scripts/migrate_maf_roundtable_phase2.py new file mode 100644 index 00000000..3841784b --- /dev/null +++ b/offline-backend-20260512/backend/scripts/migrate_maf_roundtable_phase2.py @@ -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()) diff --git a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql index e8e6e0cc..378f016d 100644 --- a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql +++ b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql @@ -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; diff --git a/offline-backend-20260512/backend/tests/maf_test_helpers.py b/offline-backend-20260512/backend/tests/maf_test_helpers.py new file mode 100644 index 00000000..82231207 --- /dev/null +++ b/offline-backend-20260512/backend/tests/maf_test_helpers.py @@ -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(), + ) diff --git a/offline-backend-20260512/backend/tests/test_maf_agent_adapter.py b/offline-backend-20260512/backend/tests/test_maf_agent_adapter.py new file mode 100644 index 00000000..31a37d43 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_agent_adapter.py @@ -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 diff --git a/offline-backend-20260512/backend/tests/test_maf_approved_tool_executor.py b/offline-backend-20260512/backend/tests/test_maf_approved_tool_executor.py new file mode 100644 index 00000000..88e9a9de --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_approved_tool_executor.py @@ -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" diff --git a/offline-backend-20260512/backend/tests/test_maf_cancel_cascade.py b/offline-backend-20260512/backend/tests/test_maf_cancel_cascade.py new file mode 100644 index 00000000..e306ec0d --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_cancel_cascade.py @@ -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"] == [] diff --git a/offline-backend-20260512/backend/tests/test_maf_capability_snapshot.py b/offline-backend-20260512/backend/tests/test_maf_capability_snapshot.py new file mode 100644 index 00000000..f1a388a9 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_capability_snapshot.py @@ -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(), + ) diff --git a/offline-backend-20260512/backend/tests/test_maf_migration_phase2.py b/offline-backend-20260512/backend/tests/test_maf_migration_phase2.py new file mode 100644 index 00000000..79ef8d6c --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_migration_phase2.py @@ -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 diff --git a/offline-backend-20260512/backend/tests/test_maf_old_deerflow_regression.py b/offline-backend-20260512/backend/tests/test_maf_old_deerflow_regression.py new file mode 100644 index 00000000..f8f2b005 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_old_deerflow_regression.py @@ -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" diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_acceptance.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_acceptance.py new file mode 100644 index 00000000..3424c576 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_acceptance.py @@ -0,0 +1,1202 @@ +"""Phase 2 P1 acceptance tests for the MAF roundtable workflow execution. + +These tests exercise the REAL MAF SDK workflow orchestration (WorkflowBuilder, +Executor subclasses, streaming events, checkpoint persistence) with a mocked +DeerFlow runtime client (no real LLM calls). They verify the four completion +criteria for Phase 2 P1 closure: + +1. Real MAF Workflow is running. +2. Write tools never execute before approval (interception before execution). +3. After approval, the workflow resumes and executes exactly once (at-most-once). +4. Cancel and restart recovery don't leave DeerFlow orphan tasks. + +Additionally covers: +- Two seats each create DeerFlow threads owned by the session user. +- MAF group_chat and structured both run a real SDK path. +- Secrets (token/cookie/password/long secret) never appear in events/errors/logs. +- source_event_id dedup at DB level. +- Audit event write failure →external write does not execute (run fails). +- ActionCoordinator: encrypted params never returned from API/events/logs. +""" + +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.maf_roundtable.action_coordinator import ( + ActionCoordinator, + classify_tool, + decrypt_params, + encrypt_params, + tool_needs_approval, +) +from app.maf_roundtable.deerflow_agent_adapter import DeerFlowAgentAdapter +from app.maf_roundtable.runtime_client import ExecutionPrincipal +from app.maf_roundtable.workflow_runner import WorkflowRunner +from deerflow.persistence.base import Base +from deerflow.persistence.maf_roundtable import make_maf_roundtable_store + +# ── fixtures ────────────────────────────────────────────────────────────── + + +@pytest_asyncio.fixture +async def store(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_accept.db'}") + async with engine.begin() as conn: + import deerflow.persistence.maf_roundtable.model # noqa: F401 + + await conn.run_sync(Base.metadata.create_all) + sf = async_sessionmaker(engine, expire_on_commit=False) + yield make_maf_roundtable_store(sf) + await engine.dispose() + + +def _svc(store): + from maf_test_helpers import make_maf_service + + return make_maf_service(store, enabled=True, admin_diagnostic_access=False) + + +def _make_app(store): + """Build a minimal fake app with the state the runner needs.""" + app = SimpleNamespace() + app.state = SimpleNamespace() + app.state.maf_roundtable_service = _svc(store) + app.state.checkpointer = AsyncMock() + app.state.thread_store = AsyncMock() + app.state.run_manager = AsyncMock() + app.state.stream_bridge = None + return app + + +class _FakeRunRecord: + """Mimics the RunRecord returned by start_run.""" + + def __init__(self, run_id="df-run-1"): + self.run_id = run_id + self.task = asyncio.ensure_future(asyncio.sleep(0)) + + +def _patch_runtime_client(adapter: DeerFlowAgentAdapter, *, responses: dict[str, str] | None = None, tool_calls_map: dict[str, list] | None = None): + """Patch the adapter's runtime client to return controlled responses. + + ``responses`` maps agent_id →content text. + ``tool_calls_map`` maps agent_id →list of tool call dicts the AI "generated". + """ + responses = responses or {} + tool_calls_map = tool_calls_map or {} + call_log: list[dict] = [] + write_tool_counter: dict[str, int] = {"count": 0} + + async def fake_create_thread(principal: ExecutionPrincipal) -> str: + return f"thread-{principal.session_id[:8]}-{len(call_log)}" + + async def fake_start_agent_run(principal, *, thread_id, agent_id, message, excluded_tools=None, approved_tool=None, turn_index=0, capability_snapshot=None, write_policy="confirm_high_risk", **kwargs): + call_log.append({ + "agent_id": agent_id, + "thread_id": thread_id, + "message": message[:200], + "excluded_tools": list(excluded_tools or []), + "approved_tool": approved_tool, + "turn_index": turn_index, + "capability_snapshot": capability_snapshot, + "write_policy": write_policy, + }) + + # Phase-2 simulation: when this is a normal seat turn (no approved_tool), + # the WriteGuard middleware would intercept write tools inside the real + # DeerFlow run and create pending actions. Since the fake bypasses the + # middleware, we replay that here so the action/approval get created + # exactly as the real middleware would. + if not approved_tool and write_policy != "auto": + from app.maf_roundtable.action_coordinator import ActionCoordinator + + tcs = tool_calls_map.get(agent_id, []) + coord = ActionCoordinator(adapter._service, user_id=principal.user_id) + try: + await coord.intercept_write_tool_calls( + run_id=principal.run_id, + session_id=principal.session_id, + agent_id=agent_id, + turn_index=turn_index, + tool_calls=tcs, + capability_snapshot=capability_snapshot, + write_policy=write_policy, + ) + except Exception: + pass # best-effort replay for the test harness + + return _FakeRunRecord(run_id=f"df-{agent_id}") + + async def fake_read_messages(principal, thread_id): + agent_id = "" + for c in call_log: + if c["thread_id"] == thread_id: + agent_id = c["agent_id"] + break + + # Phase-2 semantics: the WriteGuard middleware intercepts write tools + # inside the DeerFlow run. A normal seat turn (no ``approved_tool``) + # means the write was intercepted →counter stays put. Only the + # ToolExecutor's deterministic run (via execute_tool_directly) sets + # the write counter. + tcs = tool_calls_map.get(agent_id, []) + content = responses.get(agent_id, f"Response from {agent_id}") + messages = [{"type": "ai", "content": content, "usage_metadata": {"input_tokens": 10, "output_tokens": 20, "total_tokens": 30}}] + if tcs: + messages[0]["tool_calls"] = tcs + return messages + + async def fake_execute_tool_directly(*, thread_id, agent_id, tool_name, params, principal, **kwargs): + """Simulate direct tool execution —the LLM is bypassed (P0-2). + Increment the write counter for the approved tool. Extra P0-1 context + kwargs (turn_index/capability_snapshot/— are accepted and ignored.""" + write_tool_counter["count"] += 1 + return {"status": "succeeded", "result": {"tool": tool_name, "params": params}} + + adapter._client.create_thread = fake_create_thread + adapter._client.start_agent_run = fake_start_agent_run + adapter._client.read_thread_messages = fake_read_messages + adapter._client.cancel_run = AsyncMock(return_value=True) + adapter._client.execute_tool_directly = fake_execute_tool_directly + return call_log, write_tool_counter + + +# ── 1. Real SDK structured workflow, 2 seats ───────────────────────────── + + +@pytest.mark.asyncio +async def test_real_sdk_structured_workflow_two_seats(store): + """Build and run a real MAF SDK structured workflow with 2 seat executors.""" + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "分析市场趋势", + "mode": "structured", + "participants": [ + {"agent_id": "analyst-a", "role": "seat"}, + {"agent_id": "analyst-b", "role": "seat"}, + ], + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + _patch_runtime_client(runner.adapter, responses={ + "analyst-a": "市场呈上升趋势", + "analyst-b": "需注意风险因素", + }) + + await runner.execute(s, run) + + final_run = await svc.get_run(run_id=run["id"], user_id="u1") + assert final_run["status"] == "completed" + assert final_run["finished_at"] is not None + assert final_run["started_at"] is not None + + final_session = await svc.get_session(session_id=s["id"], user_id="u1") + assert final_session["status"] == "completed" + assert final_session["final_result"] is not None + + +# ── 2. Two seats create threads owned by session user ────────────────── + + +@pytest.mark.asyncio +async def test_two_seats_create_threads_owned_by_session_user(store): + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "t", + "participants": [ + {"agent_id": "seat-1", "role": "seat"}, + {"agent_id": "seat-2", "role": "seat"}, + ], + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + + created_threads: list[dict] = [] + orig_create = runner.adapter._client.create_thread + + async def tracking_create(principal: ExecutionPrincipal): + tid = await orig_create(principal) + created_threads.append({"thread_id": tid, "user_id": principal.user_id, "session_id": principal.session_id}) + return tid + + runner.adapter._client.create_thread = tracking_create + _patch_runtime_client(runner.adapter) + runner.adapter._client.create_thread = tracking_create + + await runner.execute(s, run) + + assert len(created_threads) == 2 + assert all(t["user_id"] == "u1" for t in created_threads) + assert all(t["session_id"] == s["id"] for t in created_threads) + assert created_threads[0]["thread_id"] != created_threads[1]["thread_id"] + + +# ── 3. Two seats execute in order, producing events ───────────────────── + + +@pytest.mark.asyncio +async def test_two_seats_execute_in_order_producing_events(store): + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "t", + "participants": [ + {"agent_id": "seat-x", "role": "seat"}, + {"agent_id": "seat-y", "role": "seat"}, + ], + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + _patch_runtime_client(runner.adapter) + + await runner.execute(s, run) + + events, _ = await svc.list_events(run_id=run["id"], user_id="u1") + event_types = [e["event_type"] for e in events] + + assert event_types.count("agent.turn_started") >= 2 + assert event_types.count("agent.message.completed") >= 2 + assert "run.completed" in event_types + + +# ── 4. Seat tool call + skill + artifact events ───────────────────────── + + +@pytest.mark.asyncio +async def test_seat_tool_call_and_skill_artifact_events(store): + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "t", + "participants": [{"agent_id": "tool-seat", "role": "seat"}], + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + + from app.maf_roundtable.event_normalizer import ( + make_artifact_created, + make_skill_completed, + make_skill_started, + ) + + await runner.adapter._emit_critical(run["id"], { + "event_type": "tool.requested", + "agent_id": "tool-seat", + "turn_index": 1, + "payload": {"tool_name": "web_search", "tool_call_id": "tc1"}, + }) + await runner.adapter._emit_critical(run["id"], { + "event_type": "tool.completed", + "agent_id": "tool-seat", + "turn_index": 1, + "payload": {"tool_name": "web_search", "tool_call_id": "tc1", "status": "completed"}, + }) + await runner.adapter._emit_critical(run["id"], make_skill_started("tool-seat", 1, skill_name="data-analysis")) + await runner.adapter._emit_critical(run["id"], make_skill_completed("tool-seat", 1, skill_name="data-analysis", result_preview="分析完成")) + await runner.adapter._emit_critical(run["id"], make_artifact_created("tool-seat", 1, artifact_type="chart", artifact_id="art-1", title="趋势图")) + + events, _ = await svc.list_events(run_id=run["id"], user_id="u1") + types = [e["event_type"] for e in events] + assert "tool.requested" in types + assert "tool.completed" in types + assert "skill.started" in types + assert "skill.completed" in types + assert "artifact.created" in types + + +# ── 5. P1: Write tool counter —0 before approval, 1 after, still 1 after repeated resume ── + + +@pytest.mark.asyncio +async def test_write_tool_counter_zero_before_approval(store): + """P1: write tools are intercepted before execution. The write tool + counter must be 0 before any approval.""" + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "t", + "write_policy": "confirm_all_writes", + "participants": [{"agent_id": "writer", "role": "seat"}], + "capability_snapshot": { + "agents": [{"agent_id": "writer"}], + "tools": [ + {"name": "create_ticket", "side_effect": "write", "risk_level": "R3"}, + ], + }, + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + _, write_counter = _patch_runtime_client( + runner.adapter, + responses={"writer": "分析完成,需要创建工单"}, + tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]}, + ) + + await runner.execute(s, run) + + # Write tool was excluded →never executed →counter is 0. + assert write_counter["count"] == 0, "write tool executed before approval!" + + # An action + approval should have been created. + actions = await svc.list_actions(run_id=run["id"], user_id="u1") + assert len(actions) == 1 + assert actions[0]["tool_name"] == "create_ticket" + assert actions[0]["status"] == "waiting_approval" + + approvals = await svc.list_approvals(run_id=run["id"], user_id="u1") + assert len(approvals) == 1 + assert approvals[0]["status"] == "pending" + + # Run should be in waiting_approval (not completed). + final_run = await svc.get_run(run_id=run["id"], user_id="u1") + assert final_run["status"] == "waiting_approval" + # waiting_approval must NOT write finished_at. + assert final_run["finished_at"] is None + + +@pytest.mark.asyncio +async def test_write_tool_counter_one_after_approval(store): + """P1: after approval, the workflow resumes and the write tool executes + exactly once.""" + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "t", + "write_policy": "confirm_all_writes", + "participants": [{"agent_id": "writer", "role": "seat"}], + "capability_snapshot": { + "agents": [{"agent_id": "writer"}], + "tools": [ + {"name": "create_ticket", "side_effect": "write", "risk_level": "R3"}, + ], + }, + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + _, write_counter = _patch_runtime_client( + runner.adapter, + responses={"writer": "工单已创建"}, + tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]}, + ) + + # Execute the workflow →creates action + approval →waiting_approval. + await runner.execute(s, run) + + assert write_counter["count"] == 0 # Before approval: 0. + + # Approve. + approvals = await svc.list_approvals(run_id=run["id"], user_id="u1") + approval = approvals[0] + decided = await svc.decide_approval(approval_id=approval["id"], decision="approved", user_id="u1") + assert decided["status"] == "approved" + + # Resume the workflow. + resumed = await runner.resume(run["id"]) + assert resumed is not None + assert resumed["status"] == "completed" + + # After approval + resume: write tool executed exactly once. + assert write_counter["count"] == 1, "write tool should execute exactly once after approval" + + # Action should be succeeded. + actions = await svc.list_actions(run_id=run["id"], user_id="u1") + assert actions[0]["status"] == "succeeded" + + +@pytest.mark.asyncio +async def test_write_tool_still_one_after_repeated_resume(store): + """P1: repeated resume does not double-execute the write tool.""" + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "t", + "write_policy": "confirm_all_writes", + "participants": [{"agent_id": "writer", "role": "seat"}], + "capability_snapshot": { + "agents": [{"agent_id": "writer"}], + "tools": [ + {"name": "create_ticket", "side_effect": "write", "risk_level": "R3"}, + ], + }, + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + _, write_counter = _patch_runtime_client( + runner.adapter, + responses={"writer": "工单已创建"}, + tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]}, + ) + + await runner.execute(s, run) + + approvals = await svc.list_approvals(run_id=run["id"], user_id="u1") + await svc.decide_approval(approval_id=approvals[0]["id"], decision="approved", user_id="u1") + + # First resume. + await runner.resume(run["id"]) + assert write_counter["count"] == 1 + + # Second resume —should not re-execute. + await runner.resume(run["id"]) + assert write_counter["count"] == 1, "repeated resume must not double-execute" + + +@pytest.mark.asyncio +async def test_concurrent_resume_second_sees_running_tool_runs_once(store): + """P0-3: two concurrent resumes. The first wins the run-level CAS and blocks + inside the tool; the second sees the run already ``running`` and returns + WITHOUT writing a terminal status. The tool executes exactly once; only after + the first resume's tool is released does the run finalize (report) →completed. + """ + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "t", + "write_policy": "confirm_all_writes", + "participants": [{"agent_id": "writer", "role": "seat"}], + "capability_snapshot": { + "agents": [{"agent_id": "writer"}], + "tools": [{"name": "create_ticket", "side_effect": "write", "risk_level": "R3"}], + }, + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + _patch_runtime_client( + runner.adapter, + responses={"writer": "工单已创建"}, + tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]}, + ) + + # Replace the direct-tool executor with a blocking one so we can interleave + # a second resume while the first is mid-execution. + write_counter = {"count": 0} + started = asyncio.Event() + release = asyncio.Event() + + async def blocking_execute_tool_directly(*, thread_id, agent_id, tool_name, params, principal, **kwargs): + write_counter["count"] += 1 + started.set() # signal: first resume has claimed run+action and is executing + await release.wait() # block until the test releases it + return {"status": "succeeded", "result": {"tool": tool_name}} + + runner.adapter._client.execute_tool_directly = blocking_execute_tool_directly + + # Drive the workflow to waiting_approval, then approve. + await runner.execute(s, run) + approvals = await svc.list_approvals(run_id=run["id"], user_id="u1") + await svc.decide_approval(approval_id=approvals[0]["id"], decision="approved", user_id="u1") + + # First resume (background) —wins the run CAS, then blocks in the tool. + first_task = asyncio.ensure_future(runner.resume(run["id"])) + await asyncio.wait_for(started.wait(), timeout=5) + assert write_counter["count"] == 1, "tool must have started exactly once" + + # Second resume —the run is already 'running'; it must return that and must + # NOT write a terminal status or re-execute the tool. + second = await runner.resume(run["id"]) + assert second is not None and second["status"] == "running", \ + f"second concurrent resume must see running, got {second and second['status']}" + assert write_counter["count"] == 1, "second resume must not re-execute the tool" + + # Release the first resume's tool →it finalizes (report) →completed. + release.set() + first = await asyncio.wait_for(first_task, timeout=10) + assert first is not None and first["status"] == "completed" + assert write_counter["count"] == 1, "tool executes exactly once across both resumes" + + # The completed run carries a synthesized final report (finalizer re-entered). + final_run = await svc.store.runs.get_run(run["id"]) + assert final_run["finished_at"] is not None + + +@pytest.mark.asyncio +async def test_resume_with_missing_secret_fails_without_executing(store): + """P0-2: if the action secret is deleted before resume, the write tool must + execute **0 times**, the action is ``failed`` (not succeeded), and the parent + run is ``failed`` —never completed, never a retry of the external write.""" + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "t", + "write_policy": "confirm_all_writes", + "participants": [{"agent_id": "writer", "role": "seat"}], + "capability_snapshot": { + "agents": [{"agent_id": "writer"}], + "tools": [{"name": "create_ticket", "side_effect": "write", "risk_level": "R3"}], + }, + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + _, write_counter = _patch_runtime_client( + runner.adapter, + responses={"writer": "工单已创建"}, + tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]}, + ) + + await runner.execute(s, run) + approvals = await svc.list_approvals(run_id=run["id"], user_id="u1") + await svc.decide_approval(approval_id=approvals[0]["id"], decision="approved", user_id="u1") + + # Delete the encrypted secret out from under the approved action. + actions = await svc.list_actions(run_id=run["id"], user_id="u1") + action_id = actions[0]["id"] + await store.action_secrets.delete_by_action(action_id) + + resumed = await runner.resume(run["id"]) + + assert write_counter["count"] == 0, "tool must NOT execute when the secret is gone" + final_actions = await svc.list_actions(run_id=run["id"], user_id="u1") + assert final_actions[0]["status"] == "failed" + assert final_actions[0]["status"] != "succeeded" + assert resumed is not None and resumed["status"] == "failed" + + +@pytest.mark.asyncio +async def test_resume_with_corrupt_secret_fails_without_executing(store): + """P0-2: if the action secret cannot be decrypted (wrong key / garbage), + resume fails the action and the run with the tool executing **0 times**.""" + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "t", + "write_policy": "confirm_all_writes", + "participants": [{"agent_id": "writer", "role": "seat"}], + "capability_snapshot": { + "agents": [{"agent_id": "writer"}], + "tools": [{"name": "create_ticket", "side_effect": "write", "risk_level": "R3"}], + }, + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + _, write_counter = _patch_runtime_client( + runner.adapter, + responses={"writer": "工单已创建"}, + tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]}, + ) + + await runner.execute(s, run) + approvals = await svc.list_approvals(run_id=run["id"], user_id="u1") + await svc.decide_approval(approval_id=approvals[0]["id"], decision="approved", user_id="u1") + + # Replace the ciphertext with garbage that Fernet cannot decrypt. + actions = await svc.list_actions(run_id=run["id"], user_id="u1") + action_id = actions[0]["id"] + await store.action_secrets.delete_by_action(action_id) + await store.action_secrets.create(action_id=action_id, arguments_encrypted="corrupt-token") + + resumed = await runner.resume(run["id"]) + + assert write_counter["count"] == 0, "tool must NOT execute when the secret is undecryptable" + final_actions = await svc.list_actions(run_id=run["id"], user_id="u1") + assert final_actions[0]["status"] == "failed" + assert resumed is not None and resumed["status"] == "failed" + + +# ── 6. P1: Rejected approval →write count stays 0 ────────────────────── + + +@pytest.mark.asyncio +async def test_rejected_approval_write_count_stays_zero(store): + """P1: after a rejected approval, the external write count stays 0 and + the action is marked rejected.""" + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "t", + "write_policy": "confirm_all_writes", + "participants": [{"agent_id": "writer", "role": "seat"}], + "capability_snapshot": { + "agents": [{"agent_id": "writer"}], + "tools": [ + {"name": "create_ticket", "side_effect": "write", "risk_level": "R3"}, + ], + }, + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + _, write_counter = _patch_runtime_client( + runner.adapter, + responses={"writer": "需要创建工单"}, + tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]}, + ) + + await runner.execute(s, run) + + approvals = await svc.list_approvals(run_id=run["id"], user_id="u1") + await svc.decide_approval(approval_id=approvals[0]["id"], decision="rejected", user_id="u1") + + await runner.resume(run["id"]) + + assert write_counter["count"] == 0, "rejected approval must not execute write tool" + + actions = await svc.list_actions(run_id=run["id"], user_id="u1") + assert actions[0]["status"] == "rejected" + + +# ── 7. P1: In-process cancel cascades to DeerFlow ─────────────────────── + + +@pytest.mark.asyncio +async def test_in_process_cancel_cascades_to_deerflow(store): + """P1: cancel calls DeerFlow run_manager.cancel on all active seat runs.""" + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={"task_text": "t", "participants": [{"agent_id": "seat-1", "role": "seat"}]}, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + # Create a seat run with a DeerFlow run id. + seat_run = await store.seat_runs.create_seat_run( + maf_run_id=run["id"], agent_id="seat-1", turn_index=1, deerflow_thread_id="thread-x" + ) + await store.seat_runs.mark_running(seat_run["id"], deerflow_run_id="df-run-42") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + runner.adapter._client.cancel_run = AsyncMock(return_value=True) + + result = await runner.cancel(run["id"]) + assert result["all_cancelled"] is True + + # DeerFlow cancel_run was called with the right run id. + runner.adapter._client.cancel_run.assert_called_once_with("df-run-42") + + # Seat run should be cancelled. + seat_runs = await store.seat_runs.get_by_maf_run(run["id"]) + assert seat_runs[0]["status"] == "cancelled" + + +# ── 8. P1: Simulated restart cancel via persisted seat runs ────────────── + + +@pytest.mark.asyncio +async def test_restart_cancel_via_persisted_seat_runs(store): + """P1: after a simulated restart, the system can still cancel outstanding + DeerFlow runs via persisted seat_run records.""" + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={"task_text": "t", "participants": [{"agent_id": "long-seat", "role": "seat"}]}, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + seat_run = await store.seat_runs.create_seat_run( + maf_run_id=run["id"], agent_id="long-seat", turn_index=1, deerflow_thread_id="thread-x" + ) + await store.seat_runs.mark_running(seat_run["id"], deerflow_run_id="df-run-99") + + # "Restart" —build a fresh runner (new adapter, new client). + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + runner.adapter._client.cancel_run = AsyncMock(return_value=True) + + cancelled = await runner.cancel(run["id"]) + assert cancelled["all_cancelled"] is True + runner.adapter._client.cancel_run.assert_called_once_with("df-run-99") + + seat_runs = await store.seat_runs.get_by_maf_run(run["id"]) + assert seat_runs[0]["status"] == "cancelled" + + +# ── 9. P1: Two-phase cancel —cancelling then cancelled ───────────────── + + +@pytest.mark.asyncio +async def test_two_phase_cancel_cancelling_then_cancelled(store): + """P1: cancel first marks seat runs as cancelling, then cancelled after + DeerFlow cancel succeeds.""" + 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") + + sr = await store.seat_runs.create_seat_run( + maf_run_id=run["id"], agent_id="s1", turn_index=1, deerflow_thread_id="t1" + ) + await store.seat_runs.mark_running(sr["id"], deerflow_run_id="df-1") + + infos = await store.seat_runs.cancel_all_active(run["id"]) + assert len(infos) == 1 + assert infos[0]["deerflow_run_id"] == "df-1" + + runs = await store.seat_runs.get_by_maf_run(run["id"]) + assert runs[0]["status"] == "cancelling" + + await store.seat_runs.mark_cancelled(sr["id"]) + runs = await store.seat_runs.get_by_maf_run(run["id"]) + assert runs[0]["status"] == "cancelled" + + +@pytest.mark.asyncio +async def test_cancel_failed_when_deerflow_cancel_fails(store): + """P1: when DeerFlow cancel fails, the seat run is marked cancel_failed.""" + 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") + + sr = await store.seat_runs.create_seat_run( + maf_run_id=run["id"], agent_id="s1", turn_index=1, deerflow_thread_id="t1" + ) + await store.seat_runs.mark_running(sr["id"], deerflow_run_id="df-1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + runner.adapter._client.cancel_run = AsyncMock(return_value=False) + + result = await runner.cancel(run["id"]) + assert result["all_cancelled"] is False + + runs = await store.seat_runs.get_by_maf_run(run["id"]) + assert runs[0]["status"] == "cancel_failed" + + +# ── 10. MAF group_chat runs a real SDK path ────────────────────────────── + + +@pytest.mark.asyncio +async def test_group_chat_runs_real_sdk_path(store): + """P1: group_chat mode runs a real MAF SDK workflow path.""" + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "讨论方案", + "mode": "group_chat", + "max_rounds": 2, + "participants": [ + {"agent_id": "g-seat-1", "role": "seat"}, + {"agent_id": "g-seat-2", "role": "seat"}, + ], + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + _patch_runtime_client(runner.adapter, responses={ + "g-seat-1": "我认为方案A更好", + "g-seat-2": "我同意", + }) + + await runner.execute(s, run) + + final_run = await svc.get_run(run_id=run["id"], user_id="u1") + assert final_run["status"] == "completed" + + +# ── 11. Audit event failure prevents external write ────────────────────── + + +@pytest.mark.asyncio +async def test_audit_event_failure_prevents_external_write(store): + """P1: when a critical audit event fails to persist, the adapter raises + AuditEventError and the run fails —external writes never execute.""" + from app.maf_roundtable.deerflow_agent_adapter import AuditEventError + + 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") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + + with patch.object(svc, "append_event", side_effect=RuntimeError("DB write failed")): + with pytest.raises(AuditEventError, match="failed to persist"): + await runner.adapter._emit_critical(run["id"], { + "event_type": "tool.requested", + "agent_id": "seat", + "turn_index": 1, + "payload": {"tool_name": "write_data"}, + }) + + +# ── 12. DeerFlow task error marks run failed ───────────────────────────── + + +@pytest.mark.asyncio +async def test_deerflow_task_error_marks_run_failed(store): + """P1: when the DeerFlow agent run raises, the MAF run is marked failed + (not completed), with a sanitized error message.""" + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={"task_text": "t", "participants": [{"agent_id": "boom-seat", "role": "seat"}]}, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + + async def exploding_start(*args, **kwargs): + raise RuntimeError("LLM provider unavailable: token=sk-secret-12345") + + runner.adapter._client.create_thread = AsyncMock(return_value="thread-1") + runner.adapter._client.start_agent_run = exploding_start + + await runner.execute(s, run) + + final_run = await svc.get_run(run_id=run["id"], user_id="u1") + assert final_run["status"] == "failed" + assert final_run["finished_at"] is not None + assert "sk-secret-12345" not in (final_run.get("error_message_safe") or "") + + +# ── 13. Secrets never in events / errors / logs ────────────────────────── + + +@pytest.mark.asyncio +async def test_secrets_never_in_events_or_errors(store): + """P1: tokens, cookies, passwords, and long secrets are stripped from + event payloads, error messages, and seat-run errors.""" + from app.maf_roundtable.sanitize import sanitize + + 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") + + secret_payload = { + "tool_name": "api_call", + "authorization": "Bearer sk-live-abcdef123456789", + "cookie": "session=abc123; token=xyz789", + "password": "hunter2", + "api_key": "sk-proj-1234567890abcdef", + "nested": {"secret": "deep-secret-value", "data": "safe"}, + "long_text": "x" * 600 + " token=embedded-secret-999 " + "y" * 600, + } + event = await svc.append_event( + run_id=run["id"], user_id="u1", event_type="tool.completed", payload=secret_payload + ) + + stored = event["payload"] + assert stored["authorization"] == "***REDACTED***" + assert stored["cookie"] == "***REDACTED***" + assert stored["password"] == "***REDACTED***" + assert stored["api_key"] == "***REDACTED***" + assert stored["nested"]["secret"] == "***REDACTED***" + assert stored["nested"]["data"] == "safe" + assert "embedded-secret-999" not in stored["long_text"] + + long_secret = "prefix " * 200 + "password=super-secret-value " + "suffix " * 200 + sanitized = sanitize(long_secret) + assert "super-secret-value" not in sanitized + + +# ── 14. source_event_id dedup at DB level ──────────────────────────────── + + +@pytest.mark.asyncio +async def test_source_event_id_dedup_at_db_level(store): + """P1: events with the same (run_id, source_event_id) are deduped at the DB level.""" + 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") + + e1 = await svc.append_event( + run_id=run["id"], user_id="u1", event_type="agent.message.delta", + payload={"text": "hello"}, source_event_id="msg-abc-123", + ) + e2 = await svc.append_event( + run_id=run["id"], user_id="u1", event_type="agent.message.delta", + payload={"text": "hello again"}, source_event_id="msg-abc-123", + ) + + assert e1["id"] == e2["id"] + assert e1["seq"] == e2["seq"] + + events, latest = await svc.list_events(run_id=run["id"], user_id="u1") + assert len(events) == 1 + assert latest == 1 + + +# ── 15. ActionCoordinator: encrypted params never returned ─────────────── + + +@pytest.mark.asyncio +async def test_encrypted_action_params_never_returned(store): + """P1: encrypted actual execution params are never returned from the + actions API, events, or logs.""" + 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") + + coord = ActionCoordinator(svc, user_id="u1") + + # Create an action with encrypted params. + action = await store.actions.create_action({ + "session_id": s["id"], + "run_id": run["id"], + "agent_id": "writer", + "tool_name": "create_ticket", + "idempotency_key": f"{run['id']}:writer:1:tc-1", + "risk_level": "R3", + "status": "waiting_approval", + "arguments": {"title": "test", "authorization": "***REDACTED***"}, + }) + await store.action_secrets.create( + action_id=action["id"], + arguments_encrypted=encrypt_params({"title": "test", "password": "real-secret-xyz"}), + ) + + # The action API response must NOT contain encrypted params. + api_action = await svc.get_action(action_id=action["id"], user_id="u1") + assert "arguments_encrypted" not in str(api_action).lower() or "arguments_encrypted" not in api_action + assert "password" not in str(api_action.get("arguments", {})) + + # The encrypted params can only be decrypted server-side. + decrypted = await coord.get_approved_action_params(action["id"]) + assert decrypted is not None + assert decrypted["password"] == "real-secret-xyz" + + # But the decrypted params must never appear in the action's API response. + api_action2 = await svc.get_action(action_id=action["id"], user_id="u1") + assert "real-secret-xyz" not in json.dumps(api_action2) + + +# ── 16. ActionCoordinator: tool classification ─────────────────────────── + + +@pytest.mark.asyncio +async def test_tool_classification_metadata_driven(store): + """P1: tools are classified from capability snapshot metadata, not name + prefixes. Tools without metadata use safe default (write/R3 →needs approval).""" + snapshot = { + "tools": [ + {"name": "search", "side_effect": "read", "risk_level": "R0"}, + {"name": "create_ticket", "side_effect": "write", "risk_level": "R3"}, + {"name": "unknown_tool"}, # no metadata →safe default + ], + } + + # Read tool →no approval needed. + assert classify_tool("search", snapshot) == ("read", "R0") + assert not tool_needs_approval("search", "confirm_high_risk", snapshot) + + # Write tool R3 →approval needed in confirm_high_risk. + assert classify_tool("create_ticket", snapshot) == ("write", "R3") + assert tool_needs_approval("create_ticket", "confirm_high_risk", snapshot) + + # Unknown tool →safe default (write/R3) →approval needed. + assert classify_tool("unknown_tool", snapshot) == ("write", "R3") + assert tool_needs_approval("unknown_tool", "confirm_high_risk", snapshot) + + # In auto mode →no approval needed. + assert not tool_needs_approval("create_ticket", "auto", snapshot) + + # In confirm_all_writes →any write needs approval. + assert tool_needs_approval("create_ticket", "confirm_all_writes", snapshot) + + # No snapshot →safe default. + assert classify_tool("any_tool", None) == ("write", "R3") + + +# ── 17. ActionCoordinator: encryption round-trip ───────────────────────── + + +def test_encryption_round_trip(): + """P1: encrypted params can be decrypted back correctly.""" + params = {"title": "test", "password": "secret123", "nested": {"key": "val"}} + encrypted = encrypt_params(params) + decrypted = decrypt_params(encrypted) + assert decrypted == params + # The ciphertext must not contain the plaintext. + assert "secret123" not in encrypted + assert "test" not in encrypted + + +# ── 18. waiting_approval does NOT write finished_at ────────────────────── + + +@pytest.mark.asyncio +async def test_waiting_approval_no_finished_at(store): + """P1: waiting_approval is NOT a terminal state —finished_at must not + be written, and both session and run should be in waiting_approval.""" + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "t", + "write_policy": "confirm_all_writes", + "participants": [{"agent_id": "writer", "role": "seat"}], + "capability_snapshot": { + "agents": [{"agent_id": "writer"}], + "tools": [ + {"name": "create_ticket", "side_effect": "write", "risk_level": "R3"}, + ], + }, + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + _patch_runtime_client( + runner.adapter, + responses={"writer": "需要创建工单"}, + tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]}, + ) + + await runner.execute(s, run) + + final_run = await svc.get_run(run_id=run["id"], user_id="u1") + assert final_run["status"] == "waiting_approval" + assert final_run["finished_at"] is None + + final_session = await svc.get_session(session_id=s["id"], user_id="u1") + assert final_session["status"] == "waiting_approval" + + +# ── 19. RoundtableState.from_dict is actually called by resume ──────────── + + +@pytest.mark.asyncio +async def test_resume_uses_from_dict(store): + """P1: RoundtableState.from_dict() is actually called by the resume path, + not left idle.""" + svc = _svc(store) + s = await svc.create_session( + user_id="u1", + body={ + "task_text": "t", + "write_policy": "confirm_all_writes", + "participants": [{"agent_id": "writer", "role": "seat"}], + "capability_snapshot": { + "agents": [{"agent_id": "writer"}], + "tools": [ + {"name": "create_ticket", "side_effect": "write", "risk_level": "R3"}, + ], + }, + }, + ) + run = await svc.create_run(session_id=s["id"], user_id="u1") + + app = _make_app(store) + runner = WorkflowRunner(app, service=svc, user_id="u1") + _patch_runtime_client( + runner.adapter, + responses={"writer": "工单已创建"}, + tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]}, + ) + + await runner.execute(s, run) + + # Verify checkpoint was persisted. + run_after = await svc.get_run(run_id=run["id"], user_id="u1") + assert run_after["maf_checkpoint"] is not None + assert run_after["maf_checkpoint"]["run_id"] == run["id"] + + # Approve and resume. + approvals = await svc.list_approvals(run_id=run["id"], user_id="u1") + await svc.decide_approval(approval_id=approvals[0]["id"], decision="approved", user_id="u1") + resumed = await runner.resume(run["id"]) + assert resumed is not None + assert resumed["status"] == "completed" + + +# ── 20. Token usage aggregation ────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_token_usage_aggregated_on_completion(store): + from app.maf_roundtable.workflow_runner import _aggregate_token_usage + + turns = [ + {"agent_id": "a", "turn_index": 1, "usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}}, + {"agent_id": "b", "turn_index": 1, "usage": {"input_tokens": 200, "output_tokens": 80, "total_tokens": 280}}, + {"agent_id": "a", "turn_index": 2, "usage": None}, + ] + agg = _aggregate_token_usage(turns) + assert agg["total_input_tokens"] == 300 + assert agg["total_output_tokens"] == 130 + assert agg["total_tokens"] == 430 + assert len(agg["per_seat"]) == 2 + + +# ── 21. Capability snapshot reading ────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_capability_snapshot_drives_seat_config(store): + from app.maf_roundtable.workflow_executors import _agent_capabilities_from_snapshot + + snapshot = { + "agents": [ + { + "agent_id": "seat-1", + "model_name": "gpt-4o", + "reasoning_effort": "high", + "thinking_enabled": False, + "subagent_enabled": True, + "excluded_tools": ["dangerous_tool"], + "memory_recall_disabled": True, + "memory_injection_enabled": False, + }, + ], + } + caps = _agent_capabilities_from_snapshot(snapshot, "seat-1") + assert caps["model_name"] == "gpt-4o" + assert caps["reasoning_effort"] == "high" + assert caps["thinking_enabled"] is False + assert caps["subagent_enabled"] is True + assert caps["excluded_tools"] == ["dangerous_tool"] + assert caps["memory_recall_disabled"] is True + assert caps["memory_injection_enabled"] is False + + caps2 = _agent_capabilities_from_snapshot(snapshot, "unknown-agent") + assert caps2 == {} + + caps3 = _agent_capabilities_from_snapshot(None, "seat-1") + assert caps3 == {} diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_admin_boundary.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_admin_boundary.py index 563f17fd..f1434b77 100644 --- a/offline-backend-20260512/backend/tests/test_maf_roundtable_admin_boundary.py +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_admin_boundary.py @@ -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): diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_api.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_api.py index f53e2253..9c8ffb44 100644 --- a/offline-backend-20260512/backend/tests/test_maf_roundtable_api.py +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_api.py @@ -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" diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_concurrency.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_concurrency.py index 1ddf2e77..58472263 100644 --- a/offline-backend-20260512/backend/tests/test_maf_roundtable_concurrency.py +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_concurrency.py @@ -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"}) diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_config.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_config.py index 19570a9f..f03fd743 100644 --- a/offline-backend-20260512/backend/tests/test_maf_roundtable_config.py +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_config.py @@ -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. """ diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_http.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_http.py index 6a959266..cff70913 100644 --- a/offline-backend-20260512/backend/tests/test_maf_roundtable_http.py +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_http.py @@ -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", diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py index d7732a92..f00df8e2 100644 --- a/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py @@ -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. diff --git a/offline-backend-20260512/backend/tests/test_maf_write_guard.py b/offline-backend-20260512/backend/tests/test_maf_write_guard.py new file mode 100644 index 00000000..59f5bfa0 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_write_guard.py @@ -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()