feat(maf): add secure roundtable workflow runtime
This commit is contained in:
1 parent
e690475e6e
commit
bd4131b4e2
52 files changed
+10315
-96
No files matched your search
@@ -81,6 +81,9 @@ skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
# MAF roundtable tests must be tracked (excepted from the broad tests/* ignore above)
|
||||
!offline-backend-20260512/backend/tests/test_maf_*.py
|
||||
!offline-backend-20260512/backend/tests/maf_test_helpers.py
|
||||
deploy
|
||||
.history
|
||||
|
||||
|
||||
@@ -328,13 +328,41 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
maf_admin_diag = (
|
||||
bool(getattr(maf_cfg, "admin_diagnostic_access", True)) if maf_cfg is not None else True
|
||||
)
|
||||
# P0-4: when MAF is switched on, its full safety stack must be in
|
||||
# place (write_guard mounted + provider complete + bridge resolvable
|
||||
# + MAF_ACTION_SECRET_KEY set). If any prerequisite is missing the
|
||||
# feature is forced OFF (clearly logged) — never "MAF on, approval
|
||||
# guard not mounted". Reads stay open for admin history review.
|
||||
if maf_enabled:
|
||||
from app.maf_roundtable.service import (
|
||||
MafConfigurationError,
|
||||
validate_maf_prerequisites,
|
||||
)
|
||||
|
||||
try:
|
||||
validate_maf_prerequisites()
|
||||
except MafConfigurationError as cfg_exc:
|
||||
logger.error(
|
||||
"[maf-roundtable] enabled but prerequisites unmet — "
|
||||
"forcing the feature OFF: %s",
|
||||
cfg_exc,
|
||||
)
|
||||
maf_enabled = False
|
||||
if app.state.maf_roundtable_store is not None:
|
||||
app.state.maf_roundtable_service = MafRoundtableService(
|
||||
app.state.maf_roundtable_store,
|
||||
enabled=maf_enabled,
|
||||
admin_diagnostic_access=maf_admin_diag,
|
||||
config=maf_cfg,
|
||||
agent_store=getattr(app.state, "agent_store", None),
|
||||
app_config=config,
|
||||
)
|
||||
# Wire the WriteGuard bridge so the harness middleware can reach
|
||||
# the service when intercepting external-write tool calls.
|
||||
if app.state.maf_roundtable_service is not None:
|
||||
from app.maf_roundtable.write_guard_bridge import register_write_guard_service
|
||||
|
||||
register_write_guard_service(app.state.maf_roundtable_service)
|
||||
else:
|
||||
app.state.maf_roundtable_service = None
|
||||
except Exception: # noqa: BLE001 — experimental feature must never abort startup
|
||||
|
||||
@@ -198,12 +198,31 @@ async def delete_session(request: Request, session_id: str) -> dict[str, Any]:
|
||||
async def create_run(request: Request, session_id: str, body: CreateRunRequest) -> RunResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
|
||||
# Check MAF SDK availability BEFORE creating the run — so the user gets a
|
||||
# clean 503 rather than a run that will immediately fail.
|
||||
from app.maf_roundtable.dispatcher import MafSdkNotAvailableError, get_dispatcher
|
||||
|
||||
dispatcher = get_dispatcher(request.app)
|
||||
try:
|
||||
dispatcher.check_sdk_available()
|
||||
except MafSdkNotAvailableError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
try:
|
||||
run = await svc.create_run(
|
||||
session_id=session_id, user_id=user_id, workflow_version=body.workflow_version, is_admin=is_admin
|
||||
)
|
||||
except (MafDisabledError, MafForbiddenError, MafNotFoundError, MafConflictError) as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
|
||||
# Launch the workflow in the background. The run is already persisted as
|
||||
# "pending"; the dispatcher transitions it to "running" then completes/fails it.
|
||||
# Auto-dispatch can be disabled (e.g. in tests) via app.state.maf_auto_dispatch=False.
|
||||
if getattr(request.app.state, "maf_auto_dispatch", True):
|
||||
session = await svc.get_session(session_id=session_id, user_id=user_id, is_admin=is_admin)
|
||||
await dispatcher.dispatch(session, run, user_id=user_id or "default")
|
||||
|
||||
return RunResponse(**run)
|
||||
|
||||
|
||||
@@ -233,11 +252,43 @@ async def get_run(request: Request, run_id: str) -> RunResponse:
|
||||
async def cancel_run(request: Request, run_id: str) -> RunResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
|
||||
# Phase 1: atomically set run+session to cancelling (not yet cancelled).
|
||||
try:
|
||||
run = await svc.cancel_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
||||
await svc.start_cancel(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
||||
except (MafDisabledError, MafForbiddenError, MafNotFoundError) as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
return RunResponse(**run)
|
||||
|
||||
# Phase 2: cascade cancel to the background task + DeerFlow sub-runs.
|
||||
from app.maf_roundtable.dispatcher import get_dispatcher
|
||||
|
||||
dispatcher = get_dispatcher(request.app)
|
||||
result = await dispatcher.cancel(run_id)
|
||||
|
||||
# Phase 3: finalize based on the structured cancel result.
|
||||
# - all_cancelled → finalize to ``cancelled`` (terminal).
|
||||
# - some sub-runs failed/still active → mark ``cancel_failed`` (non-terminal)
|
||||
# so the failure is visible and retryable. A failed cancel must NEVER
|
||||
# masquerade as cancelled.
|
||||
try:
|
||||
if result.get("all_cancelled"):
|
||||
final_run = await svc.finalize_cancel(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
||||
else:
|
||||
failed = result.get("failed_run_ids") or []
|
||||
still_active = result.get("still_active_run_ids") or []
|
||||
detail = []
|
||||
if failed:
|
||||
detail.append(f"{len(failed)} sub-run(s) failed to cancel")
|
||||
if still_active:
|
||||
detail.append(f"{len(still_active)} sub-run(s) had no DeerFlow run id")
|
||||
reason = "; ".join(detail) or "cancel cascade incomplete"
|
||||
final_run = await svc.mark_cancel_failed(
|
||||
run_id=run_id, user_id=user_id, is_admin=is_admin, reason=reason
|
||||
)
|
||||
except (MafDisabledError, MafForbiddenError, MafNotFoundError) as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
|
||||
return RunResponse(**final_run)
|
||||
|
||||
|
||||
# ── events ─────────────────────────────────────────────────────────────────
|
||||
@@ -310,4 +361,25 @@ async def decide_approval(request: Request, approval_id: str, body: ApprovalDeci
|
||||
)
|
||||
except (MafDisabledError, MafForbiddenError, MafNotFoundError, MafConflictError) as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
|
||||
# P1: after a successful approval decision, resume the workflow from its
|
||||
# checkpoint. Only the owner can resume (decide_approval already enforced
|
||||
# owner-only write). The resume loads the checkpoint, restores
|
||||
# RoundtableState.from_dict(), and executes the approved action (at-most-once).
|
||||
run_id = approval.get("run_id")
|
||||
if run_id and body.decision in ("approved", "rejected"):
|
||||
from app.maf_roundtable.dispatcher import get_dispatcher
|
||||
|
||||
dispatcher = get_dispatcher(request.app)
|
||||
try:
|
||||
await dispatcher.resume(run_id, user_id=user_id or "default")
|
||||
except Exception:
|
||||
# Resume failure should not fail the approval response itself —
|
||||
# the approval is already persisted. Log for observability.
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception(
|
||||
"MAF resume failed for run %s after approval %s", run_id, approval_id
|
||||
)
|
||||
|
||||
return ApprovalResponse(**approval)
|
||||
@@ -0,0 +1,386 @@
|
||||
"""Action Coordinator for the MAF roundtable experiment (Phase 2 P1).
|
||||
|
||||
Intercepts external-write tool calls **before** they reach the real external
|
||||
system. The current problem (pre-fix) was that seats executed DeerFlow tools
|
||||
first and the ApprovalGate only inspected ``tool_calls`` afterwards — too
|
||||
late. The ActionCoordinator fixes this by:
|
||||
|
||||
1. **Classification**: reads per-tool risk metadata from the frozen
|
||||
capability snapshot (``side_effect: read|write``, ``risk_level: R0–R4``).
|
||||
Tools without metadata use a **safe default** (treated as write/R3 →
|
||||
requires approval). Classification never relies on name-prefix heuristics
|
||||
like ``create_`` / ``delete_``.
|
||||
|
||||
2. **Pre-execution interception (Phase-2)**: write tools are NO LONGER
|
||||
excluded from the seat run — the model sees the full tool schema and can
|
||||
propose writes. A harness-level ``WriteGuardMiddleware`` intercepts each
|
||||
write tool call at ``wrap_tool_call``/``awrap_tool_call`` time (activated
|
||||
per-run by a ``maf_run_id`` flag in the run context) and records a pending
|
||||
action before the real tool ever runs. The AI's proposed call is turned
|
||||
into an ``approval_required`` ToolMessage instead of the tool's real result.
|
||||
|
||||
3. **Post-turn capture**: after the seat turn, the seat executor reads the
|
||||
run's ``tool_calls`` to surface the pending approval in workflow state so
|
||||
the ApprovalGate can pause. Action creation itself happens inside the
|
||||
WriteGuard interceptor (single transaction — see point 5).
|
||||
|
||||
For each intercepted write tool call, the coordinator creates — in a
|
||||
**single logical transaction** (``store.create_pending_action_bundle``):
|
||||
- an ``action`` row with idempotency key ``run_id:agent_id:turn_index:tool_call_id``
|
||||
- the **sanitized** arguments (for audit / API visibility)
|
||||
- the **encrypted** actual arguments (server-only, for restart recovery)
|
||||
- an ``approval`` row (pending)
|
||||
- an ``action.waiting_approval`` critical audit event
|
||||
|
||||
If the critical audit event write fails, the action stays **unexecuted**
|
||||
— the external system is never called.
|
||||
|
||||
4. **Post-approval execution**: after the user approves:
|
||||
- Only an ``approved`` ``action_id`` may re-enter the tool executor.
|
||||
- Before execution, the action is atomically occupied (``approved → running``).
|
||||
- A second entry for the same action returns the existing result or is
|
||||
rejected — **never** a second write.
|
||||
- Success → ``succeeded``, failure → ``failed``, network-unknown →
|
||||
``unknown`` (``unknown`` prohibits auto-retry).
|
||||
|
||||
Encryption: uses ``cryptography.fernet.Fernet`` with a key derived from the
|
||||
``MAF_ACTION_SECRET_KEY`` env var. There is **no default key** — the var is
|
||||
required whenever the MAF feature is enabled (validated at startup). The
|
||||
ciphertext is opaque to the store and is **never** returned from any API,
|
||||
event, log, or frontend query.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.sanitize import sanitize, sanitize_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Risk classification ───────────────────────────────────────────────────
|
||||
|
||||
# Tools whose side_effect is explicitly "read" or "skill" are never intercepted.
|
||||
# Everything else (including "unknown" / missing metadata) is treated as a
|
||||
# potential write and subject to the write policy.
|
||||
_SAFE_SIDE_EFFECTS = frozenset({"read", "skill"})
|
||||
|
||||
# Default risk level for tools without metadata — safe default requires approval.
|
||||
_DEFAULT_RISK_LEVEL = "R3"
|
||||
_DEFAULT_SIDE_EFFECT = "write"
|
||||
|
||||
|
||||
def classify_tool(
|
||||
tool_name: str,
|
||||
capability_snapshot: dict[str, Any] | None,
|
||||
agent_id: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Classify a tool's side-effect and risk level from the capability snapshot.
|
||||
|
||||
Returns ``(side_effect, risk_level)``. Tools without explicit metadata
|
||||
use a **safe default**: ``("write", "R3")`` — meaning they are treated as
|
||||
high-risk writes requiring approval. This is intentional: we never guess
|
||||
risk from the tool name.
|
||||
"""
|
||||
if not capability_snapshot:
|
||||
return (_DEFAULT_SIDE_EFFECT, _DEFAULT_RISK_LEVEL)
|
||||
|
||||
tools = capability_snapshot.get("tools")
|
||||
if isinstance(tools, list):
|
||||
for t in tools:
|
||||
if isinstance(t, dict) and t.get("name") == tool_name:
|
||||
side = t.get("side_effect") or _DEFAULT_SIDE_EFFECT
|
||||
risk = t.get("risk_level") or _DEFAULT_RISK_LEVEL
|
||||
return (side, risk)
|
||||
|
||||
agents = capability_snapshot.get("agents")
|
||||
if isinstance(agents, list) and agent_id:
|
||||
for a in agents:
|
||||
if isinstance(a, dict) and a.get("agent_id") == agent_id:
|
||||
agent_tools = a.get("tools")
|
||||
if isinstance(agent_tools, list):
|
||||
for t in agent_tools:
|
||||
if isinstance(t, dict) and t.get("name") == tool_name:
|
||||
side = t.get("side_effect") or _DEFAULT_SIDE_EFFECT
|
||||
risk = t.get("risk_level") or _DEFAULT_RISK_LEVEL
|
||||
return (side, risk)
|
||||
|
||||
return (_DEFAULT_SIDE_EFFECT, _DEFAULT_RISK_LEVEL)
|
||||
|
||||
|
||||
def tool_needs_approval(
|
||||
tool_name: str,
|
||||
write_policy: str,
|
||||
capability_snapshot: dict[str, Any] | None,
|
||||
agent_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Determine whether a tool call needs approval before execution.
|
||||
|
||||
- ``auto``: no approval needed (but the tool is still classified for audit).
|
||||
- ``confirm_high_risk``: approval needed for R3+ tools.
|
||||
- ``confirm_all_writes``: approval needed for any write tool.
|
||||
"""
|
||||
if write_policy == "auto":
|
||||
return False
|
||||
|
||||
side_effect, risk_level = classify_tool(tool_name, capability_snapshot, agent_id)
|
||||
if side_effect in _SAFE_SIDE_EFFECTS:
|
||||
return False
|
||||
|
||||
if write_policy == "confirm_all_writes":
|
||||
return True
|
||||
|
||||
# confirm_high_risk: R3 and above
|
||||
risk_order = {"R0": 0, "R1": 1, "R2": 2, "R3": 3, "R4": 4}
|
||||
risk_val = risk_order.get(risk_level, 3)
|
||||
return risk_val >= 3
|
||||
|
||||
|
||||
# ── Encryption ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MafActionSecretKeyError(RuntimeError):
|
||||
"""Raised when ``MAF_ACTION_SECRET_KEY`` is missing or empty.
|
||||
|
||||
There is no default key: the env var is required whenever the MAF feature
|
||||
is enabled (validated at startup). Using a baked-in default would let a
|
||||
deployment silently encrypt all action params with a publicly-known key.
|
||||
"""
|
||||
|
||||
|
||||
class ActionSecretUnavailableError(RuntimeError):
|
||||
"""The approved action's parameters cannot be produced — execution MUST fail.
|
||||
|
||||
Raised (instead of returning ``None``) whenever the server-only secret is
|
||||
missing, cannot be decrypted, or does not decrypt to a JSON object. Callers
|
||||
must treat this as a hard execution failure: record the action ``failed``,
|
||||
fail the parent run, and NEVER invoke the tool — they must NOT coerce the
|
||||
failure into empty params (``params or {}``) and execute anyway.
|
||||
|
||||
Carries a machine-readable ``reason`` (``missing`` / ``decrypt_failed`` /
|
||||
``invalid_type``) and a ``detail`` that is always safe to log/persist — it
|
||||
never contains the ciphertext, the key, or raw exception bodies.
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str, reason: str, detail: str) -> None:
|
||||
self.action_id = action_id
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
super().__init__(f"action secret unavailable for {action_id}: {reason} — {detail}")
|
||||
|
||||
|
||||
def _get_fernet_key() -> bytes:
|
||||
"""Derive a Fernet key from the ``MAF_ACTION_SECRET_KEY`` env var.
|
||||
|
||||
Raises ``MafActionSecretKeyError`` if the var is missing or empty — there
|
||||
is deliberately **no default key**. Callers must ensure the var is set
|
||||
before the MAF feature is used (the service validates this at startup).
|
||||
"""
|
||||
raw = os.getenv("MAF_ACTION_SECRET_KEY", "")
|
||||
if not raw or not raw.strip():
|
||||
raise MafActionSecretKeyError(
|
||||
"MAF_ACTION_SECRET_KEY environment variable is not set. "
|
||||
"It is required to encrypt external-write action parameters. "
|
||||
"Set it (e.g. a 32+ char random string) before enabling "
|
||||
"maf_roundtable.enabled."
|
||||
)
|
||||
digest = hashlib.sha256(raw.encode()).digest()
|
||||
return base64.urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
def encrypt_params(params: dict[str, Any]) -> str:
|
||||
"""Encrypt tool-call arguments for server-only storage."""
|
||||
import json
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
plaintext = json.dumps(params, ensure_ascii=False).encode()
|
||||
return Fernet(_get_fernet_key()).encrypt(plaintext).decode()
|
||||
|
||||
|
||||
def decrypt_params(ciphertext: str) -> dict[str, Any]:
|
||||
"""Decrypt tool-call arguments at execution time (server-only)."""
|
||||
import json
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
plaintext = Fernet(_get_fernet_key()).decrypt(ciphertext.encode()).decode()
|
||||
return json.loads(plaintext)
|
||||
|
||||
|
||||
# ── Action Coordinator ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ActionCoordinator:
|
||||
"""Manages the intercept-approve-execute lifecycle for external writes.
|
||||
|
||||
Used by the DeerFlowAgentAdapter and WorkflowRunner to:
|
||||
- Determine which tools to exclude before a seat turn.
|
||||
- Capture intercepted write tool calls after a seat turn.
|
||||
- Create actions + approvals + audit events in one logical transaction.
|
||||
- Atomically occupy an approved action for execution (at-most-once).
|
||||
"""
|
||||
|
||||
def __init__(self, service: Any, *, user_id: str = "") -> None:
|
||||
self._service = service
|
||||
self._store = service.store
|
||||
self._user_id = user_id
|
||||
|
||||
# ── Post-turn: capture intercepted write tool calls ──────────────────
|
||||
|
||||
async def intercept_write_tool_calls(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
session_id: str,
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
tool_calls: list[dict[str, Any]],
|
||||
capability_snapshot: dict[str, Any] | None,
|
||||
write_policy: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Process the AI's tool_calls and create actions+approvals for writes.
|
||||
|
||||
For each tool call that needs approval, create the action + encrypted
|
||||
params + approval + audit event in a **single transaction**
|
||||
(``store.create_pending_action_bundle``). If any step fails, the whole
|
||||
bundle rolls back — the DB never holds a half-finished action.
|
||||
|
||||
Idempotency: ``idempotency_key`` is DB-unique. A concurrent duplicate
|
||||
submission re-reads and returns the already-stored action instead of
|
||||
raising, so a retry never fails the run.
|
||||
|
||||
Returns a list of created (or pre-existing) action dicts.
|
||||
"""
|
||||
created: list[dict[str, Any]] = []
|
||||
|
||||
for tc in tool_calls:
|
||||
tool_name = tc.get("name", "")
|
||||
tool_call_id = tc.get("id", "")
|
||||
raw_args = tc.get("args") or {}
|
||||
|
||||
if not tool_needs_approval(tool_name, write_policy, capability_snapshot, agent_id):
|
||||
continue
|
||||
|
||||
_side_effect, risk_level = classify_tool(tool_name, capability_snapshot, agent_id)
|
||||
idempotency_key = f"{run_id}:{agent_id}:{turn_index}:{tool_call_id or tool_name}"
|
||||
|
||||
# Sanitize args for audit visibility (never expose raw args).
|
||||
sanitized_args = sanitize(raw_args)
|
||||
if not isinstance(sanitized_args, dict):
|
||||
sanitized_args = {"value": str(sanitized_args)}
|
||||
|
||||
# Encrypt actual args for server-only recovery.
|
||||
encrypted_args = encrypt_params(
|
||||
raw_args if isinstance(raw_args, dict) else {"value": str(raw_args)}
|
||||
)
|
||||
|
||||
# Single-transaction bundle: action + secret + approval + event.
|
||||
# The store retries internally (up to 8 attempts) on (run_id, seq)
|
||||
# collisions, re-computing MAX(seq)+1 each time.
|
||||
action = await self._store.create_pending_action_bundle(
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
agent_id=agent_id,
|
||||
tool_name=tool_name,
|
||||
logical_action_id=tool_call_id or None,
|
||||
idempotency_key=idempotency_key,
|
||||
risk_level=risk_level,
|
||||
target_key=None,
|
||||
turn_index=turn_index,
|
||||
arguments_sanitized=sanitized_args,
|
||||
arguments_encrypted=encrypted_args,
|
||||
approval_summary=f"外部写操作需要审批: {tool_name}(第{turn_index}轮)",
|
||||
approval_payload={
|
||||
"tool_name": tool_name,
|
||||
"risk_level": risk_level,
|
||||
"turn_index": turn_index,
|
||||
"arguments_redacted": sanitized_args,
|
||||
},
|
||||
audit_event_agent_id=agent_id,
|
||||
audit_event_turn_index=turn_index,
|
||||
)
|
||||
|
||||
created.append(action)
|
||||
|
||||
return created
|
||||
|
||||
# ── Post-approval: atomic occupy + execution ─────────────────────────
|
||||
|
||||
async def occupy_for_execution(self, action_id: str) -> str:
|
||||
"""Atomically transition an action from ``approved`` to ``running``.
|
||||
|
||||
Returns the effective claim status (never a blanket bool):
|
||||
``"claimed"`` (won — execute), ``"in_progress"`` (another executor is
|
||||
running it), or a terminal status ``succeeded`` / ``failed`` /
|
||||
``unknown`` / ``rejected`` (already finished elsewhere). Raises
|
||||
``MafActionStateMachineError`` when the action was never properly
|
||||
approved. This guarantees at-most-once execution under concurrent
|
||||
``resume()`` / retries / restart.
|
||||
"""
|
||||
return await self._store.actions.claim_for_execution(action_id)
|
||||
|
||||
async def record_result(
|
||||
self,
|
||||
action_id: str,
|
||||
*,
|
||||
status: str,
|
||||
result: dict[str, Any] | None = None,
|
||||
external_object_id: str | None = None,
|
||||
) -> None:
|
||||
"""Record the outcome of an action execution.
|
||||
|
||||
``status`` must be ``succeeded``, ``failed``, or ``unknown``.
|
||||
``unknown`` prohibits auto-retry — the action stays in ``unknown``
|
||||
until a human reconciles it.
|
||||
|
||||
State-machine constrained: only a ``running`` action may transition
|
||||
to a terminal status. A concurrent caller that lost the race (or a
|
||||
replay after restart) finds the row already terminal and its UPDATE
|
||||
is a no-op — it must NOT overwrite the real result.
|
||||
"""
|
||||
sanitized_result = sanitize_payload(result) if result else None
|
||||
await self._store.actions.record_terminal_result(
|
||||
action_id,
|
||||
status=status,
|
||||
result=sanitized_result,
|
||||
external_object_id=external_object_id,
|
||||
)
|
||||
|
||||
async def get_approved_action_params(self, action_id: str) -> dict[str, Any]:
|
||||
"""Decrypt and return the actual execution parameters for an approved action.
|
||||
|
||||
Server-only: called only by the tool executor at execution time. The
|
||||
decrypted params never reach API, events, logs, or frontend.
|
||||
|
||||
Raises ``ActionSecretUnavailableError`` (never returns ``None``) when
|
||||
the params cannot be produced — missing secret row, undecryptable
|
||||
ciphertext, or a non-object plaintext. The caller MUST treat this as a
|
||||
hard failure (record ``failed``, fail the parent run, never execute the
|
||||
tool); it must NOT fall back to empty params.
|
||||
"""
|
||||
ciphertext = await self._store.action_secrets.get_by_action(action_id)
|
||||
if ciphertext is None:
|
||||
raise ActionSecretUnavailableError(
|
||||
action_id, "missing", "no encrypted action secret is stored for this action"
|
||||
)
|
||||
try:
|
||||
params = decrypt_params(ciphertext)
|
||||
except Exception as exc:
|
||||
# Log a sanitized reason only — never the ciphertext, key, or raw body.
|
||||
logger.error(
|
||||
"Failed to decrypt action params for %s: %s",
|
||||
action_id, sanitize(str(exc)),
|
||||
)
|
||||
raise ActionSecretUnavailableError(
|
||||
action_id, "decrypt_failed", "encrypted action params could not be decrypted"
|
||||
) from exc
|
||||
if not isinstance(params, dict):
|
||||
raise ActionSecretUnavailableError(
|
||||
action_id, "invalid_type", "decrypted action params are not a JSON object"
|
||||
)
|
||||
return params
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Build server-trusted, frozen MAF capability snapshots.
|
||||
|
||||
Clients must not supply tool metadata or full snapshots — only participant
|
||||
``agent_id`` and optional per-seat ``excluded_tools`` (subtractive only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from deerflow.config.agents_config import load_agent_config
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.tools.tool_metadata import resolve_tool_metadata
|
||||
from deerflow.tools.tools import get_available_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CapabilitySnapshotError(Exception):
|
||||
"""Invalid participant / tool selection when building a trusted snapshot."""
|
||||
|
||||
|
||||
class AgentNotAccessibleError(CapabilitySnapshotError):
|
||||
def __init__(self, agent_id: str) -> None:
|
||||
super().__init__(f"agent not accessible: {agent_id}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
class InvalidExcludedToolsError(CapabilitySnapshotError):
|
||||
def __init__(self, agent_id: str, tools: list[str]) -> None:
|
||||
super().__init__(f"invalid excluded_tools for {agent_id}: {tools}")
|
||||
self.agent_id = agent_id
|
||||
self.tools = tools
|
||||
|
||||
|
||||
async def _assert_user_may_use_agent(
|
||||
*,
|
||||
agent_store: Any | None,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
) -> None:
|
||||
"""Ensure the user may run this agent (visible in catalog)."""
|
||||
if agent_store is not None:
|
||||
visible = await agent_store.get_visible(agent_id, user_id)
|
||||
if visible is None:
|
||||
raise AgentNotAccessibleError(agent_id)
|
||||
return
|
||||
cfg = load_agent_config(agent_id)
|
||||
if cfg is None:
|
||||
raise AgentNotAccessibleError(agent_id)
|
||||
|
||||
|
||||
def _config_extra_for_tool(app_config: AppConfig, tool_name: str) -> dict[str, Any] | None:
|
||||
for tc in app_config.tools:
|
||||
if tc.name == tool_name:
|
||||
extra = dict(getattr(tc, "model_extra", None) or {})
|
||||
return extra or None
|
||||
return None
|
||||
|
||||
|
||||
def _seat_tools_for_agent(
|
||||
*,
|
||||
app_config: AppConfig,
|
||||
agent_id: str,
|
||||
session_model_name: str | None,
|
||||
session_reasoning_effort: str | None,
|
||||
requested_excluded: list[str],
|
||||
) -> tuple[list[str], list[str], list[dict[str, str]]]:
|
||||
"""Resolve allowed names, effective excluded, and tool metadata for one seat."""
|
||||
agent_config = load_agent_config(agent_id)
|
||||
tool_groups = getattr(agent_config, "tool_groups", None) if agent_config else None
|
||||
model_name = (
|
||||
getattr(agent_config, "model", None) if agent_config else None
|
||||
) or session_model_name
|
||||
|
||||
available = get_available_tools(
|
||||
groups=tool_groups,
|
||||
model_name=model_name,
|
||||
excluded_tools=[],
|
||||
app_config=app_config,
|
||||
)
|
||||
allowed_set = {t.name for t in available}
|
||||
excluded_set = set(requested_excluded or [])
|
||||
unknown_excluded = excluded_set - allowed_set
|
||||
if unknown_excluded:
|
||||
raise InvalidExcludedToolsError(agent_id, sorted(unknown_excluded))
|
||||
effective_excluded = sorted(excluded_set & allowed_set)
|
||||
effective_allowed = sorted(allowed_set - set(effective_excluded))
|
||||
|
||||
tools_meta: list[dict[str, str]] = []
|
||||
for name in effective_allowed:
|
||||
extra = _config_extra_for_tool(app_config, name)
|
||||
tools_meta.append(resolve_tool_metadata(name, config_extra=extra))
|
||||
|
||||
return effective_allowed, effective_excluded, tools_meta
|
||||
|
||||
|
||||
async def build_trusted_capability_snapshot(
|
||||
*,
|
||||
user_id: str,
|
||||
participants: list[dict[str, Any]],
|
||||
app_config: AppConfig,
|
||||
agent_store: Any | None = None,
|
||||
session_model_name: str | None = None,
|
||||
session_reasoning_effort: str | None = None,
|
||||
session_excluded_tools: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the frozen snapshot stored on the session and used by WriteGuard."""
|
||||
agents_out: list[dict[str, Any]] = []
|
||||
flat_tools: dict[str, dict[str, str]] = {}
|
||||
|
||||
for p in participants:
|
||||
agent_id = str(p.get("agent_id") or "")
|
||||
if not agent_id:
|
||||
continue
|
||||
await _assert_user_may_use_agent(
|
||||
agent_store=agent_store, user_id=user_id, agent_id=agent_id
|
||||
)
|
||||
agent_config = load_agent_config(agent_id)
|
||||
seat_excluded = list(p.get("excluded_tools") or [])
|
||||
if session_excluded_tools:
|
||||
seat_excluded = sorted(set(seat_excluded) | set(session_excluded_tools))
|
||||
|
||||
allowed, excluded, tools_meta = _seat_tools_for_agent(
|
||||
app_config=app_config,
|
||||
agent_id=agent_id,
|
||||
session_model_name=session_model_name,
|
||||
session_reasoning_effort=session_reasoning_effort,
|
||||
requested_excluded=seat_excluded,
|
||||
)
|
||||
for meta in tools_meta:
|
||||
flat_tools[meta["tool_name"]] = meta
|
||||
|
||||
agents_out.append(
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"role": p.get("role", "seat"),
|
||||
"model_name": getattr(agent_config, "model", None) if agent_config else session_model_name,
|
||||
"reasoning_effort": session_reasoning_effort,
|
||||
"thinking_enabled": True,
|
||||
"subagent_enabled": bool(getattr(agent_config, "subagent_enabled", False))
|
||||
if agent_config
|
||||
else False,
|
||||
"tool_groups": list(getattr(agent_config, "tool_groups", None) or [])
|
||||
if agent_config
|
||||
else None,
|
||||
"allowed_tool_names": allowed,
|
||||
"excluded_tools": excluded,
|
||||
"tools": tools_meta,
|
||||
"memory_recall_disabled": None,
|
||||
"memory_injection_enabled": None,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"version": 1,
|
||||
"agents": agents_out,
|
||||
"tools": list(flat_tools.values()),
|
||||
}
|
||||
|
||||
|
||||
def seat_snapshot_for_agent(full_snapshot: dict[str, Any], agent_id: str) -> dict[str, Any]:
|
||||
"""Per-participant slice persisted on ``maf_roundtable_participants``."""
|
||||
for a in full_snapshot.get("agents") or []:
|
||||
if isinstance(a, dict) and a.get("agent_id") == agent_id:
|
||||
return dict(a)
|
||||
return {}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Build bounded, traceable seat input from MAF roundtable context.
|
||||
|
||||
Converts the shared roundtable state (task, previous turns, fact ledger,
|
||||
stage instructions) into a single human message for a seat's DeerFlow thread.
|
||||
|
||||
Design constraints (MAF-02-06):
|
||||
- Bounded: total output STRICTLY never exceeds ``max_context_chars``.
|
||||
- Trimmable: each section is independently truncatable.
|
||||
- Traceable: includes turn references so the seat can cite prior work.
|
||||
- Never injects credentials, tokens, or raw binary content.
|
||||
- All content is sanitized before inclusion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.sanitize import sanitize
|
||||
|
||||
_DEFAULT_MAX_CONTEXT_CHARS = 12000
|
||||
_DEFAULT_MAX_TURN_CHARS = 2000
|
||||
_DEFAULT_MAX_FACT_CHARS = 3000
|
||||
|
||||
|
||||
def build_seat_input(
|
||||
*,
|
||||
task_text: str,
|
||||
stage_instruction: str,
|
||||
role_description: str,
|
||||
prior_turns: list[dict[str, Any]],
|
||||
fact_ledger: dict[str, Any] | None = None,
|
||||
max_context_chars: int = _DEFAULT_MAX_CONTEXT_CHARS,
|
||||
max_turn_chars: int = _DEFAULT_MAX_TURN_CHARS,
|
||||
max_fact_chars: int = _DEFAULT_MAX_FACT_CHARS,
|
||||
) -> str:
|
||||
"""Assemble the human message for a seat's DeerFlow run.
|
||||
|
||||
The total output length is STRICTLY <= max_context_chars. Truncation
|
||||
happens per-section first, then a final hard cap ensures no overflow.
|
||||
"""
|
||||
sections: list[str] = []
|
||||
|
||||
sections.append(f"【任务】\n{task_text}")
|
||||
|
||||
if role_description:
|
||||
sections.append(f"【你的角色】\n{role_description}")
|
||||
|
||||
if stage_instruction:
|
||||
sections.append(f"【本阶段要求】\n{stage_instruction}")
|
||||
|
||||
if prior_turns:
|
||||
turns_text = _format_prior_turns(prior_turns, max_turn_chars)
|
||||
if turns_text:
|
||||
sections.append(f"【已有讨论】\n{turns_text}")
|
||||
|
||||
if fact_ledger:
|
||||
sanitized_ledger = sanitize(fact_ledger)
|
||||
if isinstance(sanitized_ledger, dict):
|
||||
facts_text = _format_fact_ledger(sanitized_ledger, max_fact_chars)
|
||||
if facts_text:
|
||||
sections.append(f"【共享事实账本】\n{facts_text}")
|
||||
|
||||
result = "\n\n".join(sections)
|
||||
|
||||
if len(result) > max_context_chars:
|
||||
result = result[:max_context_chars]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def build_manager_synthesis_input(
|
||||
*,
|
||||
task_text: str,
|
||||
seat_results: list[dict[str, Any]],
|
||||
fact_ledger: dict[str, Any] | None = None,
|
||||
max_context_chars: int = _DEFAULT_MAX_CONTEXT_CHARS,
|
||||
) -> str:
|
||||
"""Build the manager's final synthesis prompt."""
|
||||
sections: list[str] = []
|
||||
sections.append(f"【原始任务】\n{task_text}")
|
||||
sections.append("【各席位分析结果】")
|
||||
|
||||
for r in seat_results:
|
||||
agent_id = r.get("agent_id", "unknown")
|
||||
content = str(r.get("content", ""))
|
||||
if len(content) > 3000:
|
||||
content = content[:3000] + "..."
|
||||
sections.append(f"--- {agent_id} ---\n{content}")
|
||||
|
||||
if fact_ledger:
|
||||
sanitized_ledger = sanitize(fact_ledger)
|
||||
if isinstance(sanitized_ledger, dict):
|
||||
facts_text = _format_fact_ledger(sanitized_ledger, _DEFAULT_MAX_FACT_CHARS)
|
||||
if facts_text:
|
||||
sections.append(f"【共享事实账本】\n{facts_text}")
|
||||
|
||||
sections.append(
|
||||
"【综合要求】\n"
|
||||
"请基于以上各席位分析和事实账本,生成最终结论。要求:\n"
|
||||
"1. 明确标注共识点和分歧点\n"
|
||||
"2. 区分「建议动作」和「已实际执行的动作」\n"
|
||||
"3. 引用具体证据来源\n"
|
||||
"4. 列出失败或状态不确定的动作"
|
||||
)
|
||||
|
||||
result = "\n\n".join(sections)
|
||||
if len(result) > max_context_chars:
|
||||
result = result[:max_context_chars]
|
||||
return result
|
||||
|
||||
|
||||
# ── internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _format_prior_turns(turns: list[dict[str, Any]], max_per_turn: int) -> str:
|
||||
lines: list[str] = []
|
||||
for t in turns:
|
||||
agent_id = t.get("agent_id", "unknown")
|
||||
turn_idx = t.get("turn_index", "?")
|
||||
summary = str(t.get("summary", ""))
|
||||
if len(summary) > max_per_turn:
|
||||
summary = summary[:max_per_turn] + "..."
|
||||
lines.append(f"[轮次{turn_idx}] {agent_id}: {summary}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_fact_ledger(ledger: dict[str, Any], max_chars: int) -> str:
|
||||
parts: list[str] = []
|
||||
|
||||
facts = ledger.get("confirmed_facts")
|
||||
if facts and isinstance(facts, list):
|
||||
parts.append("已确认事实:")
|
||||
for f in facts[:20]:
|
||||
parts.append(f" - {f}")
|
||||
|
||||
tool_results = ledger.get("tool_results")
|
||||
if tool_results and isinstance(tool_results, list):
|
||||
parts.append("工具执行结果:")
|
||||
for tr in tool_results[:10]:
|
||||
if isinstance(tr, dict):
|
||||
parts.append(f" - {tr.get('tool_name', '?')}: {tr.get('status', '?')}")
|
||||
else:
|
||||
parts.append(f" - {tr}")
|
||||
|
||||
conflicts = ledger.get("conflicts")
|
||||
if conflicts and isinstance(conflicts, list):
|
||||
parts.append("未解决冲突:")
|
||||
for c in conflicts[:5]:
|
||||
parts.append(f" - {c}")
|
||||
|
||||
pending = ledger.get("pending_actions")
|
||||
if pending and isinstance(pending, list):
|
||||
parts.append("等待确认动作:")
|
||||
for p in pending[:5]:
|
||||
parts.append(f" - {p}")
|
||||
|
||||
result = "\n".join(parts)
|
||||
if len(result) > max_chars:
|
||||
result = result[:max_chars]
|
||||
return result
|
||||
@@ -0,0 +1,513 @@
|
||||
"""DeerFlow Agent Adapter for the MAF roundtable experiment (Phase 2).
|
||||
|
||||
Drives each MAF seat's actual execution through the existing DeerFlow Agent
|
||||
Runtime, preserving tools / skills / MCP / sandbox / identity / middleware
|
||||
semantics. The adapter does NOT wrap individual LangChain tools — it invokes
|
||||
a full agent run per seat turn via ``DeerFlowRuntimeClient``.
|
||||
|
||||
Architecture:
|
||||
- One DeerFlow thread per seat (bound at session start, reused across turns).
|
||||
- In-process ``start_run`` via DeerFlowRuntimeClient (no fake Request).
|
||||
- User context injected via ExecutionPrincipal (server-generated, never from body).
|
||||
- StreamBridge subscription translates runtime SSE → MAF events in real time.
|
||||
- Cancellation propagates from MAF run → DeerFlow run via RunManager.cancel.
|
||||
- All seat runs persisted to ``maf_roundtable_seat_runs`` (survives restart).
|
||||
- Critical audit events (tool.*, skill.*, approval.*, artifact.*) MUST persist;
|
||||
failure stops external writes and marks the run as failed.
|
||||
|
||||
Boundary: lives in ``app.maf_roundtable`` (may import ``deerflow.*`` and
|
||||
``app.gateway.*``); never imported by ``deerflow.*``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.context_builder import build_seat_input
|
||||
from app.maf_roundtable.event_normalizer import (
|
||||
make_run_cancelled,
|
||||
make_run_failed,
|
||||
make_turn_completed,
|
||||
make_turn_started,
|
||||
normalize_stream_event,
|
||||
)
|
||||
from app.maf_roundtable.runtime_client import DeerFlowRuntimeClient, ExecutionPrincipal
|
||||
from app.maf_roundtable.sanitize import sanitize
|
||||
from deerflow.runtime.stream_bridge.base import END_SENTINEL, HEARTBEAT_SENTINEL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_IDLE_TIMEOUT_S = int(os.getenv("MAF_SEAT_IDLE_TIMEOUT_SECONDS", "1200") or "1200")
|
||||
_IDLE_CHECK_INTERVAL_S = 10.0
|
||||
_MAX_CONTEXT_CHARS = int(os.getenv("MAF_MAX_CONTEXT_CHARS", "12000") or "12000")
|
||||
|
||||
_CRITICAL_EVENT_TYPES = frozenset({
|
||||
"tool.requested", "tool.started", "tool.completed", "tool.failed",
|
||||
"skill.selected", "skill.started", "skill.completed", "skill.failed",
|
||||
"artifact.created",
|
||||
"action.waiting_approval", "action.approved", "action.rejected",
|
||||
"run.completed", "run.failed", "run.cancelled",
|
||||
})
|
||||
|
||||
|
||||
class AuditEventError(RuntimeError):
|
||||
"""Raised when a critical audit event fails to persist."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeatResponse:
|
||||
"""Standardized response from a seat turn (MAF-02-08)."""
|
||||
|
||||
agent_id: str
|
||||
turn_index: int
|
||||
content: str
|
||||
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
||||
usage: dict[str, Any] | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
latency_ms: int | None = None
|
||||
|
||||
|
||||
class DeerFlowAgentAdapter:
|
||||
"""Drives MAF seat execution through the DeerFlow Agent Runtime.
|
||||
|
||||
Usage::
|
||||
|
||||
client = DeerFlowRuntimeClient(app)
|
||||
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
|
||||
await adapter.ensure_threads(session_id, participants)
|
||||
response = await adapter.run_seat_turn(...)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runtime_client: DeerFlowRuntimeClient,
|
||||
*,
|
||||
service: Any,
|
||||
user_id: str,
|
||||
) -> None:
|
||||
self._client = runtime_client
|
||||
self._service = service
|
||||
self._user_id = user_id
|
||||
self._seen_source_ids: set[str] = set()
|
||||
|
||||
# ── MAF-02-01: thread lifecycle ─────────────────────────────────────────
|
||||
|
||||
async def ensure_threads(
|
||||
self, session_id: str, run_id: str, participants: list[dict[str, Any]]
|
||||
) -> dict[str, str]:
|
||||
"""Create and bind a DeerFlow thread for each participant that lacks one.
|
||||
|
||||
Thread creation is wrapped in set_current_user so thread_meta.user_id
|
||||
matches the MAF session owner. Returns ``{agent_id: thread_id}``.
|
||||
"""
|
||||
principal = ExecutionPrincipal(user_id=self._user_id, session_id=session_id, run_id=run_id)
|
||||
thread_map: dict[str, str] = {}
|
||||
for p in participants:
|
||||
agent_id = p.get("agent_id", "")
|
||||
existing = p.get("thread_id")
|
||||
if existing:
|
||||
thread_map[agent_id] = existing
|
||||
continue
|
||||
thread_id = await self._client.create_thread(principal)
|
||||
await self._service.store.participants.bind_thread(
|
||||
session_id, agent_id, thread_id=thread_id
|
||||
)
|
||||
thread_map[agent_id] = thread_id
|
||||
return thread_map
|
||||
|
||||
# ── MAF-02-02/03/04/05: seat turn execution ─────────────────────────────
|
||||
|
||||
async def run_seat_turn(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
message: str,
|
||||
thread_id: str,
|
||||
model_name: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
thinking_enabled: bool = True,
|
||||
subagent_enabled: bool = False,
|
||||
excluded_tools: list[str] | None = None,
|
||||
stage_instruction: str = "",
|
||||
prior_turns: list[dict[str, Any]] | None = None,
|
||||
fact_ledger: dict[str, Any] | None = None,
|
||||
role_description: str = "",
|
||||
task_text: str = "",
|
||||
memory_recall_disabled: bool | None = None,
|
||||
memory_injection_enabled: bool | None = None,
|
||||
capability_snapshot: dict[str, Any] | None = None,
|
||||
write_policy: str = "confirm_high_risk",
|
||||
approved_tool: str | None = None,
|
||||
) -> SeatResponse:
|
||||
"""Execute one seat turn through the DeerFlow Agent Runtime."""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
seat_message = build_seat_input(
|
||||
task_text=task_text or message,
|
||||
stage_instruction=stage_instruction,
|
||||
role_description=role_description,
|
||||
prior_turns=prior_turns or [],
|
||||
fact_ledger=fact_ledger,
|
||||
max_context_chars=_MAX_CONTEXT_CHARS,
|
||||
)
|
||||
if not task_text:
|
||||
seat_message = message
|
||||
|
||||
await self._emit_critical(run_id, make_turn_started(agent_id, turn_index, {"thread_id": thread_id}))
|
||||
|
||||
principal = ExecutionPrincipal(user_id=self._user_id, session_id=session_id, run_id=run_id)
|
||||
|
||||
seat_run = await self._service.store.seat_runs.create_seat_run(
|
||||
maf_run_id=run_id, agent_id=agent_id, turn_index=turn_index, deerflow_thread_id=thread_id
|
||||
)
|
||||
seat_run_id = seat_run["id"]
|
||||
|
||||
try:
|
||||
response = await self._execute_run(
|
||||
principal=principal,
|
||||
seat_run_id=seat_run_id,
|
||||
run_id=run_id,
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
thread_id=thread_id,
|
||||
message=seat_message,
|
||||
model_name=model_name,
|
||||
reasoning_effort=reasoning_effort,
|
||||
thinking_enabled=thinking_enabled,
|
||||
subagent_enabled=subagent_enabled,
|
||||
excluded_tools=excluded_tools or [],
|
||||
memory_recall_disabled=memory_recall_disabled,
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
capability_snapshot=capability_snapshot,
|
||||
write_policy=write_policy,
|
||||
approved_tool=approved_tool,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await self._service.store.seat_runs.mark_cancelled(seat_run_id)
|
||||
await self._emit_critical(run_id, make_run_cancelled(agent_id, turn_index))
|
||||
raise
|
||||
except Exception as exc:
|
||||
latency = int((time.perf_counter() - t0) * 1000)
|
||||
# Sanitize before persisting / emitting — the raw exception body may
|
||||
# embed params or credentials; only the redacted text is stored.
|
||||
safe_error = str(sanitize(str(exc)))[:500]
|
||||
await self._service.store.seat_runs.mark_failed(seat_run_id, error=safe_error)
|
||||
await self._emit_critical(run_id, make_run_failed(agent_id, turn_index, error_code=type(exc).__name__, message=safe_error))
|
||||
return SeatResponse(
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
content="",
|
||||
error=safe_error,
|
||||
latency_ms=latency,
|
||||
)
|
||||
|
||||
await self._service.store.seat_runs.mark_completed(seat_run_id)
|
||||
response.latency_ms = int((time.perf_counter() - t0) * 1000)
|
||||
await self._emit_critical(run_id, make_turn_completed(
|
||||
agent_id, turn_index,
|
||||
content=response.content,
|
||||
usage=response.usage,
|
||||
metadata=response.metadata,
|
||||
))
|
||||
return response
|
||||
|
||||
async def _execute_run(
|
||||
self,
|
||||
*,
|
||||
principal: ExecutionPrincipal,
|
||||
seat_run_id: str,
|
||||
run_id: str,
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
thread_id: str,
|
||||
message: str,
|
||||
model_name: str | None,
|
||||
reasoning_effort: str | None,
|
||||
thinking_enabled: bool,
|
||||
subagent_enabled: bool,
|
||||
excluded_tools: list[str],
|
||||
memory_recall_disabled: bool | None,
|
||||
memory_injection_enabled: bool | None,
|
||||
capability_snapshot: dict[str, Any] | None = None,
|
||||
write_policy: str = "confirm_high_risk",
|
||||
approved_tool: str | None = None,
|
||||
) -> SeatResponse:
|
||||
record = await self._client.start_agent_run(
|
||||
principal,
|
||||
thread_id=thread_id,
|
||||
agent_id=agent_id,
|
||||
message=message,
|
||||
model_name=model_name,
|
||||
reasoning_effort=reasoning_effort,
|
||||
thinking_enabled=thinking_enabled,
|
||||
subagent_enabled=subagent_enabled,
|
||||
excluded_tools=excluded_tools,
|
||||
memory_recall_disabled=memory_recall_disabled,
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
turn_index=turn_index,
|
||||
capability_snapshot=capability_snapshot,
|
||||
write_policy=write_policy,
|
||||
approved_tool=approved_tool,
|
||||
)
|
||||
|
||||
deerflow_run_id = getattr(record, "run_id", None)
|
||||
if deerflow_run_id:
|
||||
await self._service.store.seat_runs.mark_running(seat_run_id, deerflow_run_id=deerflow_run_id)
|
||||
|
||||
await self._await_with_event_translation(
|
||||
record, deerflow_run_id, run_id=run_id, agent_id=agent_id, turn_index=turn_index
|
||||
)
|
||||
|
||||
messages = await self._client.read_thread_messages(principal, thread_id)
|
||||
content = self._latest_ai_text(messages)
|
||||
tool_calls = self._tool_calls_since_last_human(messages)
|
||||
usage = self._extract_usage(messages)
|
||||
|
||||
return SeatResponse(
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
usage=usage,
|
||||
metadata={"thread_id": thread_id, "deerflow_run_id": deerflow_run_id},
|
||||
)
|
||||
|
||||
# ── MAF-02-07: real-time event translation ──────────────────────────────
|
||||
|
||||
async def _await_with_event_translation(
|
||||
self,
|
||||
record: Any,
|
||||
deerflow_run_id: str | None,
|
||||
*,
|
||||
run_id: str,
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
) -> None:
|
||||
"""Await the DeerFlow run while translating StreamBridge events to MAF events.
|
||||
|
||||
P1.5 fix: ``if task in done: await task`` so exceptions propagate.
|
||||
"""
|
||||
task = getattr(record, "task", None)
|
||||
if task is None:
|
||||
return
|
||||
|
||||
bridge = getattr(self._client.app.state, "stream_bridge", None)
|
||||
if bridge is None or deerflow_run_id is None:
|
||||
await task
|
||||
return
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
last_activity = loop.time()
|
||||
stuck = asyncio.Event()
|
||||
|
||||
async def _watch() -> None:
|
||||
nonlocal last_activity
|
||||
async for ev in bridge.subscribe(deerflow_run_id, heartbeat_interval=_IDLE_CHECK_INTERVAL_S):
|
||||
if ev is END_SENTINEL:
|
||||
return
|
||||
now = loop.time()
|
||||
if ev is HEARTBEAT_SENTINEL:
|
||||
if now - last_activity >= _IDLE_TIMEOUT_S:
|
||||
stuck.set()
|
||||
return
|
||||
else:
|
||||
last_activity = now
|
||||
maf_events = normalize_stream_event(
|
||||
ev.event, ev.data, agent_id=agent_id, turn_index=turn_index
|
||||
)
|
||||
for me in maf_events:
|
||||
await self._emit(run_id, me)
|
||||
|
||||
watch = asyncio.create_task(_watch())
|
||||
try:
|
||||
done, _ = await asyncio.wait({task, watch}, return_when=asyncio.FIRST_COMPLETED)
|
||||
if task in done:
|
||||
await task
|
||||
return
|
||||
if not stuck.is_set():
|
||||
await task
|
||||
return
|
||||
idle_elapsed = loop.time() - last_activity
|
||||
logger.error(
|
||||
"MAF seat '%s' stalled: no output for %.0fs on run %s",
|
||||
agent_id, idle_elapsed, deerflow_run_id,
|
||||
)
|
||||
await self._client.cancel_run(deerflow_run_id)
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=10.0)
|
||||
except (TimeoutError, asyncio.CancelledError, Exception):
|
||||
logger.warning("DeerFlow task did not finish after cancel for run %s", deerflow_run_id)
|
||||
raise RuntimeError(f"MAF seat '{agent_id}' stalled (no output for {_IDLE_TIMEOUT_S}s)")
|
||||
finally:
|
||||
if not watch.done():
|
||||
watch.cancel()
|
||||
try:
|
||||
await watch
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
# ── MAF-02-09: cancellation ─────────────────────────────────────────────
|
||||
|
||||
async def cancel(self, maf_run_id: str) -> dict[str, Any]:
|
||||
"""Cancel all active DeerFlow runs for a MAF run (two-phase cascade).
|
||||
|
||||
Phase 1: mark all active seat runs as ``cancelling``.
|
||||
Phase 2: actually cancel each DeerFlow run, then mark the seat run
|
||||
as ``cancelled`` (success) or ``cancel_failed`` (failure).
|
||||
|
||||
Returns a structured result so the caller (dispatcher → router) can
|
||||
decide whether to finalize the parent run as ``cancelled`` or leave it
|
||||
in a non-terminal ``cancelling`` / ``cancel_failed`` state. A failed
|
||||
cancel must NEVER masquerade as cancelled.
|
||||
|
||||
Result shape::
|
||||
|
||||
{
|
||||
"all_cancelled": bool,
|
||||
"cancelled_run_ids": [...], # seat_run_ids that succeeded
|
||||
"failed_run_ids": [...], # seat_run_ids where cancel failed
|
||||
"still_active_run_ids": [...], # seat_run_ids with no deerflow id
|
||||
}
|
||||
"""
|
||||
seat_infos = await self._service.store.seat_runs.cancel_all_active(maf_run_id)
|
||||
cancelled_run_ids: list[str] = []
|
||||
failed_run_ids: list[str] = []
|
||||
still_active_run_ids: list[str] = []
|
||||
for info in seat_infos:
|
||||
seat_run_id = info["seat_run_id"]
|
||||
deerflow_run_id = info.get("deerflow_run_id")
|
||||
if not deerflow_run_id:
|
||||
# No DeerFlow run to cancel — record as still-active/unknown.
|
||||
await self._service.store.seat_runs.mark_cancel_unknown(
|
||||
seat_run_id, error="no deerflow_run_id recorded"
|
||||
)
|
||||
still_active_run_ids.append(seat_run_id)
|
||||
continue
|
||||
if await self._client.cancel_run(deerflow_run_id):
|
||||
await self._service.store.seat_runs.mark_cancelled(seat_run_id)
|
||||
cancelled_run_ids.append(seat_run_id)
|
||||
else:
|
||||
await self._service.store.seat_runs.mark_cancel_failed(
|
||||
seat_run_id, error="DeerFlow cancel_run returned False"
|
||||
)
|
||||
failed_run_ids.append(seat_run_id)
|
||||
all_cancelled = not failed_run_ids and not still_active_run_ids and bool(seat_infos)
|
||||
# If there were no active seat runs at all, treat as fully cancelled
|
||||
# (nothing left to cancel).
|
||||
if not seat_infos:
|
||||
all_cancelled = True
|
||||
return {
|
||||
"all_cancelled": all_cancelled,
|
||||
"cancelled_run_ids": cancelled_run_ids,
|
||||
"failed_run_ids": failed_run_ids,
|
||||
"still_active_run_ids": still_active_run_ids,
|
||||
}
|
||||
|
||||
# ── P1.6: audit event persistence ───────────────────────────────────────
|
||||
|
||||
async def _emit_critical(self, run_id: str, event: dict[str, Any]) -> None:
|
||||
"""Emit a critical audit event. Failure raises AuditEventError."""
|
||||
try:
|
||||
await self._service.append_event(
|
||||
run_id=run_id,
|
||||
user_id=self._user_id,
|
||||
event_type=event["event_type"],
|
||||
agent_id=event.get("agent_id"),
|
||||
turn_index=event.get("turn_index"),
|
||||
payload=event.get("payload"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("CRITICAL audit event %s failed to persist for run %s", event.get("event_type"), run_id, exc_info=True)
|
||||
raise AuditEventError(
|
||||
f"Critical audit event '{event.get('event_type')}' failed to persist: {exc}"
|
||||
) from exc
|
||||
|
||||
async def _emit(self, run_id: str, event: dict[str, Any]) -> None:
|
||||
"""Emit a non-critical event (agent.message.delta). Failure degrades gracefully.
|
||||
|
||||
Dedup: events with a source_event_id are deduped at the DB level via
|
||||
the (run_id, source_event_id) unique constraint — durable across restarts.
|
||||
The in-memory _seen_source_ids set is a fast-path cache only.
|
||||
"""
|
||||
source_id = event.get("source_event_id")
|
||||
if source_id:
|
||||
if source_id in self._seen_source_ids:
|
||||
return
|
||||
self._seen_source_ids.add(source_id)
|
||||
|
||||
event_type = event.get("event_type", "")
|
||||
if event_type in _CRITICAL_EVENT_TYPES:
|
||||
await self._emit_critical(run_id, event)
|
||||
return
|
||||
try:
|
||||
await self._service.append_event(
|
||||
run_id=run_id,
|
||||
user_id=self._user_id,
|
||||
event_type=event_type,
|
||||
agent_id=event.get("agent_id"),
|
||||
turn_index=event.get("turn_index"),
|
||||
payload=event.get("payload"),
|
||||
source_event_id=source_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("non-critical event %s failed for run %s (degraded)", event_type, run_id, exc_info=True)
|
||||
|
||||
# ── internal helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _latest_ai_text(self, messages: list[dict[str, Any]]) -> str:
|
||||
for m in reversed(messages):
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
if (m.get("type") or m.get("role")) not in {"ai", "assistant"}:
|
||||
continue
|
||||
text = self._message_text(m)
|
||||
if text.strip():
|
||||
return text
|
||||
return ""
|
||||
|
||||
def _message_text(self, message: dict[str, Any]) -> str:
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
|
||||
return "\n".join(parts)
|
||||
return ""
|
||||
|
||||
def _tool_calls_since_last_human(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
last_human = -1
|
||||
for i, m in enumerate(messages):
|
||||
if isinstance(m, dict) and (m.get("type") or m.get("role")) in {"human", "user"}:
|
||||
last_human = i
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in messages[last_human + 1:]:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
if (m.get("type") or m.get("role")) not in {"ai", "assistant"}:
|
||||
continue
|
||||
for tc in m.get("tool_calls") or []:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
name = tc.get("name") or ""
|
||||
if not name:
|
||||
continue
|
||||
out.append({"name": name, "args": tc.get("args") or {}, "id": tc.get("id") or ""})
|
||||
return out
|
||||
|
||||
def _extract_usage(self, messages: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
for m in reversed(messages):
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
usage = m.get("usage_metadata") or m.get("usage")
|
||||
if isinstance(usage, dict) and usage:
|
||||
return usage
|
||||
return None
|
||||
@@ -0,0 +1,171 @@
|
||||
"""MAF run dispatcher — launches WorkflowRunner after create_run succeeds.
|
||||
|
||||
The dispatcher is the bridge between the HTTP layer (router) and the workflow
|
||||
execution layer (WorkflowRunner). It:
|
||||
|
||||
1. Checks MAF SDK availability BEFORE creating the run (so the user gets a
|
||||
clean 503 rather than a run that will fail).
|
||||
2. Launches ``WorkflowRunner.execute`` as a background asyncio task.
|
||||
3. Tracks task handles for cancellation.
|
||||
4. **Resume**: after an approval decision, the router calls
|
||||
``dispatcher.resume(run_id, user_id)`` to restore the workflow from its
|
||||
checkpoint.
|
||||
5. **Cancel cascade**: ``dispatcher.cancel(run_id)`` cancels both the
|
||||
background task AND the DeerFlow sub-runs. If the task handle is gone
|
||||
(process restart), it rebuilds a runner from persisted ``seat_runs`` and
|
||||
cascades cancel to DeerFlow.
|
||||
6. Never silently degrades to sequential DeerFlow execution — if the SDK is
|
||||
missing or the workflow fails, the run is marked ``failed``.
|
||||
|
||||
Usage (from the router)::
|
||||
|
||||
from app.maf_roundtable.dispatcher import get_dispatcher
|
||||
dispatcher = get_dispatcher(request.app)
|
||||
await dispatcher.dispatch(session, run, user_id=user_id)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MafSdkNotAvailableError(RuntimeError):
|
||||
"""Raised when the MAF SDK is not installed and a run is requested."""
|
||||
|
||||
|
||||
class MafRunDispatcher:
|
||||
"""Manages background MAF workflow execution tasks."""
|
||||
|
||||
def __init__(self, app: Any) -> None:
|
||||
self._app = app
|
||||
self._tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
def check_sdk_available(self) -> None:
|
||||
"""Raise MafSdkNotAvailableError if the MAF SDK is not installed."""
|
||||
try:
|
||||
import agent_framework # noqa: F401
|
||||
except ImportError as exc:
|
||||
raise MafSdkNotAvailableError(
|
||||
"agent_framework SDK is not installed; run 'uv sync --extra maf' to enable MAF workflows"
|
||||
) from exc
|
||||
|
||||
async def dispatch(self, session: dict[str, Any], run: dict[str, Any], *, user_id: str) -> None:
|
||||
"""Launch the workflow for a newly created run as a background task.
|
||||
|
||||
The task is tracked so it can be cancelled via ``cancel(run_id)``.
|
||||
"""
|
||||
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
||||
|
||||
run_id = run["id"]
|
||||
service = getattr(self._app.state, "maf_roundtable_service", None)
|
||||
if service is None:
|
||||
raise MafSdkNotAvailableError("MAF roundtable service not available")
|
||||
|
||||
runner = WorkflowRunner(self._app, service=service, user_id=user_id)
|
||||
|
||||
task = asyncio.create_task(self._execute(runner, session, run, run_id))
|
||||
self._tasks[run_id] = task
|
||||
task.add_done_callback(lambda t, rid=run_id: self._tasks.pop(rid, None))
|
||||
|
||||
async def _execute(self, runner: Any, session: dict[str, Any], run: dict[str, Any], run_id: str) -> None:
|
||||
"""Execute the workflow, catching all exceptions (they're already persisted by the runner)."""
|
||||
try:
|
||||
await runner.execute(session, run)
|
||||
except Exception:
|
||||
# The runner already persists the failure. Log for observability.
|
||||
logger.exception("MAF workflow execution failed for run %s", run_id)
|
||||
|
||||
async def resume(self, run_id: str, *, user_id: str) -> dict[str, Any] | None:
|
||||
"""Resume a workflow from its checkpoint after an approval decision.
|
||||
|
||||
Called by the router after ``decide_approval`` succeeds. Loads the
|
||||
runner (rebuilding if the process restarted), then calls
|
||||
``runner.resume(run_id)`` which restores ``RoundtableState.from_dict()``
|
||||
and executes the approved action (at-most-once).
|
||||
"""
|
||||
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
||||
|
||||
service = getattr(self._app.state, "maf_roundtable_service", None)
|
||||
if service is None:
|
||||
raise MafSdkNotAvailableError("MAF roundtable service not available")
|
||||
|
||||
runner = WorkflowRunner(self._app, service=service, user_id=user_id)
|
||||
return await runner.resume(run_id)
|
||||
|
||||
async def cancel(self, run_id: str) -> dict[str, Any]:
|
||||
"""Cancel a running workflow AND cascade to DeerFlow sub-runs.
|
||||
|
||||
Returns a structured cancel result so the router can decide whether to
|
||||
finalize the parent run as ``cancelled`` (all sub-runs confirmed
|
||||
cancelled / already terminal) or leave it non-terminal (some sub-runs
|
||||
failed to cancel — must not masquerade as cancelled).
|
||||
|
||||
Result shape::
|
||||
|
||||
{
|
||||
"all_cancelled": bool,
|
||||
"cancelled_run_ids": [...],
|
||||
"failed_run_ids": [...],
|
||||
"still_active_run_ids": [...],
|
||||
}
|
||||
"""
|
||||
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
||||
|
||||
_EMPTY = {"all_cancelled": False, "cancelled_run_ids": [], "failed_run_ids": [], "still_active_run_ids": []}
|
||||
|
||||
# P1c cancel order: the parent run is already in ``cancelling`` (the
|
||||
# router sets it before calling here). We then (1) cascade-cancel the
|
||||
# DeerFlow seat runs first and confirm/record the outcome, and only
|
||||
# (2) cancel the local MAF asyncio task LAST. The router finalizes the
|
||||
# parent run as ``cancelled`` only when every sub-run confirmed
|
||||
# cancelled — a failed cascade must never masquerade as cancelled.
|
||||
service = getattr(self._app.state, "maf_roundtable_service", None)
|
||||
if service is None:
|
||||
return _EMPTY
|
||||
|
||||
run = await service.store.runs.get_run(run_id)
|
||||
if run is None:
|
||||
return _EMPTY
|
||||
|
||||
session = await service.store.sessions.get_session(run["session_id"], user_id=None)
|
||||
if session is None:
|
||||
return _EMPTY
|
||||
|
||||
user_id = session.get("user_id", "default")
|
||||
runner = WorkflowRunner(self._app, service=service, user_id=user_id)
|
||||
|
||||
# Step 1: cascade cancel to DeerFlow sub-runs (works even after a
|
||||
# process restart — the runner reads persisted seat_runs).
|
||||
try:
|
||||
result = await runner.cancel(run_id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("MAF cancel cascade failed for run %s", run_id)
|
||||
result = dict(_EMPTY)
|
||||
|
||||
# Step 2: cancel the local MAF asyncio task LAST (after the external
|
||||
# sub-runs have been addressed), so we never drop the orchestrator
|
||||
# before its sub-runs are cancelled/recorded.
|
||||
task = self._tasks.get(run_id)
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
_dispatchers: dict[int, MafRunDispatcher] = {}
|
||||
|
||||
|
||||
def get_dispatcher(app: Any) -> MafRunDispatcher:
|
||||
"""Get or create the dispatcher for an app instance (keyed by id)."""
|
||||
app_id = id(app)
|
||||
if app_id not in _dispatchers:
|
||||
_dispatchers[app_id] = MafRunDispatcher(app)
|
||||
return _dispatchers[app_id]
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Translate DeerFlow runtime SSE events into MAF roundtable event types.
|
||||
|
||||
Maps raw StreamBridge chunks (LangGraph Platform-aligned: ``metadata``,
|
||||
``values``, ``updates``, ``messages``, ``error``, ``end``) into the durable
|
||||
MAF event taxonomy persisted to ``maf_roundtable_events``.
|
||||
|
||||
Design constraints:
|
||||
- Pure functions only (no I/O, no state).
|
||||
- Never fabricates events: only emits what the runtime actually produced.
|
||||
- Skill events come ONLY from structured tool/skill metadata, never from
|
||||
guessing based on AI text content.
|
||||
- All payloads are sanitized (credentials/tokens/cookies stripped).
|
||||
- Supports source_event_id for dedup (messages + updates may overlap).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.sanitize import sanitize_payload
|
||||
|
||||
|
||||
def normalize_stream_event(
|
||||
sse_event: str,
|
||||
data: Any,
|
||||
*,
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert a single DeerFlow SSE frame into zero or more MAF event dicts.
|
||||
|
||||
Each returned dict has:
|
||||
``{event_type, agent_id, turn_index, payload, source_event_id?}``.
|
||||
"""
|
||||
if sse_event == "metadata":
|
||||
return [_make("run.started", agent_id, turn_index, {"run_id": _get(data, "run_id"), "thread_id": _get(data, "thread_id")})]
|
||||
|
||||
if sse_event == "messages":
|
||||
return _normalize_messages(data, agent_id, turn_index)
|
||||
|
||||
if sse_event == "updates":
|
||||
return _normalize_updates(data, agent_id, turn_index)
|
||||
|
||||
if sse_event == "error":
|
||||
return [_make("run.failed", agent_id, turn_index, {"message": _get(data, "message", ""), "name": _get(data, "name", "")})]
|
||||
|
||||
# Structured skill lifecycle events (emitted by DeerFlow skill middleware,
|
||||
# never guessed from AI text content).
|
||||
if sse_event in ("invoke_skill", "skill_search_step"):
|
||||
skill_name = _get(data, "skill_name", "") or ""
|
||||
if sse_event == "invoke_skill":
|
||||
return [_make("skill.started", agent_id, turn_index, {"skill_name": skill_name, "message": _get(data, "message", "")})]
|
||||
return [_make("skill.selected", agent_id, turn_index, {"skill_name": skill_name, "message": _get(data, "message", "")})]
|
||||
|
||||
if sse_event == "skill_result":
|
||||
return [_make("skill.completed", agent_id, turn_index, {"skill_name": _get(data, "skill_name", ""), "message": _get(data, "message", "")})]
|
||||
|
||||
if sse_event == "skill_error":
|
||||
return [_make("skill.failed", agent_id, turn_index, {"skill_name": _get(data, "skill_name", ""), "error": _get(data, "reason", "") or _get(data, "message", "")})]
|
||||
|
||||
if sse_event == "artifact_created":
|
||||
return [_make("artifact.created", agent_id, turn_index, {"artifact_type": _get(data, "artifact_type", ""), "artifact_id": _get(data, "artifact_id", ""), "title": _get(data, "title", "")})]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def make_turn_started(agent_id: str, turn_index: int, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return _make("agent.turn_started", agent_id, turn_index, payload or {})
|
||||
|
||||
|
||||
def make_turn_completed(
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
*,
|
||||
content: str,
|
||||
usage: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"content": content}
|
||||
if usage:
|
||||
payload["usage"] = usage
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
return _make("agent.message.completed", agent_id, turn_index, payload)
|
||||
|
||||
|
||||
def make_run_completed(agent_id: str | None, turn_index: int, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return _make("run.completed", agent_id or "", turn_index, payload or {})
|
||||
|
||||
|
||||
def make_run_failed(agent_id: str | None, turn_index: int, *, error_code: str, message: str) -> dict[str, Any]:
|
||||
return _make("run.failed", agent_id or "", turn_index, {"error_code": error_code, "message": message})
|
||||
|
||||
|
||||
def make_run_cancelled(agent_id: str | None, turn_index: int) -> dict[str, Any]:
|
||||
return _make("run.cancelled", agent_id or "", turn_index, {})
|
||||
|
||||
|
||||
# ── skill lifecycle events ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_skill_selected(agent_id: str, turn_index: int, *, skill_name: str, skill_id: str = "") -> dict[str, Any]:
|
||||
return _make("skill.selected", agent_id, turn_index, {"skill_name": skill_name, "skill_id": skill_id})
|
||||
|
||||
|
||||
def make_skill_started(agent_id: str, turn_index: int, *, skill_name: str, skill_id: str = "") -> dict[str, Any]:
|
||||
return _make("skill.started", agent_id, turn_index, {"skill_name": skill_name, "skill_id": skill_id})
|
||||
|
||||
|
||||
def make_skill_completed(agent_id: str, turn_index: int, *, skill_name: str, result_preview: str = "") -> dict[str, Any]:
|
||||
return _make("skill.completed", agent_id, turn_index, {"skill_name": skill_name, "result_preview": _truncate(result_preview, 500)})
|
||||
|
||||
|
||||
def make_skill_failed(agent_id: str, turn_index: int, *, skill_name: str, error: str = "") -> dict[str, Any]:
|
||||
return _make("skill.failed", agent_id, turn_index, {"skill_name": skill_name, "error": _truncate(error, 500)})
|
||||
|
||||
|
||||
# ── artifact events ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_artifact_created(agent_id: str, turn_index: int, *, artifact_type: str, artifact_id: str = "", title: str = "") -> dict[str, Any]:
|
||||
return _make("artifact.created", agent_id, turn_index, {"artifact_type": artifact_type, "artifact_id": artifact_id, "title": title})
|
||||
|
||||
|
||||
# ── approval lifecycle events ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_action_waiting_approval(agent_id: str, turn_index: int, *, action_id: str, approval_id: str, tool_name: str, summary: str = "") -> dict[str, Any]:
|
||||
return _make("action.waiting_approval", agent_id, turn_index, {"action_id": action_id, "approval_id": approval_id, "tool_name": tool_name, "summary": _truncate(summary, 500)})
|
||||
|
||||
|
||||
def make_action_approved(agent_id: str, turn_index: int, *, action_id: str, approval_id: str, decided_by: str = "") -> dict[str, Any]:
|
||||
return _make("action.approved", agent_id, turn_index, {"action_id": action_id, "approval_id": approval_id, "decided_by": decided_by})
|
||||
|
||||
|
||||
def make_action_rejected(agent_id: str, turn_index: int, *, action_id: str, approval_id: str, decided_by: str = "") -> dict[str, Any]:
|
||||
return _make("action.rejected", agent_id, turn_index, {"action_id": action_id, "approval_id": approval_id, "decided_by": decided_by})
|
||||
|
||||
|
||||
# ── workflow checkpoint events ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_workflow_checkpointed(agent_id: str | None, turn_index: int, *, checkpoint_id: str, reason: str = "") -> dict[str, Any]:
|
||||
return _make("workflow.checkpointed", agent_id or "", turn_index, {"checkpoint_id": checkpoint_id, "reason": reason})
|
||||
|
||||
|
||||
# ── internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make(event_type: str, agent_id: str, turn_index: int, payload: dict[str, Any], source_event_id: str | None = None) -> dict[str, Any]:
|
||||
ev: dict[str, Any] = {
|
||||
"event_type": event_type,
|
||||
"agent_id": agent_id,
|
||||
"turn_index": turn_index,
|
||||
"payload": sanitize_payload(payload),
|
||||
}
|
||||
if source_event_id:
|
||||
ev["source_event_id"] = source_event_id
|
||||
return ev
|
||||
|
||||
|
||||
def _get(data: Any, key: str, default: Any = None) -> Any:
|
||||
if isinstance(data, dict):
|
||||
return data.get(key, default)
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_messages(data: Any, agent_id: str, turn_index: int) -> list[dict[str, Any]]:
|
||||
"""Handle ``messages`` SSE event (streaming token deltas).
|
||||
|
||||
Format: ``[message_id, {type, content, ...}]`` (messages-tuple mode).
|
||||
"""
|
||||
events: list[dict[str, Any]] = []
|
||||
if not isinstance(data, (list, tuple)) or len(data) < 2:
|
||||
return events
|
||||
msg_id = str(data[0]) if data[0] else None
|
||||
msg = data[1] if len(data) > 1 else None
|
||||
if not isinstance(msg, dict):
|
||||
return events
|
||||
|
||||
msg_type = msg.get("type") or msg.get("role") or ""
|
||||
content = msg.get("content")
|
||||
|
||||
if msg_type in ("ai", "assistant"):
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if tool_calls:
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
tc_id = tc.get("id", "")
|
||||
events.append(_make("tool.requested", agent_id, turn_index, {
|
||||
"tool_name": tc.get("name", ""),
|
||||
"tool_call_id": tc_id,
|
||||
"args": _summarize_args(tc.get("args")),
|
||||
}, source_event_id=f"{msg_id}:{tc_id}" if msg_id and tc_id else None))
|
||||
if content:
|
||||
text = _extract_text(content)
|
||||
if text:
|
||||
events.append(_make("agent.message.delta", agent_id, turn_index, {"text": text}, source_event_id=msg_id))
|
||||
|
||||
elif msg_type == "tool":
|
||||
tc_id = msg.get("tool_call_id", "")
|
||||
status = msg.get("status", "completed")
|
||||
event_type = "tool.completed" if status != "error" else "tool.failed"
|
||||
events.append(_make(event_type, agent_id, turn_index, {
|
||||
"tool_name": msg.get("name", ""),
|
||||
"tool_call_id": tc_id,
|
||||
"content_preview": _truncate(_extract_text(content), 500),
|
||||
"status": status,
|
||||
}, source_event_id=f"tool:{tc_id}" if tc_id else None))
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _normalize_updates(data: Any, agent_id: str, turn_index: int) -> list[dict[str, Any]]:
|
||||
"""Handle ``updates`` SSE event (node completion with full output).
|
||||
|
||||
Only emits tool lifecycle events from structured data. Does NOT guess
|
||||
skill execution from text content.
|
||||
"""
|
||||
events: list[dict[str, Any]] = []
|
||||
if not isinstance(data, dict):
|
||||
return events
|
||||
|
||||
for node_name, node_output in data.items():
|
||||
if not isinstance(node_output, dict):
|
||||
continue
|
||||
messages = node_output.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
continue
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
msg_type = msg.get("type") or msg.get("role") or ""
|
||||
if msg_type in ("ai", "assistant"):
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if tool_calls:
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
tc_id = tc.get("id", "")
|
||||
events.append(_make("tool.started", agent_id, turn_index, {
|
||||
"tool_name": tc.get("name", ""),
|
||||
"tool_call_id": tc_id,
|
||||
"node": node_name,
|
||||
}, source_event_id=f"upd:{tc_id}" if tc_id else None))
|
||||
elif msg_type == "tool":
|
||||
tc_id = msg.get("tool_call_id", "")
|
||||
status = msg.get("status", "completed")
|
||||
event_type = "tool.completed" if status != "error" else "tool.failed"
|
||||
events.append(_make(event_type, agent_id, turn_index, {
|
||||
"tool_name": msg.get("name", ""),
|
||||
"tool_call_id": tc_id,
|
||||
"content_preview": _truncate(_extract_text(msg.get("content")), 500),
|
||||
"status": status,
|
||||
"node": node_name,
|
||||
}, source_event_id=f"upd:tool:{tc_id}" if tc_id else None))
|
||||
return events
|
||||
|
||||
|
||||
def _extract_text(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for p in content:
|
||||
if isinstance(p, dict) and p.get("type") == "text":
|
||||
parts.append(p.get("text", ""))
|
||||
elif isinstance(p, str):
|
||||
parts.append(p)
|
||||
return "\n".join(parts)
|
||||
return ""
|
||||
|
||||
|
||||
def _truncate(text: str, max_len: int) -> str:
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[:max_len] + "..."
|
||||
|
||||
|
||||
def _summarize_args(args: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(args, dict):
|
||||
return None
|
||||
from app.maf_roundtable.sanitize import sanitize
|
||||
result = sanitize(args)
|
||||
return result if isinstance(result, dict) else None
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Controlled DeerFlow runtime invocation client for MAF roundtable.
|
||||
|
||||
All invocations require an explicit ``ExecutionPrincipal`` — the server-generated
|
||||
identity derived ONLY from the authenticated MAF session owner. No raw tokens,
|
||||
cookies, or credentials ever pass into events, checkpoints, or prompts.
|
||||
|
||||
Two invocation modes (the adapter uses mode 2):
|
||||
1. Direct checkpointer access for thread creation and message reads.
|
||||
2. In-process ``start_run`` via a typed internal request that carries the
|
||||
principal's user identity (never ``user=None``).
|
||||
|
||||
Security invariants:
|
||||
- ExecutionPrincipal is constructed ONLY from the MAF session's user_id.
|
||||
- The internal request object explicitly carries the principal's user so
|
||||
downstream code sees a real identity, not a None placeholder.
|
||||
- Agent access is enforced server-side (enforce_access=True).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from langgraph.checkpoint.base import empty_checkpoint
|
||||
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize_tool_result(result: Any) -> Any:
|
||||
"""Convert a tool result to a JSON-safe dict/list/str/int/float/bool/None."""
|
||||
if result is None:
|
||||
return None
|
||||
if isinstance(result, (str, int, float, bool)):
|
||||
return result
|
||||
if isinstance(result, (list, tuple)):
|
||||
return [_serialize_tool_result(v) for v in result]
|
||||
if isinstance(result, dict):
|
||||
return {str(k): _serialize_tool_result(v) for k, v in result.items()}
|
||||
return str(result)[:1000]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionPrincipal:
|
||||
"""Server-generated execution identity for a MAF seat run.
|
||||
|
||||
Constructed ONLY from the authenticated MAF session owner. Never from
|
||||
request body, query params, or client-supplied data.
|
||||
"""
|
||||
|
||||
user_id: str
|
||||
session_id: str
|
||||
run_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.user_id or not self.user_id.strip():
|
||||
raise ValueError("ExecutionPrincipal requires a non-empty user_id")
|
||||
|
||||
|
||||
class _PrincipalUser:
|
||||
"""Minimal CurrentUser protocol implementation backed by ExecutionPrincipal."""
|
||||
|
||||
def __init__(self, principal: ExecutionPrincipal) -> None:
|
||||
self.id = principal.user_id
|
||||
|
||||
|
||||
class _InternalState:
|
||||
"""Typed state container for the internal run request.
|
||||
|
||||
Carries the principal's user identity explicitly — never None — so
|
||||
downstream access checks and run metadata attribution work correctly.
|
||||
"""
|
||||
|
||||
def __init__(self, principal: ExecutionPrincipal) -> None:
|
||||
self.user = _PrincipalUser(principal)
|
||||
self.auth = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _InternalRunRequest:
|
||||
"""Typed internal request for in-process agent run invocation.
|
||||
|
||||
Replaces the former ``SimpleNamespace(app=..., state=SimpleNamespace(user=None))``
|
||||
pattern. The state explicitly carries the principal's user identity.
|
||||
"""
|
||||
|
||||
app: Any
|
||||
state: _InternalState
|
||||
|
||||
@classmethod
|
||||
def from_principal(cls, app: Any, principal: ExecutionPrincipal) -> _InternalRunRequest:
|
||||
return cls(app=app, state=_InternalState(principal))
|
||||
|
||||
|
||||
class DeerFlowRuntimeClient:
|
||||
"""Controlled entry point for invoking the DeerFlow Agent Runtime from MAF.
|
||||
|
||||
All calls require an ExecutionPrincipal. The client:
|
||||
- Sets the user ContextVar before each invocation.
|
||||
- Creates threads with proper user attribution.
|
||||
- Invokes start_run with server-generated identity.
|
||||
- Never passes raw tokens or credentials downstream.
|
||||
"""
|
||||
|
||||
def __init__(self, app: Any) -> None:
|
||||
self._app = app
|
||||
|
||||
@property
|
||||
def app(self) -> Any:
|
||||
return self._app
|
||||
|
||||
async def create_thread(self, principal: ExecutionPrincipal) -> str:
|
||||
"""Create a DeerFlow thread owned by the principal's user.
|
||||
|
||||
The user ContextVar is set so thread_store.create records the correct
|
||||
user_id in thread_meta.
|
||||
"""
|
||||
token = set_current_user(_PrincipalUser(principal))
|
||||
try:
|
||||
checkpointer = self._app.state.checkpointer
|
||||
thread_store = self._app.state.thread_store
|
||||
thread_id = str(uuid.uuid4())
|
||||
await thread_store.create(
|
||||
thread_id,
|
||||
assistant_id=None,
|
||||
metadata={
|
||||
"thread_type": "maf_roundtable",
|
||||
"system": True,
|
||||
"maf_session_id": principal.session_id,
|
||||
"maf_run_id": principal.run_id,
|
||||
},
|
||||
)
|
||||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
await checkpointer.aput(
|
||||
config,
|
||||
empty_checkpoint(),
|
||||
{"step": -1, "source": "input", "writes": None, "parents": {}, "created_at": datetime.now(UTC).isoformat()},
|
||||
{},
|
||||
)
|
||||
return thread_id
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
async def start_agent_run(
|
||||
self,
|
||||
principal: ExecutionPrincipal,
|
||||
*,
|
||||
thread_id: str,
|
||||
agent_id: str,
|
||||
message: str,
|
||||
model_name: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
thinking_enabled: bool = True,
|
||||
subagent_enabled: bool = False,
|
||||
excluded_tools: list[str] | None = None,
|
||||
memory_recall_disabled: bool | None = None,
|
||||
memory_injection_enabled: bool | None = None,
|
||||
turn_index: int = 0,
|
||||
capability_snapshot: dict[str, Any] | None = None,
|
||||
write_policy: str = "confirm_high_risk",
|
||||
approved_tool: str | None = None,
|
||||
approved_action_id: str | None = None,
|
||||
) -> Any:
|
||||
"""Start a DeerFlow agent run under the principal's identity.
|
||||
|
||||
Returns the RunRecord (with .task and .run_id). The caller must await
|
||||
the task and handle exceptions.
|
||||
|
||||
WriteGuard activation: when ``write_policy != "auto"`` the run context
|
||||
carries ``maf_run_id`` (the activation flag the WriteGuard middleware
|
||||
checks) plus the per-run data the interceptor needs to record a
|
||||
pending action. ``excluded_tools`` here MUST only contain tools the
|
||||
user explicitly disabled — write tools are no longer excluded (the
|
||||
middleware intercepts them instead, keeping them visible to the model).
|
||||
"""
|
||||
from app.gateway.routers.thread_runs import RunCreateRequest
|
||||
from app.gateway.services import RUN_SCOPE_BACKGROUND_CHILD, start_run
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"agent_name": agent_id,
|
||||
"model_name": model_name or "",
|
||||
"mode": "pro",
|
||||
"reasoning_effort": reasoning_effort or "medium",
|
||||
"thinking_enabled": thinking_enabled,
|
||||
"is_plan_mode": False,
|
||||
"subagent_enabled": subagent_enabled,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
if memory_recall_disabled is not None:
|
||||
context["memory_recall_disabled"] = memory_recall_disabled
|
||||
if memory_injection_enabled is not None:
|
||||
context["memory_injection_enabled"] = memory_injection_enabled
|
||||
|
||||
# WriteGuard activation + per-run data (server-generated identity only).
|
||||
# ``maf_run_id`` is the flag the WriteGuard middleware checks to decide
|
||||
# whether to intercept; the rest is the data the interceptor reads.
|
||||
context["maf_run_id"] = principal.run_id
|
||||
context["maf_session_id"] = principal.session_id
|
||||
context["maf_user_id"] = principal.user_id
|
||||
context["agent_id"] = agent_id
|
||||
context["turn_index"] = turn_index
|
||||
context["write_policy"] = write_policy
|
||||
if capability_snapshot is not None:
|
||||
context["capability_snapshot"] = capability_snapshot
|
||||
# Post-approval deterministic execution: when set, the WriteGuard
|
||||
# middleware passes this single tool through without interception.
|
||||
# ``maf_approved_action_id`` binds the passthrough to a specific
|
||||
# approved action — the middleware validates both tool name AND
|
||||
# action_id so a model calling another tool with the same name is
|
||||
# still intercepted (defense-in-depth when combined with direct
|
||||
# tool execution that bypasses the LLM).
|
||||
if approved_tool:
|
||||
context["maf_approved_tool"] = approved_tool
|
||||
if approved_action_id:
|
||||
context["maf_approved_action_id"] = approved_action_id
|
||||
|
||||
body = RunCreateRequest(
|
||||
assistant_id="lead_agent",
|
||||
input={"messages": [{"type": "human", "content": [{"type": "text", "text": message}], "additional_kwargs": {}}]},
|
||||
metadata={"run_scope": RUN_SCOPE_BACKGROUND_CHILD, "maf_run_id": principal.run_id},
|
||||
config={"recursion_limit": 1000},
|
||||
context=context,
|
||||
excluded_tools=excluded_tools or [],
|
||||
on_disconnect="continue",
|
||||
multitask_strategy="reject",
|
||||
)
|
||||
# Typed internal request carrying the principal's identity — never user=None.
|
||||
request = _InternalRunRequest.from_principal(self._app, principal)
|
||||
|
||||
token = set_current_user(_PrincipalUser(principal))
|
||||
try:
|
||||
record = await start_run(
|
||||
body,
|
||||
thread_id,
|
||||
request,
|
||||
run_scope=RUN_SCOPE_BACKGROUND_CHILD,
|
||||
)
|
||||
return record
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
async def read_thread_messages(self, principal: ExecutionPrincipal, thread_id: str) -> list[dict[str, Any]]:
|
||||
"""Read the latest checkpoint messages from a thread."""
|
||||
from deerflow.runtime.serialization import serialize_channel_values
|
||||
|
||||
token = set_current_user(_PrincipalUser(principal))
|
||||
try:
|
||||
checkpointer = self._app.state.checkpointer
|
||||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
ct = await checkpointer.aget_tuple(config)
|
||||
if ct is None:
|
||||
return []
|
||||
checkpoint = getattr(ct, "checkpoint", {}) or {}
|
||||
values = serialize_channel_values(checkpoint.get("channel_values", {}) or {})
|
||||
return values.get("messages") or []
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
async def execute_tool_directly(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
agent_id: str,
|
||||
tool_name: str,
|
||||
params: dict[str, Any],
|
||||
principal: ExecutionPrincipal,
|
||||
turn_index: int,
|
||||
capability_snapshot: dict[str, Any] | None,
|
||||
write_policy: str,
|
||||
approved_action_id: str,
|
||||
action_record: dict[str, Any] | None,
|
||||
model_name: str | None = None,
|
||||
excluded_tools: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a DeerFlow tool directly without LLM involvement.
|
||||
|
||||
For post-approval deterministic execution this delegates to the harness
|
||||
``execute_approved_tool``, which runs the call through the **real**
|
||||
LangGraph ``ToolNode`` / ``ToolRuntime`` / middleware chain (state
|
||||
recovered from the thread checkpoint), not a faked runtime. No raw
|
||||
params enter any prompt, checkpoint, or model context.
|
||||
|
||||
Returns ``{"status": "succeeded", "result": ...}`` or
|
||||
``{"status": "failed", "error": "..."}``. A security rejection
|
||||
(unauthorized / disabled / nonexistent tool, context mismatch) is a
|
||||
``failed`` outcome and the tool executes 0 times.
|
||||
"""
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.tools.approved_tool_executor import execute_approved_tool
|
||||
|
||||
app_config = get_app_config()
|
||||
checkpointer = getattr(self._app.state, "checkpointer", None)
|
||||
store = getattr(self._app.state, "store", None)
|
||||
|
||||
return await execute_approved_tool(
|
||||
app_config=app_config,
|
||||
checkpointer=checkpointer,
|
||||
store=store,
|
||||
thread_id=thread_id,
|
||||
user_id=principal.user_id,
|
||||
session_id=principal.session_id,
|
||||
run_id=principal.run_id,
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
model_name=model_name,
|
||||
excluded_tools=excluded_tools,
|
||||
capability_snapshot=capability_snapshot,
|
||||
write_policy=write_policy,
|
||||
tool_name=tool_name,
|
||||
params=params,
|
||||
approved_action_id=approved_action_id,
|
||||
action_record=action_record,
|
||||
)
|
||||
|
||||
async def cancel_run(self, deerflow_run_id: str) -> bool:
|
||||
"""Cancel a DeerFlow run by its run_id."""
|
||||
run_manager = getattr(self._app.state, "run_manager", None)
|
||||
if run_manager is None:
|
||||
return False
|
||||
try:
|
||||
await run_manager.cancel(deerflow_run_id, action="interrupt")
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Log only the exception type — never the raw message/traceback,
|
||||
# which may carry params or credentials.
|
||||
logger.warning(
|
||||
"cancel DeerFlow run %s failed (%s)", deerflow_run_id, type(exc).__name__
|
||||
)
|
||||
return False
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Recursive sanitization for MAF roundtable events, payloads, and logs.
|
||||
|
||||
Strips credentials, tokens, cookies, passwords, secrets, and API keys from
|
||||
nested data structures before they reach the event store, context builder,
|
||||
or log output. Applied uniformly to tool arguments, tool results, fact
|
||||
ledger entries, and event payloads.
|
||||
|
||||
Design:
|
||||
- Pure functions, no I/O.
|
||||
- Recursive: handles nested dicts, lists, and strings.
|
||||
- Key-based: redacts values whose key matches a sensitive pattern.
|
||||
- Value-based: redacts string values that look like tokens/keys.
|
||||
- Never raises: malformed input passes through unchanged (best-effort).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_SENSITIVE_KEYS = frozenset({
|
||||
"token", "access_token", "refresh_token", "id_token",
|
||||
"authorization", "auth", "auth_token", "authtoken",
|
||||
"cookie", "cookies", "set-cookie",
|
||||
"password", "passwd", "pwd",
|
||||
"secret", "secret_key", "client_secret",
|
||||
"api_key", "apikey", "api-key",
|
||||
"private_key", "private-key",
|
||||
"credential", "credentials",
|
||||
"session_id", "sessionid",
|
||||
"x-api-key", "x-auth-token",
|
||||
})
|
||||
|
||||
_KEY_PATTERN = re.compile(
|
||||
r"(token|authorization|cookie|password|passwd|secret|api[_-]?key|private[_-]?key|credential|bearer)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_VALUE_PATTERNS = [
|
||||
re.compile(r"^Bearer\s+\S+", re.IGNORECASE),
|
||||
re.compile(r"^(sk|pk|ak|rk)[_-][A-Za-z0-9]{16,}$"),
|
||||
re.compile(r"^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"),
|
||||
re.compile(r"^[A-Za-z0-9+/]{40,}={0,2}$"),
|
||||
]
|
||||
|
||||
_INLINE_SECRET_PATTERN = re.compile(
|
||||
r"(token|password|passwd|secret|api[_-]?key|authorization|cookie)\s*[=:]\s*\S+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_REDACTED = "***REDACTED***"
|
||||
_MAX_DEPTH = 12
|
||||
|
||||
|
||||
def sanitize(data: Any, *, _depth: int = 0) -> Any:
|
||||
"""Recursively sanitize sensitive fields from a data structure."""
|
||||
if _depth > _MAX_DEPTH:
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
return _sanitize_dict(data, _depth)
|
||||
if isinstance(data, (list, tuple)):
|
||||
return [sanitize(item, _depth=_depth + 1) for item in data]
|
||||
if isinstance(data, str):
|
||||
return _sanitize_value_str(data)
|
||||
return data
|
||||
|
||||
|
||||
def sanitize_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Sanitize an event payload dict. Returns None if input is None."""
|
||||
if payload is None:
|
||||
return None
|
||||
result = sanitize(payload)
|
||||
return result if isinstance(result, dict) else payload
|
||||
|
||||
|
||||
def _sanitize_dict(d: dict[str, Any], depth: int) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for key, value in d.items():
|
||||
key_lower = str(key).lower().replace("-", "_")
|
||||
if key_lower in _SENSITIVE_KEYS or _KEY_PATTERN.search(key_lower):
|
||||
out[key] = _REDACTED
|
||||
elif isinstance(value, dict):
|
||||
out[key] = _sanitize_dict(value, depth + 1)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
out[key] = [sanitize(item, _depth=depth + 1) for item in value]
|
||||
elif isinstance(value, str):
|
||||
out[key] = _sanitize_value_str(value)
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def _sanitize_value_str(s: str) -> str:
|
||||
# Never skip sanitization based on length — long strings (error traces,
|
||||
# log dumps, seat-run errors) may embed credentials deep inside.
|
||||
for pat in _VALUE_PATTERNS:
|
||||
if pat.match(s):
|
||||
return _REDACTED
|
||||
if _INLINE_SECRET_PATTERN.search(s):
|
||||
return _INLINE_SECRET_PATTERN.sub(
|
||||
lambda m: m.group(0).split("=")[0].split(":")[0] + "=***REDACTED***", s
|
||||
)
|
||||
return s
|
||||
@@ -30,8 +30,8 @@ class ParticipantInput(BaseModel):
|
||||
display_name: str = Field(default="", max_length=255)
|
||||
role: MafParticipantRole = "seat"
|
||||
sort_order: int = 0
|
||||
# Frozen at creation — the agent's tools / skills snapshot.
|
||||
capability_snapshot: dict[str, Any] | None = None
|
||||
# Subtractive only: tools removed from the server-resolved allow-list for this seat.
|
||||
excluded_tools: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
@@ -48,8 +48,8 @@ class CreateSessionRequest(BaseModel):
|
||||
# Optional: omitted → MafRoundtableConfig.default_write_policy.
|
||||
write_policy: MafWritePolicy | None = None
|
||||
participants: list[ParticipantInput] = Field(default_factory=list)
|
||||
# Frozen at creation — the full session capability snapshot (agents + tools + skills).
|
||||
capability_snapshot: dict[str, Any] | None = None
|
||||
# Subtractive session-wide tool exclusions (applied per seat when building snapshot).
|
||||
excluded_tools: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# Fields that must not be explicitly set to null on PUT (→ 422).
|
||||
|
||||
@@ -30,11 +30,15 @@ reserved / not-yet-executing state) rather than pretending to be ``running``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.sanitize import sanitize, sanitize_payload
|
||||
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
|
||||
from deerflow.persistence.maf_roundtable.store import MafRoundtableStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MafDisabledError(RuntimeError):
|
||||
"""Raised when a write is attempted while the feature flag is off."""
|
||||
@@ -64,11 +68,67 @@ class MafForbiddenError(PermissionError):
|
||||
self.ident = ident
|
||||
|
||||
|
||||
class MafConfigurationError(RuntimeError):
|
||||
"""MAF is enabled but a required safety prerequisite is missing.
|
||||
|
||||
Raised by :func:`validate_maf_prerequisites` on the startup path so the
|
||||
feature can never come up in a half-safe state ("MAF on, approval guard not
|
||||
mounted"). The gateway lifespan catches this and forces the feature OFF
|
||||
(clearly logged) rather than aborting the whole (experimental, opt-in)
|
||||
service.
|
||||
"""
|
||||
|
||||
|
||||
def validate_maf_prerequisites() -> None:
|
||||
"""Fail loudly if MAF is enabled without its full safety stack in place.
|
||||
|
||||
Checks, in order:
|
||||
1. ``write_guard.enabled`` is true (the approval guard is mounted);
|
||||
2. ``write_guard.provider`` is complete (``policy_use`` + ``interceptor_use``);
|
||||
3. the bridge factories actually resolve (the app-layer guard is importable);
|
||||
4. ``MAF_ACTION_SECRET_KEY`` is set (action params are encrypted with it).
|
||||
|
||||
Raises ``MafConfigurationError`` on the first unmet condition. Callers on
|
||||
the startup path treat that as "force the feature off"; direct unit-test
|
||||
construction of :class:`MafRoundtableService` does NOT call this.
|
||||
"""
|
||||
import os
|
||||
|
||||
from deerflow.config.write_guard_config import get_write_guard_config
|
||||
from deerflow.reflection import resolve_variable
|
||||
|
||||
wg = get_write_guard_config()
|
||||
if not wg.enabled:
|
||||
raise MafConfigurationError(
|
||||
"maf_roundtable.enabled=true requires write_guard.enabled=true "
|
||||
"(the approval guard must be mounted)"
|
||||
)
|
||||
provider = wg.provider
|
||||
if provider is None or not provider.policy_use or not provider.interceptor_use:
|
||||
raise MafConfigurationError(
|
||||
"maf_roundtable.enabled=true requires a complete write_guard.provider "
|
||||
"(policy_use + interceptor_use)"
|
||||
)
|
||||
try:
|
||||
resolve_variable(provider.policy_use)
|
||||
resolve_variable(provider.interceptor_use)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise MafConfigurationError(
|
||||
f"write_guard bridge factories are not resolvable: {exc}"
|
||||
) from exc
|
||||
if not (os.getenv("MAF_ACTION_SECRET_KEY") or "").strip():
|
||||
raise MafConfigurationError(
|
||||
"maf_roundtable.enabled=true requires the MAF_ACTION_SECRET_KEY env var"
|
||||
)
|
||||
|
||||
|
||||
# Session statuses that may be edited (config changes).
|
||||
_EDITABLE_STATUSES = {"draft", "completed", "failed", "cancelled"}
|
||||
# Session statuses that mean a run is in flight (no config edits). ``starting``
|
||||
# is the transient CAS-claim state between "claimed" and "run row created".
|
||||
_RUNNING_STATUSES = {"starting", "running", "waiting_approval", "waiting_input"}
|
||||
# ``cancel_failed`` blocks edits because the cancel cascade is incomplete and
|
||||
# the may still have active sub-runs.
|
||||
_RUNNING_STATUSES = {"starting", "running", "waiting_approval", "waiting_input", "cancelling", "cancel_failed"}
|
||||
|
||||
|
||||
class MafRoundtableService:
|
||||
@@ -79,14 +139,37 @@ class MafRoundtableService:
|
||||
enabled: bool,
|
||||
admin_diagnostic_access: bool,
|
||||
config: MafRoundtableConfig | None = None,
|
||||
agent_store: Any | None = None,
|
||||
app_config: Any | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._enabled = enabled
|
||||
self._admin_diagnostic_access = admin_diagnostic_access
|
||||
self._agent_store = agent_store
|
||||
self._app_config = app_config
|
||||
# Defaults source for create_session; falls back to the pydantic
|
||||
# defaults when no config is injected (keeps unit tests lightweight).
|
||||
self._config = config or MafRoundtableConfig()
|
||||
|
||||
# Startup validation: if the MAF feature is enabled, the
|
||||
# ``MAF_ACTION_SECRET_KEY`` env var MUST be set — external-write
|
||||
# action params are encrypted with it and there is no default key.
|
||||
# A missing key forces the feature off (write paths then return 503)
|
||||
# rather than running with a publicly-known key. This never aborts
|
||||
# Gateway startup (the feature is experimental / opt-in).
|
||||
if enabled:
|
||||
import os
|
||||
|
||||
key = os.getenv("MAF_ACTION_SECRET_KEY", "")
|
||||
if not key or not key.strip():
|
||||
logger.error(
|
||||
"[maf-roundtable] MAF_ACTION_SECRET_KEY is not set — "
|
||||
"disabling the feature (write paths will return 503). "
|
||||
"Set a 32+ char random string before enabling "
|
||||
"maf_roundtable.enabled."
|
||||
)
|
||||
enabled = False
|
||||
self._enabled = enabled
|
||||
|
||||
@property
|
||||
def store(self) -> MafRoundtableStore:
|
||||
return self._store
|
||||
@@ -132,8 +215,52 @@ class MafRoundtableService:
|
||||
) -> dict[str, Any]:
|
||||
self._require_enabled()
|
||||
cfg = self._config
|
||||
# Config-driven defaults: an omitted field falls back to the deployed
|
||||
# MafRoundtableConfig value; an explicit request value always wins.
|
||||
from app.maf_roundtable.capability_snapshot import (
|
||||
AgentNotAccessibleError,
|
||||
InvalidExcludedToolsError,
|
||||
build_trusted_capability_snapshot,
|
||||
seat_snapshot_for_agent,
|
||||
)
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
app_config = self._app_config or get_app_config()
|
||||
raw_participants = list(body.get("participants") or [])
|
||||
# Strip any client-supplied snapshot / tool metadata — never trusted.
|
||||
participants: list[dict[str, Any]] = []
|
||||
for p in raw_participants:
|
||||
participants.append(
|
||||
{
|
||||
"agent_id": p.get("agent_id"),
|
||||
"display_name": p.get("display_name", ""),
|
||||
"role": p.get("role", "seat"),
|
||||
"sort_order": p.get("sort_order", 0),
|
||||
"thread_id": p.get("thread_id"),
|
||||
"excluded_tools": p.get("excluded_tools") or [],
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
trusted_snapshot = await build_trusted_capability_snapshot(
|
||||
user_id=user_id,
|
||||
participants=participants,
|
||||
app_config=app_config,
|
||||
agent_store=self._agent_store,
|
||||
session_model_name=body.get("model_name"),
|
||||
session_reasoning_effort=body.get("reasoning_effort"),
|
||||
session_excluded_tools=body.get("excluded_tools"),
|
||||
)
|
||||
except AgentNotAccessibleError as exc:
|
||||
raise MafForbiddenError("agent", exc.agent_id) from exc
|
||||
except InvalidExcludedToolsError as exc:
|
||||
raise MafConflictError(
|
||||
f"excluded_tools not allowed for agent {exc.agent_id}: {exc.tools}"
|
||||
) from exc
|
||||
|
||||
for p in participants:
|
||||
aid = p.get("agent_id")
|
||||
if aid:
|
||||
p["capability_snapshot"] = seat_snapshot_for_agent(trusted_snapshot, str(aid))
|
||||
|
||||
session_data: dict[str, Any] = {
|
||||
"user_id": user_id,
|
||||
"title": body.get("title", ""),
|
||||
@@ -146,10 +273,9 @@ class MafRoundtableService:
|
||||
"manager_agent_id": body.get("manager_agent_id"),
|
||||
"max_rounds": body.get("max_rounds") if body.get("max_rounds") is not None else cfg.default_max_rounds,
|
||||
"write_policy": body.get("write_policy") or cfg.default_write_policy,
|
||||
"capability_snapshot": body.get("capability_snapshot"),
|
||||
"capability_snapshot": trusted_snapshot,
|
||||
}
|
||||
session = await self._store.sessions.create_session(session_data)
|
||||
participants = body.get("participants") or []
|
||||
if participants:
|
||||
await self._store.participants.replace_participants(session["id"], participants)
|
||||
return await self.get_session(session_id=session["id"], user_id=user_id, is_admin=is_admin)
|
||||
@@ -287,6 +413,76 @@ class MafRoundtableService:
|
||||
raise MafForbiddenError("run", run_id)
|
||||
raise MafNotFoundError("run", run_id)
|
||||
|
||||
async def start_cancel(self, *, run_id: str, user_id: str | None, is_admin: bool = False) -> dict[str, Any]:
|
||||
"""Phase 1 of two-phase cancel: atomically set run+session to ``cancelling``.
|
||||
|
||||
The caller (router) then calls ``dispatcher.cancel(run_id)`` to
|
||||
cascade to DeerFlow, then calls ``cancel_run`` to finalize to
|
||||
``cancelled``.
|
||||
"""
|
||||
self._require_enabled()
|
||||
if not user_id:
|
||||
raise MafNotFoundError("run", run_id)
|
||||
|
||||
result = await self._store.start_cancel_atomically(run_id=run_id, owner_user_id=user_id)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# Classify the miss.
|
||||
run = await self._store.runs.get_run(run_id)
|
||||
if run is None:
|
||||
raise MafNotFoundError("run", run_id)
|
||||
if is_admin:
|
||||
raise MafForbiddenError("run", run_id)
|
||||
raise MafNotFoundError("run", run_id)
|
||||
|
||||
async def finalize_cancel(self, *, run_id: str, user_id: str | None, is_admin: bool = False) -> dict[str, Any]:
|
||||
"""Phase 2 of two-phase cancel: finalize ``cancelling`` → ``cancelled``."""
|
||||
return await self.cancel_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
||||
|
||||
async def mark_cancel_failed(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
user_id: str | None,
|
||||
is_admin: bool = False,
|
||||
reason: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Mark a cancelling run as ``cancel_failed`` (non-terminal).
|
||||
|
||||
Used when the dispatcher's cancel cascade could not confirm all
|
||||
DeerFlow sub-runs were cancelled. The run AND session both enter
|
||||
``cancel_failed`` atomically so their statuses never diverge.
|
||||
The user can retry cancel later — ``cancel_run_atomically``
|
||||
accepts ``cancel_failed`` as a valid input state.
|
||||
A failed cancel must NEVER masquerade as ``cancelled``.
|
||||
"""
|
||||
self._require_enabled()
|
||||
if not user_id:
|
||||
raise MafNotFoundError("run", run_id)
|
||||
|
||||
scope = self._write_scope_user(user_id, is_admin)
|
||||
run = await self._store.runs.get_run(run_id)
|
||||
if run is None:
|
||||
raise MafNotFoundError("run", run_id)
|
||||
# Owner (or admin) check.
|
||||
session = await self._store.sessions.get_session(run["session_id"], user_id=scope)
|
||||
if session is None:
|
||||
if is_admin and await self._store.sessions.get_session(run["session_id"], user_id=None) is not None:
|
||||
raise MafForbiddenError("run", run_id)
|
||||
raise MafNotFoundError("run", run_id)
|
||||
|
||||
safe_reason = str(sanitize(reason))[:500] if reason else "one or more sub-runs could not be cancelled"
|
||||
updated = await self._store.mark_cancel_failed_atomically(
|
||||
run_id=run_id,
|
||||
owner_user_id=user_id,
|
||||
error_code="CancelCascadeIncomplete",
|
||||
error_message=safe_reason,
|
||||
)
|
||||
if updated is None:
|
||||
raise MafNotFoundError("run", run_id)
|
||||
return updated
|
||||
|
||||
# ── events ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def append_event(
|
||||
@@ -298,6 +494,7 @@ class MafRoundtableService:
|
||||
agent_id: str | None = None,
|
||||
turn_index: int | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
source_event_id: str | None = None,
|
||||
is_admin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Append an event to a run's stream (internal / executor only).
|
||||
@@ -324,7 +521,8 @@ class MafRoundtableService:
|
||||
event_type=event_type,
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
payload=payload,
|
||||
payload=sanitize_payload(payload),
|
||||
source_event_id=source_event_id,
|
||||
)
|
||||
|
||||
async def list_events(
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Deterministic post-approval tool executor for the MAF roundtable.
|
||||
|
||||
After a human approves an external-write action, this executor calls the
|
||||
approved DeerFlow tool **directly** with the **decrypted original params** —
|
||||
no model free-will, no re-decision, no raw params in any prompt or checkpoint.
|
||||
|
||||
It is invoked by ``WorkflowRunner.resume()`` instead of re-running an LLM seat
|
||||
turn (which would let the model pick different arguments or a different tool).
|
||||
|
||||
Design — direct tool invocation
|
||||
-------------------------------
|
||||
1. **Atomic occupy** (``approved → running``): only the caller whose
|
||||
conditional UPDATE affects one row wins the right to execute. Concurrent
|
||||
``resume()`` or post-restart replay finds the row already ``running`` or
|
||||
terminal and bails out — at-most-once execution is guaranteed at the DB
|
||||
level, not by locking.
|
||||
2. **Direct tool call**: decrypts approved params (server-only), looks up the
|
||||
tool in DeerFlow's registry, builds a minimal ToolRuntime from the thread's
|
||||
persisted state (sandbox/thread_data), and calls ``tool.func()`` or
|
||||
``tool.ainvoke()`` directly. The LLM is never involved.
|
||||
3. **Authority**: the approved ``action_id`` is carried in the runtime context
|
||||
so the WriteGuard middleware can validate both tool name AND action_id as
|
||||
defense-in-depth (though the LLM is bypassed, so this is purely a secondary
|
||||
check).
|
||||
4. **Outcome recording**: ``succeeded`` / ``failed`` / ``unknown`` are
|
||||
recorded via the state-machine-constrained ``record_terminal_result``
|
||||
(only ``running → terminal``). ``unknown`` prohibits auto-retry.
|
||||
|
||||
Security: the decrypted params are read server-side from the encrypted secret
|
||||
and never leave this process. They do NOT appear in prompts, checkpoints,
|
||||
events, or API responses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.action_coordinator import ActionCoordinator
|
||||
from app.maf_roundtable.sanitize import sanitize
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToolExecutor:
|
||||
"""Executes an approved external-write action deterministically.
|
||||
|
||||
Constructed per-resume from the ``WorkflowRunner``; takes the already-built
|
||||
adapter + coordinator + runtime client so it reuses identity / sandbox /
|
||||
middleware wiring.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
adapter: Any,
|
||||
action_coordinator: ActionCoordinator,
|
||||
user_id: str,
|
||||
) -> None:
|
||||
self._adapter = adapter
|
||||
self._coordinator = action_coordinator
|
||||
self._user_id = user_id
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
agent_id: str,
|
||||
thread_id: str,
|
||||
approved_tool: str,
|
||||
capability_snapshot: dict[str, Any] | None = None,
|
||||
write_policy: str = "confirm_high_risk",
|
||||
model_name: str | None = None,
|
||||
excluded_tools: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute the approved tool deterministically and record the outcome.
|
||||
|
||||
Returns::
|
||||
|
||||
{"status": "succeeded"|"failed"|"skipped"|"unknown", ...}
|
||||
|
||||
``skipped`` means another caller already occupied/finished the action
|
||||
(at-most-once) — the caller should return the existing result.
|
||||
|
||||
The approved params are recovered **server-side** from the encrypted
|
||||
action secret *after* the atomic occupy. If the secret is missing or
|
||||
cannot be decrypted, ``ActionSecretUnavailableError`` is raised by the
|
||||
coordinator and turned into a terminal ``failed`` outcome here — the
|
||||
tool is NEVER called with empty params (no ``params or {}`` fallback),
|
||||
so the external side effect happens exactly zero times.
|
||||
|
||||
The actual call is delegated to the harness ``execute_approved_tool``
|
||||
(real ``ToolNode`` / ``ToolRuntime`` / middleware chain), rebuilt from
|
||||
the seat's frozen capability snapshot / model / ``excluded_tools``. The
|
||||
stored action record is passed so the harness can verify run/session/
|
||||
agent/tool consistency before executing.
|
||||
"""
|
||||
# 1. Atomic occupy — only the winner ("claimed") proceeds to execute.
|
||||
# A lost claim reflects the action's ACTUAL state (in_progress /
|
||||
# terminal) rather than a blanket "skip", so the caller can keep the
|
||||
# parent run running while another executor finishes it.
|
||||
from deerflow.persistence.maf_roundtable.actions import (
|
||||
MafActionStateMachineError,
|
||||
)
|
||||
|
||||
try:
|
||||
claim = await self._coordinator.occupy_for_execution(action_id)
|
||||
except MafActionStateMachineError as exc:
|
||||
logger.error(
|
||||
"ToolExecutor: action %s cannot be claimed: %s", action_id, exc,
|
||||
)
|
||||
return {"status": "failed", "error": f"action state machine error: {exc}"}
|
||||
|
||||
if claim != "claimed":
|
||||
existing = await self._coordinator._store.actions.get_action(action_id) # noqa: SLF001
|
||||
if claim == "in_progress":
|
||||
logger.info(
|
||||
"ToolExecutor: action %s already running elsewhere (in_progress)",
|
||||
action_id,
|
||||
)
|
||||
return {"status": "in_progress", "action": existing}
|
||||
logger.info(
|
||||
"ToolExecutor: action %s already terminal (%s) — at-most-once",
|
||||
action_id, claim,
|
||||
)
|
||||
return {"status": claim, "action": existing}
|
||||
|
||||
if not approved_tool:
|
||||
await self._coordinator.record_result(
|
||||
action_id, status="failed",
|
||||
result={"error": "no approved tool name — cannot execute"},
|
||||
)
|
||||
return {"status": "failed", "error": "no approved tool name"}
|
||||
|
||||
# 2. Recover the approved params from the server-only encrypted secret.
|
||||
# A missing/undecryptable/invalid secret is a HARD failure: record
|
||||
# the action failed and never invoke the tool (execution count == 0).
|
||||
from app.maf_roundtable.action_coordinator import ActionSecretUnavailableError
|
||||
|
||||
try:
|
||||
params = await self._coordinator.get_approved_action_params(action_id)
|
||||
except ActionSecretUnavailableError as exc:
|
||||
safe_msg = str(sanitize(exc.detail))[:500]
|
||||
logger.error(
|
||||
"ToolExecutor: action %s params unavailable (%s): %s",
|
||||
action_id, exc.reason, safe_msg,
|
||||
)
|
||||
await self._coordinator.record_result(
|
||||
action_id, status="failed",
|
||||
result={"error": f"action secret unavailable: {exc.reason}"},
|
||||
)
|
||||
return {"status": "failed", "error": safe_msg}
|
||||
|
||||
# 3. Read the stored action record (authoritative turn_index + the
|
||||
# run/session/agent/tool the harness will verify against).
|
||||
action_record = await self._coordinator._store.actions.get_action(action_id) # noqa: SLF001
|
||||
turn_index = int((action_record or {}).get("turn_index") or 0)
|
||||
|
||||
# 4. Execute via the real DeerFlow tool path — no LLM, no prompt
|
||||
# injection. The raw params are read from the encrypted secret and
|
||||
# passed straight to the tool; they never enter any model context.
|
||||
from app.maf_roundtable.runtime_client import ExecutionPrincipal
|
||||
|
||||
principal = ExecutionPrincipal(
|
||||
user_id=self._user_id,
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
client = self._adapter._client # noqa: SLF001 — DeerFlowRuntimeClient
|
||||
|
||||
try:
|
||||
outcome = await client.execute_tool_directly(
|
||||
thread_id=thread_id,
|
||||
agent_id=agent_id,
|
||||
tool_name=approved_tool,
|
||||
params=params,
|
||||
principal=principal,
|
||||
turn_index=turn_index,
|
||||
capability_snapshot=capability_snapshot,
|
||||
write_policy=write_policy,
|
||||
model_name=model_name,
|
||||
excluded_tools=excluded_tools,
|
||||
approved_action_id=action_id,
|
||||
action_record=action_record,
|
||||
)
|
||||
except Exception as exc:
|
||||
safe_msg = str(sanitize(str(exc)))[:500]
|
||||
logger.error(
|
||||
"ToolExecutor: direct tool call '%s' raised: %s",
|
||||
approved_tool, safe_msg,
|
||||
)
|
||||
await self._coordinator.record_result(
|
||||
action_id, status="failed", result={"error": safe_msg},
|
||||
)
|
||||
return {"status": "failed", "error": safe_msg}
|
||||
|
||||
# 5. Record terminal result (state-machine constrained: only
|
||||
# running → terminal; a lost race is a no-op).
|
||||
outcome_status = outcome.get("status")
|
||||
if outcome_status == "succeeded":
|
||||
await self._coordinator.record_result(
|
||||
action_id, status="succeeded",
|
||||
result={"tool_result": outcome.get("result")},
|
||||
)
|
||||
return {"status": "succeeded", "tool_result": outcome.get("result")}
|
||||
elif outcome_status == "unknown":
|
||||
# Inconclusive external result (timeout / disconnect / gateway):
|
||||
# record ``unknown`` — never auto-retry, a human must reconcile.
|
||||
error = outcome.get("error", "inconclusive external result")
|
||||
await self._coordinator.record_result(
|
||||
action_id, status="unknown", result={"error": error},
|
||||
)
|
||||
return {"status": "unknown", "error": error}
|
||||
else:
|
||||
error = outcome.get("error", "tool execution failed")
|
||||
await self._coordinator.record_result(
|
||||
action_id, status="failed", result={"error": error},
|
||||
)
|
||||
return {"status": "failed", "error": error}
|
||||
|
||||
async def record_unknown(self, action_id: str, *, reason: str) -> None:
|
||||
"""Record an ``unknown`` outcome (network timeout / inconclusive).
|
||||
|
||||
``unknown`` prohibits auto-retry — the action stays in ``unknown``
|
||||
until a human reconciles it.
|
||||
"""
|
||||
logger.warning(
|
||||
"ToolExecutor: action %s outcome unknown — %s (no auto-retry)",
|
||||
action_id, reason,
|
||||
)
|
||||
await self._coordinator.record_result(
|
||||
action_id, status="unknown", result={"reason": reason},
|
||||
)
|
||||
@@ -0,0 +1,606 @@
|
||||
"""MAF Workflow node executors for the roundtable experiment.
|
||||
|
||||
Each executor is a real MAF ``Executor`` subclass instance. Every seat gets
|
||||
its own executor bound to a fixed ``agent_id`` — no shared function that
|
||||
guesses the current speaker from input metadata.
|
||||
|
||||
Executors delegate to ``DeerFlowAgentAdapter.run_seat_turn()`` for actual
|
||||
model/tool execution and emit MAF events via the adapter.
|
||||
|
||||
Nodes:
|
||||
- CoordinatorExecutor: decides which seat speaks next, whether to continue.
|
||||
- SeatExecutor (one per seat): runs one seat turn via DeerFlowAgentAdapter.
|
||||
- ApprovalGateExecutor: pauses for external-write approval before proceeding.
|
||||
- FinalizerExecutor: generates the final synthesis and ends the run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Executor, WorkflowContext, handler
|
||||
|
||||
from app.maf_roundtable.action_coordinator import (
|
||||
ActionCoordinator,
|
||||
classify_tool,
|
||||
tool_needs_approval,
|
||||
)
|
||||
from app.maf_roundtable.context_builder import build_manager_synthesis_input
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WriteGuardConfigurationError(RuntimeError):
|
||||
"""Raised when WriteGuard should have intercepted a write tool but didn't.
|
||||
|
||||
Indicates write_guard is misconfigured (not enabled or provider missing)
|
||||
while a MAF run requires approval. The run is failed — we never fall back
|
||||
to creating generic post-hoc approval records.
|
||||
"""
|
||||
|
||||
|
||||
# ── Serializable workflow state ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class RoundtableState:
|
||||
"""Serializable shared state for the MAF roundtable workflow.
|
||||
|
||||
Persisted to the run's ``maf_checkpoint_json`` so the workflow survives
|
||||
restarts and approval-pause/resume cycles.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
task_text: str,
|
||||
mode: str,
|
||||
model_name: str | None,
|
||||
reasoning_effort: str | None,
|
||||
max_rounds: int,
|
||||
write_policy: str,
|
||||
manager_agent_id: str | None,
|
||||
seat_agent_ids: list[str],
|
||||
thread_map: dict[str, str],
|
||||
capability_snapshot: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.session_id = session_id
|
||||
self.run_id = run_id
|
||||
self.task_text = task_text
|
||||
self.mode = mode
|
||||
self.model_name = model_name
|
||||
self.reasoning_effort = reasoning_effort
|
||||
self.max_rounds = max_rounds
|
||||
self.write_policy = write_policy
|
||||
self.manager_agent_id = manager_agent_id
|
||||
self.seat_agent_ids = seat_agent_ids
|
||||
self.thread_map = thread_map
|
||||
self.capability_snapshot = capability_snapshot
|
||||
|
||||
self.current_round = 0
|
||||
self.turn_results: list[dict[str, Any]] = []
|
||||
self.fact_ledger: dict[str, Any] = {
|
||||
"confirmed_facts": [],
|
||||
"tool_results": [],
|
||||
"conflicts": [],
|
||||
"pending_actions": [],
|
||||
}
|
||||
self.final_result: dict[str, Any] | None = None
|
||||
self.status = "running"
|
||||
self.pending_approvals: list[dict[str, Any]] = []
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"run_id": self.run_id,
|
||||
"task_text": self.task_text,
|
||||
"mode": self.mode,
|
||||
"model_name": self.model_name,
|
||||
"reasoning_effort": self.reasoning_effort,
|
||||
"max_rounds": self.max_rounds,
|
||||
"write_policy": self.write_policy,
|
||||
"manager_agent_id": self.manager_agent_id,
|
||||
"seat_agent_ids": self.seat_agent_ids,
|
||||
"thread_map": self.thread_map,
|
||||
"capability_snapshot": self.capability_snapshot,
|
||||
"current_round": self.current_round,
|
||||
"turn_results": self.turn_results,
|
||||
"fact_ledger": self.fact_ledger,
|
||||
"final_result": self.final_result,
|
||||
"status": self.status,
|
||||
"pending_approvals": self.pending_approvals,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> RoundtableState:
|
||||
state = cls(
|
||||
session_id=d["session_id"],
|
||||
run_id=d["run_id"],
|
||||
task_text=d["task_text"],
|
||||
mode=d["mode"],
|
||||
model_name=d.get("model_name"),
|
||||
reasoning_effort=d.get("reasoning_effort"),
|
||||
max_rounds=d.get("max_rounds", 8),
|
||||
write_policy=d.get("write_policy", "confirm_high_risk"),
|
||||
manager_agent_id=d.get("manager_agent_id"),
|
||||
seat_agent_ids=d.get("seat_agent_ids", []),
|
||||
thread_map=d.get("thread_map", {}),
|
||||
capability_snapshot=d.get("capability_snapshot"),
|
||||
)
|
||||
state.current_round = d.get("current_round", 0)
|
||||
state.turn_results = d.get("turn_results", [])
|
||||
state.fact_ledger = d.get("fact_ledger", state.fact_ledger)
|
||||
state.final_result = d.get("final_result")
|
||||
state.status = d.get("status", "running")
|
||||
state.pending_approvals = d.get("pending_approvals", []) or d.get("pending_approval") and [d["pending_approval"]] or []
|
||||
return state
|
||||
|
||||
|
||||
# ── Coordinator executor ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CoordinatorExecutor(Executor):
|
||||
"""Decides which seat speaks next, whether to continue or finish."""
|
||||
|
||||
def __init__(self, adapter: Any, state: RoundtableState) -> None:
|
||||
super().__init__(id="coordinator")
|
||||
self._adapter = adapter
|
||||
self._state = state
|
||||
|
||||
@handler(input=Any, output=dict)
|
||||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
self._state.current_round += 1
|
||||
|
||||
if self._state.current_round > self._state.max_rounds:
|
||||
await ctx.send_message({"decision": "done", "reason": "max_rounds_reached"})
|
||||
return
|
||||
|
||||
if self._state.mode == "structured":
|
||||
if self._state.current_round == 1:
|
||||
await ctx.send_message({"decision": "parallel_seats", "agents": self._state.seat_agent_ids})
|
||||
return
|
||||
await ctx.send_message({"decision": "done", "reason": "structured_complete"})
|
||||
return
|
||||
|
||||
# group_chat: round-robin speaker selection
|
||||
remaining = [
|
||||
a for a in self._state.seat_agent_ids
|
||||
if not any(
|
||||
r.get("agent_id") == a and r.get("turn_index", 0) >= self._state.current_round - 1
|
||||
for r in self._state.turn_results
|
||||
)
|
||||
]
|
||||
if not remaining:
|
||||
await ctx.send_message({"decision": "done", "reason": "all_seats_spoke"})
|
||||
return
|
||||
|
||||
next_agent = remaining[(self._state.current_round - 1) % len(remaining)]
|
||||
await self._adapter._emit_critical(self._state.run_id, {
|
||||
"event_type": "manager.decision",
|
||||
"agent_id": self._state.manager_agent_id or "coordinator",
|
||||
"turn_index": self._state.current_round,
|
||||
"payload": {"next_speaker": next_agent, "reason": "round_robin", "round": self._state.current_round},
|
||||
})
|
||||
await ctx.send_message({"decision": "next_speaker", "agent_id": next_agent})
|
||||
|
||||
|
||||
# ── Seat executor (one instance per seat) ───────────────────────────────────
|
||||
|
||||
|
||||
class SeatExecutor(Executor):
|
||||
"""Runs one seat turn via DeerFlowAgentAdapter.
|
||||
|
||||
Each instance is bound to a fixed ``agent_id`` — the MAF workflow routes
|
||||
to the correct seat executor by edge, not by guessing from input.
|
||||
|
||||
Phase-2 fix: write tools are NO LONGER excluded from the seat run. The
|
||||
WriteGuard middleware (harness layer) intercepts them at execution time
|
||||
via ``wrap_tool_call``/``awrap_tool_call`` when the run context carries a
|
||||
``maf_run_id`` flag. The model therefore SEES the write-tool schema and
|
||||
can propose the call, but the real tool never executes before approval.
|
||||
``excluded_tools`` now only contains tools the user explicitly disabled
|
||||
(from the capability snapshot). The post-turn capture is retained solely
|
||||
to surface the intercepted calls in the run state / pending approval.
|
||||
"""
|
||||
|
||||
def __init__(self, adapter: Any, state: RoundtableState, agent_id: str, *, action_coordinator: ActionCoordinator | None = None) -> None:
|
||||
super().__init__(id=f"seat_{agent_id}")
|
||||
self._adapter = adapter
|
||||
self._state = state
|
||||
self._agent_id = agent_id
|
||||
self._action_coordinator = action_coordinator
|
||||
|
||||
@handler(input=Any, output=dict)
|
||||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
thread_id = self._state.thread_map.get(self._agent_id, "")
|
||||
if not thread_id:
|
||||
await ctx.send_message({"error": f"no thread for agent {self._agent_id}", "agent_id": self._agent_id})
|
||||
return
|
||||
|
||||
prior_turns = [
|
||||
{"agent_id": r["agent_id"], "turn_index": r["turn_index"], "summary": r.get("content", "")[:500]}
|
||||
for r in self._state.turn_results
|
||||
]
|
||||
|
||||
# Read ALL capabilities from the frozen snapshot — never default-disable.
|
||||
caps = _agent_capabilities_from_snapshot(self._state.capability_snapshot, self._agent_id)
|
||||
|
||||
# Phase-2: excluded_tools contains ONLY user-disabled tools. Write tools
|
||||
# that need approval are no longer excluded — the WriteGuard middleware
|
||||
# intercepts them at execution time so the model still sees the schema.
|
||||
excluded = list(caps.get("excluded_tools", []))
|
||||
|
||||
response = await self._adapter.run_seat_turn(
|
||||
session_id=self._state.session_id,
|
||||
run_id=self._state.run_id,
|
||||
agent_id=self._agent_id,
|
||||
turn_index=self._state.current_round,
|
||||
message=self._state.task_text,
|
||||
thread_id=thread_id,
|
||||
model_name=caps.get("model_name") or self._state.model_name,
|
||||
reasoning_effort=caps.get("reasoning_effort") or self._state.reasoning_effort,
|
||||
thinking_enabled=caps.get("thinking_enabled", True),
|
||||
subagent_enabled=caps.get("subagent_enabled", False),
|
||||
task_text=self._state.task_text,
|
||||
stage_instruction=f"第{self._state.current_round}轮分析",
|
||||
prior_turns=prior_turns,
|
||||
fact_ledger=self._state.fact_ledger,
|
||||
excluded_tools=excluded,
|
||||
memory_recall_disabled=caps.get("memory_recall_disabled"),
|
||||
memory_injection_enabled=caps.get("memory_injection_enabled"),
|
||||
capability_snapshot=self._state.capability_snapshot,
|
||||
write_policy=self._state.write_policy,
|
||||
)
|
||||
|
||||
# Phase-2: the WriteGuard middleware already created pending actions for
|
||||
# intercepted write tools during the run (inside DeerFlow). We now read
|
||||
# the waiting_approval actions that match THIS agent + THIS turn to
|
||||
# surface the pending approval in the workflow state so the ApprovalGate
|
||||
# can pause. No action creation here.
|
||||
if response.tool_calls and self._state.write_policy != "auto":
|
||||
pending_calls = [
|
||||
tc for tc in response.tool_calls
|
||||
if tc.get("name") and tool_needs_approval(
|
||||
tc.get("name"), self._state.write_policy,
|
||||
self._state.capability_snapshot, self._agent_id,
|
||||
)
|
||||
]
|
||||
if pending_calls:
|
||||
all_actions = await self._store.actions.list_actions(
|
||||
run_id=self._state.run_id, status="waiting_approval"
|
||||
)
|
||||
# P1-5/P1b: candidate actions are scoped to this run + agent +
|
||||
# THIS turn (never an earlier turn's waiting actions).
|
||||
turn_actions = [
|
||||
a for a in all_actions
|
||||
if a.get("agent_id") == self._agent_id
|
||||
and a.get("turn_index") == self._state.current_round
|
||||
]
|
||||
# Every pending tool call must match EXACTLY one action, by
|
||||
# tool_call_id (logical_action_id); fall back to tool_name only
|
||||
# when the call carries no id. Any count/ID mismatch is a hard
|
||||
# failure — we never guess which action a call maps to.
|
||||
matched: list[dict[str, Any]] = []
|
||||
for tc in pending_calls:
|
||||
tc_id = tc.get("id") or ""
|
||||
tc_name = tc.get("name", "")
|
||||
if tc_id:
|
||||
hits = [
|
||||
a for a in turn_actions
|
||||
if (a.get("logical_action_id") or "") == tc_id
|
||||
]
|
||||
else:
|
||||
hits = [
|
||||
a for a in turn_actions
|
||||
if a.get("tool_name") == tc_name
|
||||
and not a.get("logical_action_id")
|
||||
]
|
||||
if len(hits) != 1:
|
||||
raise RuntimeError(
|
||||
f"seat {self._agent_id} turn {self._state.current_round}: "
|
||||
f"pending tool call '{tc_name}' (id={tc_id or '<none>'}) matched "
|
||||
f"{len(hits)} waiting actions (expected exactly 1) — refusing to guess"
|
||||
)
|
||||
matched.append(hits[0])
|
||||
|
||||
self._state.pending_approvals = [
|
||||
{
|
||||
"action_id": a["id"],
|
||||
"approval_id": a.get("approval_id", ""),
|
||||
"agent_id": self._agent_id,
|
||||
"tool_name": a.get("tool_name", ""),
|
||||
"turn_index": self._state.current_round,
|
||||
}
|
||||
for a in matched
|
||||
]
|
||||
|
||||
self._state.turn_results.append({
|
||||
"agent_id": self._agent_id,
|
||||
"turn_index": self._state.current_round,
|
||||
"content": response.content,
|
||||
"tool_calls": response.tool_calls,
|
||||
"error": response.error,
|
||||
"usage": response.usage,
|
||||
})
|
||||
|
||||
if response.tool_calls:
|
||||
for tc in response.tool_calls:
|
||||
tc_name = tc.get("name", "")
|
||||
is_intercepted = any(
|
||||
a.get("tool_name") == tc_name
|
||||
for a in (self._state.pending_approvals or [])
|
||||
)
|
||||
self._state.fact_ledger["tool_results"].append({
|
||||
"tool_name": tc_name,
|
||||
"status": "waiting_approval" if is_intercepted else "completed",
|
||||
"agent_id": self._agent_id,
|
||||
})
|
||||
|
||||
# SDK/API errors must fail the run, never silently complete.
|
||||
if response.error:
|
||||
raise RuntimeError(
|
||||
f"seat {self._agent_id} turn {self._state.current_round} failed: {response.error}"
|
||||
)
|
||||
|
||||
await ctx.send_message({"agent_id": self._agent_id, "content": response.content, "error": response.error})
|
||||
|
||||
@property
|
||||
def _store(self) -> Any:
|
||||
return self._adapter._service.store
|
||||
|
||||
def _pending_write_calls(self, tool_calls: list[dict[str, Any]]) -> list[str]:
|
||||
"""Return the names of write tool calls that the policy requires approval for."""
|
||||
names: list[str] = []
|
||||
for tc in tool_calls:
|
||||
name = tc.get("name", "")
|
||||
if name and tool_needs_approval(
|
||||
name, self._state.write_policy, self._state.capability_snapshot, self._agent_id
|
||||
):
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
# ── Approval gate executor ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ApprovalGateExecutor(Executor):
|
||||
"""Pauses for external-write approval before proceeding.
|
||||
|
||||
When the write policy requires approval and a seat produced external write
|
||||
tool calls, this executor:
|
||||
1. Reads the pending approvals already created by WriteGuard (zero or more).
|
||||
2. If none exist but the policy requires approval for detected write tools:
|
||||
raises ``WriteGuardConfigurationError`` — the WriteGuard middleware
|
||||
should have intercepted and recorded them. We NEVER fall back to
|
||||
creating generic post-hoc actions.
|
||||
3. Emits ``action.waiting_approval`` events.
|
||||
4. Sets the run status to ``waiting_approval``.
|
||||
|
||||
On resume (after user approves), the workflow re-enters and this executor
|
||||
checks the approval statuses. When all are decided, it routes to the
|
||||
finalizer.
|
||||
"""
|
||||
|
||||
def __init__(self, adapter: Any, state: RoundtableState, service: Any) -> None:
|
||||
super().__init__(id="approval_gate")
|
||||
self._adapter = adapter
|
||||
self._state = state
|
||||
self._service = service
|
||||
|
||||
@handler(input=Any, output=dict)
|
||||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
# Check if we're resuming from a previous approval pause.
|
||||
if self._state.pending_approvals:
|
||||
all_decided = True
|
||||
for pa in self._state.pending_approvals:
|
||||
approval_id = pa.get("approval_id", "")
|
||||
approval = await self._service.store.approvals.get_approval(approval_id)
|
||||
if approval is None or approval["status"] == "pending":
|
||||
all_decided = False
|
||||
continue
|
||||
if approval["status"] == "approved":
|
||||
from app.maf_roundtable.event_normalizer import make_action_approved
|
||||
await self._adapter._emit_critical(self._state.run_id, make_action_approved(
|
||||
pa.get("agent_id", ""),
|
||||
self._state.current_round,
|
||||
action_id=pa.get("action_id", ""),
|
||||
approval_id=approval_id,
|
||||
decided_by=approval.get("decided_by", ""),
|
||||
))
|
||||
elif approval["status"] == "rejected":
|
||||
from app.maf_roundtable.event_normalizer import make_action_rejected
|
||||
await self._adapter._emit_critical(self._state.run_id, make_action_rejected(
|
||||
pa.get("agent_id", ""),
|
||||
self._state.current_round,
|
||||
action_id=pa.get("action_id", ""),
|
||||
approval_id=approval_id,
|
||||
decided_by=approval.get("decided_by", ""),
|
||||
))
|
||||
|
||||
if all_decided:
|
||||
self._state.pending_approvals = []
|
||||
await ctx.send_message({"decision": "approved"})
|
||||
return
|
||||
|
||||
# Still pending — stay in waiting state.
|
||||
await ctx.send_message({"decision": "waiting_approval"})
|
||||
return
|
||||
|
||||
# First entry: check if any seat produced write tool calls needing approval.
|
||||
needs_approval = self._check_needs_approval()
|
||||
if not needs_approval:
|
||||
await ctx.send_message({"decision": "approved", "policy": self._state.write_policy})
|
||||
return
|
||||
|
||||
# P0-4: WriteGuard should already have created pending actions for every
|
||||
# tool that needs approval. If we detect write tools that need approval
|
||||
# but NO pending_approvals were set by the SeatExecutor, the WriteGuard
|
||||
# middleware is misconfigured — fail the run rather than silently
|
||||
# bypassing approval.
|
||||
if not self._state.pending_approvals:
|
||||
raise WriteGuardConfigurationError(
|
||||
f"Write policy '{self._state.write_policy}' requires approval "
|
||||
f"for detected write tools, but WriteGuard did not create any "
|
||||
f"pending actions. Check that write_guard.enabled is true and "
|
||||
f"the provider is configured in config.yaml."
|
||||
)
|
||||
|
||||
# Update run status to waiting_approval.
|
||||
await self._service.store.runs.update_run(self._state.run_id, {"status": "waiting_approval"})
|
||||
await self._service.store.sessions.update_session(
|
||||
self._state.session_id, user_id=self._adapter._user_id, fields={"status": "waiting_approval"}
|
||||
)
|
||||
|
||||
await ctx.send_message({"decision": "waiting_approval"})
|
||||
|
||||
def _check_needs_approval(self) -> bool:
|
||||
"""Check if the write policy requires approval for any pending tool calls.
|
||||
|
||||
P1: uses metadata-driven classification from the capability snapshot,
|
||||
not name-prefix heuristics. Tools without metadata use a safe default
|
||||
(requires approval).
|
||||
"""
|
||||
if self._state.write_policy == "auto":
|
||||
return False
|
||||
for result in self._state.turn_results:
|
||||
for tc in result.get("tool_calls") or []:
|
||||
name = tc.get("name", "")
|
||||
if not name:
|
||||
continue
|
||||
if tool_needs_approval(name, self._state.write_policy, self._state.capability_snapshot):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Finalizer executor ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[str, Any]:
|
||||
"""Run the manager synthesis (the MAF finalizer path) and set ``state.final_result``.
|
||||
|
||||
Shared by the in-graph ``FinalizerExecutor`` and ``WorkflowRunner.resume`` so
|
||||
that a run which paused for approval still gets a real synthesized report
|
||||
after its approved actions execute — resume must re-enter the finalizer
|
||||
path, not merely flip ``state.status`` to ``completed``.
|
||||
"""
|
||||
from app.maf_roundtable.event_normalizer import make_run_completed
|
||||
|
||||
manager_id = state.manager_agent_id or "coordinator"
|
||||
thread_id = state.thread_map.get(manager_id, "")
|
||||
|
||||
synthesis_input = build_manager_synthesis_input(
|
||||
task_text=state.task_text,
|
||||
seat_results=state.turn_results,
|
||||
fact_ledger=state.fact_ledger,
|
||||
)
|
||||
|
||||
if thread_id:
|
||||
response = await adapter.run_seat_turn(
|
||||
session_id=state.session_id,
|
||||
run_id=state.run_id,
|
||||
agent_id=manager_id,
|
||||
turn_index=state.current_round + 1,
|
||||
message=synthesis_input,
|
||||
thread_id=thread_id,
|
||||
model_name=state.model_name,
|
||||
reasoning_effort=state.reasoning_effort,
|
||||
task_text=synthesis_input,
|
||||
stage_instruction="综合所有席位分析,生成最终结论",
|
||||
)
|
||||
state.final_result = {"content": response.content, "agent_id": manager_id}
|
||||
else:
|
||||
state.final_result = {"content": synthesis_input, "agent_id": manager_id, "note": "no_manager_thread"}
|
||||
|
||||
await adapter._emit_critical(state.run_id, make_run_completed(manager_id, state.current_round + 1, {
|
||||
"total_rounds": state.current_round,
|
||||
"seats_completed": len(state.turn_results),
|
||||
}))
|
||||
return state.final_result
|
||||
|
||||
|
||||
class FinalizerExecutor(Executor):
|
||||
"""Generates the final synthesis and ends the run."""
|
||||
|
||||
def __init__(self, adapter: Any, state: RoundtableState) -> None:
|
||||
super().__init__(id="finalizer")
|
||||
self._adapter = adapter
|
||||
self._state = state
|
||||
|
||||
@handler(input=Any, workflow_output=dict)
|
||||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
await generate_final_report(self._adapter, self._state)
|
||||
self._state.status = "completed"
|
||||
await ctx.yield_output({"final_result": self._state.final_result})
|
||||
|
||||
|
||||
# ── Waiting terminal executor ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class WaitingTerminalExecutor(Executor):
|
||||
"""Terminal executor for the approval-waiting path.
|
||||
|
||||
When the approval gate routes here, the workflow stops. The run stays in
|
||||
``waiting_approval`` until the user decides, at which point the service
|
||||
resumes the workflow from its checkpoint.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="waiting_terminal")
|
||||
|
||||
@handler(input=Any, workflow_output=dict)
|
||||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
await ctx.yield_output({"status": "waiting_approval"})
|
||||
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _excluded_tools_from_snapshot(snapshot: dict[str, Any] | None, agent_id: str) -> list[str]:
|
||||
"""Extract excluded_tools from the frozen capability snapshot for an agent."""
|
||||
caps = _agent_capabilities_from_snapshot(snapshot, agent_id)
|
||||
return caps.get("excluded_tools", [])
|
||||
|
||||
|
||||
def _agent_capabilities_from_snapshot(snapshot: dict[str, Any] | None, agent_id: str) -> dict[str, Any]:
|
||||
"""Extract ALL capabilities from the frozen snapshot for an agent.
|
||||
|
||||
Returns a dict with keys: model_name, reasoning_effort, thinking_enabled,
|
||||
subagent_enabled, excluded_tools, memory_recall_disabled,
|
||||
memory_injection_enabled, write_policy. Missing keys default to
|
||||
permissive values (tools/skills/memory NOT disabled by default).
|
||||
"""
|
||||
if not snapshot:
|
||||
return {}
|
||||
agents = snapshot.get("agents")
|
||||
if not isinstance(agents, list):
|
||||
return {}
|
||||
for a in agents:
|
||||
if isinstance(a, dict) and a.get("agent_id") == agent_id:
|
||||
return {
|
||||
"model_name": a.get("model_name"),
|
||||
"reasoning_effort": a.get("reasoning_effort"),
|
||||
"thinking_enabled": a.get("thinking_enabled", True),
|
||||
"subagent_enabled": a.get("subagent_enabled", False),
|
||||
"excluded_tools": a.get("excluded_tools") or [],
|
||||
"memory_recall_disabled": a.get("memory_recall_disabled"),
|
||||
"memory_injection_enabled": a.get("memory_injection_enabled"),
|
||||
"write_policy": a.get("write_policy"),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def _is_high_risk_tool(tool_name: str, snapshot: dict[str, Any] | None = None) -> bool:
|
||||
"""Determine if a tool is high-risk using capability snapshot metadata.
|
||||
|
||||
P1 fix: no longer guesses risk from name prefixes (``create_`` /
|
||||
``delete_`` etc.). Instead, reads the per-tool ``side_effect`` and
|
||||
``risk_level`` from the frozen capability snapshot. Tools without
|
||||
metadata use a **safe default** (R3 write → high-risk → requires approval).
|
||||
"""
|
||||
side_effect, risk_level = classify_tool(tool_name, snapshot)
|
||||
if side_effect in ("read", "skill"):
|
||||
return False
|
||||
risk_order = {"R0": 0, "R1": 1, "R2": 2, "R3": 3, "R4": 4}
|
||||
return risk_order.get(risk_level, 3) >= 3
|
||||
@@ -0,0 +1,139 @@
|
||||
"""MAF WorkflowBuilder graph construction for the roundtable experiment.
|
||||
|
||||
Creates real MAF Workflow graphs using the official SDK's WorkflowBuilder.
|
||||
The SDK is imported lazily so the Gateway starts without it when the feature
|
||||
flag is off.
|
||||
|
||||
Two topologies:
|
||||
- ``structured``: coordinator → parallel seats → approval gate → finalizer
|
||||
- ``group_chat``: coordinator loop (dynamic speaker selection) → finalizer
|
||||
|
||||
Each node is a real MAF ``Executor`` subclass instance (from
|
||||
``workflow_executors.py``). The builder uses the SDK's actual API:
|
||||
``WorkflowBuilder(start_executor=...)``, ``add_edge``, ``add_fan_out_edges``,
|
||||
``add_fan_in_edges``, ``add_switch_case_edge_group``, ``build()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_structured_workflow(
|
||||
*,
|
||||
coordinator: Any,
|
||||
seat_executors: list[Any],
|
||||
approval_gate: Any,
|
||||
finalizer: Any,
|
||||
waiting_terminal: Any,
|
||||
) -> Any:
|
||||
"""Build a structured (fixed-topology) MAF Workflow.
|
||||
|
||||
Topology: coordinator → parallel seat executors → approval gate → finalizer.
|
||||
The approval gate conditionally routes to waiting_terminal (pause) or finalizer.
|
||||
"""
|
||||
from agent_framework import Case, Default, WorkflowBuilder
|
||||
|
||||
builder = WorkflowBuilder(
|
||||
start_executor=coordinator,
|
||||
name="maf-structured-roundtable",
|
||||
output_from=[finalizer, waiting_terminal],
|
||||
)
|
||||
|
||||
# coordinator → seats → approval gate. Handle 0/1/≥2 seats since the SDK's
|
||||
# FanOut/FanIn edge groups require at least two members.
|
||||
if len(seat_executors) >= 2:
|
||||
builder.add_fan_out_edges(coordinator, seat_executors)
|
||||
builder.add_fan_in_edges(seat_executors, approval_gate)
|
||||
elif len(seat_executors) == 1:
|
||||
builder.add_edge(coordinator, seat_executors[0])
|
||||
builder.add_edge(seat_executors[0], approval_gate)
|
||||
else:
|
||||
# No seats — coordinator feeds the approval gate directly.
|
||||
builder.add_edge(coordinator, approval_gate)
|
||||
|
||||
# approval gate → conditional: approved → finalizer, waiting → waiting_terminal
|
||||
builder.add_switch_case_edge_group(approval_gate, [
|
||||
Case(condition=lambda out: isinstance(out, dict) and out.get("decision") == "waiting_approval", target=waiting_terminal),
|
||||
Default(target=finalizer),
|
||||
])
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
def build_group_chat_workflow(
|
||||
*,
|
||||
coordinator: Any,
|
||||
seat_executors: list[Any],
|
||||
approval_gate: Any,
|
||||
finalizer: Any,
|
||||
waiting_terminal: Any,
|
||||
seat_agent_ids: list[str],
|
||||
) -> Any:
|
||||
"""Build a dynamic group-chat MAF Workflow.
|
||||
|
||||
The coordinator selects the next speaker each round via switch-case edges.
|
||||
Loop terminates on: coordinator signals completion, max_rounds reached.
|
||||
"""
|
||||
from agent_framework import Case, Default, WorkflowBuilder
|
||||
|
||||
builder = WorkflowBuilder(
|
||||
start_executor=coordinator,
|
||||
name="maf-groupchat-roundtable",
|
||||
output_from=[finalizer, waiting_terminal],
|
||||
)
|
||||
|
||||
# coordinator → switch-case: route to the selected seat, or to approval gate
|
||||
cases = []
|
||||
for i, agent_id in enumerate(seat_agent_ids):
|
||||
cases.append(Case(
|
||||
condition=lambda out, aid=agent_id: isinstance(out, dict) and out.get("agent_id") == aid,
|
||||
target=seat_executors[i],
|
||||
))
|
||||
# Default: coordinator said "done" → go to approval gate
|
||||
cases.append(Default(target=approval_gate))
|
||||
builder.add_switch_case_edge_group(coordinator, cases)
|
||||
|
||||
# each seat → back to coordinator (loop)
|
||||
for seat_exec in seat_executors:
|
||||
builder.add_edge(seat_exec, coordinator)
|
||||
|
||||
# approval gate → conditional: waiting → waiting_terminal, else → finalizer
|
||||
builder.add_switch_case_edge_group(approval_gate, [
|
||||
Case(condition=lambda out: isinstance(out, dict) and out.get("decision") == "waiting_approval", target=waiting_terminal),
|
||||
Default(target=finalizer),
|
||||
])
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
def create_workflow(
|
||||
*,
|
||||
mode: str,
|
||||
coordinator: Any,
|
||||
seat_executors: list[Any],
|
||||
approval_gate: Any,
|
||||
finalizer: Any,
|
||||
waiting_terminal: Any,
|
||||
seat_agent_ids: list[str],
|
||||
) -> Any:
|
||||
"""Factory: create the appropriate workflow for the given mode."""
|
||||
if mode == "group_chat":
|
||||
return build_group_chat_workflow(
|
||||
coordinator=coordinator,
|
||||
seat_executors=seat_executors,
|
||||
approval_gate=approval_gate,
|
||||
finalizer=finalizer,
|
||||
waiting_terminal=waiting_terminal,
|
||||
seat_agent_ids=seat_agent_ids,
|
||||
)
|
||||
return build_structured_workflow(
|
||||
coordinator=coordinator,
|
||||
seat_executors=seat_executors,
|
||||
approval_gate=approval_gate,
|
||||
finalizer=finalizer,
|
||||
waiting_terminal=waiting_terminal,
|
||||
)
|
||||
@@ -0,0 +1,483 @@
|
||||
"""MAF Workflow runner — orchestrates workflow execution for a MAF run.
|
||||
|
||||
Called after ``create_run`` to actually execute the workflow. Translates all
|
||||
MAF Workflow events into ``maf_roundtable_events`` (the single event stream
|
||||
the frontend reads). Manages run status lifecycle and checkpoint persistence.
|
||||
|
||||
There is NO sequential fallback. If the MAF SDK is missing or the workflow
|
||||
fails, the run is marked ``failed`` with a sanitized error message. The runner
|
||||
never pretends a workflow executed when it did not.
|
||||
|
||||
Usage::
|
||||
|
||||
runner = WorkflowRunner(app, service=svc, user_id="u1")
|
||||
await runner.execute(session, run)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.action_coordinator import ActionCoordinator
|
||||
from app.maf_roundtable.deerflow_agent_adapter import DeerFlowAgentAdapter
|
||||
from app.maf_roundtable.runtime_client import DeerFlowRuntimeClient
|
||||
from app.maf_roundtable.sanitize import sanitize
|
||||
from app.maf_roundtable.workflow_executors import (
|
||||
ApprovalGateExecutor,
|
||||
CoordinatorExecutor,
|
||||
FinalizerExecutor,
|
||||
RoundtableState,
|
||||
SeatExecutor,
|
||||
WaitingTerminalExecutor,
|
||||
_agent_capabilities_from_snapshot,
|
||||
generate_final_report,
|
||||
)
|
||||
from app.maf_roundtable.workflow_factory import create_workflow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MafSdkMissingError(RuntimeError):
|
||||
"""Raised when the MAF SDK (agent_framework) is not installed."""
|
||||
|
||||
|
||||
class WorkflowRunner:
|
||||
"""Executes a MAF workflow for a given session/run pair."""
|
||||
|
||||
def __init__(self, app: Any, *, service: Any, user_id: str) -> None:
|
||||
self._app = app
|
||||
self._service = service
|
||||
self._user_id = user_id
|
||||
self._client = DeerFlowRuntimeClient(app)
|
||||
self._adapter = DeerFlowAgentAdapter(self._client, service=service, user_id=user_id)
|
||||
self._action_coordinator = ActionCoordinator(service, user_id=user_id)
|
||||
|
||||
@property
|
||||
def adapter(self) -> DeerFlowAgentAdapter:
|
||||
return self._adapter
|
||||
|
||||
@property
|
||||
def action_coordinator(self) -> ActionCoordinator:
|
||||
return self._action_coordinator
|
||||
|
||||
async def execute(self, session: dict[str, Any], run: dict[str, Any]) -> None:
|
||||
"""Execute the full workflow for a session/run.
|
||||
|
||||
Transitions run status: pending → running → completed/failed/cancelled.
|
||||
Persists checkpoint (RoundtableState) after each major step so the
|
||||
workflow can resume after approval or restart.
|
||||
"""
|
||||
run_id = run["id"]
|
||||
session_id = session["id"]
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# Verify the MAF SDK is available before touching anything.
|
||||
try:
|
||||
import agent_framework # noqa: F401
|
||||
except ImportError as exc:
|
||||
await self._fail_run(run_id, session_id, t0, "MafSdkMissing", "agent_framework SDK is not installed")
|
||||
raise MafSdkMissingError("agent_framework SDK is not installed") from exc
|
||||
|
||||
await self._service.store.runs.update_run(run_id, {
|
||||
"status": "running",
|
||||
"started_at": datetime.now(UTC),
|
||||
})
|
||||
|
||||
try:
|
||||
participants = await self._service.store.participants.list_participants(session_id)
|
||||
thread_map = await self._adapter.ensure_threads(session_id, run_id, participants)
|
||||
|
||||
seat_agents = [p["agent_id"] for p in participants if p.get("role") != "manager"]
|
||||
manager_id = session.get("manager_agent_id")
|
||||
|
||||
state = RoundtableState(
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
task_text=session.get("task_text", ""),
|
||||
mode=session.get("mode", "structured"),
|
||||
model_name=session.get("model_name"),
|
||||
reasoning_effort=session.get("reasoning_effort"),
|
||||
max_rounds=session.get("max_rounds", 8),
|
||||
write_policy=session.get("write_policy", "confirm_high_risk"),
|
||||
manager_agent_id=manager_id,
|
||||
seat_agent_ids=seat_agents,
|
||||
thread_map=thread_map,
|
||||
capability_snapshot=session.get("capability_snapshot"),
|
||||
)
|
||||
|
||||
# Build executor instances — one per seat, each bound to a fixed agent_id.
|
||||
coordinator = CoordinatorExecutor(self._adapter, state)
|
||||
seat_executors = [
|
||||
SeatExecutor(self._adapter, state, agent_id, action_coordinator=self._action_coordinator)
|
||||
for agent_id in seat_agents
|
||||
]
|
||||
approval_gate = ApprovalGateExecutor(self._adapter, state, self._service)
|
||||
finalizer = FinalizerExecutor(self._adapter, state)
|
||||
waiting_terminal = WaitingTerminalExecutor()
|
||||
|
||||
workflow = create_workflow(
|
||||
mode=state.mode,
|
||||
coordinator=coordinator,
|
||||
seat_executors=seat_executors,
|
||||
approval_gate=approval_gate,
|
||||
finalizer=finalizer,
|
||||
waiting_terminal=waiting_terminal,
|
||||
seat_agent_ids=seat_agents,
|
||||
)
|
||||
|
||||
await self._run_workflow(workflow, state)
|
||||
|
||||
# Persist final state. waiting_approval is NOT a terminal state —
|
||||
# it must NOT write finished_at. Only completed/failed/cancelled do.
|
||||
latency = int((time.perf_counter() - t0) * 1000)
|
||||
final_status = state.status
|
||||
token_usage = _aggregate_token_usage(state.turn_results)
|
||||
|
||||
update_fields: dict[str, Any] = {
|
||||
"status": final_status,
|
||||
"latency_ms": latency,
|
||||
"maf_checkpoint": state.to_dict(),
|
||||
"token_usage": token_usage,
|
||||
}
|
||||
# Only write finished_at for terminal statuses.
|
||||
if final_status in ("completed", "failed", "cancelled"):
|
||||
update_fields["finished_at"] = datetime.now(UTC)
|
||||
|
||||
await self._service.store.runs.update_run(run_id, update_fields)
|
||||
|
||||
session_fields: dict[str, Any] = {"status": final_status}
|
||||
if final_status == "completed":
|
||||
session_fields["final_result"] = state.final_result
|
||||
if final_status != "waiting_approval":
|
||||
session_fields["fact_ledger"] = state.fact_ledger
|
||||
await self._service.store.sessions.update_session(
|
||||
session_id, user_id=self._user_id, fields=session_fields
|
||||
)
|
||||
|
||||
except MafSdkMissingError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
await self._fail_run(run_id, session_id, t0, type(exc).__name__, str(exc))
|
||||
|
||||
async def _run_workflow(self, workflow: Any, state: RoundtableState) -> None:
|
||||
"""Execute the workflow graph via the MAF SDK's streaming API.
|
||||
|
||||
Consumes ``WorkflowEvent``s and translates them into MAF roundtable
|
||||
events. Persists the RoundtableState checkpoint after each superstep.
|
||||
"""
|
||||
from app.maf_roundtable.event_normalizer import make_workflow_checkpointed
|
||||
|
||||
async for event in workflow.run(state.task_text, stream=True, include_status_events=True):
|
||||
event_type = getattr(event, "type", "")
|
||||
executor_id = getattr(event, "executor_id", None)
|
||||
|
||||
if event_type == "superstep_completed":
|
||||
# Persist checkpoint after each superstep.
|
||||
iteration = getattr(event, "iteration", None)
|
||||
await self._service.store.runs.update_run(state.run_id, {
|
||||
"maf_checkpoint": state.to_dict(),
|
||||
})
|
||||
await self._adapter._emit_critical(state.run_id, make_workflow_checkpointed(
|
||||
executor_id, state.current_round,
|
||||
checkpoint_id=f"superstep-{iteration}",
|
||||
reason="superstep_completed",
|
||||
))
|
||||
|
||||
elif event_type == "executor_failed":
|
||||
details = getattr(event, "details", None)
|
||||
error_msg = str(details) if details else "executor failed"
|
||||
raise RuntimeError(f"MAF executor '{executor_id}' failed: {error_msg}")
|
||||
|
||||
# Check if the workflow ended in a waiting_approval state.
|
||||
if state.status == "waiting_approval" or state.pending_approvals:
|
||||
state.status = "waiting_approval"
|
||||
|
||||
async def _fail_run(self, run_id: str, session_id: str, t0: float, error_code: str, error_message: str) -> None:
|
||||
"""Mark a run as failed with a sanitized error message."""
|
||||
latency = int((time.perf_counter() - t0) * 1000)
|
||||
safe_message = str(sanitize(error_message))[:500]
|
||||
logger.error("MAF workflow failed for run %s: %s", run_id, safe_message, exc_info=True)
|
||||
await self._service.store.runs.update_run(run_id, {
|
||||
"status": "failed",
|
||||
"finished_at": datetime.now(UTC),
|
||||
"error_code": error_code,
|
||||
"error_message_safe": safe_message,
|
||||
"latency_ms": latency,
|
||||
})
|
||||
await self._service.store.sessions.update_session(session_id, user_id=self._user_id, fields={
|
||||
"status": "failed",
|
||||
})
|
||||
|
||||
async def cancel(self, run_id: str) -> dict[str, Any]:
|
||||
"""Cancel a running workflow (cascades to all active seat runs).
|
||||
|
||||
Returns the structured cancel result from the adapter so the caller
|
||||
can decide whether to finalize the parent run as ``cancelled`` or
|
||||
leave it non-terminal when some sub-runs failed to cancel.
|
||||
"""
|
||||
return await self._adapter.cancel(run_id)
|
||||
|
||||
async def resume(self, run_id: str) -> dict[str, Any] | None:
|
||||
"""Resume a workflow from its persisted checkpoint after an approval decision.
|
||||
|
||||
P1: ``RoundtableState.from_dict()`` is actually called here. Flow:
|
||||
|
||||
1. Load ``maf_checkpoint_json`` from the run row.
|
||||
2. Restore ``RoundtableState`` via ``from_dict()``.
|
||||
3. Check the pending approvals' status:
|
||||
- ``approved``: execute the approved action via ToolExecutor
|
||||
(at-most-once, deterministic, no LLM).
|
||||
- ``rejected`` / ``expired``: write ``action.rejected``, skip.
|
||||
- ``pending``: do NOT start a second Workflow — return immediately.
|
||||
4. After all actions are executed, determine the parent run status:
|
||||
- All succeeded → ``completed``.
|
||||
- Any failed → ``failed`` (P1-8).
|
||||
- Any unknown → ``waiting_input`` (not completed; human reconcile needed).
|
||||
- All skipped (already executed) → ``completed``.
|
||||
|
||||
Only the owner can resume (enforced by the router before calling
|
||||
``dispatcher.resume``).
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
run = await self._service.store.runs.get_run(run_id)
|
||||
if run is None:
|
||||
return None
|
||||
|
||||
if run["status"] != "waiting_approval":
|
||||
return run
|
||||
|
||||
checkpoint = run.get("maf_checkpoint")
|
||||
if not checkpoint:
|
||||
logger.warning("Cannot resume run %s: no checkpoint", run_id)
|
||||
return run
|
||||
|
||||
state = RoundtableState.from_dict(checkpoint)
|
||||
session_id = state.session_id
|
||||
|
||||
if not state.pending_approvals:
|
||||
logger.warning("Cannot resume run %s: no pending_approvals in checkpoint", run_id)
|
||||
return run
|
||||
|
||||
# Check if any approval is still pending — if so, don't resume yet.
|
||||
any_pending = False
|
||||
for pa in state.pending_approvals:
|
||||
approval_id = pa.get("approval_id", "")
|
||||
approval = await self._service.store.approvals.get_approval(approval_id)
|
||||
if approval and approval.get("status") == "pending":
|
||||
any_pending = True
|
||||
if any_pending:
|
||||
return run
|
||||
|
||||
# P0-3 run-level CAS: only ONE concurrent resume may execute the
|
||||
# approved actions and write the parent run's final state. The winner
|
||||
# moves the run waiting_approval → running; every other concurrent
|
||||
# caller loses the CAS and returns the current run WITHOUT writing a
|
||||
# terminal status (so it can never race a completed/failed write).
|
||||
claimed_run = await self._service.store.runs.claim_for_resume(run_id)
|
||||
if not claimed_run:
|
||||
logger.info("resume(%s): lost run-level CAS — another resume is executing", run_id)
|
||||
return await self._service.store.runs.get_run(run_id)
|
||||
|
||||
# Execute each approved action (skip rejected/expired).
|
||||
action_outcomes: list[dict[str, Any]] = []
|
||||
for pa in state.pending_approvals:
|
||||
approval_id = pa.get("approval_id", "")
|
||||
action_id = pa.get("action_id", "")
|
||||
approval = await self._service.store.approvals.get_approval(approval_id)
|
||||
if approval is None:
|
||||
action_outcomes.append({"action_id": action_id, "status": "failed",
|
||||
"error": "approval not found"})
|
||||
continue
|
||||
|
||||
approval_status = approval.get("status", "pending")
|
||||
|
||||
if approval_status in ("rejected", "expired"):
|
||||
from app.maf_roundtable.event_normalizer import make_action_rejected
|
||||
# Conditional transition (waiting_approval → rejected) — never
|
||||
# overwrite a terminal state via a generic update.
|
||||
await self._service.store.actions.mark_rejected(action_id)
|
||||
await self._adapter._emit_critical(run_id, make_action_rejected(
|
||||
pa.get("agent_id", ""),
|
||||
state.current_round,
|
||||
action_id=action_id,
|
||||
approval_id=approval_id,
|
||||
decided_by=approval.get("decided_by", ""),
|
||||
))
|
||||
action_outcomes.append({"action_id": action_id, "status": "rejected"})
|
||||
continue
|
||||
|
||||
if approval_status != "approved":
|
||||
action_outcomes.append({"action_id": action_id, "status": "failed",
|
||||
"error": f"unexpected approval status: {approval_status}"})
|
||||
continue
|
||||
|
||||
# Approved — execute deterministically via ToolExecutor.
|
||||
agent_id = pa.get("agent_id", "")
|
||||
thread_id = state.thread_map.get(agent_id, "")
|
||||
tool_name = pa.get("tool_name", "")
|
||||
|
||||
# Conditional transition (waiting_approval → approved). The
|
||||
# authoritative at-most-once gate is the ToolExecutor's
|
||||
# approved → running claim below; this just advances the state
|
||||
# machine without ever clobbering a terminal status.
|
||||
await self._service.store.actions.mark_approved(action_id)
|
||||
|
||||
from app.maf_roundtable.event_normalizer import make_action_approved
|
||||
await self._adapter._emit_critical(run_id, make_action_approved(
|
||||
agent_id,
|
||||
state.current_round,
|
||||
action_id=action_id,
|
||||
approval_id=approval_id,
|
||||
decided_by=approval.get("decided_by", ""),
|
||||
))
|
||||
|
||||
# Params are recovered server-side inside ToolExecutor (after the
|
||||
# atomic occupy). A missing/undecryptable secret fails the action
|
||||
# there — it is never coerced into empty params, so the tool is
|
||||
# never called (execution count == 0) and the parent run fails.
|
||||
if thread_id and tool_name:
|
||||
from app.maf_roundtable.tool_executor import ToolExecutor
|
||||
|
||||
executor = ToolExecutor(
|
||||
adapter=self._adapter,
|
||||
action_coordinator=self._action_coordinator,
|
||||
user_id=self._user_id,
|
||||
)
|
||||
try:
|
||||
outcome = await executor.execute(
|
||||
action_id=action_id,
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
agent_id=agent_id,
|
||||
thread_id=thread_id,
|
||||
approved_tool=tool_name,
|
||||
capability_snapshot=state.capability_snapshot,
|
||||
write_policy=state.write_policy,
|
||||
model_name=state.model_name,
|
||||
excluded_tools=list(
|
||||
_agent_capabilities_from_snapshot(
|
||||
state.capability_snapshot, agent_id
|
||||
).get("excluded_tools")
|
||||
or []
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
safe_msg = str(sanitize(str(exc)))[:500]
|
||||
logger.error("ToolExecutor for action %s failed: %s", action_id, safe_msg)
|
||||
outcome = {"status": "failed", "error": safe_msg}
|
||||
else:
|
||||
outcome = {"status": "failed", "error": f"no thread_id or tool_name for action {action_id}"}
|
||||
|
||||
action_outcomes.append({
|
||||
"action_id": action_id,
|
||||
"tool_name": tool_name,
|
||||
"status": outcome.get("status"),
|
||||
"error": outcome.get("error"),
|
||||
})
|
||||
|
||||
state.fact_ledger["pending_actions"].append({
|
||||
"action_id": action_id,
|
||||
"tool_name": tool_name,
|
||||
"outcome": outcome.get("status"),
|
||||
})
|
||||
|
||||
# P0-3: determine parent run status from action outcomes.
|
||||
statuses = {o.get("status") for o in action_outcomes}
|
||||
|
||||
# While any action is still in_progress (another executor running it),
|
||||
# the parent run stays running — never write finished_at, never finalize.
|
||||
if "in_progress" in statuses:
|
||||
state.status = "running"
|
||||
await self._service.store.runs.update_run(run_id, {
|
||||
"status": "running",
|
||||
"maf_checkpoint": state.to_dict(),
|
||||
})
|
||||
logger.info("resume(%s): action(s) still in_progress — run stays running", run_id)
|
||||
return await self._service.store.runs.get_run(run_id)
|
||||
|
||||
state.pending_approvals = []
|
||||
|
||||
has_unknown = "unknown" in statuses
|
||||
has_failed = "failed" in statuses
|
||||
all_resolved = statuses <= {"succeeded", "skipped", "rejected"}
|
||||
|
||||
if has_unknown:
|
||||
state.status = "waiting_input" # human reconciliation needed
|
||||
elif has_failed:
|
||||
state.status = "failed"
|
||||
elif all_resolved:
|
||||
# Re-enter the MAF finalizer path to synthesize the real report —
|
||||
# do NOT just flip the status to completed.
|
||||
try:
|
||||
await generate_final_report(self._adapter, state)
|
||||
state.status = "completed"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
safe_msg = str(sanitize(str(exc)))[:500]
|
||||
logger.error("resume(%s): finalizer failed: %s", run_id, safe_msg)
|
||||
state.status = "failed"
|
||||
action_outcomes.append({"action_id": "", "status": "failed",
|
||||
"error": f"finalizer failed: {safe_msg}"})
|
||||
else:
|
||||
state.status = "failed"
|
||||
|
||||
# Persist final state.
|
||||
latency = int((time.perf_counter() - t0) * 1000)
|
||||
token_usage = _aggregate_token_usage(state.turn_results)
|
||||
update_fields: dict[str, Any] = {
|
||||
"status": state.status,
|
||||
"latency_ms": latency,
|
||||
"maf_checkpoint": state.to_dict(),
|
||||
"token_usage": token_usage,
|
||||
}
|
||||
if state.status in ("completed", "failed", "cancelled"):
|
||||
update_fields["finished_at"] = datetime.now(UTC)
|
||||
if state.status == "failed":
|
||||
errors = [o.get("error") for o in action_outcomes if o.get("error")]
|
||||
update_fields["error_code"] = "ActionExecutionFailed"
|
||||
update_fields["error_message_safe"] = str(sanitize("; ".join(errors)))[:500]
|
||||
|
||||
await self._service.store.runs.update_run(run_id, update_fields)
|
||||
|
||||
session_fields: dict[str, Any] = {"status": state.status}
|
||||
if state.status == "completed":
|
||||
session_fields["final_result"] = state.final_result
|
||||
session_fields["fact_ledger"] = state.fact_ledger
|
||||
await self._service.store.sessions.update_session(
|
||||
session_id, user_id=self._user_id, fields=session_fields
|
||||
)
|
||||
|
||||
return await self._service.store.runs.get_run(run_id)
|
||||
|
||||
|
||||
def _aggregate_token_usage(turn_results: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Aggregate token usage across all seat turns into a run-level summary."""
|
||||
total_input = 0
|
||||
total_output = 0
|
||||
total_total = 0
|
||||
per_seat: list[dict[str, Any]] = []
|
||||
for turn in turn_results:
|
||||
usage = turn.get("usage")
|
||||
if not isinstance(usage, dict):
|
||||
continue
|
||||
inp = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0) or 0
|
||||
out = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0) or 0
|
||||
tot = usage.get("total_tokens", 0) or (inp + out)
|
||||
total_input += inp
|
||||
total_output += out
|
||||
total_total += tot
|
||||
per_seat.append({
|
||||
"agent_id": turn.get("agent_id", ""),
|
||||
"turn_index": turn.get("turn_index", 0),
|
||||
"input_tokens": inp,
|
||||
"output_tokens": out,
|
||||
"total_tokens": tot,
|
||||
})
|
||||
return {
|
||||
"total_input_tokens": total_input,
|
||||
"total_output_tokens": total_output,
|
||||
"total_tokens": total_total,
|
||||
"per_seat": per_seat,
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Application-layer bridge between the harness ``WriteGuardMiddleware`` and
|
||||
the MAF ``ActionCoordinator``.
|
||||
|
||||
The harness middleware (``deerflow.agents.middlewares.write_guard_middleware``)
|
||||
is constructed once at agent-build time and cannot hold per-run state. It
|
||||
resolves two factories — ``make_policy`` / ``make_interceptor`` — by dotted
|
||||
path (``resolve_variable``). These factories return lightweight singleton
|
||||
objects that, at interception time, read the MAF service + per-run data from
|
||||
the run context.
|
||||
|
||||
Wiring
|
||||
------
|
||||
``register_write_guard_service(service)`` is called from ``deps.py`` once the
|
||||
``MafRoundtableService`` is built, stashing a module-level reference. The
|
||||
factories use it to build an ``ActionCoordinator`` on demand.
|
||||
|
||||
Per-run data (``session_id`` / ``run_id`` / ``agent_id`` / ``turn_index`` /
|
||||
``write_policy`` / ``capability_snapshot``) is injected into the DeerFlow run
|
||||
context by ``runtime_client.start_agent_run`` (see Task 1.4) and read back
|
||||
here from the ``run_context`` dict the middleware forwards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.action_coordinator import (
|
||||
ActionCoordinator,
|
||||
tool_needs_approval,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level reference to the MAF service, set by ``register_write_guard_service``.
|
||||
# Resolved lazily so importing this module never requires the service to exist.
|
||||
_maf_service: Any = None
|
||||
|
||||
|
||||
def register_write_guard_service(service: Any) -> None:
|
||||
"""Stash the MAF service so the WriteGuard factories can reach it.
|
||||
|
||||
Called once from ``app.gateway.deps`` after ``MafRoundtableService`` is built.
|
||||
"""
|
||||
global _maf_service
|
||||
_maf_service = service
|
||||
|
||||
|
||||
def _get_service() -> Any:
|
||||
if _maf_service is None: # pragma: no cover - defensive; wiring is enforced at startup
|
||||
raise RuntimeError(
|
||||
"WriteGuard bridge used before register_write_guard_service() — "
|
||||
"MAF service is not wired."
|
||||
)
|
||||
return _maf_service
|
||||
|
||||
|
||||
def _coordinator_for_context(run_context: dict) -> ActionCoordinator:
|
||||
"""Build an ActionCoordinator bound to the run's user identity.
|
||||
|
||||
The user_id is sourced ONLY from the server-injected run context (set by
|
||||
``runtime_client`` from the authenticated ``ExecutionPrincipal``), never
|
||||
from tool arguments.
|
||||
"""
|
||||
service = _get_service()
|
||||
user_id = str(run_context.get("maf_user_id") or run_context.get("user_id") or "")
|
||||
return ActionCoordinator(service, user_id=user_id)
|
||||
|
||||
|
||||
# ── Policy ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MafWriteGuardPolicy:
|
||||
"""Decides whether a tool call must be intercepted for approval.
|
||||
|
||||
Reads the per-run ``write_policy`` and ``capability_snapshot`` from the
|
||||
run context. Uses the same metadata-driven classification as the rest of
|
||||
the ActionCoordinator (never name-prefix heuristics). Tools without
|
||||
metadata use the safe default (treated as a write requiring approval).
|
||||
"""
|
||||
|
||||
def should_intercept(self, tool_name: str, args: Any, run_context: dict | None = None) -> bool: # noqa: ANN401
|
||||
"""Decide interception from the run context (read vs write aware).
|
||||
|
||||
Reads / skills (``side_effect`` of ``read`` / ``skill``) are NEVER
|
||||
intercepted, so they pass straight through — including on the sync
|
||||
path. Writes are gated by the active write policy via the same
|
||||
metadata-driven ``tool_needs_approval`` classification used everywhere
|
||||
else (never name-prefix heuristics). When no run context is available
|
||||
we defer to the interceptor (legacy behaviour).
|
||||
"""
|
||||
if not tool_name:
|
||||
return False
|
||||
if not run_context:
|
||||
# Cannot classify without context — let the interceptor decide.
|
||||
return True
|
||||
write_policy = str(run_context.get("write_policy") or "confirm_high_risk")
|
||||
capability_snapshot = run_context.get("capability_snapshot")
|
||||
agent_id = str(run_context.get("agent_id") or "")
|
||||
return tool_needs_approval(tool_name, write_policy, capability_snapshot, agent_id)
|
||||
|
||||
|
||||
# ── Interceptor ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MafWriteGuardInterceptor:
|
||||
"""Records an intercepted write tool call as a pending action + approval.
|
||||
|
||||
Implements the harness ``WriteGuardInterceptor`` Protocol. Uses the
|
||||
single-transaction ``store.create_pending_action_bundle`` (Task 2) so an
|
||||
action + secret + approval + audit event are written atomically — a
|
||||
partial action never reaches the database.
|
||||
"""
|
||||
|
||||
async def intercept(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
args: Any, # noqa: ANN401
|
||||
tool_call_id: str,
|
||||
run_context: dict,
|
||||
) -> dict[str, Any]:
|
||||
write_policy = str(run_context.get("write_policy") or "confirm_high_risk")
|
||||
capability_snapshot = run_context.get("capability_snapshot")
|
||||
agent_id = str(run_context.get("agent_id") or "")
|
||||
run_id = str(run_context.get("maf_run_id") or "")
|
||||
session_id = str(run_context.get("maf_session_id") or "")
|
||||
turn_index = int(run_context.get("turn_index") or 0)
|
||||
|
||||
# Apply the real policy now that we have the run context.
|
||||
if not tool_needs_approval(tool_name, write_policy, capability_snapshot, agent_id):
|
||||
return {"status": "allowed", "tool_name": tool_name}
|
||||
|
||||
coordinator = _coordinator_for_context(run_context)
|
||||
raw_args = args if isinstance(args, dict) else {"value": str(args)}
|
||||
|
||||
created = await coordinator.intercept_write_tool_calls(
|
||||
run_id=run_id,
|
||||
session_id=session_id,
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
tool_calls=[{"name": tool_name, "id": tool_call_id, "args": raw_args}],
|
||||
capability_snapshot=capability_snapshot,
|
||||
write_policy=write_policy,
|
||||
)
|
||||
|
||||
if not created:
|
||||
return {"status": "allowed", "tool_name": tool_name}
|
||||
|
||||
action = created[0]
|
||||
return {
|
||||
"status": "approval_required",
|
||||
"action_id": action.get("id", ""),
|
||||
"approval_id": action.get("approval_id", ""),
|
||||
"tool_name": tool_name,
|
||||
}
|
||||
|
||||
async def validate_approved_action(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
run_context: dict,
|
||||
) -> bool:
|
||||
"""Validate a post-approval deterministic execution against the store.
|
||||
|
||||
Field presence is NOT trusted: this queries the bound action and confirms
|
||||
it is ``running`` (the executor already occupied it), belongs to the
|
||||
current ``run_id`` / ``session_id`` / ``agent_id``, and its ``tool_name``
|
||||
matches. Returns True only when every check holds; otherwise the
|
||||
middleware fails closed and the tool does not run.
|
||||
"""
|
||||
action_id = str(run_context.get("maf_approved_action_id") or "")
|
||||
if not action_id:
|
||||
return False
|
||||
approved_tool = str(run_context.get("maf_approved_tool") or "")
|
||||
if approved_tool and tool_name != approved_tool:
|
||||
return False
|
||||
|
||||
run_id = str(run_context.get("maf_run_id") or "")
|
||||
session_id = str(run_context.get("maf_session_id") or "")
|
||||
agent_id = str(run_context.get("agent_id") or "")
|
||||
|
||||
service = _get_service()
|
||||
action = await service.store.actions.get_action(action_id)
|
||||
if action is None:
|
||||
return False
|
||||
if action.get("status") != "running":
|
||||
return False
|
||||
if run_id and str(action.get("run_id") or "") != run_id:
|
||||
return False
|
||||
if session_id and str(action.get("session_id") or "") != session_id:
|
||||
return False
|
||||
if agent_id and str(action.get("agent_id") or "") != agent_id:
|
||||
return False
|
||||
if str(action.get("tool_name") or "") != tool_name:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ── Factories resolved by the harness via resolve_variable ──────────────────
|
||||
|
||||
|
||||
def make_policy(**_: Any) -> MafWriteGuardPolicy:
|
||||
"""Factory for the harness ``resolve_variable`` resolution."""
|
||||
return MafWriteGuardPolicy()
|
||||
|
||||
|
||||
def make_interceptor(**_: Any) -> MafWriteGuardInterceptor:
|
||||
"""Factory for the harness ``resolve_variable`` resolution."""
|
||||
return MafWriteGuardInterceptor()
|
||||
@@ -563,3 +563,15 @@ maf_roundtable:
|
||||
default_write_policy: confirm_high_risk # auto | confirm_high_risk | confirm_all_writes
|
||||
default_max_rounds: 8 # 群聊模式轮次硬上限
|
||||
admin_diagnostic_access: true # 管理员是否可跨用户读取所有 MAF 会商用于诊断(仅读,不含写)
|
||||
|
||||
# ── WriteGuard 中间件配置 ────────────────────────────────────────────────────
|
||||
# 与 maf_roundtable 联动:MAF 会商运行时,WriteGuard 中间件拦截外部写工具调用,
|
||||
# 记录为待审批动作。仅在 write_guard.enabled=true 且 maf_roundtable.enabled=true
|
||||
# 时生效;关闭时不影响任何已存在功能。
|
||||
# policy_use / interceptor_use 指向 app.maf_roundtable 的具体实现(通过
|
||||
# resolve_variable 解析),保证 harness 层不导入 app.*。
|
||||
write_guard:
|
||||
enabled: false
|
||||
provider:
|
||||
policy_use: app.maf_roundtable.write_guard_bridge:make_policy
|
||||
interceptor_use: app.maf_roundtable.write_guard_bridge:make_interceptor
|
||||
+73
-8
@@ -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())
|
||||
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
"""WriteGuardMiddleware — intercepts external-write tool calls for human approval.
|
||||
|
||||
This middleware keeps write tools **visible** to the model (the schema is not
|
||||
removed from the tool list), but short-circuits their execution so the real
|
||||
tool never runs until a human approves. The model sees a structured
|
||||
``ToolMessage`` describing the pending approval instead of the tool's real
|
||||
result.
|
||||
|
||||
Activation model (zero-impact on legacy runs)
|
||||
---------------------------------------------
|
||||
The middleware is always present in the chain when configured, but it only
|
||||
acts when the run context carries a truthy ``maf_run_id`` flag. Every other
|
||||
run — legacy single-agent chats, subagents, ephemeral runs — calls the
|
||||
``handler`` unchanged. This mirrors how ``TaskExternalContextMiddleware``
|
||||
reads a run-context flag to switch on per-run behaviour.
|
||||
|
||||
Injection
|
||||
---------
|
||||
The application layer supplies a ``WriteGuardPolicy`` (decides *whether* to
|
||||
intercept a given call) and a ``WriteGuardInterceptor`` (records the
|
||||
action/approval/audit bundle). Both are injected via constructor so the
|
||||
harness never imports ``app.*``; resolution happens in
|
||||
``_build_runtime_middlewares`` through ``resolve_variable``.
|
||||
|
||||
Note: unlike ``GuardrailMiddleware`` (which is fail-closed on provider error),
|
||||
this middleware is **fail-open by default for legacy runs** — but inside an
|
||||
active MAF run a failure to record an approval aborts the call with an error
|
||||
ToolMessage rather than letting the write proceed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.types import Command
|
||||
|
||||
from deerflow.config.write_guard_config import WriteGuardInterceptor, WriteGuardPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WriteGuardMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Intercept write tool calls for approval when running inside a MAF seat run.
|
||||
|
||||
The middleware reads ``request.runtime.context`` (the shared run context)
|
||||
to detect an active MAF run (``maf_run_id`` truthy). When inactive it is a
|
||||
pure pass-through. When active, each tool call is offered to the injected
|
||||
``WriteGuardPolicy``; if it says ``should_intercept`` is True, the
|
||||
``WriteGuardInterceptor`` records the pending action and the middleware
|
||||
returns an ``approval_required`` ToolMessage **without** calling
|
||||
``handler`` — so the real tool executes exactly zero times before approval.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
policy: WriteGuardPolicy,
|
||||
interceptor: WriteGuardInterceptor,
|
||||
) -> None:
|
||||
self.policy = policy
|
||||
self.interceptor = interceptor
|
||||
|
||||
# ── context helpers ──────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _run_context(request: ToolCallRequest) -> dict:
|
||||
"""Return the run context dict from the runtime, or {} if absent.
|
||||
|
||||
``request.runtime`` is a ``ToolRuntime`` whose ``.context`` attribute
|
||||
holds the shared run-context dict (the same one ``Runtime.context``
|
||||
exposes to graph nodes / other middlewares). Outside a graph it may
|
||||
be None.
|
||||
"""
|
||||
runtime = getattr(request, "runtime", None)
|
||||
if runtime is None:
|
||||
return {}
|
||||
ctx = getattr(runtime, "context", None)
|
||||
if isinstance(ctx, dict):
|
||||
return ctx
|
||||
# Some context objects are Mapping-like but not plain dicts.
|
||||
try:
|
||||
return dict(ctx) # type: ignore[arg-type]
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _tool_name(request: ToolCallRequest) -> str:
|
||||
return str(request.tool_call.get("name", ""))
|
||||
|
||||
@staticmethod
|
||||
def _tool_call_id(request: ToolCallRequest) -> str:
|
||||
return str(request.tool_call.get("id", ""))
|
||||
|
||||
@staticmethod
|
||||
def _tool_args(request: ToolCallRequest) -> object:
|
||||
return request.tool_call.get("args", {})
|
||||
|
||||
def _build_approval_message(self, request: ToolCallRequest, outcome: dict) -> ToolMessage:
|
||||
"""Build the ToolMessage the model sees in place of the real tool result.
|
||||
|
||||
The content is a structured JSON envelope so a frontend / coordinator
|
||||
can parse the ``action_id`` to drive the approval UI. The real tool's
|
||||
return value is never produced.
|
||||
"""
|
||||
tool_name = self._tool_name(request)
|
||||
tool_call_id = self._tool_call_id(request)
|
||||
envelope = {
|
||||
"status": outcome.get("status", "approval_required"),
|
||||
"action_id": outcome.get("action_id", ""),
|
||||
"approval_id": outcome.get("approval_id", ""),
|
||||
"tool_name": tool_name,
|
||||
"message": (
|
||||
f"外部写操作 '{tool_name}' 已记录,等待人工审批后执行。"
|
||||
"本次调用未执行实际写入。"
|
||||
),
|
||||
}
|
||||
return ToolMessage(
|
||||
content=json.dumps(envelope, ensure_ascii=False),
|
||||
tool_call_id=tool_call_id,
|
||||
name=tool_name,
|
||||
status="error",
|
||||
)
|
||||
|
||||
# ── interception core (shared by sync/async) ─────────────────────────
|
||||
|
||||
def _is_active(self, request: ToolCallRequest) -> bool:
|
||||
"""True only when running inside an active MAF seat run."""
|
||||
return bool(self._run_context(request).get("maf_run_id"))
|
||||
|
||||
def _approved_passthrough(self, request: ToolCallRequest) -> bool:
|
||||
"""True when this run is a deterministic, post-approval tool execution.
|
||||
|
||||
The ToolExecutor sets ``maf_approved_tool`` + ``maf_approved_action_id``
|
||||
in the run context for defense-in-depth validation. The middleware
|
||||
checks:
|
||||
1. ``maf_approved_tool`` matches the tool name (required).
|
||||
2. ``maf_approved_action_id`` is present (required when tool is set).
|
||||
3. The action is in ``approved`` status and belongs to the current
|
||||
run / session / agent (verified through the interceptor).
|
||||
|
||||
A model calling another tool with the same name but different
|
||||
action_id is still intercepted. When combined with the direct tool
|
||||
executor (P0-2) which bypasses the LLM entirely, this is defense-in-
|
||||
depth — the real enforcement is that the LLM never sees the approved
|
||||
params and never decides whether to call the tool.
|
||||
"""
|
||||
ctx = self._run_context(request)
|
||||
approved_tool = ctx.get("maf_approved_tool")
|
||||
if not approved_tool:
|
||||
return False
|
||||
if self._tool_name(request) != approved_tool:
|
||||
return False
|
||||
|
||||
approved_action_id = ctx.get("maf_approved_action_id")
|
||||
if not approved_action_id:
|
||||
# When no action_id is bound, the passthrough is too permissive.
|
||||
# In production the ToolExecutor always sets this; the absence
|
||||
# means the tool was flagged by some other mechanism without
|
||||
# proper action binding — block to be safe.
|
||||
logger.warning(
|
||||
"WriteGuard: approved tool '%s' has no maf_approved_action_id "
|
||||
"in run context — blocking passthrough",
|
||||
approved_tool,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _validate_approved_async(self, request: ToolCallRequest) -> bool:
|
||||
"""Confirm a bound approved action is genuinely executable (async path).
|
||||
|
||||
Field presence (``maf_approved_tool`` / ``maf_approved_action_id``) is
|
||||
necessary but NOT sufficient: the interceptor queries the stored action
|
||||
and verifies status==``running`` + run/session/agent ownership + tool
|
||||
match. Fails closed (False) when the interceptor cannot confirm.
|
||||
"""
|
||||
run_context = self._run_context(request)
|
||||
validator = getattr(self.interceptor, "validate_approved_action", None)
|
||||
if validator is None:
|
||||
# No validator available — cannot confirm the action is legitimate.
|
||||
logger.warning(
|
||||
"WriteGuard: approved tool '%s' bound but interceptor lacks "
|
||||
"validate_approved_action — blocking (fail-closed)",
|
||||
self._tool_name(request),
|
||||
)
|
||||
return False
|
||||
try:
|
||||
return bool(await validator(tool_name=self._tool_name(request), run_context=run_context))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"WriteGuard: validate_approved_action failed for '%s' — blocking",
|
||||
self._tool_name(request),
|
||||
)
|
||||
return False
|
||||
|
||||
async def _intercept_async(self, request: ToolCallRequest) -> ToolMessage | None:
|
||||
"""Record an interception. Returns the approval ToolMessage, or None to allow."""
|
||||
tool_name = self._tool_name(request)
|
||||
args = self._tool_args(request)
|
||||
run_context = self._run_context(request)
|
||||
if not self.policy.should_intercept(tool_name, args, run_context):
|
||||
return None
|
||||
try:
|
||||
outcome = await self.interceptor.intercept(
|
||||
tool_name=tool_name,
|
||||
args=args,
|
||||
tool_call_id=self._tool_call_id(request),
|
||||
run_context=run_context,
|
||||
)
|
||||
except GraphBubbleUp:
|
||||
raise
|
||||
except Exception:
|
||||
# Recording the approval failed. We must NOT let the write
|
||||
# proceed silently — fail the call so the run surfaces the error
|
||||
# rather than executing an unapproved write.
|
||||
logger.exception("WriteGuard interceptor failed for tool '%s'", tool_name)
|
||||
return ToolMessage(
|
||||
content=json.dumps(
|
||||
{"status": "intercept_failed", "tool_name": tool_name,
|
||||
"message": f"记录写操作审批失败,{tool_name} 未执行。"},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
tool_call_id=self._tool_call_id(request),
|
||||
name=tool_name,
|
||||
status="error",
|
||||
)
|
||||
# If the interceptor decided no approval is needed (e.g. a read tool
|
||||
# under a write policy, or ``auto`` policy), allow the call through.
|
||||
if outcome.get("status") == "allowed":
|
||||
return None
|
||||
return self._build_approval_message(request, outcome)
|
||||
|
||||
# ── AgentMiddleware overrides ────────────────────────────────────────
|
||||
|
||||
@override
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
if not self._is_active(request):
|
||||
return handler(request)
|
||||
# Post-approval deterministic execution: the approved tool passes through.
|
||||
if self._approved_passthrough(request):
|
||||
return handler(request)
|
||||
# The interceptor is async; recording synchronously is not supported.
|
||||
# Reads are never intercepted (should_intercept False) so they pass
|
||||
# straight through on the sync path. Writes that cannot be safely
|
||||
# async-intercepted fail closed here rather than running unapproved.
|
||||
tool_name = self._tool_name(request)
|
||||
run_context = self._run_context(request)
|
||||
if self.policy.should_intercept(tool_name, self._tool_args(request), run_context):
|
||||
# Cannot await here; return an immediate error so the write does
|
||||
# not proceed. The async path records the action properly.
|
||||
logger.warning(
|
||||
"WriteGuard: sync write call '%s' blocked (use async tool path)", tool_name
|
||||
)
|
||||
return ToolMessage(
|
||||
content=json.dumps(
|
||||
{"status": "intercept_blocked_sync", "tool_name": tool_name,
|
||||
"message": f"{tool_name} 写操作需走异步审批路径,未执行。"},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
tool_call_id=self._tool_call_id(request),
|
||||
name=tool_name,
|
||||
status="error",
|
||||
)
|
||||
return handler(request)
|
||||
|
||||
@override
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
|
||||
) -> ToolMessage | Command:
|
||||
if not self._is_active(request):
|
||||
return await handler(request)
|
||||
# Post-approval deterministic execution: the approved tool passes
|
||||
# through only after the bound action is validated against the store
|
||||
# (status running + run/session/agent ownership + tool match). Field
|
||||
# presence alone is not trusted.
|
||||
if self._approved_passthrough(request):
|
||||
if await self._validate_approved_async(request):
|
||||
return await handler(request)
|
||||
tool_name = self._tool_name(request)
|
||||
logger.warning(
|
||||
"WriteGuard: approved execution of '%s' failed action validation — blocking",
|
||||
tool_name,
|
||||
)
|
||||
return ToolMessage(
|
||||
content=json.dumps(
|
||||
{"status": "approval_invalid", "tool_name": tool_name,
|
||||
"message": f"{tool_name} 的已审批动作未通过校验,未执行。"},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
tool_call_id=self._tool_call_id(request),
|
||||
name=tool_name,
|
||||
status="error",
|
||||
)
|
||||
try:
|
||||
approval_msg = await self._intercept_async(request)
|
||||
except GraphBubbleUp:
|
||||
# Preserve interrupt/pause/resume control-flow signals.
|
||||
raise
|
||||
if approval_msg is not None:
|
||||
logger.info(
|
||||
"WriteGuard intercepted '%s' (call_id=%s) — awaiting approval",
|
||||
self._tool_name(request), self._tool_call_id(request),
|
||||
)
|
||||
return approval_msg
|
||||
return await handler(request)
|
||||
@@ -41,6 +41,7 @@ from deerflow.config.token_usage_config import TokenUsageConfig
|
||||
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
|
||||
from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict
|
||||
from deerflow.config.vision_config import VisionConfig
|
||||
from deerflow.config.write_guard_config import WriteGuardConfig, load_write_guard_config_from_dict
|
||||
from deerflow.config.y_log_config import YLogConfig
|
||||
|
||||
load_dotenv()
|
||||
@@ -131,6 +132,7 @@ class AppConfig(BaseModel):
|
||||
acp_agents: dict[str, ACPAgentConfig] = Field(default_factory=dict, description="ACP-compatible agent configuration")
|
||||
subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration")
|
||||
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
|
||||
write_guard: WriteGuardConfig = Field(default_factory=WriteGuardConfig, description="WriteGuard middleware configuration (intercepts external-write tools for approval)")
|
||||
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration")
|
||||
excluded_tools: list[str] = Field(default_factory=list, description="Tool names to exclude from the main agent (e.g. ['task', 'ask_clarification'])")
|
||||
skill_stop: SkillStopConfig = Field(default_factory=SkillStopConfig, description="Skill stop middleware configuration")
|
||||
@@ -228,6 +230,10 @@ class AppConfig(BaseModel):
|
||||
if "guardrails" in config_data:
|
||||
load_guardrails_config_from_dict(config_data["guardrails"])
|
||||
|
||||
# Load write_guard config if present
|
||||
if "write_guard" in config_data:
|
||||
load_write_guard_config_from_dict(config_data["write_guard"])
|
||||
|
||||
# Load circuit_breaker config if present
|
||||
if "circuit_breaker" in config_data:
|
||||
config_data["circuit_breaker"] = config_data["circuit_breaker"]
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
"""Configuration for the WriteGuard middleware.
|
||||
|
||||
The WriteGuard middleware intercepts external-write tool calls so they can be
|
||||
recorded for human approval **before** the underlying tool executes. It is
|
||||
activated per-run via a ``maf_run_id`` flag in the run context, so legacy
|
||||
single-agent runs are completely unaffected (the middleware no-ops).
|
||||
|
||||
This module is harness-only: it defines the config schema and the
|
||||
``WriteGuardPolicy`` / ``WriteGuardInterceptor`` Protocols. The concrete
|
||||
implementations live in the application layer (``app.maf_roundtable``) and are
|
||||
resolved at runtime via ``resolve_variable`` from the ``use`` dotted path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WriteGuardProviderConfig(BaseModel):
|
||||
"""Configuration for the application-layer WriteGuard implementations.
|
||||
|
||||
``policy_use`` resolves to a callable/class implementing
|
||||
``WriteGuardPolicy``; ``interceptor_use`` resolves to one implementing
|
||||
``WriteGuardInterceptor``. Both are resolved lazily by the harness via
|
||||
``deerflow.reflection.resolve_variable`` so the harness never imports
|
||||
``app.*``.
|
||||
"""
|
||||
|
||||
policy_use: str = Field(
|
||||
description="Dotted path to the WriteGuardPolicy factory, e.g. 'app.maf_roundtable.write_guard_bridge:make_policy'"
|
||||
)
|
||||
interceptor_use: str = Field(
|
||||
description="Dotted path to the WriteGuardInterceptor factory, e.g. 'app.maf_roundtable.write_guard_bridge:make_interceptor'"
|
||||
)
|
||||
config: dict = Field(default_factory=dict, description="Implementation-specific settings passed as kwargs")
|
||||
|
||||
|
||||
class WriteGuardConfig(BaseModel):
|
||||
"""Configuration for the WriteGuard middleware.
|
||||
|
||||
When ``enabled`` and a ``provider`` is configured, the harness appends a
|
||||
``WriteGuardMiddleware`` to the runtime middleware chain. The middleware
|
||||
itself only acts when the run context carries a ``maf_run_id`` flag —
|
||||
every other run is a pass-through.
|
||||
"""
|
||||
|
||||
enabled: bool = Field(default=False, description="Enable the WriteGuard middleware in the chain")
|
||||
provider: WriteGuardProviderConfig | None = Field(
|
||||
default=None, description="Application-layer policy/interceptor factories"
|
||||
)
|
||||
|
||||
|
||||
_write_guard_config: WriteGuardConfig | None = None
|
||||
|
||||
|
||||
def get_write_guard_config() -> WriteGuardConfig:
|
||||
"""Get the write-guard config, returning defaults if not loaded."""
|
||||
global _write_guard_config
|
||||
if _write_guard_config is None:
|
||||
_write_guard_config = WriteGuardConfig()
|
||||
return _write_guard_config
|
||||
|
||||
|
||||
def load_write_guard_config_from_dict(data: dict) -> WriteGuardConfig:
|
||||
"""Load write-guard config from a dict (called during AppConfig loading)."""
|
||||
global _write_guard_config
|
||||
_write_guard_config = WriteGuardConfig.model_validate(data)
|
||||
return _write_guard_config
|
||||
|
||||
|
||||
def reset_write_guard_config() -> None:
|
||||
"""Reset the cached config instance. Used in tests to prevent singleton leaks."""
|
||||
global _write_guard_config
|
||||
_write_guard_config = None
|
||||
|
||||
|
||||
# ── Protocols (harness ↔ application contract) ─────────────────────────────
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WriteGuardPolicy(Protocol):
|
||||
"""Decides whether a given tool call must be intercepted for approval.
|
||||
|
||||
Implementations are constructed by the application layer and passed into
|
||||
the harness ``WriteGuardMiddleware``. They must NOT import deerflow.*
|
||||
(the harness owns this Protocol definition).
|
||||
"""
|
||||
|
||||
def should_intercept(self, tool_name: str, args: Any, run_context: dict | None = None) -> bool: # noqa: ANN401
|
||||
"""Return True if the tool call must be recorded for approval before execution.
|
||||
|
||||
``run_context`` (the shared run-context dict) lets the policy distinguish
|
||||
reads from writes using the frozen capability snapshot + write policy, so
|
||||
read tools are never intercepted (they pass straight through, including
|
||||
on the sync path) while writes are gated by the active write policy.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WriteGuardInterceptor(Protocol):
|
||||
"""Records an intercepted write tool call (action + approval + audit event).
|
||||
|
||||
The interceptor is responsible for the full create-pending-action flow
|
||||
(see ``store.create_pending_action_bundle``). It receives the run context
|
||||
dict (the same one ``request.runtime.context`` exposes) so it can resolve
|
||||
the current ``session_id`` / ``run_id`` / ``agent_id`` / ``turn_index``
|
||||
and the write policy. It returns a dict describing the interception
|
||||
outcome so the middleware can build the ToolMessage the model sees in
|
||||
place of the real tool result.
|
||||
|
||||
Expected return shape::
|
||||
|
||||
{
|
||||
"status": "approval_required" | "allowed",
|
||||
"action_id": "<id when approval_required>",
|
||||
"approval_id": "<id when approval_required>",
|
||||
"tool_name": "<name>",
|
||||
}
|
||||
"""
|
||||
|
||||
async def intercept(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
args: Any, # noqa: ANN401
|
||||
tool_call_id: str,
|
||||
run_context: dict,
|
||||
) -> dict[str, Any]:
|
||||
"""Record the tool call for approval. Never executes the real tool."""
|
||||
...
|
||||
|
||||
async def validate_approved_action(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
run_context: dict,
|
||||
) -> bool:
|
||||
"""Validate a post-approval deterministic execution before it runs.
|
||||
|
||||
Called on the async path when the run context binds ``maf_approved_tool``
|
||||
+ ``maf_approved_action_id``. Must query the stored action and confirm:
|
||||
its status is ``running`` (the executor already occupied it), it belongs
|
||||
to the current ``run_id`` / ``session_id`` / ``agent_id``, and its
|
||||
``tool_name`` matches. Returns True only when all hold — the middleware
|
||||
fails closed (blocks the call) otherwise. Field-existence alone is NOT
|
||||
sufficient.
|
||||
"""
|
||||
...
|
||||
+16
-6
@@ -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",
|
||||
]
|
||||
|
||||
+65
@@ -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()
|
||||
+152
-2
@@ -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)
|
||||
+32
-1
@@ -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
|
||||
|
||||
+89
@@ -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",
|
||||
]
|
||||
+22
@@ -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)
|
||||
+21
@@ -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:
|
||||
|
||||
+195
@@ -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
|
||||
+314
-7
@@ -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",
|
||||
|
||||
+482
@@ -0,0 +1,482 @@
|
||||
"""Server-side executor for a single, human-approved tool call (MAF roundtable).
|
||||
|
||||
This is the harness-layer "approved tool executor". After a human approves an
|
||||
external-write action, the MAF app layer hands the server-constructed tool call
|
||||
to :func:`execute_approved_tool`, which runs it through the **real** LangGraph
|
||||
tool-execution path — the same path a normal DeerFlow tool call uses — without
|
||||
any LLM involvement:
|
||||
|
||||
- a real ``ToolNode`` (so ``_inject_tool_args`` runs internally: ``InjectedState``
|
||||
/ ``InjectedStore`` / ``ToolRuntime`` are populated by the framework, the
|
||||
caller-provided args are stripped-then-rebuilt so nothing is forged);
|
||||
- a real ``ToolRuntime`` (``state`` recovered from the thread checkpoint's
|
||||
``channel_values`` — sandbox / thread_data / etc. — plus ``config`` / ``store``
|
||||
/ ``stream_writer``), NOT a duck-typed ``SimpleNamespace``;
|
||||
- the same ``wrap_tool_call`` / ``awrap_tool_call`` middleware chain the lead
|
||||
agent uses (``build_lead_runtime_middlewares``), composed first-outermost
|
||||
exactly like ``langchain.agents.factory.create_agent``;
|
||||
- unified tool-error handling (``handle_tool_errors=True``).
|
||||
|
||||
The tool list is rebuilt from the seat's frozen capability snapshot / agent
|
||||
config / model / ``excluded_tools`` — never the global default registry — and
|
||||
four security gates run BEFORE execution:
|
||||
|
||||
1. the action's ``run_id`` / ``session_id`` / ``agent_id`` / ``tool_name`` must
|
||||
match the current execution context;
|
||||
2. the tool must not be disabled (``excluded_tools``);
|
||||
3. the tool must exist in the rebuilt allow-list (i.e. be permitted by the
|
||||
capability snapshot);
|
||||
4. a trusted MAF context (``maf_run_id`` / ``maf_approved_tool`` /
|
||||
``maf_approved_action_id`` / …) is injected so ``WriteGuardMiddleware`` can
|
||||
validate the approved binding and pass the call through.
|
||||
|
||||
Boundary: this module lives in ``deerflow.*`` and imports only ``deerflow.*`` +
|
||||
``langgraph`` / ``langchain`` — never ``app.*``. The MAF app layer calls it,
|
||||
passing all secret-derived params and the stored action record as plain data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
from langgraph.types import Command
|
||||
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from deerflow.tools.tools import get_available_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MafApprovedToolContext(TypedDict, total=False):
|
||||
"""Typed run context for post-approval ToolNode / ToolRuntime injection.
|
||||
|
||||
Declared so LangGraph ``Runtime[MafApprovedToolContext]`` serializes cleanly
|
||||
(bare ``Runtime`` defaults ``context`` to ``None`` and warns on dict values).
|
||||
"""
|
||||
|
||||
thread_id: str
|
||||
run_id: str
|
||||
agent_id: str
|
||||
user_id: str
|
||||
turn_index: int
|
||||
maf_run_id: str
|
||||
maf_session_id: str
|
||||
maf_user_id: str
|
||||
capability_snapshot: dict[str, Any]
|
||||
write_policy: str
|
||||
maf_approved_tool: str
|
||||
maf_approved_action_id: str
|
||||
maf_safe_error_mode: bool
|
||||
|
||||
|
||||
class _ExecutorUser:
|
||||
"""Minimal ``CurrentUser`` shim (structural ``.id``) for path resolution."""
|
||||
|
||||
def __init__(self, user_id: str) -> None:
|
||||
self.id = user_id
|
||||
|
||||
|
||||
class ApprovedToolRejection(Exception):
|
||||
"""A security gate refused the approved tool call (never executed).
|
||||
|
||||
``reason`` is a stable machine-readable code; ``detail`` is safe to log /
|
||||
persist (never contains params or credentials).
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str, detail: str) -> None:
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
super().__init__(f"approved tool rejected: {reason} — {detail}")
|
||||
|
||||
|
||||
_INCONCLUSIVE_ERROR_MARKERS = (
|
||||
"timeout", "timedout", "deadline",
|
||||
"connection", "connect", "disconnect", "reset", "refused", "broken",
|
||||
"gateway", "badgateway", "serviceunavailable", "unavailable",
|
||||
"protocol", "remoteprotocol", "eof", "network", "socket",
|
||||
"temporarily", "tryagain", "econnreset", "etimedout",
|
||||
)
|
||||
|
||||
|
||||
def _is_inconclusive_text(text: str) -> bool:
|
||||
"""True when an error string denotes an unknowable external result."""
|
||||
lowered = text.lower()
|
||||
return any(marker in lowered for marker in _INCONCLUSIVE_ERROR_MARKERS)
|
||||
|
||||
|
||||
def _is_inconclusive_error(exc: BaseException) -> bool:
|
||||
"""True when an exception means the external result is unknowable.
|
||||
|
||||
Classified by exception type name + message keywords (no hard dependency on
|
||||
httpx/aiohttp). Timeouts, dropped/reset connections, and 502/503-gateway
|
||||
errors leave the external write's fate undetermined → ``unknown``.
|
||||
"""
|
||||
return _is_inconclusive_text(type(exc).__name__) or _is_inconclusive_text(str(exc))
|
||||
|
||||
|
||||
# ── middleware-chain composition (mirrors langchain create_agent) ──────────
|
||||
|
||||
|
||||
def _overrides_hook(middleware: Any, hook: str) -> bool:
|
||||
"""True if ``middleware`` overrides ``hook`` (vs. the no-op base default)."""
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
|
||||
base = getattr(AgentMiddleware, hook, None)
|
||||
impl = getattr(type(middleware), hook, None)
|
||||
return impl is not None and impl is not base
|
||||
|
||||
|
||||
def _compose_two_async(outer: Any, inner: Any) -> Any:
|
||||
"""Compose two async ``wrap_tool_call`` wrappers; ``outer`` runs first."""
|
||||
|
||||
async def composed(request: Any, handler: Any) -> Any:
|
||||
async def call_inner(req: Any) -> Any:
|
||||
return await inner(req, handler)
|
||||
|
||||
return await outer(request, call_inner)
|
||||
|
||||
return composed
|
||||
|
||||
|
||||
def compose_async_tool_call_wrappers(wrappers: list[Any]) -> Any | None:
|
||||
"""Fold async wrappers into one chain, **first = outermost**.
|
||||
|
||||
Replicates ``langchain.agents.factory._chain_async_tool_call_wrappers``
|
||||
without importing the private helper, so the approved executor reuses the
|
||||
exact same middleware ordering the lead agent gets.
|
||||
"""
|
||||
if not wrappers:
|
||||
return None
|
||||
result = wrappers[-1]
|
||||
for wrapper in reversed(wrappers[:-1]):
|
||||
result = _compose_two_async(wrapper, result)
|
||||
return result
|
||||
|
||||
|
||||
def _build_tool_call_middleware_chain(app_config: AppConfig) -> Any | None:
|
||||
"""Build the composed async ``wrap_tool_call`` chain from the real middlewares.
|
||||
|
||||
Uses ``build_lead_runtime_middlewares`` (the same instances the lead agent
|
||||
runs — WriteGuard / ToolErrorHandling / SandboxAudit / ToolMetrics /
|
||||
Guardrail when enabled), filtered to those overriding ``awrap_tool_call``
|
||||
(falling back to a sync ``wrap_tool_call`` adapter), composed first-outermost.
|
||||
"""
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import (
|
||||
build_lead_runtime_middlewares,
|
||||
)
|
||||
|
||||
middlewares = build_lead_runtime_middlewares(app_config=app_config)
|
||||
|
||||
wrappers: list[Any] = []
|
||||
for mw in middlewares:
|
||||
if _overrides_hook(mw, "awrap_tool_call"):
|
||||
wrappers.append(mw.awrap_tool_call)
|
||||
elif _overrides_hook(mw, "wrap_tool_call"):
|
||||
# A sync-only wrap_tool_call cannot drive the async approved path
|
||||
# safely; DeerFlow's tool middlewares all implement awrap_tool_call,
|
||||
# so this is defensive. Skip rather than risk a broken chain.
|
||||
logger.warning(
|
||||
"execute_approved_tool: skipping sync-only tool middleware %s",
|
||||
type(mw).__name__,
|
||||
)
|
||||
|
||||
return compose_async_tool_call_wrappers(wrappers)
|
||||
|
||||
|
||||
# ── security gates ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_SAFE_SUCCESS_KEYS = frozenset({"executed", "external_object_id", "safe_summary", "command", "has_update"})
|
||||
_ERROR_CODE_INCONCLUSIVE = "inconclusive_external_result"
|
||||
_ERROR_CODE_FAILED = "tool_execution_failed"
|
||||
_ERROR_CODE_REJECTED = "approved_action_rejected"
|
||||
|
||||
|
||||
def _seat_excluded_tools(
|
||||
capability_snapshot: dict[str, Any] | None,
|
||||
agent_id: str,
|
||||
fallback: list[str] | None,
|
||||
) -> list[str]:
|
||||
if not capability_snapshot:
|
||||
return list(fallback or [])
|
||||
for a in capability_snapshot.get("agents") or []:
|
||||
if isinstance(a, dict) and a.get("agent_id") == agent_id:
|
||||
return list(a.get("excluded_tools") or [])
|
||||
return list(fallback or [])
|
||||
|
||||
|
||||
def _frozen_allowed_tool_names(
|
||||
capability_snapshot: dict[str, Any] | None,
|
||||
agent_id: str,
|
||||
) -> frozenset[str] | None:
|
||||
"""Return the frozen allow-list for a seat, or None if snapshot has no list."""
|
||||
if not capability_snapshot:
|
||||
return None
|
||||
for a in capability_snapshot.get("agents") or []:
|
||||
if isinstance(a, dict) and a.get("agent_id") == agent_id:
|
||||
names = a.get("allowed_tool_names")
|
||||
if isinstance(names, list):
|
||||
return frozenset(str(n) for n in names)
|
||||
return None
|
||||
|
||||
|
||||
def _check_action_consistency(
|
||||
*,
|
||||
action_record: dict[str, Any] | None,
|
||||
approved_action_id: str,
|
||||
run_id: str,
|
||||
session_id: str,
|
||||
agent_id: str,
|
||||
tool_name: str,
|
||||
turn_index: int,
|
||||
) -> None:
|
||||
"""Gate 1: the stored action must belong to this exact execution context."""
|
||||
if not action_record:
|
||||
raise ApprovedToolRejection(
|
||||
"missing_action", "no action record supplied for approved execution"
|
||||
)
|
||||
required: dict[str, Any] = {
|
||||
"id": approved_action_id,
|
||||
"status": "running",
|
||||
"run_id": run_id,
|
||||
"session_id": session_id,
|
||||
"agent_id": agent_id,
|
||||
"tool_name": tool_name,
|
||||
"turn_index": turn_index,
|
||||
}
|
||||
for field, expected in required.items():
|
||||
actual = action_record.get(field)
|
||||
if actual != expected:
|
||||
raise ApprovedToolRejection(
|
||||
_ERROR_CODE_REJECTED,
|
||||
f"action {field} mismatch or missing (expected {expected!r}, got {actual!r})",
|
||||
)
|
||||
|
||||
|
||||
# ── the executor ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def execute_approved_tool(
|
||||
*,
|
||||
app_config: AppConfig,
|
||||
checkpointer: Any,
|
||||
store: Any,
|
||||
thread_id: str,
|
||||
# identity / trusted context
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
# frozen per-seat tool selection
|
||||
model_name: str | None,
|
||||
excluded_tools: list[str] | None,
|
||||
capability_snapshot: dict[str, Any] | None,
|
||||
write_policy: str,
|
||||
# the approved call (params already decrypted server-side by the caller)
|
||||
tool_name: str,
|
||||
params: dict[str, Any],
|
||||
approved_action_id: str,
|
||||
action_record: dict[str, Any] | None,
|
||||
tool_call_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute one approved tool call via the real LangGraph tool path.
|
||||
|
||||
Returns ``{"status": "succeeded", "result": ...}`` or
|
||||
``{"status": "failed", "error": ...}``. A security rejection is a
|
||||
``failed`` outcome (so the parent run fails) and the tool executes 0 times.
|
||||
The LLM is never involved.
|
||||
"""
|
||||
from deerflow.config.agents_config import load_agent_config
|
||||
|
||||
excluded = _seat_excluded_tools(capability_snapshot, agent_id, excluded_tools)
|
||||
|
||||
# Gate 1 — action/context consistency (before building anything).
|
||||
try:
|
||||
_check_action_consistency(
|
||||
action_record=action_record,
|
||||
approved_action_id=approved_action_id,
|
||||
run_id=run_id,
|
||||
session_id=session_id,
|
||||
agent_id=agent_id,
|
||||
tool_name=tool_name,
|
||||
turn_index=turn_index,
|
||||
)
|
||||
except ApprovedToolRejection as exc:
|
||||
logger.error(
|
||||
"execute_approved_tool rejected (%s): %s", exc.reason, exc.detail
|
||||
)
|
||||
return {"status": "failed", "error": f"{exc.reason}: {exc.detail}"}
|
||||
|
||||
# Rebuild the tool list exactly as the seat's agent run would: the agent's
|
||||
# configured tool_groups + model, minus disabled tools. Never the global
|
||||
# default registry.
|
||||
agent_config = load_agent_config(agent_id)
|
||||
tool_groups = getattr(agent_config, "tool_groups", None) if agent_config else None
|
||||
effective_model = model_name or (getattr(agent_config, "model", None) if agent_config else None)
|
||||
|
||||
all_tools = get_available_tools(
|
||||
groups=tool_groups,
|
||||
model_name=effective_model,
|
||||
excluded_tools=excluded,
|
||||
app_config=app_config,
|
||||
)
|
||||
tool_map = {t.name: t for t in all_tools}
|
||||
|
||||
frozen_allowed = _frozen_allowed_tool_names(capability_snapshot, agent_id)
|
||||
if frozen_allowed is not None:
|
||||
tool_map = {name: tool for name, tool in tool_map.items() if name in frozen_allowed}
|
||||
|
||||
# Gate 2 — disabled tool.
|
||||
if tool_name in excluded:
|
||||
logger.error("execute_approved_tool: tool '%s' is disabled", tool_name)
|
||||
return {"status": "failed", "error": f"tool '{tool_name}' is disabled for this seat"}
|
||||
|
||||
# Gate 3 — allowed / exists in the rebuilt capability allow-list.
|
||||
if tool_name not in tool_map:
|
||||
logger.error("execute_approved_tool: tool '%s' not in seat allow-list", tool_name)
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": f"tool '{tool_name}' is not permitted by this seat's capability snapshot",
|
||||
}
|
||||
|
||||
# Recover real runtime state from the thread checkpoint.
|
||||
channel_values: dict[str, Any] = {}
|
||||
config_dict: dict[str, Any] = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
if checkpointer is not None:
|
||||
try:
|
||||
ct = await checkpointer.aget_tuple(config_dict)
|
||||
checkpoint = getattr(ct, "checkpoint", None) if ct is not None else None
|
||||
if isinstance(checkpoint, dict):
|
||||
raw = checkpoint.get("channel_values", {}) or {}
|
||||
if isinstance(raw, dict):
|
||||
channel_values = dict(raw)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"execute_approved_tool: could not read checkpoint for thread %s; "
|
||||
"continuing with empty state",
|
||||
thread_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Build the trusted run context. These keys come ONLY from the server — the
|
||||
# decrypted params never enter this dict or any model context.
|
||||
call_id = tool_call_id or uuid.uuid4().hex
|
||||
run_context: MafApprovedToolContext = {
|
||||
"thread_id": thread_id,
|
||||
"run_id": run_id,
|
||||
"agent_id": agent_id,
|
||||
"user_id": user_id,
|
||||
"turn_index": turn_index,
|
||||
"maf_run_id": run_id,
|
||||
"maf_session_id": session_id,
|
||||
"maf_user_id": user_id,
|
||||
"capability_snapshot": capability_snapshot or {},
|
||||
"write_policy": write_policy or "",
|
||||
"maf_approved_tool": tool_name,
|
||||
"maf_approved_action_id": approved_action_id,
|
||||
"maf_safe_error_mode": True,
|
||||
}
|
||||
|
||||
# Compose the real middleware chain (WriteGuard sees the approved binding
|
||||
# and passes the call through; ToolErrorHandling/etc. behave as usual).
|
||||
awrap = _build_tool_call_middleware_chain(app_config)
|
||||
|
||||
from langgraph.prebuilt import ToolNode
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
collected_writes: list[Any] = []
|
||||
|
||||
def _stream_writer(payload: Any) -> None:
|
||||
collected_writes.append(payload)
|
||||
|
||||
tool_node = ToolNode(
|
||||
list(tool_map.values()),
|
||||
awrap_tool_call=awrap,
|
||||
handle_tool_errors=True,
|
||||
)
|
||||
|
||||
# Server-constructed single tool call — no LLM. Carried on a synthetic
|
||||
# AIMessage so the ToolNode parses it exactly like a graph tool turn.
|
||||
tool_call = {"name": tool_name, "args": dict(params), "id": call_id, "type": "tool_call"}
|
||||
ai_msg = AIMessage(content="", tool_calls=[tool_call])
|
||||
input_state: dict[str, Any] = dict(channel_values)
|
||||
input_state["messages"] = [ai_msg]
|
||||
|
||||
# Context lives only on typed Runtime — do not also set config["context"].
|
||||
runtime = Runtime[MafApprovedToolContext](
|
||||
context=run_context, store=store, stream_writer=_stream_writer
|
||||
)
|
||||
config_dict["configurable"]["__pregel_runtime"] = runtime
|
||||
|
||||
token = set_current_user(_ExecutorUser(user_id))
|
||||
try:
|
||||
result = await tool_node.ainvoke(input_state, config_dict)
|
||||
except GraphBubbleUp:
|
||||
# Preserve interrupt / pause / resume control-flow signals.
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — map any failure to a safe outcome
|
||||
# Never leak raw exception text (it may embed params / credentials):
|
||||
# surface only the exception type. Inconclusive failures (timeout /
|
||||
# disconnect / gateway) become ``unknown`` and are NEVER auto-retried —
|
||||
# the external write may or may not have happened.
|
||||
if _is_inconclusive_error(exc):
|
||||
logger.error(
|
||||
"execute_approved_tool: tool '%s' inconclusive (%s) — recording unknown",
|
||||
tool_name,
|
||||
type(exc).__name__,
|
||||
)
|
||||
return {"status": "unknown", "error": _ERROR_CODE_INCONCLUSIVE}
|
||||
logger.error(
|
||||
"execute_approved_tool: tool '%s' raised %s",
|
||||
tool_name,
|
||||
type(exc).__name__,
|
||||
)
|
||||
return {"status": "failed", "error": _ERROR_CODE_FAILED}
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
return _interpret_tool_node_result(result, tool_name)
|
||||
|
||||
|
||||
def _safe_success_payload(content: Any) -> dict[str, Any]:
|
||||
if isinstance(content, dict):
|
||||
safe = {k: content[k] for k in _SAFE_SUCCESS_KEYS if k in content}
|
||||
if safe:
|
||||
return safe
|
||||
return {"executed": True}
|
||||
|
||||
|
||||
def _interpret_tool_node_result(result: Any, tool_name: str) -> dict[str, Any]:
|
||||
"""Translate the ToolNode output (ToolMessage / Command) into an outcome dict."""
|
||||
messages: list[Any] = []
|
||||
if isinstance(result, dict):
|
||||
messages = result.get("messages", []) or []
|
||||
elif isinstance(result, list):
|
||||
messages = result
|
||||
|
||||
output = messages[0] if messages else result
|
||||
|
||||
if isinstance(output, Command):
|
||||
update = getattr(output, "update", None)
|
||||
return {
|
||||
"status": "succeeded",
|
||||
"result": {"command": type(output).__name__, "has_update": update is not None},
|
||||
}
|
||||
|
||||
if isinstance(output, ToolMessage):
|
||||
status = getattr(output, "status", "success")
|
||||
content = output.content
|
||||
if status == "error":
|
||||
text = str(content)
|
||||
if _is_inconclusive_text(text):
|
||||
return {"status": "unknown", "error": _ERROR_CODE_INCONCLUSIVE}
|
||||
return {"status": "failed", "error": _ERROR_CODE_FAILED}
|
||||
return {"status": "succeeded", "result": _safe_success_payload(content)}
|
||||
|
||||
return {"status": "succeeded", "result": {"executed": True}}
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Server-trusted tool side-effect / risk metadata for MAF WriteGuard.
|
||||
|
||||
Harness-only — never imports ``app.*``. Used when building frozen capability
|
||||
snapshots and when classifying tools at runtime. Client-supplied metadata is
|
||||
never trusted; only deployment config (``ToolConfig`` extras) and this registry
|
||||
apply.
|
||||
|
||||
Tools without explicit metadata default to ``write`` / ``R3`` (requires approval
|
||||
under ``confirm_high_risk``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
_DEFAULT_SIDE_EFFECT = "write"
|
||||
_DEFAULT_RISK_LEVEL = "R3"
|
||||
|
||||
# Built-in tools whose side effects are known server-side (not name-prefix guessing
|
||||
# for arbitrary tools — only this curated allow-list).
|
||||
_BUILTIN_METADATA: dict[str, tuple[str, str]] = {
|
||||
"memory": ("read", "R0"),
|
||||
"ask_clarification": ("read", "R0"),
|
||||
"view_image": ("read", "R0"),
|
||||
"present_file": ("read", "R0"),
|
||||
"web_search": ("read", "R1"),
|
||||
"web_fetch": ("read", "R1"),
|
||||
"read_file": ("read", "R0"),
|
||||
"grep": ("read", "R0"),
|
||||
"glob": ("read", "R0"),
|
||||
"ls": ("read", "R0"),
|
||||
"invoke_skill": ("skill", "R1"),
|
||||
"skill_search": ("skill", "R1"),
|
||||
"task": ("read", "R2"),
|
||||
"bash": ("write", "R3"),
|
||||
"write_file": ("write", "R3"),
|
||||
"edit_file": ("write", "R3"),
|
||||
}
|
||||
|
||||
|
||||
def resolve_tool_metadata(
|
||||
tool_name: str,
|
||||
*,
|
||||
config_extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Return ``{name, tool_name, side_effect, risk_level}`` for one tool."""
|
||||
side = _DEFAULT_SIDE_EFFECT
|
||||
risk = _DEFAULT_RISK_LEVEL
|
||||
if config_extra:
|
||||
if config_extra.get("side_effect"):
|
||||
side = str(config_extra["side_effect"])
|
||||
if config_extra.get("risk_level"):
|
||||
risk = str(config_extra["risk_level"])
|
||||
elif tool_name in _BUILTIN_METADATA:
|
||||
side, risk = _BUILTIN_METADATA[tool_name]
|
||||
return {
|
||||
"name": tool_name,
|
||||
"tool_name": tool_name,
|
||||
"side_effect": side,
|
||||
"risk_level": risk,
|
||||
}
|
||||
@@ -29,6 +29,7 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
postgres = ["deerflow-harness[postgres]"]
|
||||
mysql = ["deerflow-harness[mysql]"]
|
||||
maf = ["agent-framework-core>=1.13.0,<2", "cryptography>=42.0"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Phase 2 migration for the MAF roundtable experiment.
|
||||
|
||||
Idempotent migration that brings a Phase-1 deployment up to Phase-2 schema:
|
||||
|
||||
1. Create ``maf_roundtable_seat_runs`` (MAF↔DeerFlow run mapping, survives restart).
|
||||
2. Add ``source_event_id`` column to ``maf_roundtable_events`` (durable event dedup).
|
||||
3. Create the ``(run_id, source_event_id)`` unique index on ``maf_roundtable_events``.
|
||||
4. Create ``maf_roundtable_action_secrets`` (encrypted actual execution params, server-only).
|
||||
5. Add ``turn_index`` column to ``maf_roundtable_actions`` (Phase-1 DBs may lack it).
|
||||
|
||||
Supports SQLite, PostgreSQL, and MySQL. Each step checks whether the object
|
||||
already exists before creating it, so running the script multiple times has no
|
||||
side effect. Old event rows keep ``source_event_id = NULL`` (SQL treats NULLs as
|
||||
distinct in a UNIQUE constraint, so they are never deduped).
|
||||
|
||||
**Deployment order**: run this migration BEFORE enabling the
|
||||
``maf_roundtable.enabled`` feature flag.
|
||||
|
||||
Usage (from ``offline-backend-20260512/backend/``)::
|
||||
|
||||
# Preview (no writes)
|
||||
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_phase2.py --dry-run
|
||||
|
||||
# Execute
|
||||
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_phase2.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger("migrate_maf_roundtable_phase2")
|
||||
|
||||
|
||||
async def _migrate(dry_run: bool) -> int:
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
|
||||
|
||||
config = get_app_config()
|
||||
await init_engine_from_config(config.database)
|
||||
sf = get_session_factory()
|
||||
if sf is None:
|
||||
logger.error("Database backend is 'memory' — nothing to migrate.")
|
||||
return 0
|
||||
|
||||
engine = sf.kw["bind"] # the async engine
|
||||
|
||||
# Run introspection + DDL on the sync connection (works across all dialects).
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(_do_migrate_sync, dry_run=dry_run, logger=logger, changes_ref=[0])
|
||||
|
||||
await close_engine()
|
||||
logger.info("Migration complete (dry_run=%s).", dry_run)
|
||||
return 0
|
||||
|
||||
|
||||
def _do_migrate_sync(
|
||||
sync_conn: Any,
|
||||
*,
|
||||
dry_run: bool,
|
||||
logger: logging.Logger,
|
||||
changes_ref: list[int],
|
||||
) -> None:
|
||||
"""Run all migration steps on a sync connection (called via run_sync)."""
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
insp = inspect(sync_conn)
|
||||
dialect_name = sync_conn.dialect.name
|
||||
|
||||
def _exec(sql: str) -> None:
|
||||
if dry_run:
|
||||
logger.info(" [dry-run] would execute: %s", sql.strip()[:300])
|
||||
return
|
||||
sync_conn.execute(text(sql))
|
||||
changes_ref[0] += 1
|
||||
|
||||
# ── 1. maf_roundtable_seat_runs ──────────────────────────────────────
|
||||
if "maf_roundtable_seat_runs" not in insp.get_table_names():
|
||||
logger.info("Creating table: maf_roundtable_seat_runs")
|
||||
for stmt in _DDL_SEAT_RUNS:
|
||||
_exec(stmt)
|
||||
else:
|
||||
logger.info("Table maf_roundtable_seat_runs already exists — skip.")
|
||||
|
||||
# ── 2. source_event_id column on maf_roundtable_events ──────────────
|
||||
if "maf_roundtable_events" in insp.get_table_names():
|
||||
event_cols = {c["name"] for c in insp.get_columns("maf_roundtable_events")}
|
||||
if "source_event_id" not in event_cols:
|
||||
logger.info("Adding column source_event_id to maf_roundtable_events")
|
||||
_exec(_ddl_add_source_event_id(dialect_name))
|
||||
else:
|
||||
logger.info("Column source_event_id already exists on maf_roundtable_events — skip.")
|
||||
else:
|
||||
logger.info("Table maf_roundtable_events not present — skip source_event_id migration.")
|
||||
|
||||
# ── 3. (run_id, source_event_id) unique index ───────────────────────
|
||||
index_found = False
|
||||
if "maf_roundtable_events" in insp.get_table_names():
|
||||
for idx in insp.get_indexes("maf_roundtable_events"):
|
||||
if idx.get("name") == "uq_maf_events_run_source":
|
||||
index_found = True
|
||||
break
|
||||
if not index_found and "maf_roundtable_events" in insp.get_table_names():
|
||||
logger.info("Creating unique index uq_maf_events_run_source on maf_roundtable_events")
|
||||
_exec(_DDL_EVENTS_SOURCE_INDEX)
|
||||
else:
|
||||
logger.info("Index uq_maf_events_run_source already exists or events table absent — skip.")
|
||||
|
||||
# ── 4. maf_roundtable_action_secrets ─────────────────────────────────
|
||||
if "maf_roundtable_action_secrets" not in insp.get_table_names():
|
||||
logger.info("Creating table: maf_roundtable_action_secrets")
|
||||
for stmt in _DDL_ACTION_SECRETS:
|
||||
_exec(stmt)
|
||||
else:
|
||||
logger.info("Table maf_roundtable_action_secrets already exists — skip.")
|
||||
|
||||
# ── 5. turn_index on maf_roundtable_actions (Phase-1 deployments) ───
|
||||
action_cols = (
|
||||
{c["name"] for c in insp.get_columns("maf_roundtable_actions")}
|
||||
if "maf_roundtable_actions" in insp.get_table_names()
|
||||
else set()
|
||||
)
|
||||
if "maf_roundtable_actions" in insp.get_table_names() and "turn_index" not in action_cols:
|
||||
logger.info("Adding column turn_index to maf_roundtable_actions")
|
||||
_exec(_ddl_add_action_turn_index(dialect_name))
|
||||
elif "turn_index" in action_cols:
|
||||
logger.info("Column turn_index already exists on maf_roundtable_actions — skip.")
|
||||
|
||||
logger.info("Migration steps: %d applied.", changes_ref[0])
|
||||
|
||||
|
||||
# ── DDL templates (portable across sqlite / postgres / mysql) ──────────────
|
||||
# Each DDL is a LIST of single SQL statements so ``text()`` receives one
|
||||
# statement at a time — multi-statement strings fail on MySQL and several
|
||||
# SQLite builds because ``text()`` does not split on ``;``.
|
||||
|
||||
_DDL_SEAT_RUNS: list[str] = [
|
||||
"""
|
||||
CREATE TABLE maf_roundtable_seat_runs (
|
||||
id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
maf_run_id VARCHAR(64) NOT NULL,
|
||||
agent_id VARCHAR(64) NOT NULL,
|
||||
turn_index INTEGER NOT NULL,
|
||||
deerflow_run_id VARCHAR(64),
|
||||
deerflow_thread_id VARCHAR(64),
|
||||
sandbox_id VARCHAR(64),
|
||||
workflow_checkpoint_id VARCHAR(128),
|
||||
status VARCHAR(32) DEFAULT 'pending' NOT NULL,
|
||||
started_at DATETIME,
|
||||
finished_at DATETIME,
|
||||
error TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (maf_run_id) REFERENCES maf_roundtable_runs (id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX ix_maf_seat_runs_maf_run_id ON maf_roundtable_seat_runs (maf_run_id)",
|
||||
"CREATE INDEX ix_maf_seat_runs_agent_id ON maf_roundtable_seat_runs (agent_id)",
|
||||
"CREATE INDEX ix_maf_seat_runs_status ON maf_roundtable_seat_runs (status)",
|
||||
"CREATE UNIQUE INDEX uq_maf_seat_runs_run_agent_turn ON maf_roundtable_seat_runs (maf_run_id, agent_id, turn_index)",
|
||||
]
|
||||
|
||||
_DDL_EVENTS_SOURCE_INDEX = "CREATE UNIQUE INDEX uq_maf_events_run_source ON maf_roundtable_events (run_id, source_event_id)"
|
||||
|
||||
_DDL_ACTION_SECRETS: list[str] = [
|
||||
"""
|
||||
CREATE TABLE maf_roundtable_action_secrets (
|
||||
id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
action_id VARCHAR(64) NOT NULL,
|
||||
arguments_encrypted TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (action_id) REFERENCES maf_roundtable_actions (id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX ix_maf_action_secrets_action_id ON maf_roundtable_action_secrets (action_id)",
|
||||
"CREATE UNIQUE INDEX uq_maf_action_secrets_action ON maf_roundtable_action_secrets (action_id)",
|
||||
]
|
||||
|
||||
|
||||
def _ddl_add_source_event_id(dialect: str) -> str:
|
||||
"""ALTER TABLE to add source_event_id column (dialect-specific NULL handling)."""
|
||||
col_type = "VARCHAR(191)"
|
||||
return f"ALTER TABLE maf_roundtable_events ADD COLUMN source_event_id {col_type} NULL"
|
||||
|
||||
|
||||
def _ddl_add_action_turn_index(dialect: str) -> str:
|
||||
if dialect == "mysql":
|
||||
return "ALTER TABLE maf_roundtable_actions ADD COLUMN turn_index INT NULL"
|
||||
return "ALTER TABLE maf_roundtable_actions ADD COLUMN turn_index INTEGER NULL"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Phase 2 migration for MAF roundtable tables")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview only; do not write to the database")
|
||||
args = parser.parse_args()
|
||||
return asyncio.run(_migrate(args.dry_run))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -191,6 +191,13 @@ CALL _add_column_if_missing('task_buttons', 'append_auth', 'TINYINT(1) NOT NULL
|
||||
CALL _add_column_if_missing('task_buttons', 'auth_token_param', "VARCHAR(64) NULL DEFAULT 'token'");
|
||||
CALL _add_column_if_missing('task_buttons', 'auth_name_param', "VARCHAR(64) NULL DEFAULT 'username'");
|
||||
|
||||
-- ---- maf_roundtable_actions.turn_index --------------------------------------
|
||||
-- MAF 会商外部写动作所属的席位轮次。持久化该列(而非仅藏在 idempotency_key 里),
|
||||
-- 使待审批动作可精确按 run_id + agent_id + turn_index + logical_action_id 匹配,
|
||||
-- 不会把同一席位历史轮次的 waiting action 混进当前审批。
|
||||
-- ORM: MafRoundtableActionRow.turn_index = Integer, nullable。
|
||||
CALL _add_column_if_missing('maf_roundtable_actions', 'turn_index', 'INT NULL');
|
||||
|
||||
-- ---- 执行后自检:确认本批次涉及的列都已存在 ---------------------------------
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
@@ -199,5 +206,6 @@ SELECT table_name, column_name
|
||||
OR (table_name = 'agents' AND column_name IN ('square_id'))
|
||||
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id')
|
||||
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id')
|
||||
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param')))
|
||||
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param'))
|
||||
OR (table_name = 'maf_roundtable_actions' AND column_name = 'turn_index'))
|
||||
ORDER BY table_name, column_name;
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Shared helpers for MAF roundtable unit tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.service import MafRoundtableService
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
|
||||
|
||||
|
||||
class FakeAgentStore:
|
||||
"""Treat every agent id as visible to any user (tests only)."""
|
||||
|
||||
async def get_visible(self, agent_id: str, user_id: str) -> dict[str, Any]:
|
||||
return {"id": agent_id, "user_id": user_id}
|
||||
|
||||
|
||||
def make_maf_service(
|
||||
store,
|
||||
*,
|
||||
enabled: bool = True,
|
||||
admin_diagnostic_access: bool = True,
|
||||
config: MafRoundtableConfig | None = None,
|
||||
) -> MafRoundtableService:
|
||||
return MafRoundtableService(
|
||||
store,
|
||||
enabled=enabled,
|
||||
admin_diagnostic_access=admin_diagnostic_access,
|
||||
config=config,
|
||||
agent_store=FakeAgentStore(),
|
||||
app_config=get_app_config(),
|
||||
)
|
||||
@@ -0,0 +1,438 @@
|
||||
"""Tests for the MAF DeerFlow Agent Adapter (Phase 2 rework).
|
||||
|
||||
Tests: event normalization, context building, sanitization, cancellation,
|
||||
audit event reliability, seat run persistence, and user isolation.
|
||||
Uses in-memory SQLite + mocks for the DeerFlow runtime (no real model calls).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.maf_roundtable.context_builder import build_manager_synthesis_input, build_seat_input
|
||||
from app.maf_roundtable.deerflow_agent_adapter import (
|
||||
AuditEventError,
|
||||
DeerFlowAgentAdapter,
|
||||
SeatResponse,
|
||||
)
|
||||
from app.maf_roundtable.event_normalizer import (
|
||||
make_run_completed,
|
||||
make_turn_completed,
|
||||
make_turn_started,
|
||||
normalize_stream_event,
|
||||
)
|
||||
from app.maf_roundtable.runtime_client import DeerFlowRuntimeClient, ExecutionPrincipal
|
||||
from app.maf_roundtable.sanitize import sanitize, sanitize_payload
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.persistence.maf_roundtable.model import Base
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store():
|
||||
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
s = make_maf_roundtable_store(sf)
|
||||
assert s is not None
|
||||
yield s
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _svc(store, enabled: bool = True):
|
||||
from maf_test_helpers import make_maf_service
|
||||
|
||||
return make_maf_service(store, enabled=enabled)
|
||||
|
||||
|
||||
def _mock_client() -> DeerFlowRuntimeClient:
|
||||
app = MagicMock()
|
||||
app.state.checkpointer = AsyncMock()
|
||||
app.state.thread_store = AsyncMock()
|
||||
app.state.stream_bridge = None
|
||||
app.state.run_manager = AsyncMock()
|
||||
return DeerFlowRuntimeClient(app)
|
||||
|
||||
|
||||
# ── event_normalizer tests ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEventNormalizer:
|
||||
def test_metadata_event(self):
|
||||
events = normalize_stream_event("metadata", {"run_id": "r1", "thread_id": "t1"}, agent_id="a1", turn_index=0)
|
||||
assert len(events) == 1
|
||||
assert events[0]["event_type"] == "run.started"
|
||||
|
||||
def test_error_event(self):
|
||||
events = normalize_stream_event("error", {"message": "boom", "name": "TimeoutError"}, agent_id="a1", turn_index=1)
|
||||
assert len(events) == 1
|
||||
assert events[0]["event_type"] == "run.failed"
|
||||
|
||||
def test_messages_ai_with_tool_calls(self):
|
||||
data = ["msg-1", {"type": "ai", "content": "", "tool_calls": [{"name": "search", "id": "tc1", "args": {"q": "test"}}]}]
|
||||
events = normalize_stream_event("messages", data, agent_id="a1", turn_index=1)
|
||||
assert any(e["event_type"] == "tool.requested" for e in events)
|
||||
|
||||
def test_messages_tool_result(self):
|
||||
data = ["msg-1", {"type": "tool", "name": "search", "tool_call_id": "tc1", "content": "results...", "status": "completed"}]
|
||||
events = normalize_stream_event("messages", data, agent_id="a1", turn_index=1)
|
||||
assert events[0]["event_type"] == "tool.completed"
|
||||
|
||||
def test_no_skill_guessing_from_text(self):
|
||||
data = {"agent_node": {"messages": [{"type": "ai", "content": "I will use the skill to analyze this"}]}}
|
||||
events = normalize_stream_event("updates", data, agent_id="a1", turn_index=1)
|
||||
assert not any("skill" in e["event_type"] for e in events)
|
||||
|
||||
def test_source_event_id_for_dedup(self):
|
||||
data = ["msg-1", {"type": "tool", "name": "search", "tool_call_id": "tc1", "content": "ok", "status": "completed"}]
|
||||
events = normalize_stream_event("messages", data, agent_id="a1", turn_index=1)
|
||||
assert events[0].get("source_event_id") == "tool:tc1"
|
||||
|
||||
def test_unknown_event_ignored(self):
|
||||
events = normalize_stream_event("checkpoints", {"foo": "bar"}, agent_id="a1", turn_index=0)
|
||||
assert events == []
|
||||
|
||||
|
||||
# ── sanitization tests ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSanitization:
|
||||
def test_sensitive_keys_redacted(self):
|
||||
data = {"token": "abc123", "name": "safe", "nested": {"password": "secret", "ok": "yes"}}
|
||||
result = sanitize(data)
|
||||
assert result["token"] == "***REDACTED***"
|
||||
assert result["name"] == "safe"
|
||||
assert result["nested"]["password"] == "***REDACTED***"
|
||||
assert result["nested"]["ok"] == "yes"
|
||||
|
||||
def test_bearer_token_redacted(self):
|
||||
data = {"header": "Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig"}
|
||||
result = sanitize(data)
|
||||
assert result["header"] == "***REDACTED***"
|
||||
|
||||
def test_api_key_pattern_redacted(self):
|
||||
data = {"key": "sk-abcdefghijklmnopqrstuvwxyz123456"}
|
||||
result = sanitize(data)
|
||||
assert result["key"] == "***REDACTED***"
|
||||
|
||||
def test_normal_values_untouched(self):
|
||||
data = {"message": "hello world", "count": 42, "items": ["a", "b"]}
|
||||
result = sanitize(data)
|
||||
assert result == data
|
||||
|
||||
def test_sanitize_payload_none(self):
|
||||
assert sanitize_payload(None) is None
|
||||
|
||||
def test_event_payload_sanitized(self):
|
||||
events = normalize_stream_event(
|
||||
"messages",
|
||||
["m1", {"type": "ai", "content": "", "tool_calls": [{"name": "api_call", "id": "tc1", "args": {"api_key": "secret123", "url": "http://x"}}]}],
|
||||
agent_id="a1",
|
||||
turn_index=1,
|
||||
)
|
||||
tool_ev = next(e for e in events if e["event_type"] == "tool.requested")
|
||||
assert tool_ev["payload"]["args"]["api_key"] == "***REDACTED***"
|
||||
assert tool_ev["payload"]["args"]["url"] == "http://x"
|
||||
|
||||
|
||||
# ── context_builder tests ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestContextBuilder:
|
||||
def test_strict_truncation(self):
|
||||
result = build_seat_input(
|
||||
task_text="x" * 20000,
|
||||
stage_instruction="",
|
||||
role_description="",
|
||||
prior_turns=[],
|
||||
max_context_chars=100,
|
||||
)
|
||||
assert len(result) <= 100
|
||||
|
||||
def test_no_overflow_after_truncation(self):
|
||||
result = build_seat_input(
|
||||
task_text="task",
|
||||
stage_instruction="stage",
|
||||
role_description="role",
|
||||
prior_turns=[{"agent_id": "a", "turn_index": 1, "summary": "y" * 5000}],
|
||||
fact_ledger={"confirmed_facts": ["f" * 5000]},
|
||||
max_context_chars=200,
|
||||
)
|
||||
assert len(result) <= 200
|
||||
|
||||
def test_fact_ledger_sanitized(self):
|
||||
ledger = {"confirmed_facts": ["server uses token=abc123"]}
|
||||
result = build_seat_input(
|
||||
task_text="check",
|
||||
stage_instruction="",
|
||||
role_description="",
|
||||
prior_turns=[],
|
||||
fact_ledger=ledger,
|
||||
)
|
||||
assert "abc123" not in result or "token" not in result.lower()
|
||||
|
||||
def test_manager_synthesis(self):
|
||||
result = build_manager_synthesis_input(
|
||||
task_text="evaluate",
|
||||
seat_results=[{"agent_id": "risk", "content": "high risk"}],
|
||||
)
|
||||
assert "risk" in result
|
||||
assert "建议动作" in result
|
||||
|
||||
|
||||
# ── ExecutionPrincipal tests ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExecutionPrincipal:
|
||||
def test_requires_user_id(self):
|
||||
with pytest.raises(ValueError):
|
||||
ExecutionPrincipal(user_id="", session_id="s1", run_id="r1")
|
||||
|
||||
def test_valid_principal(self):
|
||||
p = ExecutionPrincipal(user_id="u1", session_id="s1", run_id="r1")
|
||||
assert p.user_id == "u1"
|
||||
|
||||
def test_satisfies_current_user_protocol(self):
|
||||
from app.maf_roundtable.runtime_client import _PrincipalUser
|
||||
from deerflow.runtime.user_context import CurrentUser
|
||||
|
||||
p = ExecutionPrincipal(user_id="test-user", session_id="s", run_id="r")
|
||||
user = _PrincipalUser(p)
|
||||
assert isinstance(user, CurrentUser)
|
||||
assert user.id == "test-user"
|
||||
|
||||
|
||||
# ── adapter unit tests ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeerFlowAgentAdapter:
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_threads_creates_and_binds(self, store):
|
||||
svc = _svc(store)
|
||||
session = await svc.create_session(
|
||||
user_id="u1",
|
||||
body={"task_text": "test", "participants": [{"agent_id": "a1"}, {"agent_id": "a2"}]},
|
||||
)
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
client = _mock_client()
|
||||
client._app.state.checkpointer.aput = AsyncMock()
|
||||
client._app.state.thread_store.create = AsyncMock()
|
||||
|
||||
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
|
||||
thread_map = await adapter.ensure_threads(session["id"], run["id"], session["participants"])
|
||||
|
||||
assert len(thread_map) == 2
|
||||
assert thread_map["a1"] != thread_map["a2"]
|
||||
|
||||
updated = await svc.get_session(session_id=session["id"], user_id="u1")
|
||||
for p in updated["participants"]:
|
||||
assert p["thread_id"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_threads_reuses_existing(self, store):
|
||||
svc = _svc(store)
|
||||
session = await svc.create_session(
|
||||
user_id="u1",
|
||||
body={"task_text": "test", "participants": [{"agent_id": "a1", "thread_id": "existing-t1"}]},
|
||||
)
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
client = _mock_client()
|
||||
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
|
||||
thread_map = await adapter.ensure_threads(session["id"], run["id"], session["participants"])
|
||||
assert thread_map["a1"] == "existing-t1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_cascades_via_seat_runs(self, store):
|
||||
svc = _svc(store)
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
|
||||
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
|
||||
seat_run_id = active[0]["id"]
|
||||
await svc.store.seat_runs.mark_running(seat_run_id, deerflow_run_id="df-run-1")
|
||||
|
||||
client = _mock_client()
|
||||
client._app.state.run_manager.cancel = AsyncMock()
|
||||
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
|
||||
result = await adapter.cancel(run["id"])
|
||||
|
||||
# Structured cancel result (Task 4): all_cancelled when every active
|
||||
# sub-run was confirmed cancelled.
|
||||
assert result["all_cancelled"] is True
|
||||
assert result["cancelled_run_ids"] == [seat_run_id]
|
||||
assert result["failed_run_ids"] == []
|
||||
client._app.state.run_manager.cancel.assert_called_once_with("df-run-1", action="interrupt")
|
||||
remaining = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
|
||||
assert len(remaining) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critical_event_failure_raises(self, store):
|
||||
svc = _svc(store)
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
client = _mock_client()
|
||||
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
|
||||
|
||||
original_append = svc.append_event
|
||||
async def _failing_append(**kwargs):
|
||||
raise RuntimeError("DB connection lost")
|
||||
svc.append_event = _failing_append
|
||||
|
||||
with pytest.raises(AuditEventError):
|
||||
await adapter._emit_critical(run["id"], make_turn_started("a1", 0))
|
||||
|
||||
svc.append_event = original_append
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noncritical_event_degrades_gracefully(self, store):
|
||||
svc = _svc(store)
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
client = _mock_client()
|
||||
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
|
||||
|
||||
async def _failing_append(**kwargs):
|
||||
raise RuntimeError("DB connection lost")
|
||||
svc.append_event = _failing_append
|
||||
|
||||
await adapter._emit(run["id"], {"event_type": "agent.message.delta", "agent_id": "a1", "turn_index": 0, "payload": {"text": "hi"}})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seat_response_dataclass(self):
|
||||
r = SeatResponse(agent_id="a1", turn_index=1, content="hello", tool_calls=[{"name": "search"}])
|
||||
assert r.agent_id == "a1"
|
||||
assert r.error is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_latest_ai_text_extraction(self, store):
|
||||
svc = _svc(store)
|
||||
client = _mock_client()
|
||||
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
|
||||
messages = [
|
||||
{"type": "human", "content": "hello"},
|
||||
{"type": "ai", "content": "", "tool_calls": [{"name": "search", "args": {}, "id": "1"}]},
|
||||
{"type": "ai", "content": "The answer is 42."},
|
||||
]
|
||||
assert adapter._latest_ai_text(messages) == "The answer is 42."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_calls_since_last_human(self, store):
|
||||
svc = _svc(store)
|
||||
client = _mock_client()
|
||||
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
|
||||
messages = [
|
||||
{"type": "human", "content": "old"},
|
||||
{"type": "ai", "content": "", "tool_calls": [{"name": "old_tool", "args": {}, "id": "0"}]},
|
||||
{"type": "human", "content": "new"},
|
||||
{"type": "ai", "content": "", "tool_calls": [{"name": "search", "args": {"q": "x"}, "id": "1"}]},
|
||||
]
|
||||
calls = adapter._tool_calls_since_last_human(messages)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["name"] == "search"
|
||||
|
||||
|
||||
# ── seat_runs persistence tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSeatRunPersistence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_lifecycle(self, store):
|
||||
svc = _svc(store)
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
sr = await svc.store.seat_runs.create_seat_run(
|
||||
maf_run_id=run["id"], agent_id="expert-a", turn_index=1, deerflow_thread_id="t1"
|
||||
)
|
||||
assert sr["status"] == "pending"
|
||||
|
||||
await svc.store.seat_runs.mark_running(sr["id"], deerflow_run_id="df-1")
|
||||
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
|
||||
assert len(active) == 1
|
||||
assert active[0]["deerflow_run_id"] == "df-1"
|
||||
|
||||
await svc.store.seat_runs.mark_completed(sr["id"], checkpoint_id="cp-1")
|
||||
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
|
||||
assert len(active) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_all_active(self, store):
|
||||
svc = _svc(store)
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
sr1 = await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
|
||||
sr2 = await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a2", turn_index=1)
|
||||
await svc.store.seat_runs.mark_running(sr1["id"], deerflow_run_id="df-1")
|
||||
await svc.store.seat_runs.mark_running(sr2["id"], deerflow_run_id="df-2")
|
||||
|
||||
cancelled = await svc.store.seat_runs.cancel_all_active(run["id"])
|
||||
# Two-phase cancel: returns {seat_run_id, deerflow_run_id} dicts and
|
||||
# leaves rows in 'cancelling' (caller finalizes to cancelled).
|
||||
assert {c["deerflow_run_id"] for c in cancelled} == {"df-1", "df-2"}
|
||||
assert {c["seat_run_id"] for c in cancelled} == {sr1["id"], sr2["id"]}
|
||||
# 'cancelling' is not an active status, so active count drops to 0.
|
||||
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
|
||||
assert len(active) == 0
|
||||
# Rows are mid-cancel, not yet finalized.
|
||||
all_runs = await svc.store.seat_runs.get_by_maf_run(run["id"])
|
||||
assert {r["status"] for r in all_runs} == {"cancelling"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unique_constraint(self, store):
|
||||
svc = _svc(store)
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
|
||||
with pytest.raises(Exception):
|
||||
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
|
||||
|
||||
|
||||
# ── event flow integration tests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAdapterEventFlow:
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_event_sequence_persisted(self, store):
|
||||
svc = _svc(store)
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "analyze"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
client = _mock_client()
|
||||
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
|
||||
|
||||
await adapter._emit_critical(run["id"], make_turn_started("expert", 1))
|
||||
await adapter._emit(run["id"], {"event_type": "tool.requested", "agent_id": "expert", "turn_index": 1, "payload": {"tool_name": "search"}})
|
||||
await adapter._emit(run["id"], {"event_type": "tool.completed", "agent_id": "expert", "turn_index": 1, "payload": {"tool_name": "search", "status": "succeeded"}})
|
||||
await adapter._emit_critical(run["id"], make_turn_completed("expert", 1, content="done"))
|
||||
await adapter._emit_critical(run["id"], make_run_completed("expert", 1))
|
||||
|
||||
events, latest = await svc.list_events(run_id=run["id"], user_id="u1")
|
||||
assert len(events) == 5
|
||||
assert latest == 5
|
||||
types = [e["event_type"] for e in events]
|
||||
assert types == ["agent.turn_started", "tool.requested", "tool.completed", "agent.message.completed", "run.completed"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dedup_via_source_event_id(self, store):
|
||||
svc = _svc(store)
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
client = _mock_client()
|
||||
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
|
||||
|
||||
ev = {"event_type": "tool.completed", "agent_id": "a1", "turn_index": 1, "payload": {"tool_name": "x"}, "source_event_id": "tool:tc1"}
|
||||
await adapter._emit(run["id"], ev)
|
||||
await adapter._emit(run["id"], ev)
|
||||
|
||||
events, _ = await svc.list_events(run_id=run["id"], user_id="u1")
|
||||
assert len(events) == 1
|
||||
@@ -0,0 +1,669 @@
|
||||
"""P0-1 acceptance tests for the harness approved-tool executor.
|
||||
|
||||
These exercise ``deerflow.tools.approved_tool_executor.execute_approved_tool``
|
||||
directly on the real LangGraph ToolNode / ToolRuntime / middleware path with
|
||||
controlled tools (no LLM, no DeerFlow runtime).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.tools import StructuredTool
|
||||
from langgraph.prebuilt import ToolRuntime
|
||||
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import (
|
||||
build_lead_runtime_middlewares,
|
||||
)
|
||||
from deerflow.agents.middlewares.write_guard_middleware import (
|
||||
WriteGuardMiddleware,
|
||||
)
|
||||
from deerflow.config.write_guard_config import WriteGuardConfig
|
||||
from deerflow.tools import approved_tool_executor
|
||||
from deerflow.tools.approved_tool_executor import execute_approved_tool
|
||||
|
||||
# ?? fakes ?????????????????????????????????????????????????????????????????
|
||||
|
||||
|
||||
class _FakeCheckpointTuple:
|
||||
def __init__(self, channel_values: dict[str, Any]) -> None:
|
||||
self.checkpoint = {"channel_values": channel_values}
|
||||
|
||||
|
||||
class _FakeCheckpointer:
|
||||
def __init__(self, channel_values: dict[str, Any]) -> None:
|
||||
self._cv = channel_values
|
||||
|
||||
async def aget_tuple(self, config: dict[str, Any]) -> _FakeCheckpointTuple:
|
||||
return _FakeCheckpointTuple(self._cv)
|
||||
|
||||
|
||||
CHANNEL_VALUES: dict[str, Any] = {
|
||||
"thread_data": {"marker": "THREAD_MARKER_42"},
|
||||
"sandbox": {"sandbox_id": "local"},
|
||||
"messages": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_config():
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
return get_app_config()
|
||||
|
||||
|
||||
def _snapshot(action_tool: str) -> dict[str, Any]:
|
||||
meta = {"name": action_tool, "tool_name": action_tool, "side_effect": "write", "risk_level": "R3"}
|
||||
return {
|
||||
"version": 1,
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": "a1",
|
||||
"allowed_tool_names": [action_tool],
|
||||
"excluded_tools": [],
|
||||
"tools": [meta],
|
||||
}
|
||||
],
|
||||
"tools": [meta],
|
||||
}
|
||||
|
||||
|
||||
def _ctx(action_tool: str = "sync_write") -> dict[str, Any]:
|
||||
"""Common trusted execution context + matching action record."""
|
||||
return {
|
||||
"thread_id": "thread-1",
|
||||
"user_id": "u1",
|
||||
"session_id": "s1",
|
||||
"run_id": "r1",
|
||||
"agent_id": "a1",
|
||||
"turn_index": 2,
|
||||
"model_name": None,
|
||||
"excluded_tools": [],
|
||||
"capability_snapshot": _snapshot(action_tool),
|
||||
"write_policy": "confirm_high_risk",
|
||||
"approved_action_id": "action-1",
|
||||
"action_record": {
|
||||
"id": "action-1",
|
||||
"status": "running",
|
||||
"run_id": "r1",
|
||||
"session_id": "s1",
|
||||
"agent_id": "a1",
|
||||
"tool_name": action_tool,
|
||||
"turn_index": 2,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ?? 1. sync StructuredTool.func runs once ??succeeded ?????????????????????
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_tool_runs_once_succeeded(app_config, monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fn(payload: str) -> str:
|
||||
calls["n"] += 1
|
||||
return f"sync-ok:{payload}"
|
||||
|
||||
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
ctx = _ctx("sync_write")
|
||||
result = await execute_approved_tool(
|
||||
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
|
||||
tool_name="sync_write", params={"payload": "X"}, **ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "succeeded", result
|
||||
assert calls["n"] == 1, "sync tool must run exactly once"
|
||||
|
||||
|
||||
# ?? 2. async tool runs normally ???????????????????????????????????????????
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_tool_runs(app_config, monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
async def _fn(payload: str) -> str:
|
||||
calls["n"] += 1
|
||||
return f"async-ok:{payload}"
|
||||
|
||||
tool = StructuredTool.from_function(coroutine=_fn, name="async_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
ctx = _ctx("async_write")
|
||||
result = await execute_approved_tool(
|
||||
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
|
||||
tool_name="async_write", params={"payload": "Y"}, **ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "succeeded", result
|
||||
assert calls["n"] == 1
|
||||
|
||||
|
||||
# ?? 3. tool needing ToolRuntime gets the REAL runtime + checkpoint state ??
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_receives_real_tool_runtime_and_state(app_config, monkeypatch):
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def _fn(payload: str, runtime: ToolRuntime[dict[str, Any], dict[str, Any]]) -> str:
|
||||
# Typed ToolRuntime context avoids PydanticSerializationUnexpectedValue
|
||||
# (bare ToolRuntime defaults context to None).
|
||||
seen["runtime_type"] = type(runtime).__name__
|
||||
seen["is_real_toolruntime"] = isinstance(runtime, ToolRuntime)
|
||||
seen["marker"] = runtime.state.get("thread_data", {}).get("marker")
|
||||
seen["context_run_id"] = runtime.context.get("maf_run_id")
|
||||
seen["approved_tool"] = runtime.context.get("maf_approved_tool")
|
||||
seen["context_action_id"] = runtime.context.get("maf_approved_action_id")
|
||||
return "rt-ok"
|
||||
|
||||
tool = StructuredTool.from_function(func=_fn, name="rt_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
ctx = _ctx("rt_write")
|
||||
result = await execute_approved_tool(
|
||||
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
|
||||
tool_name="rt_write", params={"payload": "Z"}, **ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "succeeded", result
|
||||
# A real ToolRuntime, not a duck-typed SimpleNamespace.
|
||||
assert seen["is_real_toolruntime"] is True
|
||||
assert seen["runtime_type"] != "SimpleNamespace"
|
||||
# Real checkpoint state flowed through (sandbox / thread_data recovered).
|
||||
assert seen["marker"] == "THREAD_MARKER_42"
|
||||
# Trusted MAF context injected by the server (via Runtime on __pregel_runtime).
|
||||
assert seen["context_run_id"] == "r1"
|
||||
assert seen["approved_tool"] == "rt_write"
|
||||
assert seen["context_action_id"] == "action-1"
|
||||
|
||||
|
||||
# ?? 4. unauthorized / disabled / nonexistent ??0 executions ???????????????
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonexistent_tool_executes_zero_times(app_config, monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fn(payload: str) -> str:
|
||||
calls["n"] += 1
|
||||
return "ok"
|
||||
|
||||
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
ctx = _ctx("ghost_tool") # action says ghost_tool; not in the allow-list
|
||||
result = await execute_approved_tool(
|
||||
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
|
||||
tool_name="ghost_tool", params={"payload": "X"}, **ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert calls["n"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_tool_executes_zero_times(app_config, monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fn(payload: str) -> str:
|
||||
calls["n"] += 1
|
||||
return "ok"
|
||||
|
||||
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
ctx = _ctx("sync_write")
|
||||
ctx["capability_snapshot"]["agents"][0]["excluded_tools"] = ["sync_write"]
|
||||
result = await execute_approved_tool(
|
||||
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
|
||||
tool_name="sync_write", params={"payload": "X"}, **ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert calls["n"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_mismatch_executes_zero_times(app_config, monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fn(payload: str) -> str:
|
||||
calls["n"] += 1
|
||||
return "ok"
|
||||
|
||||
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
ctx = _ctx("sync_write")
|
||||
# The stored action belongs to a DIFFERENT run ??must be refused.
|
||||
ctx["action_record"] = {
|
||||
"id": "action-1",
|
||||
"status": "running",
|
||||
"run_id": "OTHER_RUN",
|
||||
"session_id": "s1",
|
||||
"agent_id": "a1",
|
||||
"tool_name": "sync_write",
|
||||
"turn_index": 2,
|
||||
}
|
||||
result = await execute_approved_tool(
|
||||
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
|
||||
tool_name="sync_write", params={"payload": "X"}, **ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert calls["n"] == 0
|
||||
|
||||
|
||||
# ?? 5. LLM is never involved ??????????????????????????????????????????????
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_never_involved(app_config, monkeypatch):
|
||||
llm_calls = {"n": 0}
|
||||
|
||||
def _boom(*args: Any, **kwargs: Any) -> Any:
|
||||
llm_calls["n"] += 1
|
||||
raise AssertionError("model factory must never be called by the approved executor")
|
||||
|
||||
# Guard every plausible model-construction entry point.
|
||||
import deerflow.models.factory as factory
|
||||
|
||||
monkeypatch.setattr(factory, "create_chat_model", _boom)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fn(payload: str) -> str:
|
||||
calls["n"] += 1
|
||||
return "ok"
|
||||
|
||||
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
ctx = _ctx("sync_write")
|
||||
result = await execute_approved_tool(
|
||||
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
|
||||
tool_name="sync_write", params={"payload": "X"}, **ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "succeeded", result
|
||||
assert calls["n"] == 1
|
||||
assert llm_calls["n"] == 0, "the LLM must never be involved"
|
||||
|
||||
|
||||
# ?? 6. inconclusive external errors ??unknown (no auto-retry) ???????????????
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_error_records_unknown(app_config, monkeypatch):
|
||||
"""P1c: a timeout (external result unknowable) is recorded ``unknown`` ?? never ``failed`` and never auto-retried."""
|
||||
|
||||
async def _fn(payload: str) -> str:
|
||||
raise TimeoutError("upstream timed out")
|
||||
|
||||
tool = StructuredTool.from_function(coroutine=_fn, name="timeout_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
ctx = _ctx("timeout_write")
|
||||
result = await execute_approved_tool(
|
||||
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
|
||||
tool_name="timeout_write", params={"payload": "X"}, **ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "unknown", result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_error_records_unknown(app_config, monkeypatch):
|
||||
"""P1c: a dropped/reset connection is likewise inconclusive ??``unknown``."""
|
||||
|
||||
async def _fn(payload: str) -> str:
|
||||
raise ConnectionResetError("connection reset by peer")
|
||||
|
||||
tool = StructuredTool.from_function(coroutine=_fn, name="conn_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
ctx = _ctx("conn_write")
|
||||
result = await execute_approved_tool(
|
||||
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
|
||||
tool_name="conn_write", params={"payload": "X"}, **ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "unknown", result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ordinary_error_records_failed(app_config, monkeypatch):
|
||||
"""P1c: a deterministic validation error (external write definitely did NOT
|
||||
happen) is ``failed``, not ``unknown``."""
|
||||
|
||||
async def _fn(payload: str) -> str:
|
||||
raise ValueError("invalid payload value")
|
||||
|
||||
tool = StructuredTool.from_function(coroutine=_fn, name="bad_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
ctx = _ctx("bad_write")
|
||||
result = await execute_approved_tool(
|
||||
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
|
||||
tool_name="bad_write", params={"payload": "X"}, **ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "failed", result
|
||||
assert result.get("error") == "tool_execution_failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_error_never_leaks_secrets(app_config, monkeypatch, caplog):
|
||||
secret = "TOP_SECRET_123"
|
||||
|
||||
def _fn(payload: str) -> str:
|
||||
raise ValueError(f"token={secret}")
|
||||
|
||||
tool = StructuredTool.from_function(func=_fn, name="bad_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
ctx = _ctx("bad_write")
|
||||
with caplog.at_level("ERROR"):
|
||||
result = await execute_approved_tool(
|
||||
app_config=app_config,
|
||||
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
|
||||
store=None,
|
||||
tool_name="bad_write",
|
||||
params={"payload": "X"},
|
||||
**ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert secret not in str(result)
|
||||
assert secret not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_frozen_allow_list_blocks_extra_registry_tool(app_config, monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def _allowed(payload: str) -> str:
|
||||
calls["n"] += 1
|
||||
return "ok"
|
||||
|
||||
def _blocked(payload: str) -> str:
|
||||
calls["n"] += 1
|
||||
return "no"
|
||||
|
||||
allowed = StructuredTool.from_function(func=_allowed, name="tool_a", description="d")
|
||||
blocked = StructuredTool.from_function(func=_blocked, name="tool_b", description="d")
|
||||
monkeypatch.setattr(
|
||||
approved_tool_executor, "get_available_tools", lambda **kw: [allowed, blocked]
|
||||
)
|
||||
|
||||
snap = _snapshot("tool_a")
|
||||
snap["agents"][0]["allowed_tool_names"] = ["tool_a"]
|
||||
ctx = _ctx("tool_b")
|
||||
ctx["capability_snapshot"] = snap
|
||||
ctx["action_record"]["tool_name"] = "tool_b"
|
||||
ctx["approved_action_id"] = "action-1"
|
||||
|
||||
result = await execute_approved_tool(
|
||||
app_config=app_config,
|
||||
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
|
||||
store=None,
|
||||
tool_name="tool_b",
|
||||
params={"payload": "X"},
|
||||
**ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert calls["n"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_not_running_executes_zero_times(app_config, monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fn(payload: str) -> str:
|
||||
calls["n"] += 1
|
||||
return "ok"
|
||||
|
||||
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
ctx = _ctx("sync_write")
|
||||
ctx["action_record"]["status"] = "approved"
|
||||
result = await execute_approved_tool(
|
||||
app_config=app_config,
|
||||
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
|
||||
store=None,
|
||||
tool_name="sync_write",
|
||||
params={"payload": "X"},
|
||||
**ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert calls["n"] == 0
|
||||
|
||||
|
||||
def _enabled_write_guard_app_config(app_config):
|
||||
enabled_guard = WriteGuardConfig.model_validate(
|
||||
{
|
||||
"enabled": True,
|
||||
"provider": {
|
||||
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
|
||||
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
|
||||
"config": {},
|
||||
},
|
||||
}
|
||||
)
|
||||
return app_config.model_copy(update={"write_guard": enabled_guard})
|
||||
|
||||
|
||||
async def _writeguard_store_fixture(tmp_path, monkeypatch):
|
||||
"""Create a real MAF store + service with WriteGuard bridge registered.
|
||||
|
||||
Uses monkeypatch so ``write_guard_bridge._maf_service`` is restored when the
|
||||
test ends (avoids leaking a disposed sqlite service into later tests).
|
||||
"""
|
||||
from maf_test_helpers import make_maf_service
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
import deerflow.persistence.maf_roundtable.model # noqa: F401
|
||||
from app.maf_roundtable import write_guard_bridge
|
||||
from app.maf_roundtable.write_guard_bridge import register_write_guard_service
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wg.db'}")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
store = make_maf_roundtable_store(sf)
|
||||
svc = make_maf_service(store)
|
||||
# Capture the pre-test service *before* registration. Registering first
|
||||
# would make monkeypatch restore this disposable sqlite-backed service at
|
||||
# teardown and leak it into the next test.
|
||||
monkeypatch.setattr(write_guard_bridge, "_maf_service", None)
|
||||
register_write_guard_service(svc)
|
||||
return engine, store, svc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approved_tool_executor_with_writeguard_enabled(app_config, monkeypatch, tmp_path):
|
||||
"""P1: real middleware chain with WriteGuard enabled passes approved execution."""
|
||||
from app.maf_roundtable.write_guard_bridge import MafWriteGuardInterceptor
|
||||
|
||||
enabled_app_config = _enabled_write_guard_app_config(app_config)
|
||||
middlewares = build_lead_runtime_middlewares(app_config=enabled_app_config)
|
||||
assert any(isinstance(item, WriteGuardMiddleware) for item in middlewares)
|
||||
|
||||
engine, store, svc = await _writeguard_store_fixture(tmp_path, monkeypatch)
|
||||
|
||||
validate_spy_calls = {"n": 0}
|
||||
original_validate = MafWriteGuardInterceptor.validate_approved_action
|
||||
|
||||
async def _spy_validate(self, *, tool_name: str, run_context: dict) -> bool:
|
||||
validate_spy_calls["n"] += 1
|
||||
return await original_validate(self, tool_name=tool_name, run_context=run_context)
|
||||
|
||||
monkeypatch.setattr(MafWriteGuardInterceptor, "validate_approved_action", _spy_validate)
|
||||
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
action = await store.actions.create_action(
|
||||
{
|
||||
"session_id": session["id"],
|
||||
"run_id": run["id"],
|
||||
"agent_id": "a1",
|
||||
"tool_name": "sync_write",
|
||||
"idempotency_key": "wg-test-1",
|
||||
"risk_level": "R3",
|
||||
"status": "running",
|
||||
"turn_index": 2,
|
||||
}
|
||||
)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fn(payload: str) -> str:
|
||||
calls["n"] += 1
|
||||
return "wg-ok"
|
||||
|
||||
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
ctx = _ctx("sync_write")
|
||||
ctx["session_id"] = session["id"]
|
||||
ctx["run_id"] = run["id"]
|
||||
ctx["approved_action_id"] = action["id"]
|
||||
ctx["action_record"] = {
|
||||
"id": action["id"],
|
||||
"status": "running",
|
||||
"run_id": run["id"],
|
||||
"session_id": session["id"],
|
||||
"agent_id": "a1",
|
||||
"tool_name": "sync_write",
|
||||
"turn_index": 2,
|
||||
}
|
||||
try:
|
||||
result = await execute_approved_tool(
|
||||
app_config=enabled_app_config,
|
||||
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
|
||||
store=None,
|
||||
tool_name="sync_write",
|
||||
params={"payload": "X"},
|
||||
**ctx,
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
assert result["status"] == "succeeded", result
|
||||
assert calls["n"] == 1
|
||||
assert validate_spy_calls["n"] == 1
|
||||
assert result["result"] == {"executed": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"db_mismatch",
|
||||
[
|
||||
"status",
|
||||
"run_id",
|
||||
"session_id",
|
||||
"agent_id",
|
||||
"tool_name",
|
||||
],
|
||||
)
|
||||
async def test_writeguard_rejects_db_mismatched_action(
|
||||
app_config, monkeypatch, tmp_path, db_mismatch
|
||||
):
|
||||
"""Second-line defense: Guard re-reads the DB action and rejects mismatches.
|
||||
|
||||
``action_record`` passed to the executor stays consistent so the first-layer
|
||||
field check passes; only the DB row is wrong.
|
||||
"""
|
||||
from app.maf_roundtable.write_guard_bridge import MafWriteGuardInterceptor
|
||||
|
||||
enabled_app_config = _enabled_write_guard_app_config(app_config)
|
||||
middlewares = build_lead_runtime_middlewares(app_config=enabled_app_config)
|
||||
assert any(isinstance(item, WriteGuardMiddleware) for item in middlewares)
|
||||
|
||||
engine, store, svc = await _writeguard_store_fixture(tmp_path, monkeypatch)
|
||||
|
||||
validate_spy_calls = {"n": 0}
|
||||
original_validate = MafWriteGuardInterceptor.validate_approved_action
|
||||
|
||||
async def _spy_validate(self, *, tool_name: str, run_context: dict) -> bool:
|
||||
validate_spy_calls["n"] += 1
|
||||
return await original_validate(self, tool_name=tool_name, run_context=run_context)
|
||||
|
||||
monkeypatch.setattr(MafWriteGuardInterceptor, "validate_approved_action", _spy_validate)
|
||||
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
other = await svc.create_session(user_id="u1", body={"task_text": "other"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
db_fields = {
|
||||
"session_id": session["id"],
|
||||
"run_id": run["id"],
|
||||
"agent_id": "a1",
|
||||
"tool_name": "sync_write",
|
||||
"idempotency_key": f"wg-mismatch-{db_mismatch}",
|
||||
"risk_level": "R3",
|
||||
"status": "running",
|
||||
"turn_index": 2,
|
||||
}
|
||||
if db_mismatch == "status":
|
||||
db_fields["status"] = "approved"
|
||||
elif db_mismatch == "run_id":
|
||||
# No second create_run (session allows only one active run) ? fake id is enough.
|
||||
db_fields["run_id"] = "other-run-id"
|
||||
elif db_mismatch == "session_id":
|
||||
db_fields["session_id"] = other["id"]
|
||||
elif db_mismatch == "agent_id":
|
||||
db_fields["agent_id"] = "other-agent"
|
||||
elif db_mismatch == "tool_name":
|
||||
db_fields["tool_name"] = "other_tool"
|
||||
|
||||
action = await store.actions.create_action(db_fields)
|
||||
|
||||
tool_calls = {"n": 0}
|
||||
|
||||
def _fn(payload: str) -> str:
|
||||
tool_calls["n"] += 1
|
||||
return "should-not-run"
|
||||
|
||||
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
|
||||
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
|
||||
|
||||
# Executor-facing record looks correct ? Guard must catch the DB mismatch.
|
||||
ctx = _ctx("sync_write")
|
||||
ctx["session_id"] = session["id"]
|
||||
ctx["run_id"] = run["id"]
|
||||
ctx["approved_action_id"] = action["id"]
|
||||
ctx["action_record"] = {
|
||||
"id": action["id"],
|
||||
"status": "running",
|
||||
"run_id": run["id"],
|
||||
"session_id": session["id"],
|
||||
"agent_id": "a1",
|
||||
"tool_name": "sync_write",
|
||||
"turn_index": 2,
|
||||
}
|
||||
try:
|
||||
result = await execute_approved_tool(
|
||||
app_config=enabled_app_config,
|
||||
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
|
||||
store=None,
|
||||
tool_name="sync_write",
|
||||
params={"payload": "X"},
|
||||
**ctx,
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
assert tool_calls["n"] == 0
|
||||
assert validate_spy_calls["n"] == 1
|
||||
assert result["status"] == "failed"
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Acceptance tests for the structured cancel cascade (Task 4).
|
||||
|
||||
Verifies that when DeerFlow cancel fails, the parent run is NOT marked
|
||||
``cancelled`` —it stays non-terminal (``cancel_failed``) so the failure is
|
||||
visible and retryable. A failed cancel must never masquerade as cancelled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.maf_roundtable.deerflow_agent_adapter import DeerFlowAgentAdapter
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.persistence.maf_roundtable.model import Base
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store():
|
||||
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
s = make_maf_roundtable_store(sf)
|
||||
assert s is not None
|
||||
yield s
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def svc(store):
|
||||
from maf_test_helpers import make_maf_service
|
||||
|
||||
return make_maf_service(store, enabled=True, admin_diagnostic_access=True)
|
||||
|
||||
|
||||
def _mock_client(*, cancel_succeeds: bool) -> MagicMock:
|
||||
client = MagicMock()
|
||||
client._app.state.run_manager.cancel = AsyncMock(return_value=None)
|
||||
client.cancel_run = AsyncMock(return_value=cancel_succeeds)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_all_succeeds_marks_cancelled(svc, store):
|
||||
"""When every sub-run cancels, all_cancelled is True."""
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
|
||||
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
|
||||
await svc.store.seat_runs.mark_running(active[0]["id"], deerflow_run_id="df-1")
|
||||
|
||||
adapter = DeerFlowAgentAdapter(_mock_client(cancel_succeeds=True), service=svc, user_id="u1")
|
||||
result = await adapter.cancel(run["id"])
|
||||
assert result["all_cancelled"] is True
|
||||
assert result["failed_run_ids"] == []
|
||||
assert result["cancelled_run_ids"] == [active[0]["id"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_failure_keeps_run_nonterminal(svc, store):
|
||||
"""When DeerFlow cancel fails, the parent run must NOT be marked cancelled.
|
||||
|
||||
The adapter returns all_cancelled=False; the router would then mark the
|
||||
run ``cancel_failed`` (non-terminal). This test verifies the adapter-level
|
||||
signal that drives that decision.
|
||||
"""
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
|
||||
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
|
||||
await svc.store.seat_runs.mark_running(active[0]["id"], deerflow_run_id="df-2")
|
||||
|
||||
adapter = DeerFlowAgentAdapter(_mock_client(cancel_succeeds=False), service=svc, user_id="u1")
|
||||
result = await adapter.cancel(run["id"])
|
||||
|
||||
assert result["all_cancelled"] is False
|
||||
assert result["failed_run_ids"] == [active[0]["id"]]
|
||||
assert result["cancelled_run_ids"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_cancel_failed_service_method(svc, store):
|
||||
"""The service ``mark_cancel_failed`` transitions a cancelling run to
|
||||
``cancel_failed`` (non-terminal), recording a sanitized reason."""
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
# Put the run into cancelling via start_cancel.
|
||||
await svc.start_cancel(run_id=run["id"], user_id="u1")
|
||||
|
||||
updated = await svc.mark_cancel_failed(
|
||||
run_id=run["id"], user_id="u1", reason="1 sub-run failed to cancel"
|
||||
)
|
||||
assert updated["status"] == "cancel_failed"
|
||||
assert updated["error_code"] == "CancelCascadeIncomplete"
|
||||
assert "failed to cancel" in updated["error_message_safe"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_no_active_runs_is_all_cancelled(svc, store):
|
||||
"""With no active seat runs, cancel is trivially all_cancelled (nothing to do)."""
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
adapter = DeerFlowAgentAdapter(_mock_client(cancel_succeeds=True), service=svc, user_id="u1")
|
||||
result = await adapter.cancel(run["id"])
|
||||
assert result["all_cancelled"] is True
|
||||
assert result["cancelled_run_ids"] == []
|
||||
@@ -0,0 +1,88 @@
|
||||
"""P0-1: trusted capability snapshot builder tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from maf_test_helpers import FakeAgentStore
|
||||
|
||||
from app.maf_roundtable.capability_snapshot import (
|
||||
AgentNotAccessibleError,
|
||||
build_trusted_capability_snapshot,
|
||||
)
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.tools.tool_metadata import resolve_tool_metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_forged_side_effect_not_stored(monkeypatch):
|
||||
app_config = get_app_config()
|
||||
|
||||
def _fake_build_seat(*, app_config, agent_id, session_model_name, session_reasoning_effort, requested_excluded):
|
||||
meta = resolve_tool_metadata("write_file")
|
||||
return (["write_file"], [], [meta])
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.maf_roundtable.capability_snapshot._seat_tools_for_agent",
|
||||
_fake_build_seat,
|
||||
)
|
||||
|
||||
snap = await build_trusted_capability_snapshot(
|
||||
user_id="u1",
|
||||
participants=[
|
||||
{
|
||||
"agent_id": "seat-a",
|
||||
"role": "seat",
|
||||
"excluded_tools": [],
|
||||
# Client forgery ??must be ignored (field not read).
|
||||
"capability_snapshot": {
|
||||
"tools": [{"name": "write_file", "side_effect": "read", "risk_level": "R0"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
app_config=app_config,
|
||||
agent_store=FakeAgentStore(),
|
||||
)
|
||||
|
||||
tool = snap["agents"][0]["tools"][0]
|
||||
assert tool["side_effect"] == "write"
|
||||
assert tool["risk_level"] == "R3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_frozen_allow_list_excludes_registry_only_tool(monkeypatch):
|
||||
app_config = get_app_config()
|
||||
|
||||
def _fake_build_seat(*, app_config, agent_id, session_model_name, session_reasoning_effort, requested_excluded):
|
||||
return (["tool_a"], [], [resolve_tool_metadata("tool_a")])
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.maf_roundtable.capability_snapshot._seat_tools_for_agent",
|
||||
_fake_build_seat,
|
||||
)
|
||||
|
||||
snap = await build_trusted_capability_snapshot(
|
||||
user_id="u1",
|
||||
participants=[{"agent_id": "seat-a", "role": "seat"}],
|
||||
app_config=app_config,
|
||||
agent_store=FakeAgentStore(),
|
||||
)
|
||||
|
||||
assert snap["agents"][0]["allowed_tool_names"] == ["tool_a"]
|
||||
assert "tool_b" not in snap["agents"][0]["allowed_tool_names"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inaccessible_agent_rejected():
|
||||
app_config = get_app_config()
|
||||
|
||||
class _DenyStore:
|
||||
async def get_visible(self, agent_id: str, user_id: str):
|
||||
return None
|
||||
|
||||
with pytest.raises(AgentNotAccessibleError):
|
||||
await build_trusted_capability_snapshot(
|
||||
user_id="u1",
|
||||
participants=[{"agent_id": "missing-agent", "role": "seat"}],
|
||||
app_config=app_config,
|
||||
agent_store=_DenyStore(),
|
||||
)
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Acceptance tests for the Phase-2 migration script (Task 5).
|
||||
|
||||
Verifies:
|
||||
1. The migration creates ``maf_roundtable_seat_runs`` + ``source_event_id``
|
||||
column + the unique index + ``maf_roundtable_action_secrets`` on SQLite.
|
||||
2. Running the migration twice is idempotent (no error, no duplicate objects).
|
||||
3. Phase-1 data (an existing event row) remains readable after migration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.persistence.maf_roundtable.model import Base
|
||||
|
||||
|
||||
def _load_migration_module():
|
||||
"""Load scripts/migrate_maf_roundtable_phase2.py as a module."""
|
||||
here = Path(__file__).resolve().parent.parent
|
||||
script = here / "scripts" / "migrate_maf_roundtable_phase2.py"
|
||||
spec = importlib.util.spec_from_file_location("migrate_maf_roundtable_phase2", script)
|
||||
assert spec and spec.loader, "could not load migration script"
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_creates_phase2_objects_and_is_idempotent():
|
||||
"""Run the migration on a fresh Phase-1 schema, then re-run it; both must
|
||||
succeed and produce the expected Phase-2 objects."""
|
||||
mod = _load_migration_module()
|
||||
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
|
||||
# Build the Phase-1 schema (everything except the Phase-2 additions).
|
||||
# We create the base tables the migration expects to already exist.
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Drop the Phase-2 tables if create_all created them, so we test creation.
|
||||
await conn.execute(text("DROP TABLE IF EXISTS maf_roundtable_seat_runs"))
|
||||
await conn.execute(text("DROP TABLE IF EXISTS maf_roundtable_action_secrets"))
|
||||
|
||||
# Seed a Phase-1 event row so we can verify it survives migration.
|
||||
# Use the ORM (not raw SQL) so column Python-defaults apply.
|
||||
from datetime import datetime
|
||||
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableEventRow,
|
||||
MafRoundtableRunRow,
|
||||
MafRoundtableSessionRow,
|
||||
)
|
||||
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
now = datetime(2026, 1, 1)
|
||||
async with sf() as session:
|
||||
session.add(MafRoundtableSessionRow(
|
||||
id="s1", user_id="u1", title="t", task_text="t",
|
||||
mode="structured", status="draft", write_policy="confirm_high_risk",
|
||||
max_rounds=8, created_at=now, updated_at=now,
|
||||
))
|
||||
session.add(MafRoundtableRunRow(
|
||||
id="r1", session_id="s1", status="pending",
|
||||
created_at=now, updated_at=now,
|
||||
))
|
||||
session.add(MafRoundtableEventRow(
|
||||
id="e1", session_id="s1", run_id="r1", seq=1,
|
||||
event_type="run.started", created_at=now,
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
# Run the migration's sync step directly on a connection (dry_run=False).
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(mod._do_migrate_sync, dry_run=False, logger=mod.logger, changes_ref=[0])
|
||||
|
||||
# Verify all Phase-2 objects exist.
|
||||
def _check(sync_conn):
|
||||
insp = sa_inspect(sync_conn)
|
||||
tables = set(insp.get_table_names())
|
||||
assert "maf_roundtable_seat_runs" in tables
|
||||
assert "maf_roundtable_action_secrets" in tables
|
||||
event_cols = {c["name"] for c in insp.get_columns("maf_roundtable_events")}
|
||||
assert "source_event_id" in event_cols
|
||||
# Unique indexes.
|
||||
idx_names: set[str] = set()
|
||||
for t in insp.get_table_names():
|
||||
for idx in insp.get_indexes(t):
|
||||
idx_names.add(idx.get("name"))
|
||||
assert "uq_maf_events_run_source" in idx_names
|
||||
assert "uq_maf_seat_runs_run_agent_turn" in idx_names
|
||||
assert "uq_maf_action_secrets_action" in idx_names
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(_check)
|
||||
|
||||
# Phase-1 data still readable + the new column is nullable.
|
||||
async with sf() as session:
|
||||
rows = (await session.execute(text("SELECT id, source_event_id FROM maf_roundtable_events"))).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0][0] == "e1"
|
||||
assert rows[0][1] is None # old row keeps NULL
|
||||
|
||||
# Re-run the migration ??must be idempotent (no error).
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(mod._do_migrate_sync, dry_run=False, logger=mod.logger, changes_ref=[0])
|
||||
|
||||
# Dry-run must also not throw and must not write.
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(mod._do_migrate_sync, dry_run=True, logger=mod.logger, changes_ref=[0])
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_adds_turn_index_to_legacy_actions_table():
|
||||
"""Phase-1 actions table without turn_index is upgraded idempotently."""
|
||||
mod = _load_migration_module()
|
||||
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE maf_roundtable_actions (
|
||||
id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
session_id VARCHAR(64) NOT NULL,
|
||||
run_id VARCHAR(64),
|
||||
agent_id VARCHAR(64),
|
||||
tool_name VARCHAR(128) NOT NULL,
|
||||
logical_action_id VARCHAR(128),
|
||||
idempotency_key VARCHAR(191) NOT NULL UNIQUE,
|
||||
target_key VARCHAR(255),
|
||||
risk_level VARCHAR(8) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
arguments_redacted_json TEXT,
|
||||
result_redacted_json TEXT,
|
||||
external_object_id VARCHAR(255),
|
||||
approval_id VARCHAR(64),
|
||||
attempt_count INTEGER NOT NULL,
|
||||
started_at DATETIME,
|
||||
finished_at DATETIME,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(mod._do_migrate_sync, dry_run=False, logger=mod.logger, changes_ref=[0])
|
||||
|
||||
def _has_turn_index(sync_conn):
|
||||
insp = sa_inspect(sync_conn)
|
||||
cols = {c["name"] for c in insp.get_columns("maf_roundtable_actions")}
|
||||
assert "turn_index" in cols
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(_has_turn_index)
|
||||
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
store = make_maf_roundtable_store(sf)
|
||||
assert store is not None
|
||||
from datetime import datetime
|
||||
|
||||
from deerflow.persistence.maf_roundtable.model import MafRoundtableSessionRow
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all, tables=[MafRoundtableSessionRow.__table__])
|
||||
|
||||
now = datetime(2026, 1, 1)
|
||||
async with sf() as session:
|
||||
session.add(
|
||||
MafRoundtableSessionRow(
|
||||
id="s1",
|
||||
user_id="u1",
|
||||
title="t",
|
||||
task_text="t",
|
||||
mode="structured",
|
||||
status="draft",
|
||||
write_policy="confirm_high_risk",
|
||||
max_rounds=8,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
row = await store.actions.create_action(
|
||||
{
|
||||
"session_id": "s1",
|
||||
"tool_name": "write_file",
|
||||
"idempotency_key": "legacy-1",
|
||||
"turn_index": 1,
|
||||
"risk_level": "R3",
|
||||
"status": "pending",
|
||||
}
|
||||
)
|
||||
assert row["turn_index"] == 1
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(mod._do_migrate_sync, dry_run=False, logger=mod.logger, changes_ref=[0])
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def test_ddl_add_action_turn_index_by_dialect():
|
||||
mod = _load_migration_module()
|
||||
assert "INTEGER NULL" in mod._ddl_add_action_turn_index("sqlite")
|
||||
assert "INTEGER NULL" in mod._ddl_add_action_turn_index("postgresql")
|
||||
mysql = mod._ddl_add_action_turn_index("mysql")
|
||||
assert "INT NULL" in mysql
|
||||
assert "INTEGER" not in mysql.replace("INT NULL", "")
|
||||
|
||||
|
||||
def test_ddl_lists_are_single_statement():
|
||||
"""The DDL constants are lists of single SQL statements (no ``;`` joining)."""
|
||||
mod = _load_migration_module()
|
||||
for stmt in mod._DDL_SEAT_RUNS + mod._DDL_ACTION_SECRETS:
|
||||
# Each statement must not contain a statement separator mid-string
|
||||
# (trailing/leading whitespace stripped). A CREATE TABLE followed by
|
||||
# a semicolon + another CREATE is the bug we fixed.
|
||||
stripped = stmt.strip().rstrip(";").strip()
|
||||
assert stripped.count(";") == 0, f"DDL statement contains ';': {stmt[:80]!r}"
|
||||
# The events index is a single statement string.
|
||||
assert mod._DDL_EVENTS_SOURCE_INDEX.count(";") <= 1
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Regression test: legacy single-agent runs are unaffected by WriteGuard.
|
||||
|
||||
The WriteGuard middleware is registered unconditionally (when configured) but
|
||||
must be a pure pass-through when the run context has no ``maf_run_id`` flag.
|
||||
This test proves a write tool call in a legacy run executes normally (0
|
||||
interceptions, real handler invoked), so the new middleware introduces zero
|
||||
behaviour change for non-MAF runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.agents.middlewares.write_guard_middleware import WriteGuardMiddleware
|
||||
|
||||
|
||||
class _StubPolicy:
|
||||
def should_intercept(self, tool_name: str, args: Any, run_context: Any = None) -> bool: # noqa: ANN401
|
||||
return True # would intercept — but only if active
|
||||
|
||||
|
||||
class _StubInterceptor:
|
||||
async def intercept(self, *, tool_name: str, args: Any, tool_call_id: str, run_context: dict) -> dict: # noqa: ANN401
|
||||
raise AssertionError("interceptor must NOT be called in a legacy run")
|
||||
|
||||
async def validate_approved_action(self, *, tool_name: str, run_context: dict) -> bool:
|
||||
# Legacy stub: no real store. The approved-passthrough tests supply
|
||||
# their own validating interceptor; this default fails closed.
|
||||
return False
|
||||
|
||||
|
||||
class _ValidatingStubInterceptor:
|
||||
"""Approved-passthrough interceptor whose action validation is configurable."""
|
||||
|
||||
def __init__(self, *, valid: bool) -> None:
|
||||
self._valid = valid
|
||||
self.validated_with: dict | None = None
|
||||
|
||||
async def intercept(self, *, tool_name: str, args: Any, tool_call_id: str, run_context: dict) -> dict: # noqa: ANN401
|
||||
return {"status": "approval_required", "action_id": "a?", "tool_name": tool_name}
|
||||
|
||||
async def validate_approved_action(self, *, tool_name: str, run_context: dict) -> bool:
|
||||
self.validated_with = {"tool_name": tool_name, "run_context": run_context}
|
||||
return self._valid
|
||||
|
||||
|
||||
def _request(run_context: dict | None = None):
|
||||
runtime = MagicMock()
|
||||
runtime.context = run_context or {}
|
||||
req = MagicMock()
|
||||
req.tool_call = {"name": "write_anything", "args": {"x": 1}, "id": "c1"}
|
||||
req.runtime = runtime
|
||||
return req
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_run_write_tool_not_intercepted():
|
||||
"""No ``maf_run_id`` → middleware passes through, real handler runs."""
|
||||
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=_StubInterceptor())
|
||||
called = {"n": 0}
|
||||
|
||||
async def handler(request):
|
||||
called["n"] += 1
|
||||
return MagicMock(content="ok")
|
||||
|
||||
# No maf_run_id in context = legacy run.
|
||||
result = await mw.awrap_tool_call(_request({}), handler)
|
||||
assert called["n"] == 1
|
||||
assert result.content == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maf_run_write_tool_intercepted():
|
||||
"""With ``maf_run_id`` set, the same policy DOES intercept (sanity check)."""
|
||||
called = {"n": 0}
|
||||
|
||||
async def handler(request):
|
||||
called["n"] += 1
|
||||
return MagicMock(content="ok")
|
||||
|
||||
# Active MAF run → interceptor would be called, but ours raises to prove it.
|
||||
# Use a real interceptor here to avoid the assertion.
|
||||
class _Real:
|
||||
async def intercept(self, *, tool_name, args, tool_call_id, run_context):
|
||||
return {"status": "approval_required", "action_id": "a1", "approval_id": "p1"}
|
||||
|
||||
mw2 = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=_Real())
|
||||
result = await mw2.awrap_tool_call(_request({"maf_run_id": "r1"}), handler)
|
||||
assert called["n"] == 0 # handler NOT called
|
||||
envelope = json.loads(result.content)
|
||||
assert envelope["status"] == "approval_required"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approved_passthrough_executes():
|
||||
"""An approved-tool run (maf_approved_tool + maf_approved_action_id bound and
|
||||
the action validates) lets that one tool through (P0-4 action-id security)."""
|
||||
interceptor = _ValidatingStubInterceptor(valid=True)
|
||||
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=interceptor)
|
||||
called = {"n": 0}
|
||||
|
||||
async def handler(request):
|
||||
called["n"] += 1
|
||||
return MagicMock(content="approved-exec")
|
||||
|
||||
ctx = {
|
||||
"maf_run_id": "r1",
|
||||
"maf_session_id": "s1",
|
||||
"agent_id": "seat-1",
|
||||
"maf_approved_tool": "write_anything",
|
||||
"maf_approved_action_id": "action-1",
|
||||
}
|
||||
result = await mw.awrap_tool_call(_request(ctx), handler)
|
||||
assert called["n"] == 1
|
||||
assert result.content == "approved-exec"
|
||||
# The async path actually validated the bound action (not just field presence).
|
||||
assert interceptor.validated_with is not None
|
||||
assert interceptor.validated_with["tool_name"] == "write_anything"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approved_passthrough_fails_closed_when_validation_false():
|
||||
"""P0-4: if the bound action does NOT validate (status/ownership/tool mismatch),
|
||||
the approved passthrough fails closed — the tool does not run."""
|
||||
interceptor = _ValidatingStubInterceptor(valid=False)
|
||||
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=interceptor)
|
||||
called = {"n": 0}
|
||||
|
||||
async def handler(request):
|
||||
called["n"] += 1
|
||||
return MagicMock(content="should-not-run")
|
||||
|
||||
ctx = {
|
||||
"maf_run_id": "r1",
|
||||
"maf_approved_tool": "write_anything",
|
||||
"maf_approved_action_id": "action-bad",
|
||||
}
|
||||
result = await mw.awrap_tool_call(_request(ctx), handler)
|
||||
assert called["n"] == 0, "invalid approved action must fail closed"
|
||||
envelope = json.loads(result.content)
|
||||
assert envelope["status"] == "approval_invalid"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approved_passthrough_requires_action_id():
|
||||
"""P0-4: maf_approved_tool WITHOUT maf_approved_action_id no longer passes
|
||||
through — action-id binding is mandatory."""
|
||||
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=_ValidatingStubInterceptor(valid=True))
|
||||
called = {"n": 0}
|
||||
|
||||
async def handler(request):
|
||||
called["n"] += 1
|
||||
return MagicMock(content="ok")
|
||||
|
||||
ctx = {"maf_run_id": "r1", "maf_approved_tool": "write_anything"} # no action id
|
||||
result = await mw.awrap_tool_call(_request(ctx), handler)
|
||||
assert called["n"] == 0
|
||||
envelope = json.loads(result.content)
|
||||
assert envelope["status"] in ("approval_required", "intercept_failed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approved_passthrough_only_for_named_tool():
|
||||
"""When maf_approved_tool is set but the call is a DIFFERENT tool, it is
|
||||
still intercepted (only the approved tool passes through)."""
|
||||
class _Real:
|
||||
async def intercept(self, *, tool_name, args, tool_call_id, run_context):
|
||||
return {"status": "approval_required", "action_id": "a2"}
|
||||
|
||||
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=_Real())
|
||||
called = {"n": 0}
|
||||
|
||||
async def handler(request):
|
||||
called["n"] += 1
|
||||
return MagicMock(content="ok")
|
||||
|
||||
ctx = {"maf_run_id": "r1", "maf_approved_tool": "update_task"}
|
||||
# The request is for a different tool → not allowed through.
|
||||
req = _request(ctx)
|
||||
req.tool_call = {"name": "delete_thing", "args": {}, "id": "c2"}
|
||||
result = await mw.awrap_tool_call(req, handler)
|
||||
assert called["n"] == 0 # intercepted, not executed
|
||||
envelope = json.loads(result.content)
|
||||
assert envelope["status"] == "approval_required"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,14 @@
|
||||
"""P1 admin write-boundary + P2 config-default tests for the MAF roundtable.
|
||||
|
||||
P1 — admin diagnostic access is **read-only**:
|
||||
P1 —admin diagnostic access is **read-only**:
|
||||
- An admin with ``admin_diagnostic_access=True`` can *read* another user's
|
||||
session / run / action / approval (cross-user diagnostics).
|
||||
- The same admin **cannot** *write* another user's resources: update / delete
|
||||
session, start / cancel run, append event, decide approval all raise
|
||||
``MafForbiddenError`` (→ HTTP 403), never silently succeeding.
|
||||
``MafForbiddenError`` (→HTTP 403), never silently succeeding.
|
||||
- Normal users remain fully isolated from each other.
|
||||
|
||||
P2 — config-driven create defaults:
|
||||
P2 —config-driven create defaults:
|
||||
- Omitted ``mode`` / ``write_policy`` / ``max_rounds`` fall back to the deployed
|
||||
``MafRoundtableConfig`` values.
|
||||
- An explicit request value overrides the config default.
|
||||
@@ -18,13 +18,10 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from maf_test_helpers import make_maf_service
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.maf_roundtable.service import (
|
||||
MafForbiddenError,
|
||||
MafNotFoundError,
|
||||
MafRoundtableService,
|
||||
)
|
||||
from app.maf_roundtable.service import MafForbiddenError, MafNotFoundError
|
||||
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
@@ -43,7 +40,7 @@ async def store(tmp_path):
|
||||
|
||||
|
||||
def _svc(store, *, enabled=True, admin_diagnostic_access=True, config=None):
|
||||
return MafRoundtableService(
|
||||
return make_maf_service(
|
||||
store,
|
||||
enabled=enabled,
|
||||
admin_diagnostic_access=admin_diagnostic_access,
|
||||
@@ -165,7 +162,7 @@ async def test_admin_diagnostic_off_cannot_even_read_cross_user(store):
|
||||
async def test_normal_users_remain_fully_isolated(store):
|
||||
svc = _svc(store, admin_diagnostic_access=True)
|
||||
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
|
||||
# bob is a non-admin → isolated on both read and write.
|
||||
# bob is a non-admin →isolated on both read and write.
|
||||
with pytest.raises(MafNotFoundError):
|
||||
await svc.get_session(session_id=s["id"], user_id="bob")
|
||||
with pytest.raises(MafNotFoundError):
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""Tests for the MAF roundtable persistence + service layer (阶段一骨架).
|
||||
"""Tests for the MAF roundtable persistence + service layer (?????).
|
||||
|
||||
Covers the Phase 1 exit criteria:
|
||||
- Session CRUD with participants + capability snapshot.
|
||||
- Run lifecycle (create / get / list / cancel).
|
||||
- Monotonic per-run event seq under concurrent appends (MAF-01-06).
|
||||
- Event 断线补拉 (list_events after_seq=N).
|
||||
- Event ???? (list_events after_seq=N).
|
||||
- Action idempotency-key uniqueness.
|
||||
- Approval decision transition + "already decided" guard.
|
||||
- User isolation (MAF-01-07) — non-owner gets None / 404, not 403.
|
||||
- User isolation (MAF-01-07) ??non-owner gets None / 404, not 403.
|
||||
- Admin diagnostic access bypass.
|
||||
- Feature flag (MAF-01-04) — writes raise MafDisabledError when off; reads open.
|
||||
- Run gating — running session rejects config edits.
|
||||
- Feature flag (MAF-01-04) ??writes raise MafDisabledError when off; reads open.
|
||||
- Run gating ??running session rejects config edits.
|
||||
- capability_snapshot frozen at creation.
|
||||
- MAF tables have no FK to legacy roundtable_* tables.
|
||||
"""
|
||||
@@ -21,12 +21,12 @@ import asyncio
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from maf_test_helpers import make_maf_service
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.maf_roundtable.service import (
|
||||
MafConflictError,
|
||||
MafNotFoundError,
|
||||
MafRoundtableService,
|
||||
)
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
@@ -45,7 +45,7 @@ async def store(tmp_path):
|
||||
|
||||
|
||||
def _svc(store, *, enabled=True, admin_diagnostic_access=True):
|
||||
return MafRoundtableService(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
|
||||
return make_maf_service(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -54,23 +54,26 @@ async def test_create_and_get_session_round_trips(store):
|
||||
created = await svc.create_session(
|
||||
user_id="u1",
|
||||
body={
|
||||
"title": "舆情会商",
|
||||
"task_text": "分析暑期文旅舆情",
|
||||
"title": "????",
|
||||
"task_text": "????????",
|
||||
"mode": "structured",
|
||||
"model_name": "glm-5",
|
||||
"max_rounds": 5,
|
||||
"write_policy": "confirm_high_risk",
|
||||
"capability_snapshot": {"agents": ["a1"]},
|
||||
"participants": [
|
||||
{"agent_id": "mgr", "display_name": "主持人", "role": "manager", "sort_order": 0},
|
||||
{"agent_id": "seat-a", "display_name": "席A", "role": "seat", "sort_order": 1},
|
||||
{"agent_id": "mgr", "display_name": "host", "role": "manager", "sort_order": 0},
|
||||
{"agent_id": "seat-a", "display_name": "?A", "role": "seat", "sort_order": 1},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert created["user_id"] == "u1"
|
||||
assert created["status"] == "draft"
|
||||
assert created["mode"] == "structured"
|
||||
assert created["capability_snapshot"] == {"agents": ["a1"]}
|
||||
snap = created["capability_snapshot"]
|
||||
assert snap is not None
|
||||
assert snap.get("version") == 1
|
||||
assert isinstance(snap.get("agents"), list)
|
||||
assert len(snap["agents"]) == 2
|
||||
assert len(created["participants"]) == 2
|
||||
assert created["participants"][0]["agent_id"] == "mgr"
|
||||
|
||||
@@ -102,9 +105,9 @@ async def test_update_session_partial_and_participants_replace(store):
|
||||
updated = await svc.update_session(
|
||||
session_id=s["id"],
|
||||
user_id="u1",
|
||||
fields={"title": "新标题", "participants": [{"agent_id": "a2", "role": "seat"}]},
|
||||
fields={"title": "new-title", "participants": [{"agent_id": "a2", "role": "seat"}]},
|
||||
)
|
||||
assert updated["title"] == "新标题"
|
||||
assert updated["title"] == "new-title"
|
||||
assert updated["task_text"] == "orig"
|
||||
assert {p["agent_id"] for p in updated["participants"]} == {"a2"}
|
||||
|
||||
@@ -112,11 +115,12 @@ async def test_update_session_partial_and_participants_replace(store):
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_session_freezes_capability_snapshot(store):
|
||||
svc = _svc(store)
|
||||
s = await svc.create_session(user_id="u1", body={"task_text": "t", "capability_snapshot": {"v": 1}})
|
||||
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
original = s["capability_snapshot"]
|
||||
updated = await svc.update_session(
|
||||
session_id=s["id"], user_id="u1", fields={"capability_snapshot": {"v": 999}}
|
||||
)
|
||||
assert updated["capability_snapshot"] == {"v": 1}
|
||||
assert updated["capability_snapshot"] == original
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -130,7 +134,7 @@ async def test_delete_session_owner_scoped(store):
|
||||
await svc.get_session(session_id=s["id"], user_id="u1")
|
||||
|
||||
|
||||
# ── run lifecycle ──────────────────────────────────────────────────────────
|
||||
# ?? run lifecycle ??????????????????????????????????????????????????????????
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -175,7 +179,7 @@ async def test_cancel_run_marks_session_cancelled(store):
|
||||
assert refreshed["status"] == "cancelled"
|
||||
|
||||
|
||||
# ── events: monotonic seq + concurrent appends (MAF-01-06) ────────────────
|
||||
# ?? events: monotonic seq + concurrent appends (MAF-01-06) ????????????????
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -193,8 +197,8 @@ async def test_append_event_monotonic_seq(store):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_appends_produce_dense_seq_set(store):
|
||||
"""Many parallel appends must produce a dense 1..N seq set — no gaps, no
|
||||
collisions — exercising the MAX+1 retry on the (run_id, seq) unique
|
||||
"""Many parallel appends must produce a dense 1..N seq set ??no gaps, no
|
||||
collisions ??exercising the MAX+1 retry on the (run_id, seq) unique
|
||||
constraint (MAF-01-06)."""
|
||||
svc = _svc(store)
|
||||
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
@@ -218,7 +222,7 @@ async def test_concurrent_appends_produce_dense_seq_set(store):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_after_seq_pull(store):
|
||||
"""断线补拉: events with seq > after_seq in ascending order."""
|
||||
"""????: events with seq > after_seq in ascending order."""
|
||||
svc = _svc(store)
|
||||
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=s["id"], user_id="u1")
|
||||
@@ -229,7 +233,7 @@ async def test_list_events_after_seq_pull(store):
|
||||
assert latest == 5
|
||||
|
||||
|
||||
# ── actions: idempotency key ──────────────────────────────────────────────
|
||||
# ?? actions: idempotency key ??????????????????????????????????????????????
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -264,7 +268,7 @@ async def test_action_idempotency_key_unique(store):
|
||||
assert found["tool_name"] == "update_task"
|
||||
|
||||
|
||||
# ── approvals: decision transition ────────────────────────────────────────
|
||||
# ?? approvals: decision transition ????????????????????????????????????????
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -273,7 +277,7 @@ async def test_approval_decision_and_already_decided_guard(store):
|
||||
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=s["id"], user_id="u1")
|
||||
appr = await store.approvals.create_approval(
|
||||
{"session_id": s["id"], "run_id": run["id"], "summary": "高风险写入", "status": "pending"}
|
||||
{"session_id": s["id"], "run_id": run["id"], "summary": "approve write", "status": "pending"}
|
||||
)
|
||||
decided = await svc.decide_approval(approval_id=appr["id"], decision="approved", user_id="u1")
|
||||
assert decided["status"] == "approved"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""P1 concurrency + single-transaction launch tests for MAF roundtable.
|
||||
|
||||
Covers:
|
||||
- **Concurrent run launch**: ``asyncio.gather`` of ≥8 ``create_run`` calls must
|
||||
- **Concurrent run launch**: ``asyncio.gather`` of ≥ ``create_run`` calls must
|
||||
yield exactly one active run; the rest get ``MafConflictError``.
|
||||
- **Single-transaction atomicity**: flush / pre-commit failures roll back fully —
|
||||
session stays at its prior status, no orphan run, no residual ``starting``.
|
||||
- **Success shape**: session ``running``, unique run ``pending``.
|
||||
- **Cancel then re-launch** works.
|
||||
- **Concurrent approval decision**: parallel approve + reject → exactly one winner.
|
||||
- **Concurrent approval decision**: parallel approve + reject →exactly one winner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,7 +21,6 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||
|
||||
from app.maf_roundtable.service import (
|
||||
MafConflictError,
|
||||
MafRoundtableService,
|
||||
)
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
@@ -40,7 +39,9 @@ async def store(tmp_path):
|
||||
|
||||
|
||||
def _svc(store, *, enabled=True, admin_diagnostic_access=True):
|
||||
return MafRoundtableService(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
|
||||
from maf_test_helpers import make_maf_service
|
||||
|
||||
return make_maf_service(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
|
||||
|
||||
|
||||
# ── P1: concurrent run launch (single-tx CAS) ─────────────────────────────
|
||||
@@ -48,7 +49,7 @@ def _svc(store, *, enabled=True, admin_diagnostic_access=True):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_create_run_only_one_wins(store):
|
||||
"""8 parallel ``create_run`` calls → exactly one active run, 7 conflicts."""
|
||||
"""8 parallel ``create_run`` calls →exactly one active run, 7 conflicts."""
|
||||
svc = _svc(store)
|
||||
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
|
||||
@@ -217,7 +218,7 @@ async def test_approval_decision_cannot_be_overwritten(store):
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_run_flush_failure_rolls_back_run_and_session(store):
|
||||
"""If the cancel transaction's flush fails, run stays pending and session
|
||||
stays running — nothing is half-committed."""
|
||||
stays running —nothing is half-committed."""
|
||||
svc = _svc(store)
|
||||
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=s["id"], user_id="u1")
|
||||
@@ -275,7 +276,7 @@ async def test_cancel_terminal_run_does_not_change_run(store):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_cancel_same_run_consistent(store):
|
||||
"""Two concurrent cancels of the same run → run and session both cancelled,
|
||||
"""Two concurrent cancels of the same run →run and session both cancelled,
|
||||
no exceptions, no inconsistent state."""
|
||||
svc = _svc(store)
|
||||
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""P2 deployment-config enum / range validation for MafRoundtableConfig.
|
||||
|
||||
Config and API schemas share ``MafMode`` / ``MafWritePolicy`` from
|
||||
``deerflow.config.maf_roundtable_config`` — invalid deployment values must fail
|
||||
``deerflow.config.maf_roundtable_config`` —invalid deployment values must fail
|
||||
at load time, not silently become runtime bugs.
|
||||
"""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Covers the full HTTP status code matrix:
|
||||
- **503 Service Unavailable**: feature disabled, mock events disabled
|
||||
|
||||
Also verifies that identity comes from ``request.state.user`` (stamped by auth
|
||||
middleware), not from body parameters — a client cannot forge ``user_id`` in the
|
||||
middleware), not from body parameters —a client cannot forge ``user_id`` in the
|
||||
request body to impersonate another user.
|
||||
"""
|
||||
|
||||
@@ -19,10 +19,10 @@ import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI, Request
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from maf_test_helpers import make_maf_service
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.gateway.routers.maf_roundtable import router as maf_router
|
||||
from app.maf_roundtable import MafRoundtableService
|
||||
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
@@ -48,14 +48,15 @@ async def app_and_client(tmp_path):
|
||||
store = make_maf_roundtable_store(sf)
|
||||
|
||||
cfg = MafRoundtableConfig(enabled=True, admin_diagnostic_access=True)
|
||||
svc = MafRoundtableService(store, enabled=True, admin_diagnostic_access=True, config=cfg)
|
||||
svc = make_maf_service(store, enabled=True, admin_diagnostic_access=True, config=cfg)
|
||||
|
||||
app = FastAPI()
|
||||
app.state.maf_roundtable_service = svc
|
||||
app.state.maf_auto_dispatch = False
|
||||
app.include_router(maf_router)
|
||||
|
||||
# Fake auth middleware: reads X-Test-User / X-Test-Role headers and stamps
|
||||
# request.state.user. No header → 401 (unauthenticated).
|
||||
# request.state.user. No header →401 (unauthenticated).
|
||||
@app.middleware("http")
|
||||
async def fake_auth(request: Request, call_next):
|
||||
user_id = request.headers.get("X-Test-User")
|
||||
@@ -99,7 +100,7 @@ async def test_403_admin_cannot_update_other_user_session(app_and_client):
|
||||
assert resp.status_code == 200
|
||||
session_id = resp.json()["id"]
|
||||
|
||||
# admin tries to update it → 403
|
||||
# admin tries to update it →403
|
||||
resp = await client.put(
|
||||
f"/api/maf-roundtable/sessions/{session_id}",
|
||||
json={"title": "hijack"},
|
||||
@@ -146,7 +147,7 @@ async def test_404_other_user_session_not_leaked(app_and_client):
|
||||
headers={"X-Test-User": "alice"},
|
||||
)
|
||||
session_id = resp.json()["id"]
|
||||
# bob (non-admin) tries to read alice's session → 404 (not 403, so existence is not leaked)
|
||||
# bob (non-admin) tries to read alice's session →404 (not 403, so existence is not leaked)
|
||||
resp = await client.get(
|
||||
f"/api/maf-roundtable/sessions/{session_id}",
|
||||
headers={"X-Test-User": "bob"},
|
||||
@@ -173,7 +174,7 @@ async def test_409_second_run_launch_conflicts(app_and_client):
|
||||
headers={"X-Test-User": "alice"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Second run launch → 409
|
||||
# Second run launch →409
|
||||
resp = await client.post(
|
||||
f"/api/maf-roundtable/sessions/{session_id}/runs",
|
||||
json={},
|
||||
@@ -208,7 +209,7 @@ async def test_409_approval_already_decided(app_and_client):
|
||||
headers={"X-Test-User": "alice"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Second decision → 409
|
||||
# Second decision →409
|
||||
resp = await client.post(
|
||||
f"/api/maf-roundtable/approvals/{appr['id']}/decision",
|
||||
json={"decision": "rejected"},
|
||||
@@ -268,10 +269,11 @@ async def test_503_feature_disabled(tmp_path):
|
||||
store = make_maf_roundtable_store(sf)
|
||||
|
||||
cfg = MafRoundtableConfig(enabled=False)
|
||||
svc = MafRoundtableService(store, enabled=False, admin_diagnostic_access=True, config=cfg)
|
||||
svc = make_maf_service(store, enabled=False, admin_diagnostic_access=True, config=cfg)
|
||||
|
||||
app = FastAPI()
|
||||
app.state.maf_roundtable_service = svc
|
||||
app.state.maf_auto_dispatch = False
|
||||
app.include_router(maf_router)
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -289,7 +291,7 @@ async def test_503_feature_disabled(tmp_path):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_post_events_endpoint_removed(app_and_client):
|
||||
"""POST /runs/{id}/events is deliberately absent — no public mock write path."""
|
||||
"""POST /runs/{id}/events is deliberately absent —no public mock write path."""
|
||||
_, client, _ = app_and_client
|
||||
resp = await client.post(
|
||||
"/api/maf-roundtable/sessions",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
These tests focus on the security boundary: a non-owner cannot read or mutate
|
||||
another user's session / run / action / approval, and the six MAF tables have
|
||||
no foreign-key relationship to the legacy ``roundtable_*`` tables (the
|
||||
experiment is fully isolated — see implementation plan §2.3 "不改原有的工程定义").
|
||||
experiment is fully isolated —see implementation plan §2.3 "不改原有的工程定义).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,15 +13,17 @@ import pytest_asyncio
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.maf_roundtable.service import MafNotFoundError, MafRoundtableService
|
||||
from app.maf_roundtable.service import MafNotFoundError
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableActionRow,
|
||||
MafRoundtableActionSecretRow,
|
||||
MafRoundtableApprovalRow,
|
||||
MafRoundtableEventRow,
|
||||
MafRoundtableParticipantRow,
|
||||
MafRoundtableRunRow,
|
||||
MafRoundtableSeatRunRow,
|
||||
MafRoundtableSessionRow,
|
||||
)
|
||||
|
||||
@@ -39,7 +41,9 @@ async def store(tmp_path):
|
||||
|
||||
|
||||
def _svc(store, *, enabled=True, admin_diagnostic_access=True):
|
||||
return MafRoundtableService(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
|
||||
from maf_test_helpers import make_maf_service
|
||||
|
||||
return make_maf_service(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
|
||||
|
||||
|
||||
# ── user isolation: non-owner cannot read ────────────────────────────────
|
||||
@@ -146,7 +150,7 @@ async def test_admin_diagnostic_access_off_keeps_owner_scope(store):
|
||||
@pytest.mark.asyncio
|
||||
async def test_maf_tables_isolated_from_legacy_roundtable(store):
|
||||
"""The six MAF tables must exist and none of their FKs may target a legacy
|
||||
roundtable_* table (MAF-01-02 / 退出标准: MAF 表和旧 roundtable 表完全无关联外键)."""
|
||||
roundtable_* table (MAF-01-02 / 退出标净 MAF 表和旀roundtable 表完全无关联外键)."""
|
||||
_, engine = store
|
||||
async with engine.connect() as conn:
|
||||
table_names = await conn.run_sync(lambda sync_conn: set(inspect(sync_conn).get_table_names()))
|
||||
@@ -159,6 +163,8 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
|
||||
"maf_roundtable_events",
|
||||
"maf_roundtable_actions",
|
||||
"maf_roundtable_approvals",
|
||||
"maf_roundtable_seat_runs",
|
||||
"maf_roundtable_action_secrets",
|
||||
}
|
||||
|
||||
# Every FK defined on an MAF model must reference another maf_roundtable_* table.
|
||||
@@ -168,7 +174,9 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
|
||||
MafRoundtableRunRow,
|
||||
MafRoundtableEventRow,
|
||||
MafRoundtableActionRow,
|
||||
MafRoundtableActionSecretRow,
|
||||
MafRoundtableApprovalRow,
|
||||
MafRoundtableSeatRunRow,
|
||||
]
|
||||
for model in maf_models:
|
||||
for fk in model.__table__.foreign_keys:
|
||||
@@ -197,7 +205,7 @@ async def test_disabled_feature_keeps_reads_open(store):
|
||||
svc_on = _svc(store[0], enabled=True)
|
||||
s = await svc_on.create_session(user_id="u1", body={"task_text": "t"})
|
||||
svc_off = _svc(store[0], enabled=False)
|
||||
# Reads do not raise — admin can review history while the feature is off.
|
||||
# Reads do not raise —admin can review history while the feature is off.
|
||||
fetched = await svc_off.get_session(session_id=s["id"], user_id="u1")
|
||||
assert fetched["id"] == s["id"]
|
||||
# But a write through the disabled service still raises.
|
||||
|
||||
@@ -0,0 +1,955 @@
|
||||
"""Acceptance tests for the WriteGuard middleware + MAF bridge (Task 1).
|
||||
|
||||
Verifies the core security invariants:
|
||||
1. Write tools stay visible to the model (NOT excluded) but the real tool
|
||||
executes **0 times** before approval —the middleware intercepts and
|
||||
returns an ``approval_required`` ToolMessage instead.
|
||||
2. After approval, the ToolExecutor runs the tool **exactly once** with the
|
||||
approved original params (model deviation is ignored).
|
||||
3. Two concurrent ``resume()``/occupy calls →only one executor wins.
|
||||
4. The single-transaction action bundle rolls back if any step fails.
|
||||
5. ``auto`` policy →write tools pass through (no interception).
|
||||
6. Legacy runs (no ``maf_run_id`` in context) →middleware is a no-op.
|
||||
|
||||
These tests exercise the middleware + interceptor in isolation (no LLM, no
|
||||
DeerFlow runtime) so the security properties are deterministic and fast.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from maf_test_helpers import make_maf_service
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.maf_roundtable.action_coordinator import ActionCoordinator
|
||||
from app.maf_roundtable.write_guard_bridge import (
|
||||
MafWriteGuardInterceptor,
|
||||
MafWriteGuardPolicy,
|
||||
make_interceptor,
|
||||
make_policy,
|
||||
register_write_guard_service,
|
||||
)
|
||||
from deerflow.agents.middlewares.write_guard_middleware import WriteGuardMiddleware
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.persistence.maf_roundtable.model import Base
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store():
|
||||
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
s = make_maf_roundtable_store(sf)
|
||||
assert s is not None
|
||||
yield s
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def svc(store):
|
||||
|
||||
return make_maf_service(store, enabled=True, admin_diagnostic_access=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _wire_bridge(svc):
|
||||
register_write_guard_service(svc)
|
||||
|
||||
|
||||
def _capability_snapshot(*, write_policy: str = "confirm_all_writes") -> dict[str, Any]:
|
||||
"""A snapshot with one read tool and one write tool."""
|
||||
return {
|
||||
"tools": [
|
||||
{"name": "read_data", "side_effect": "read", "risk_level": "R0"},
|
||||
{"name": "update_task", "side_effect": "write", "risk_level": "R3"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _runtime_context(*, active: bool = True, write_policy: str = "confirm_all_writes") -> dict[str, Any]:
|
||||
"""A run context mimicking what runtime_client.start_agent_run injects."""
|
||||
ctx: dict[str, Any] = {
|
||||
"agent_id": "seat-1",
|
||||
"turn_index": 1,
|
||||
"write_policy": write_policy,
|
||||
"capability_snapshot": _capability_snapshot(write_policy=write_policy),
|
||||
}
|
||||
if active:
|
||||
ctx["maf_run_id"] = "run-1"
|
||||
ctx["maf_session_id"] = "sess-1"
|
||||
ctx["maf_user_id"] = "u1"
|
||||
return ctx
|
||||
|
||||
|
||||
def _tool_call_request(
|
||||
name: str,
|
||||
args: dict[str, Any] | None = None,
|
||||
*,
|
||||
run_context: dict[str, Any] | None = None,
|
||||
):
|
||||
"""Build a ToolCallRequest-like object with the fields the middleware reads."""
|
||||
runtime = MagicMock()
|
||||
runtime.context = run_context or {}
|
||||
req = MagicMock()
|
||||
req.tool_call = {"name": name, "args": args or {}, "id": f"call-{name}"}
|
||||
req.runtime = runtime
|
||||
return req
|
||||
|
||||
|
||||
def _real_tool_handler(call_counter: dict[str, int]) -> Callable[[Any], Awaitable[Any]]:
|
||||
"""An async handler that records each invocation —proves the real tool ran."""
|
||||
async def handler(request):
|
||||
name = request.tool_call["name"]
|
||||
call_counter[name] = call_counter.get(name, 0) + 1
|
||||
msg = MagicMock()
|
||||
msg.content = f"executed {name}"
|
||||
return msg
|
||||
return handler
|
||||
|
||||
|
||||
# ── 1. Real tool executes 0 times before approval ──────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_tool_never_executes_before_approval(svc, store):
|
||||
"""The real tool handler is NOT called for a write tool awaiting approval.
|
||||
|
||||
The middleware returns an approval_required ToolMessage and short-circuits.
|
||||
"""
|
||||
call_counter: dict[str, int] = {}
|
||||
policy = MafWriteGuardPolicy()
|
||||
interceptor = MafWriteGuardInterceptor()
|
||||
mw = WriteGuardMiddleware(policy=policy, interceptor=interceptor)
|
||||
|
||||
# Need a session + run for the bundle to reference.
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
ctx = _runtime_context()
|
||||
ctx["maf_session_id"] = session["id"]
|
||||
|
||||
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
|
||||
handler = _real_tool_handler(call_counter)
|
||||
|
||||
result = await mw.awrap_tool_call(request, handler)
|
||||
|
||||
# Real tool never ran.
|
||||
assert call_counter.get("update_task", 0) == 0
|
||||
# An approval_required message was returned.
|
||||
envelope = json.loads(result.content)
|
||||
assert envelope["status"] == "approval_required"
|
||||
assert envelope["action_id"]
|
||||
assert envelope["tool_name"] == "update_task"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_tool_passes_through(svc, store):
|
||||
"""A read tool is NOT intercepted —it executes normally even in an active run."""
|
||||
call_counter: dict[str, int] = {}
|
||||
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
|
||||
request = _tool_call_request("read_data", {"q": "x"}, run_context=_runtime_context())
|
||||
await mw.awrap_tool_call(request, _real_tool_handler(call_counter))
|
||||
assert call_counter.get("read_data", 0) == 1
|
||||
|
||||
|
||||
# ── 5. auto policy →no interception ────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_policy_write_tool_executes(svc, store):
|
||||
"""Under ``auto`` policy, a write tool passes through without interception."""
|
||||
call_counter: dict[str, int] = {}
|
||||
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
|
||||
ctx = _runtime_context(write_policy="auto")
|
||||
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
|
||||
await mw.awrap_tool_call(request, _real_tool_handler(call_counter))
|
||||
assert call_counter.get("update_task", 0) == 1
|
||||
|
||||
|
||||
# ── 6. Legacy runs (no maf_run_id) →no-op ──────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_run_noop():
|
||||
"""Without ``maf_run_id`` in the context, the middleware passes through."""
|
||||
call_counter: dict[str, int] = {}
|
||||
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
|
||||
request = _tool_call_request("update_task", {"title": "A"}, run_context=_runtime_context(active=False))
|
||||
await mw.awrap_tool_call(request, _real_tool_handler(call_counter))
|
||||
assert call_counter.get("update_task", 0) == 1
|
||||
|
||||
|
||||
# ── 3. At-most-once: concurrent occupy →only one winner ────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_occupy_only_one_winner(svc, store):
|
||||
"""Two concurrent claim_for_execution calls →exactly one returns "claimed";
|
||||
the loser reflects the real state (in_progress), not a blanket skip (P0-3)."""
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
# Create an action in 'approved' state directly.
|
||||
action = await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
|
||||
"tool_name": "update_task", "idempotency_key": "k1",
|
||||
"risk_level": "R3", "status": "approved",
|
||||
})
|
||||
|
||||
coord = ActionCoordinator(svc, user_id="u1")
|
||||
# Two concurrent occupies.
|
||||
r1, r2 = await asyncio.gather(
|
||||
coord.occupy_for_execution(action["id"]),
|
||||
coord.occupy_for_execution(action["id"]),
|
||||
)
|
||||
claimed = [r for r in (r1, r2) if r == "claimed"]
|
||||
assert len(claimed) == 1, f"expected exactly one 'claimed', got {(r1, r2)}"
|
||||
# The loser sees the action is already being executed (real state), so the
|
||||
# parent run can stay running instead of treating it as a no-op skip.
|
||||
losers = [r for r in (r1, r2) if r != "claimed"]
|
||||
assert losers == ["in_progress"], f"loser must reflect in_progress, got {losers}"
|
||||
|
||||
|
||||
# ── 3b. record_terminal_result is state-machine constrained ─────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_result_only_from_running(svc, store):
|
||||
"""record_terminal_result only transitions running →terminal."""
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
# Action in 'running' state.
|
||||
action = await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
|
||||
"tool_name": "update_task", "idempotency_key": "k2",
|
||||
"risk_level": "R3", "status": "running",
|
||||
})
|
||||
|
||||
# First record →succeeds, returns the updated row.
|
||||
res1 = await store.actions.record_terminal_result(action["id"], status="succeeded", result={"ok": True})
|
||||
assert res1 is not None
|
||||
assert res1["status"] == "succeeded"
|
||||
|
||||
# Second record (already terminal) →no-op, returns None, does NOT overwrite.
|
||||
res2 = await store.actions.record_terminal_result(action["id"], status="failed", result={"ok": False})
|
||||
assert res2 is None
|
||||
final = await store.actions.get_action(action["id"])
|
||||
assert final["status"] == "succeeded" # not overwritten
|
||||
|
||||
|
||||
# ── 2. ToolExecutor runs once with approved params (direct tool execution) ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_executor_runs_once_with_approved_params(svc, store):
|
||||
"""The ToolExecutor calls execute_tool_directly exactly once with the
|
||||
approved params, and records the terminal result. The LLM is never involved.
|
||||
|
||||
P0-2 fix: raw params never enter a prompt —they go directly to the tool
|
||||
function via execute_tool_directly(). The adapter.run_seat_turn() is NOT
|
||||
called (the LLM is bypassed).
|
||||
"""
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
# Seed an approved action with encrypted params {title: "A"}.
|
||||
from app.maf_roundtable.action_coordinator import encrypt_params
|
||||
coord = ActionCoordinator(svc, user_id="u1")
|
||||
enc = encrypt_params({"title": "A"})
|
||||
action = await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
|
||||
"tool_name": "update_task", "idempotency_key": "k3",
|
||||
"risk_level": "R3", "status": "approved",
|
||||
})
|
||||
await store.action_secrets.create(action_id=action["id"], arguments_encrypted=enc)
|
||||
|
||||
# Mock client.execute_tool_directly —this is what ToolExecutor now calls.
|
||||
original_params_received = None
|
||||
exec_count = {"n": 0}
|
||||
async def _mock_execute_tool(*, thread_id, agent_id, tool_name, params, principal, **kwargs):
|
||||
nonlocal original_params_received
|
||||
original_params_received = params
|
||||
exec_count["n"] += 1
|
||||
return {"status": "succeeded", "result": {"output": f"called {tool_name}"}}
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.execute_tool_directly = _mock_execute_tool
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter._client = mock_client
|
||||
|
||||
from app.maf_roundtable.tool_executor import ToolExecutor
|
||||
executor = ToolExecutor(adapter=mock_adapter, action_coordinator=coord, user_id="u1")
|
||||
# P0-2: no approved_params kwarg —the executor recovers the params itself
|
||||
# from the encrypted secret stored above.
|
||||
outcome = await executor.execute(
|
||||
action_id=action["id"], session_id=session["id"], run_id=run["id"],
|
||||
agent_id="a1", thread_id="t1",
|
||||
approved_tool="update_task",
|
||||
)
|
||||
assert outcome["status"] == "succeeded"
|
||||
# The tool received the exact approved params (title=A, not something the
|
||||
# LLM modified).
|
||||
assert original_params_received == {"title": "A"}
|
||||
|
||||
# The action is now terminal.
|
||||
final = await store.actions.get_action(action["id"])
|
||||
assert final["status"] == "succeeded"
|
||||
|
||||
# A second execution attempt reflects the terminal state (P0-3: no blanket
|
||||
# "skipped") and —critically —does NOT re-execute the tool (at-most-once).
|
||||
outcome2 = await executor.execute(
|
||||
action_id=action["id"], session_id=session["id"], run_id=run["id"],
|
||||
agent_id="a1", thread_id="t1",
|
||||
approved_tool="update_task",
|
||||
)
|
||||
assert outcome2["status"] == "succeeded" # reflects the real terminal status
|
||||
assert outcome2.get("action") is not None
|
||||
assert exec_count["n"] == 1, "tool must execute exactly once (at-most-once)"
|
||||
|
||||
|
||||
# ── 2b. P0-2: secret unavailable ⇀hard fail, zero tool executions ─────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_missing_secret_fails_without_calling_tool(svc, store):
|
||||
"""P0-2: an approved action whose secret row is missing must FAIL with the
|
||||
tool executing **0 times** —never fall back to empty params."""
|
||||
from app.maf_roundtable.action_coordinator import encrypt_params
|
||||
from app.maf_roundtable.tool_executor import ToolExecutor
|
||||
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
coord = ActionCoordinator(svc, user_id="u1")
|
||||
|
||||
action = await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
|
||||
"tool_name": "update_task", "idempotency_key": "k-missing",
|
||||
"risk_level": "R3", "status": "approved",
|
||||
})
|
||||
# Seed then DELETE the secret so the row is gone.
|
||||
await store.action_secrets.create(
|
||||
action_id=action["id"], arguments_encrypted=encrypt_params({"title": "A"})
|
||||
)
|
||||
await store.action_secrets.delete_by_action(action["id"])
|
||||
assert await store.action_secrets.get_by_action(action["id"]) is None
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
async def _boom(*, thread_id, agent_id, tool_name, params, principal):
|
||||
calls["n"] += 1
|
||||
return {"status": "succeeded", "result": {}}
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.execute_tool_directly = _boom
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter._client = mock_client
|
||||
|
||||
executor = ToolExecutor(adapter=mock_adapter, action_coordinator=coord, user_id="u1")
|
||||
outcome = await executor.execute(
|
||||
action_id=action["id"], session_id=session["id"], run_id=run["id"],
|
||||
agent_id="a1", thread_id="t1", approved_tool="update_task",
|
||||
)
|
||||
|
||||
assert calls["n"] == 0, "tool must NOT run when the secret is missing"
|
||||
assert outcome["status"] == "failed"
|
||||
final = await store.actions.get_action(action["id"])
|
||||
assert final["status"] == "failed"
|
||||
assert final["status"] != "succeeded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_corrupt_secret_fails_without_calling_tool(svc, store):
|
||||
"""P0-2: an undecryptable secret (wrong key / garbage) must FAIL with the
|
||||
tool executing **0 times** —not run with empty params."""
|
||||
from app.maf_roundtable.tool_executor import ToolExecutor
|
||||
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
coord = ActionCoordinator(svc, user_id="u1")
|
||||
|
||||
action = await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
|
||||
"tool_name": "update_task", "idempotency_key": "k-corrupt",
|
||||
"risk_level": "R3", "status": "approved",
|
||||
})
|
||||
# Garbage ciphertext that Fernet cannot decrypt.
|
||||
await store.action_secrets.create(
|
||||
action_id=action["id"], arguments_encrypted="not-a-valid-fernet-token"
|
||||
)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
async def _boom(*, thread_id, agent_id, tool_name, params, principal):
|
||||
calls["n"] += 1
|
||||
return {"status": "succeeded", "result": {}}
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.execute_tool_directly = _boom
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter._client = mock_client
|
||||
|
||||
executor = ToolExecutor(adapter=mock_adapter, action_coordinator=coord, user_id="u1")
|
||||
outcome = await executor.execute(
|
||||
action_id=action["id"], session_id=session["id"], run_id=run["id"],
|
||||
agent_id="a1", thread_id="t1", approved_tool="update_task",
|
||||
)
|
||||
|
||||
assert calls["n"] == 0, "tool must NOT run when the secret cannot be decrypted"
|
||||
assert outcome["status"] == "failed"
|
||||
final = await store.actions.get_action(action["id"])
|
||||
assert final["status"] == "failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_approved_action_params_raises_with_reasons(svc, store):
|
||||
"""P0-2: get_approved_action_params raises ActionSecretUnavailableError
|
||||
(never returns None) with a distinct reason per failure mode."""
|
||||
from app.maf_roundtable.action_coordinator import (
|
||||
ActionSecretUnavailableError,
|
||||
encrypt_params,
|
||||
)
|
||||
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
coord = ActionCoordinator(svc, user_id="u1")
|
||||
|
||||
# missing
|
||||
a_missing = await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
|
||||
"tool_name": "update_task", "idempotency_key": "r-missing",
|
||||
"risk_level": "R3", "status": "approved",
|
||||
})
|
||||
with pytest.raises(ActionSecretUnavailableError) as ei:
|
||||
await coord.get_approved_action_params(a_missing["id"])
|
||||
assert ei.value.reason == "missing"
|
||||
|
||||
# decrypt_failed
|
||||
a_corrupt = await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
|
||||
"tool_name": "update_task", "idempotency_key": "r-corrupt",
|
||||
"risk_level": "R3", "status": "approved",
|
||||
})
|
||||
await store.action_secrets.create(action_id=a_corrupt["id"], arguments_encrypted="garbage")
|
||||
with pytest.raises(ActionSecretUnavailableError) as ei:
|
||||
await coord.get_approved_action_params(a_corrupt["id"])
|
||||
assert ei.value.reason == "decrypt_failed"
|
||||
|
||||
# invalid_type —valid Fernet token whose plaintext is a JSON list, not object
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.maf_roundtable.action_coordinator import _get_fernet_key
|
||||
|
||||
list_token = Fernet(_get_fernet_key()).encrypt(b"[1, 2, 3]").decode()
|
||||
a_list = await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
|
||||
"tool_name": "update_task", "idempotency_key": "r-list",
|
||||
"risk_level": "R3", "status": "approved",
|
||||
})
|
||||
await store.action_secrets.create(action_id=a_list["id"], arguments_encrypted=list_token)
|
||||
with pytest.raises(ActionSecretUnavailableError) as ei:
|
||||
await coord.get_approved_action_params(a_list["id"])
|
||||
assert ei.value.reason == "invalid_type"
|
||||
|
||||
# happy path still returns the dict
|
||||
a_ok = await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
|
||||
"tool_name": "update_task", "idempotency_key": "r-ok",
|
||||
"risk_level": "R3", "status": "approved",
|
||||
})
|
||||
await store.action_secrets.create(
|
||||
action_id=a_ok["id"], arguments_encrypted=encrypt_params({"title": "A"})
|
||||
)
|
||||
assert await coord.get_approved_action_params(a_ok["id"]) == {"title": "A"}
|
||||
|
||||
|
||||
# ── 4. Single-transaction bundle atomicity + idempotency ──────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_bundle_writes_all_four_atomically(svc, store):
|
||||
"""A successful bundle writes action + secret + approval + audit event
|
||||
together —none can exist without the others (single transaction)."""
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
coord = ActionCoordinator(svc, user_id="u1")
|
||||
created = await coord.intercept_write_tool_calls(
|
||||
run_id=run["id"], session_id=session["id"], agent_id="a1", turn_index=1,
|
||||
tool_calls=[{"name": "update_task", "id": "c1", "args": {"title": "A"}}],
|
||||
capability_snapshot=_capability_snapshot(), write_policy="confirm_all_writes",
|
||||
)
|
||||
assert len(created) == 1
|
||||
action_id = created[0]["id"]
|
||||
|
||||
# All four rows exist (atomic success).
|
||||
action = await store.actions.get_action(action_id)
|
||||
assert action is not None and action["status"] == "waiting_approval"
|
||||
assert action["approval_id"], "approval must be linked"
|
||||
|
||||
secret = await store.action_secrets.get_by_action(action_id)
|
||||
assert secret is not None, "encrypted secret must exist"
|
||||
|
||||
approval = await store.approvals.get_approval(action["approval_id"])
|
||||
assert approval is not None and approval["status"] == "pending"
|
||||
|
||||
events = await store.events.list_events(run_id=run["id"])
|
||||
assert any(e["event_type"] == "action.waiting_approval" for e in events), \
|
||||
"audit event must exist"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_bundle_idempotent_on_duplicate(svc, store):
|
||||
"""A duplicate (same idempotency_key) submission re-reads and returns the
|
||||
existing action instead of creating a second one —retries never fail the run."""
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
coord = ActionCoordinator(svc, user_id="u1")
|
||||
args = [dict(run_id=run["id"], session_id=session["id"], agent_id="a1", turn_index=1,
|
||||
tool_calls=[{"name": "update_task", "id": "c1", "args": {"title": "A"}}],
|
||||
capability_snapshot=_capability_snapshot(), write_policy="confirm_all_writes")]
|
||||
|
||||
first = await coord.intercept_write_tool_calls(**args[0])
|
||||
second = await coord.intercept_write_tool_calls(**args[0])
|
||||
|
||||
assert first[0]["id"] == second[0]["id"], "duplicate must return the same action id"
|
||||
actions = await store.actions.list_actions(run_id=run["id"])
|
||||
assert len(actions) == 1, f"expected 1 action, got {len(actions)}"
|
||||
|
||||
|
||||
# ── Factory resolution ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_factories_return_singletons():
|
||||
"""make_policy/make_interceptor return the bridge objects."""
|
||||
p = make_policy()
|
||||
i = make_interceptor()
|
||||
assert isinstance(p, MafWriteGuardPolicy)
|
||||
assert isinstance(i, MafWriteGuardInterceptor)
|
||||
|
||||
|
||||
# ── P0-3: Strengthened WriteGuard passthrough ───────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approved_passthrough_requires_action_id(svc, store):
|
||||
"""When maf_approved_tool is set but maf_approved_action_id is missing,
|
||||
the WriteGuard blocks the passthrough —tool name matching alone is
|
||||
insufficient (P0-3 fix)."""
|
||||
call_counter: dict[str, int] = {}
|
||||
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
|
||||
ctx = _runtime_context()
|
||||
ctx["maf_approved_tool"] = "update_task"
|
||||
# Deliberately omit maf_approved_action_id —should be blocked.
|
||||
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
|
||||
handler = _real_tool_handler(call_counter)
|
||||
|
||||
result = await mw.awrap_tool_call(request, handler)
|
||||
|
||||
# Real tool should NOT execute —the passthrough requires action_id.
|
||||
assert call_counter.get("update_task", 0) == 0
|
||||
# Should return an approval_required or error message, not pass through.
|
||||
envelope = json.loads(result.content)
|
||||
assert envelope["status"] in ("approval_required", "intercept_failed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approved_passthrough_allows_with_action_id(svc, store):
|
||||
"""When both maf_approved_tool and maf_approved_action_id are set AND the
|
||||
bound action validates (status running + run/session/agent + tool match),
|
||||
the WriteGuard allows the passthrough (defense-in-depth)."""
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
# Seed the bound action in the state the executor leaves it (running) with
|
||||
# ownership matching the run context —validate_approved_action queries it.
|
||||
await store.actions.create_action({
|
||||
"id": "action-1", "session_id": session["id"], "run_id": "run-1",
|
||||
"agent_id": "seat-1", "tool_name": "update_task",
|
||||
"idempotency_key": "pt-allow", "risk_level": "R3", "status": "running",
|
||||
})
|
||||
call_counter: dict[str, int] = {}
|
||||
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
|
||||
ctx = _runtime_context()
|
||||
ctx["maf_session_id"] = session["id"]
|
||||
ctx["maf_approved_tool"] = "update_task"
|
||||
ctx["maf_approved_action_id"] = "action-1"
|
||||
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
|
||||
handler = _real_tool_handler(call_counter)
|
||||
|
||||
await mw.awrap_tool_call(request, handler)
|
||||
|
||||
# Real tool should execute —passthrough is active and validated.
|
||||
assert call_counter.get("update_task", 0) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approved_passthrough_blocks_when_action_not_running(svc, store):
|
||||
"""P0-4: field presence is not enough —if the bound action is NOT in
|
||||
``running`` status, the async passthrough fails closed (tool does not run)."""
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
# Action exists but is still waiting_approval (not occupied/running).
|
||||
await store.actions.create_action({
|
||||
"id": "action-2", "session_id": session["id"], "run_id": "run-1",
|
||||
"agent_id": "seat-1", "tool_name": "update_task",
|
||||
"idempotency_key": "pt-notrunning", "risk_level": "R3", "status": "waiting_approval",
|
||||
})
|
||||
call_counter: dict[str, int] = {}
|
||||
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
|
||||
ctx = _runtime_context()
|
||||
ctx["maf_session_id"] = session["id"]
|
||||
ctx["maf_approved_tool"] = "update_task"
|
||||
ctx["maf_approved_action_id"] = "action-2"
|
||||
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
|
||||
handler = _real_tool_handler(call_counter)
|
||||
|
||||
result = await mw.awrap_tool_call(request, handler)
|
||||
|
||||
assert call_counter.get("update_task", 0) == 0, "must fail closed when action is not running"
|
||||
envelope = json.loads(result.content)
|
||||
assert envelope["status"] == "approval_invalid"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approved_passthrough_blocks_when_action_ownership_mismatches(svc, store):
|
||||
"""P0-4: a running action belonging to a DIFFERENT run must not validate."""
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
await store.actions.create_action({
|
||||
"id": "action-3", "session_id": session["id"], "run_id": "OTHER-RUN",
|
||||
"agent_id": "seat-1", "tool_name": "update_task",
|
||||
"idempotency_key": "pt-ownership", "risk_level": "R3", "status": "running",
|
||||
})
|
||||
call_counter: dict[str, int] = {}
|
||||
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
|
||||
ctx = _runtime_context() # maf_run_id = "run-1"
|
||||
ctx["maf_session_id"] = session["id"]
|
||||
ctx["maf_approved_tool"] = "update_task"
|
||||
ctx["maf_approved_action_id"] = "action-3"
|
||||
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
|
||||
handler = _real_tool_handler(call_counter)
|
||||
|
||||
result = await mw.awrap_tool_call(request, handler)
|
||||
|
||||
assert call_counter.get("update_task", 0) == 0
|
||||
envelope = json.loads(result.content)
|
||||
assert envelope["status"] == "approval_invalid"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approved_passthrough_blocks_different_tool(svc, store):
|
||||
"""When maf_approved_tool is 'update_task' but the LLM calls 'delete_task',
|
||||
the passthrough blocks —different tool name (defense-in-depth)."""
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
call_counter: dict[str, int] = {}
|
||||
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
|
||||
ctx = _runtime_context()
|
||||
ctx["maf_session_id"] = session["id"]
|
||||
ctx["maf_approved_tool"] = "update_task"
|
||||
ctx["maf_approved_action_id"] = "action-1"
|
||||
request = _tool_call_request("delete_task", {"id": "x"}, run_context=ctx)
|
||||
handler = _real_tool_handler(call_counter)
|
||||
|
||||
result = await mw.awrap_tool_call(request, handler)
|
||||
|
||||
assert call_counter.get("delete_task", 0) == 0
|
||||
envelope = json.loads(result.content)
|
||||
assert envelope["status"] == "approval_required"
|
||||
|
||||
|
||||
# ── P0-4: sync path distinguishes read (pass) vs write (fail-closed) ────────
|
||||
|
||||
|
||||
def _sync_tool_handler(call_counter: dict[str, int]):
|
||||
"""A sync handler that records each invocation —proves the real tool ran."""
|
||||
def handler(request):
|
||||
name = request.tool_call["name"]
|
||||
call_counter[name] = call_counter.get(name, 0) + 1
|
||||
msg = MagicMock()
|
||||
msg.content = f"executed {name}"
|
||||
return msg
|
||||
return handler
|
||||
|
||||
|
||||
def test_sync_read_tool_passes_through(svc, store):
|
||||
"""P0-4: on the SYNC path a read tool is never intercepted —it runs."""
|
||||
call_counter: dict[str, int] = {}
|
||||
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
|
||||
ctx = _runtime_context() # confirm_all_writes; read_data is side_effect=read
|
||||
request = _tool_call_request("read_data", {"q": "x"}, run_context=ctx)
|
||||
handler = _sync_tool_handler(call_counter)
|
||||
|
||||
mw.wrap_tool_call(request, handler)
|
||||
|
||||
assert call_counter.get("read_data", 0) == 1, "read tools must pass the sync path"
|
||||
|
||||
|
||||
def test_sync_write_tool_fails_closed(svc, store):
|
||||
"""P0-4: on the SYNC path a write tool that cannot be async-intercepted
|
||||
fails closed (blocked), it does NOT run unapproved."""
|
||||
call_counter: dict[str, int] = {}
|
||||
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
|
||||
ctx = _runtime_context() # confirm_all_writes; update_task is side_effect=write
|
||||
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
|
||||
handler = _sync_tool_handler(call_counter)
|
||||
|
||||
result = mw.wrap_tool_call(request, handler)
|
||||
|
||||
assert call_counter.get("update_task", 0) == 0, "write tools must fail closed on the sync path"
|
||||
envelope = json.loads(result.content)
|
||||
assert envelope["status"] == "intercept_blocked_sync"
|
||||
|
||||
|
||||
def test_sync_approved_passthrough_runs(svc, store):
|
||||
"""P0-4: an approved deterministic execution (maf_approved_tool bound)
|
||||
passes the sync path without being blocked as a write."""
|
||||
call_counter: dict[str, int] = {}
|
||||
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
|
||||
ctx = _runtime_context()
|
||||
ctx["maf_approved_tool"] = "update_task"
|
||||
ctx["maf_approved_action_id"] = "action-sync"
|
||||
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
|
||||
handler = _sync_tool_handler(call_counter)
|
||||
|
||||
mw.wrap_tool_call(request, handler)
|
||||
|
||||
assert call_counter.get("update_task", 0) == 1, "approved passthrough must run on the sync path"
|
||||
|
||||
|
||||
# ── P0-4: startup prerequisite validation ───────────────────────────────────
|
||||
|
||||
|
||||
def test_validate_maf_prerequisites_requires_write_guard(monkeypatch):
|
||||
"""validate_maf_prerequisites raises when write_guard is disabled."""
|
||||
from app.maf_roundtable.service import (
|
||||
MafConfigurationError,
|
||||
validate_maf_prerequisites,
|
||||
)
|
||||
from deerflow.config.write_guard_config import (
|
||||
load_write_guard_config_from_dict,
|
||||
reset_write_guard_config,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "x" * 32)
|
||||
try:
|
||||
load_write_guard_config_from_dict({"enabled": False})
|
||||
with pytest.raises(MafConfigurationError):
|
||||
validate_maf_prerequisites()
|
||||
finally:
|
||||
reset_write_guard_config()
|
||||
|
||||
|
||||
def test_validate_maf_prerequisites_passes_when_all_met(monkeypatch):
|
||||
"""validate_maf_prerequisites passes when write_guard + provider + key present."""
|
||||
from app.maf_roundtable.service import validate_maf_prerequisites
|
||||
from deerflow.config.write_guard_config import (
|
||||
load_write_guard_config_from_dict,
|
||||
reset_write_guard_config,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "x" * 32)
|
||||
try:
|
||||
load_write_guard_config_from_dict({
|
||||
"enabled": True,
|
||||
"provider": {
|
||||
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
|
||||
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
|
||||
},
|
||||
})
|
||||
validate_maf_prerequisites() # must not raise
|
||||
finally:
|
||||
reset_write_guard_config()
|
||||
|
||||
|
||||
def test_validate_maf_prerequisites_requires_secret_key(monkeypatch):
|
||||
"""validate_maf_prerequisites raises when MAF_ACTION_SECRET_KEY is missing."""
|
||||
from app.maf_roundtable.service import (
|
||||
MafConfigurationError,
|
||||
validate_maf_prerequisites,
|
||||
)
|
||||
from deerflow.config.write_guard_config import (
|
||||
load_write_guard_config_from_dict,
|
||||
reset_write_guard_config,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("MAF_ACTION_SECRET_KEY", raising=False)
|
||||
try:
|
||||
load_write_guard_config_from_dict({
|
||||
"enabled": True,
|
||||
"provider": {
|
||||
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
|
||||
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
|
||||
},
|
||||
})
|
||||
with pytest.raises(MafConfigurationError):
|
||||
validate_maf_prerequisites()
|
||||
finally:
|
||||
reset_write_guard_config()
|
||||
|
||||
|
||||
# ── P1b: SeatExecutor precise pending-approval matching ─────────────────────
|
||||
|
||||
|
||||
class _FakeSeatResponse:
|
||||
def __init__(self, tool_calls, content="ok", error=None, usage=None):
|
||||
self.tool_calls = tool_calls
|
||||
self.content = content
|
||||
self.error = error
|
||||
self.usage = usage or {}
|
||||
|
||||
|
||||
class _FakeSeatAdapter:
|
||||
"""Minimal adapter: returns a canned seat response, exposes a real store."""
|
||||
|
||||
def __init__(self, store, response):
|
||||
from types import SimpleNamespace
|
||||
|
||||
self._service = SimpleNamespace(store=store)
|
||||
self._response = response
|
||||
|
||||
async def run_seat_turn(self, **kwargs):
|
||||
return self._response
|
||||
|
||||
async def _emit_critical(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeSeatCtx:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
async def send_message(self, msg):
|
||||
self.sent.append(msg)
|
||||
|
||||
|
||||
def _seat_state(session_id, run_id):
|
||||
from app.maf_roundtable.workflow_executors import RoundtableState
|
||||
|
||||
state = RoundtableState(
|
||||
session_id=session_id, run_id=run_id, task_text="t", mode="structured",
|
||||
model_name=None, reasoning_effort=None, max_rounds=8,
|
||||
write_policy="confirm_all_writes", manager_agent_id=None,
|
||||
seat_agent_ids=["seat-1"], thread_map={"seat-1": "thread-x"},
|
||||
capability_snapshot=_capability_snapshot(),
|
||||
)
|
||||
state.current_round = 1
|
||||
return state
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seat_matching_scopes_by_turn_index(svc, store):
|
||||
"""P1b: a stale waiting action from an EARLIER turn is NOT mixed into the
|
||||
current turn's pending approval —matching keys on turn_index + tool_call_id."""
|
||||
from app.maf_roundtable.workflow_executors import SeatExecutor
|
||||
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
# Stale action from turn 0 (same agent/run, different turn + call id).
|
||||
stale = await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1",
|
||||
"tool_name": "update_task", "turn_index": 0, "logical_action_id": "old-call",
|
||||
"idempotency_key": "stale", "risk_level": "R3", "status": "waiting_approval",
|
||||
})
|
||||
# Current action from turn 1 matching the response's tool_call id.
|
||||
current = await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1",
|
||||
"tool_name": "update_task", "turn_index": 1, "logical_action_id": "call-1",
|
||||
"idempotency_key": "current", "risk_level": "R3", "status": "waiting_approval",
|
||||
})
|
||||
|
||||
state = _seat_state(session["id"], run["id"])
|
||||
response = _FakeSeatResponse(tool_calls=[{"name": "update_task", "id": "call-1", "args": {}}])
|
||||
executor = SeatExecutor(_FakeSeatAdapter(store, response), state, "seat-1",
|
||||
action_coordinator=ActionCoordinator(svc, user_id="u1"))
|
||||
await executor.handle("msg", _FakeSeatCtx())
|
||||
|
||||
assert len(state.pending_approvals) == 1
|
||||
assert state.pending_approvals[0]["action_id"] == current["id"]
|
||||
assert state.pending_approvals[0]["action_id"] != stale["id"]
|
||||
assert state.pending_approvals[0]["turn_index"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seat_matching_count_mismatch_fails(svc, store):
|
||||
"""P1b: a pending tool call that does not match EXACTLY one waiting action
|
||||
(by tool_call_id) fails the turn —the executor never guesses."""
|
||||
from app.maf_roundtable.workflow_executors import SeatExecutor
|
||||
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
# One waiting action for turn 1, but its logical_action_id != the response's.
|
||||
await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1",
|
||||
"tool_name": "update_task", "turn_index": 1, "logical_action_id": "other-call",
|
||||
"idempotency_key": "mismatch", "risk_level": "R3", "status": "waiting_approval",
|
||||
})
|
||||
|
||||
state = _seat_state(session["id"], run["id"])
|
||||
response = _FakeSeatResponse(tool_calls=[{"name": "update_task", "id": "call-1", "args": {}}])
|
||||
executor = SeatExecutor(_FakeSeatAdapter(store, response), state, "seat-1",
|
||||
action_coordinator=ActionCoordinator(svc, user_id="u1"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="expected exactly 1"):
|
||||
await executor.handle("msg", _FakeSeatCtx())
|
||||
|
||||
|
||||
# ── P0-1: WriteGuard middleware loads from config ────────────────────────────
|
||||
|
||||
|
||||
def test_write_guard_middleware_in_chain_when_enabled():
|
||||
"""When write_guard.enabled is True and provider is configured,
|
||||
build_lead_runtime_middlewares includes WriteGuardMiddleware."""
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import (
|
||||
_build_runtime_middlewares,
|
||||
)
|
||||
from deerflow.config.write_guard_config import (
|
||||
WriteGuardConfig,
|
||||
WriteGuardProviderConfig,
|
||||
load_write_guard_config_from_dict,
|
||||
reset_write_guard_config,
|
||||
)
|
||||
|
||||
try:
|
||||
load_write_guard_config_from_dict({
|
||||
"enabled": True,
|
||||
"provider": {
|
||||
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
|
||||
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
|
||||
},
|
||||
})
|
||||
# Build a minimal AppConfig with write_guard enabled.
|
||||
# Use model_construct to skip full validation (we only need
|
||||
# write_guard and sandbox fields for this middleware test).
|
||||
from deerflow.config import get_app_config
|
||||
cfg = get_app_config().__class__.model_construct(
|
||||
sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
||||
write_guard=WriteGuardConfig(
|
||||
enabled=True,
|
||||
provider=WriteGuardProviderConfig(
|
||||
policy_use="app.maf_roundtable.write_guard_bridge:make_policy",
|
||||
interceptor_use="app.maf_roundtable.write_guard_bridge:make_interceptor",
|
||||
),
|
||||
),
|
||||
)
|
||||
middlewares = _build_runtime_middlewares(
|
||||
app_config=cfg,
|
||||
include_uploads=False,
|
||||
include_dangling_tool_call_patch=False,
|
||||
)
|
||||
from deerflow.agents.middlewares.write_guard_middleware import WriteGuardMiddleware
|
||||
assert any(isinstance(mw, WriteGuardMiddleware) for mw in middlewares), (
|
||||
"WriteGuardMiddleware should be in the chain when write_guard is enabled"
|
||||
)
|
||||
finally:
|
||||
reset_write_guard_config()
|
||||
Reference in new issue
Block a user