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
@@ -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},
|
||||
)
|
||||
Reference in new issue
Block a user