Files
董宏凯andCursor f82363d81f feat(maf): Phase 5 - action coordinator, approval flow, frontend MAF roundtable pages
- Backend: action_coordinator, capability_snapshot, event_normalizer, tool_executor updates
- Backend: approved_tool_executor with write guard, action leases, migration script
- Frontend: maf-roundtable module (API client, event timeline, approval cards, fact flow panel)
- Frontend: MafRoutes, MafSessionsPage, MafRunDetailPage
- Tests: action_leases, migration_phase5, phase5_entry_e2e, real_write_e2e/http_e2e
- Docs: update implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 13:07:21 +08:00

338 lines
13 KiB
Python

"""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
# This marker is set only on the server-created request used by the
# MAF dispatcher. Gateway code uses it to distinguish trusted MAF
# execution context from user-supplied run configuration.
self.maf_internal = True
@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