mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
M4 implementada e backlog validado
This commit is contained in:
114
RI/src/emcp_bus/agent_runtime/global_supervisor.py
Normal file
114
RI/src/emcp_bus/agent_runtime/global_supervisor.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Modo A -- Global Supervisor externo (EP-13-T05, ADR-024, README.md §6.2).
|
||||
|
||||
ADR-024 defines two equally valid integration modes for a multiagent
|
||||
platform sitting on top of the E-MCP-BUS:
|
||||
|
||||
- **Modo A (governança-somente)**: an external Global Supervisor (e.g. the
|
||||
Agent Platform OCI reference, ADR-022's StateGraph/router) decides which
|
||||
backend agent handles a conversation turn. Our own Service Orchestrator
|
||||
(EP-11) does not participate in that decision at all.
|
||||
- **Modo B (substituição completa, EP-13-T06)**: our own Service
|
||||
Orchestrator computes the NBA/NBO directly, and drives handoff through
|
||||
`emcp_bus.agent_runtime.handoff_graph.execute_handoff` with a real
|
||||
`NBADecision` (EP-11-T03).
|
||||
|
||||
`GlobalSupervisor` below is Modo A's stand-in for that external platform --
|
||||
deliberately simple, keyword-based routing (it is not, and must never
|
||||
become, a security boundary) that never touches `emcp_bus.orchestrator` at
|
||||
all (checked mechanically in `tests/unit/test_agent_runtime_global_supervisor.py`,
|
||||
the same AST-import discipline `identity_resolver.py`, EP-12-T04, uses for
|
||||
the same reason). This is *why* `global_supervisor.py` does not build on
|
||||
`handoff_graph.execute_handoff`/`AgentDispatcher.dispatch` even though both
|
||||
already exist (EP-12-T01/T03): those consume an `NBADecision`, which only
|
||||
our own Orchestrator produces -- requiring one here would silently turn
|
||||
Modo A into Modo B.
|
||||
|
||||
The actual security property Modo A must uphold (EP-13-T05's acceptance
|
||||
criteria) does not live in this module at all: it lives in the fact that
|
||||
each `BackendAgent` this class routes to resolves its own MCP
|
||||
Client/entitlement completely independently (its own bearer token, its own
|
||||
`tools/list`, EP-01/EP-05/EP-03) -- `GlobalSupervisor` never reads, stores,
|
||||
or forwards one backend's credential to another. Routing to the "wrong"
|
||||
backend, or a backend being tricked by a prompt into wanting to act outside
|
||||
its own catalog, is always still caught downstream by the real Gateway/PDP,
|
||||
exactly as for a single-agent deployment (README.md §29).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
RouterFn = Callable[[str], "str | None"]
|
||||
"""Picks a backend name from a message's content, or `None` to stay on the
|
||||
current backend -- see `keyword_router` below for the reference implementation
|
||||
and the module docstring for why routing quality is deliberately out of scope."""
|
||||
|
||||
|
||||
class BackendAgent(Protocol):
|
||||
"""What `GlobalSupervisor` needs from a backend agent -- structurally
|
||||
typed (not `agents.example_agent.agent.ExampleAgent` directly) so this
|
||||
core-library module never imports from `agents/` (example/demo code),
|
||||
matching the discipline `dispatcher.py`'s `AgentTokenProvider` already
|
||||
applies to credentials."""
|
||||
|
||||
async def run_turn(self, message: str) -> object: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutingResult:
|
||||
backend_name: str
|
||||
handed_off: bool
|
||||
"""True if this turn was routed to a *different* backend than
|
||||
`current_backend` -- the caller/demo UI can use this to render "you are
|
||||
now speaking with billing" or similar, purely cosmetic."""
|
||||
turn_result: object
|
||||
|
||||
|
||||
class UnknownBackendError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GlobalSupervisor:
|
||||
def __init__(self, backends: dict[str, BackendAgent]) -> None:
|
||||
if not backends:
|
||||
raise ValueError("GlobalSupervisor needs at least one backend")
|
||||
self._backends = dict(backends)
|
||||
|
||||
async def route_and_run(
|
||||
self, message: str, *, current_backend: str, router: RouterFn
|
||||
) -> RoutingResult:
|
||||
"""`router` picks a backend name (or `None` to stay on
|
||||
`current_backend`) from the message content alone -- a real
|
||||
deployment plugs in whatever routing intelligence the external
|
||||
platform provides (an LLM-based intent classifier, a rules engine,
|
||||
...); this RI ships `keyword_router` below as a minimal, honest
|
||||
stand-in, not a claim about routing quality."""
|
||||
if current_backend not in self._backends:
|
||||
raise UnknownBackendError(current_backend)
|
||||
|
||||
target = router(message) or current_backend
|
||||
if target not in self._backends:
|
||||
target = current_backend
|
||||
|
||||
result = await self._backends[target].run_turn(message)
|
||||
return RoutingResult(
|
||||
backend_name=target, handed_off=target != current_backend, turn_result=result
|
||||
)
|
||||
|
||||
|
||||
def keyword_router(message: str, *, routes: dict[str, frozenset[str]] | None = None) -> str | None:
|
||||
"""Minimal reference router: the first backend whose keyword set
|
||||
appears in `message` (case-insensitive), else `None` (stay put). Not
|
||||
the security boundary (see module docstring) -- just enough to
|
||||
demonstrate a real handoff decision without requiring a second LLM
|
||||
call to make it."""
|
||||
routes = routes or {
|
||||
"finance_agent": frozenset({"invoice", "invoices", "pay", "payment", "bill", "billing"})
|
||||
}
|
||||
lowered = message.lower()
|
||||
for backend_name, keywords in routes.items():
|
||||
if any(keyword in lowered for keyword in keywords):
|
||||
return backend_name
|
||||
return None
|
||||
@@ -10,6 +10,7 @@ queued-and-allowed.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -26,6 +27,15 @@ class ApprovalService:
|
||||
def __init__(self, audit_sink: AuditSink | None = None) -> None:
|
||||
self._requests: dict[str, ApprovalRequest] = {}
|
||||
self._audit_sink = audit_sink
|
||||
# EP-15-T04 ("approval replay" under concurrency): `check_and_consume`'s
|
||||
# check-then-set (read `.status`, then write it) is not a single
|
||||
# bytecode op, so without this lock two real OS threads racing the
|
||||
# same granted approval could both observe "granted" before either
|
||||
# writes "consumed" -- a double-spend of a single-use grant. Plain
|
||||
# `threading.Lock`, not `asyncio.Lock`: this service is called from
|
||||
# sync code paths (the Gateway's request handler) that may run under
|
||||
# a thread pool, not only from a single asyncio event loop.
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def request(
|
||||
self,
|
||||
@@ -86,15 +96,16 @@ class ApprovalService:
|
||||
operation_hash = compute_operation_hash(
|
||||
client_profile=client_profile, tool=tool, arguments=arguments
|
||||
)
|
||||
for approval in self._requests.values():
|
||||
if (
|
||||
approval.operation_hash == operation_hash
|
||||
and approval.status == "granted"
|
||||
and not approval.is_expired()
|
||||
):
|
||||
approval.status = "consumed"
|
||||
return True
|
||||
return False
|
||||
with self._lock:
|
||||
for approval in self._requests.values():
|
||||
if (
|
||||
approval.operation_hash == operation_hash
|
||||
and approval.status == "granted"
|
||||
and not approval.is_expired()
|
||||
):
|
||||
approval.status = "consumed"
|
||||
return True
|
||||
return False
|
||||
|
||||
def _pending_for_operation(self, operation_hash: str) -> ApprovalRequest | None:
|
||||
for approval in self._requests.values():
|
||||
|
||||
@@ -37,6 +37,7 @@ top of that (EP-08-T03).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
@@ -65,6 +66,7 @@ from emcp_bus.audit.correlation import (
|
||||
compute_policy_decision_id,
|
||||
new_decision_id,
|
||||
)
|
||||
from emcp_bus.audit.models import EventEnvelope
|
||||
from emcp_bus.audit.sink import AuditSink, JSONLFileAuditSink
|
||||
from emcp_bus.common.config import load_yaml_models
|
||||
from emcp_bus.common.otel import setup_tracing
|
||||
@@ -270,6 +272,19 @@ def _request_id_str(ctx: ServerRequestContext[GatewayState]) -> str | None:
|
||||
return str(ctx.request_id) if ctx.request_id is not None else None
|
||||
|
||||
|
||||
def _emit_audit(state: GatewayState, event: EventEnvelope) -> None:
|
||||
"""README.md §38 ("Audit sink | buffer seguro; não bloquear low-risk
|
||||
apenas se política permitir"): the PDP/approval decision has already been
|
||||
made by the time any of this module's `emit()` calls run, for every
|
||||
outcome (ALLOW, DENY, REQUIRE_APPROVAL) -- a sink failure (disk full,
|
||||
unreachable queue) must never turn an already-decided response into an
|
||||
unrelated 500 for the caller, nor silently retry into a duplicate
|
||||
downstream side effect. A production sink is responsible for its own
|
||||
retry/alerting; this Gateway's request/response cycle is not it (EP-15-T05)."""
|
||||
with contextlib.suppress(Exception):
|
||||
state.audit_sink.emit(event)
|
||||
|
||||
|
||||
async def on_list_tools(
|
||||
ctx: ServerRequestContext[GatewayState],
|
||||
params: types.PaginatedRequestParams | None,
|
||||
@@ -284,10 +299,11 @@ async def on_list_tools(
|
||||
except UnknownClientError:
|
||||
return types.ListToolsResult(tools=[])
|
||||
|
||||
state.audit_sink.emit(
|
||||
_emit_audit(
|
||||
state,
|
||||
events.client_authenticated(
|
||||
client_id=access_token.client_id, mcp_request_id=_request_id_str(ctx)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# EP-06-T01: query only the backends that actually serve a tool this
|
||||
@@ -304,14 +320,15 @@ async def on_list_tools(
|
||||
)
|
||||
all_tools.extend(t for t in downstream_result.tools if t.name in resolved.allowed_tools)
|
||||
|
||||
state.audit_sink.emit(
|
||||
_emit_audit(
|
||||
state,
|
||||
events.tools_list_filtered(
|
||||
client_id=access_token.client_id,
|
||||
profile_name=resolved.profile_name,
|
||||
entitlement_version=resolved.entitlement_version,
|
||||
allowed=len(all_tools),
|
||||
total=len(resolved.allowed_tools),
|
||||
)
|
||||
),
|
||||
)
|
||||
# EP-05-T03: see cache.py's module docstring -- `ttl_ms` will not
|
||||
# actually reach a client on this RI's classic-session transport (a
|
||||
@@ -356,20 +373,21 @@ async def on_call_tool(
|
||||
client_profile=resolved.profile_name, tool=params.name, arguments=arguments
|
||||
)
|
||||
except PDPUnavailableError:
|
||||
state.audit_sink.emit(
|
||||
events.pdp_unavailable(client_id=access_token.client_id, tool=params.name)
|
||||
_emit_audit(
|
||||
state, events.pdp_unavailable(client_id=access_token.client_id, tool=params.name)
|
||||
)
|
||||
return _denied("PDP_UNAVAILABLE")
|
||||
|
||||
if decision.outcome is PDPOutcome.DENY:
|
||||
state.audit_sink.emit(
|
||||
_emit_audit(
|
||||
state,
|
||||
events.tool_call_denied(
|
||||
client_id=access_token.client_id,
|
||||
profile_name=resolved.profile_name,
|
||||
tool=params.name,
|
||||
reason_code=decision.reason_code,
|
||||
correlation=correlation,
|
||||
)
|
||||
),
|
||||
)
|
||||
return _denied(decision.reason_code)
|
||||
|
||||
@@ -390,28 +408,30 @@ async def on_call_tool(
|
||||
arguments=arguments,
|
||||
reason_code=decision.reason_code,
|
||||
)
|
||||
state.audit_sink.emit(
|
||||
_emit_audit(
|
||||
state,
|
||||
events.tool_call_require_approval(
|
||||
client_id=access_token.client_id,
|
||||
profile_name=resolved.profile_name,
|
||||
tool=params.name,
|
||||
approval_id=request.approval_id,
|
||||
correlation=correlation,
|
||||
)
|
||||
),
|
||||
)
|
||||
return _denied(f"REQUIRE_APPROVAL:{decision.reason_code}:{request.approval_id}")
|
||||
|
||||
try:
|
||||
route = await state.router.route(params.name)
|
||||
except UnroutableCapabilityError:
|
||||
state.audit_sink.emit(
|
||||
_emit_audit(
|
||||
state,
|
||||
events.tool_call_denied(
|
||||
client_id=access_token.client_id,
|
||||
profile_name=resolved.profile_name,
|
||||
tool=params.name,
|
||||
reason_code="UNROUTABLE_CAPABILITY",
|
||||
correlation=correlation,
|
||||
)
|
||||
),
|
||||
)
|
||||
return _denied("UNROUTABLE_CAPABILITY")
|
||||
|
||||
@@ -419,10 +439,11 @@ async def on_call_tool(
|
||||
# EP-06-T04: a "rest"/"grpc" backend.type has no seed capability yet
|
||||
# (see fabric/adapters/rest_adapter.py's module docstring) -- fail
|
||||
# closed rather than guess at a dispatch path with no test coverage.
|
||||
state.audit_sink.emit(
|
||||
_emit_audit(
|
||||
state,
|
||||
events.backend_credential_unavailable(
|
||||
client_id=access_token.client_id, tool=params.name, backend=route.backend_id
|
||||
)
|
||||
),
|
||||
)
|
||||
return _denied(f"UNSUPPORTED_BACKEND_TYPE:{route.backend_type}")
|
||||
|
||||
@@ -432,13 +453,14 @@ async def on_call_tool(
|
||||
lambda session: session.call_tool(params.name, params.arguments or {}),
|
||||
)
|
||||
if isinstance(result, types.CallToolResult):
|
||||
state.audit_sink.emit(
|
||||
_emit_audit(
|
||||
state,
|
||||
events.tool_call_allowed(
|
||||
client_id=access_token.client_id,
|
||||
profile_name=resolved.profile_name,
|
||||
tool=params.name,
|
||||
correlation=correlation,
|
||||
)
|
||||
),
|
||||
)
|
||||
return result
|
||||
raise TypeError(f"Unexpected downstream result type for tools/call: {type(result)!r}")
|
||||
|
||||
Reference in New Issue
Block a user