mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
M3 implementada e backlog atualizado
This commit is contained in:
0
RI/src/emcp_bus/orchestrator/__init__.py
Normal file
0
RI/src/emcp_bus/orchestrator/__init__.py
Normal file
40
RI/src/emcp_bus/orchestrator/decision_context.py
Normal file
40
RI/src/emcp_bus/orchestrator/decision_context.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""DecisionContext contract (EP-11-T01, README.md §1, §6.12).
|
||||
|
||||
Combines everything the Service Orchestrator's decisioning node (EP-11-T03)
|
||||
needs into one versioned, serializable object: `ProfileView` (EP-09),
|
||||
resolved MaximumEntitlement (EP-04), `FilteredOfferings` (EP-10), and
|
||||
runtime context (channel/consent). Nothing downstream of this object can
|
||||
widen MaximumEntitlement -- `allowed_tools`/`entitlement_version` are
|
||||
carried through unchanged from EP-04's `ResolvedEntitlement`, and
|
||||
`filtered_offerings` already went through EP-10's intersection; the
|
||||
decisioning node only ever ranks/selects among what's already here
|
||||
(Axiom 8/11).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from emcp_bus.common.models import RiskTier
|
||||
from emcp_bus.profile_intelligence.models import ProfileView
|
||||
from emcp_bus.registry.models import CapabilityManifest
|
||||
|
||||
|
||||
class RuntimeContext(BaseModel):
|
||||
channel: str
|
||||
consent_purposes: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
class DecisionContext(BaseModel):
|
||||
client_id: str
|
||||
entitlement_version: str
|
||||
"""Required -- a DecisionContext cannot be constructed without an
|
||||
entitlement version to correlate against (EP-11-T01 acceptance criterion)."""
|
||||
risk_ceiling: RiskTier
|
||||
allowed_tools: frozenset[str]
|
||||
profile: ProfileView
|
||||
filtered_offerings: list[CapabilityManifest]
|
||||
runtime_context: RuntimeContext
|
||||
resolved_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
48
RI/src/emcp_bus/orchestrator/decisioning.py
Normal file
48
RI/src/emcp_bus/orchestrator/decisioning.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Reference `NBADecisionModel` (EP-11-T03): deterministic rules/priority
|
||||
over `DecisionContext.filtered_offerings` -- not an AI model, not
|
||||
clustering. See `nba_model.py`'s module docstring for the pluggable
|
||||
interface a real ranking model would implement instead, and P7
|
||||
(docs/RI-PLANNING.md) for why this RI doesn't attempt "real" ranking without
|
||||
a real dataset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from emcp_bus.orchestrator.decision_context import DecisionContext
|
||||
from emcp_bus.orchestrator.nba_model import NBADecision
|
||||
|
||||
DEFAULT_AGENT = "engagement-assistant"
|
||||
DEFAULT_TTL = timedelta(hours=1)
|
||||
|
||||
|
||||
class RuleBasedNBADecisionModel:
|
||||
"""Picks the lowest-risk-tier offering (least friction first), tie-broken
|
||||
by name for determinism -- a simple, legible priority rule, not a claim
|
||||
of optimality. `reason_codes` always includes `ELIGIBLE` plus whatever
|
||||
`ProfileView.reason_codes` the profile carried (README.md §6.13 example:
|
||||
`["ELIGIBLE", "ACTIVATION_OBJECTIVE", "FREQUENCY_OK"]`)."""
|
||||
|
||||
def decide(self, context: DecisionContext) -> NBADecision | None:
|
||||
if not context.filtered_offerings:
|
||||
return None
|
||||
|
||||
offering = min(context.filtered_offerings, key=lambda m: (m.risk_tier.value, m.name))
|
||||
reason_codes = ["ELIGIBLE", *context.profile.reason_codes]
|
||||
|
||||
return NBADecision(
|
||||
decision_id=f"dec-{uuid.uuid4().hex[:12]}",
|
||||
action=offering.name,
|
||||
capability_version=offering.version,
|
||||
subject_ref=context.profile.subject_ref,
|
||||
channel=context.runtime_context.channel,
|
||||
agent=DEFAULT_AGENT,
|
||||
reason_codes=reason_codes,
|
||||
policy_context={
|
||||
"entitlementVersion": context.entitlement_version,
|
||||
"consentPurposes": sorted(context.runtime_context.consent_purposes),
|
||||
},
|
||||
expires_at=datetime.now(UTC) + DEFAULT_TTL,
|
||||
)
|
||||
70
RI/src/emcp_bus/orchestrator/fallback.py
Normal file
70
RI/src/emcp_bus/orchestrator/fallback.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Recalculation / safe degradation (EP-11-T04, README.md §10.1, Axiom 12, P9).
|
||||
|
||||
When the real Gateway (EP-05) denies the `tools/call` the Agent Runtime
|
||||
(EP-12) attempted for a previously-issued NBA, the Orchestrator must never
|
||||
retry the same action directly against the Fabric -- that would just be the
|
||||
PEP bypassed under a different name. This module's only two outcomes are:
|
||||
recompute NBA over the *remaining* `filtered_offerings` (the denied action
|
||||
excluded), or end the journey with an audited reason. There is no code path
|
||||
here that calls a backend, the Fabric, or the Gateway -- `handle_denial`
|
||||
only ever returns a new `NBADecision` or `None` for the caller (the journey
|
||||
graph's `handle_denial_node`, or the Agent Runtime directly) to act on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from emcp_bus.audit.events import journey_ended_after_denial, nba_recalculated
|
||||
from emcp_bus.audit.sink import AuditSink
|
||||
from emcp_bus.orchestrator.decision_context import DecisionContext
|
||||
from emcp_bus.orchestrator.nba_model import NBADecision, NBADecisionModel
|
||||
|
||||
|
||||
class DenialOutcome(StrEnum):
|
||||
RECALCULATED = "recalculated"
|
||||
ENDED = "ended"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FallbackResult:
|
||||
outcome: DenialOutcome
|
||||
nba: NBADecision | None
|
||||
|
||||
|
||||
def handle_denial(
|
||||
context: DecisionContext,
|
||||
*,
|
||||
denied_action: str,
|
||||
decision_model: NBADecisionModel,
|
||||
audit_sink: AuditSink | None = None,
|
||||
) -> FallbackResult:
|
||||
"""`context.filtered_offerings` minus `denied_action`, re-run through
|
||||
`decision_model` -- never a direct call to any backend."""
|
||||
remaining = [o for o in context.filtered_offerings if o.name != denied_action]
|
||||
|
||||
if not remaining:
|
||||
if audit_sink is not None:
|
||||
audit_sink.emit(
|
||||
journey_ended_after_denial(
|
||||
subject_ref=context.profile.subject_ref, denied_action=denied_action
|
||||
)
|
||||
)
|
||||
return FallbackResult(outcome=DenialOutcome.ENDED, nba=None)
|
||||
|
||||
narrowed_context = context.model_copy(update={"filtered_offerings": remaining})
|
||||
new_nba = decision_model.decide(narrowed_context)
|
||||
|
||||
if audit_sink is not None:
|
||||
audit_sink.emit(
|
||||
nba_recalculated(
|
||||
subject_ref=context.profile.subject_ref,
|
||||
denied_action=denied_action,
|
||||
new_action=new_nba.action if new_nba is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
if new_nba is None:
|
||||
return FallbackResult(outcome=DenialOutcome.ENDED, nba=None)
|
||||
return FallbackResult(outcome=DenialOutcome.RECALCULATED, nba=new_nba)
|
||||
148
RI/src/emcp_bus/orchestrator/graph.py
Normal file
148
RI/src/emcp_bus/orchestrator/graph.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Journey StateGraph (EP-11-T02, README.md §6.12).
|
||||
|
||||
Fixed corporate-standard nodes (`docs/research/hoshikawa-agent-platform-oci.md`'s
|
||||
pattern: dev only adds domain nodes, never touches the backbone):
|
||||
|
||||
resolve_profile -> resolve_entitlement -> filter_offerings -> decide_nba
|
||||
-> approval_gate (EP-11-T05, may pause the graph) -> END
|
||||
|
||||
Plus a second entry path for the denial-recalculation loop (EP-11-T04):
|
||||
re-invoking the graph with `denied_action` set routes straight to
|
||||
`handle_denial_node` (skipping profile/entitlement/offering resolution,
|
||||
which haven't changed) -> `decide_nba` again over the narrowed offerings ->
|
||||
`approval_gate` -> END, or straight to END if no offering remains.
|
||||
|
||||
Checkpointing (this task's other acceptance criterion): pass any LangGraph
|
||||
`Checkpointer` (`langgraph.checkpoint.sqlite.SqliteSaver` in dev, per
|
||||
ADR-022's plugable-provider pattern -- a `postgres`/`mongodb` one is a
|
||||
drop-in swap in production, no graph code changes). A real IdP-integrated
|
||||
deployment resumes a paused `approval_gate` by calling
|
||||
`graph.invoke(Command(resume="approved"), config)` once
|
||||
`emcp_bus.approval.service.ApprovalService.grant()` reports the matching
|
||||
approval as granted (EP-11-T05's docstring) -- wiring that callback is a
|
||||
thin adapter, not shown here since it depends on how a deployment surfaces
|
||||
approvals (CLI, API, ApprovalService callback), not on this graph's shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.graph.state import CompiledStateGraph
|
||||
|
||||
from emcp_bus.audit.sink import AuditSink
|
||||
from emcp_bus.common.models import RiskTier
|
||||
from emcp_bus.entitlement.manager import EntitlementManager
|
||||
from emcp_bus.offering_filter.service import ConsentContext, OfferingFilterService
|
||||
from emcp_bus.orchestrator.decision_context import DecisionContext, RuntimeContext
|
||||
from emcp_bus.orchestrator.fallback import handle_denial
|
||||
from emcp_bus.orchestrator.hitl_node import approval_gate_node, requires_approval
|
||||
from emcp_bus.orchestrator.nba_model import NBADecisionModel
|
||||
from emcp_bus.orchestrator.state import JourneyState
|
||||
from emcp_bus.profile_intelligence.rule_based_provider import ProfileIntelligenceProvider
|
||||
|
||||
|
||||
def _decision_context_from_state(
|
||||
state: JourneyState, *, exclude: frozenset[str] = frozenset()
|
||||
) -> DecisionContext:
|
||||
offerings = [o for o in state["filtered_offerings"] if o.name not in exclude]
|
||||
return DecisionContext(
|
||||
client_id=state["client_id"],
|
||||
entitlement_version=state["entitlement_version"],
|
||||
risk_ceiling=RiskTier(state["risk_ceiling"]),
|
||||
allowed_tools=frozenset(state["allowed_tools"]),
|
||||
profile=state["profile"],
|
||||
filtered_offerings=offerings,
|
||||
runtime_context=RuntimeContext(channel=state["channel"]),
|
||||
)
|
||||
|
||||
|
||||
def build_journey_graph(
|
||||
*,
|
||||
profile_provider: ProfileIntelligenceProvider,
|
||||
entitlement_manager: EntitlementManager,
|
||||
offering_filter: OfferingFilterService,
|
||||
decision_model: NBADecisionModel,
|
||||
checkpointer: Any,
|
||||
audit_sink: AuditSink | None = None,
|
||||
consent_context: ConsentContext | None = None,
|
||||
) -> CompiledStateGraph[JourneyState, None, JourneyState, JourneyState]:
|
||||
consent_context = consent_context or ConsentContext()
|
||||
|
||||
def resolve_profile_node(state: JourneyState) -> dict[str, Any]:
|
||||
profile = profile_provider.resolve(state["raw_subject_id"])
|
||||
return {"profile": profile}
|
||||
|
||||
def resolve_entitlement_node(state: JourneyState) -> dict[str, Any]:
|
||||
resolved = entitlement_manager.resolve(state["client_id"])
|
||||
return {
|
||||
"allowed_tools": sorted(resolved.allowed_tools),
|
||||
"entitlement_version": resolved.entitlement_version,
|
||||
"risk_ceiling": resolved.risk_ceiling.value,
|
||||
}
|
||||
|
||||
def filter_offerings_node(state: JourneyState) -> dict[str, Any]:
|
||||
offerings = offering_filter.filtered_offerings(
|
||||
allowed_tools=frozenset(state["allowed_tools"]), context=consent_context
|
||||
)
|
||||
return {"filtered_offerings": offerings}
|
||||
|
||||
def decide_nba_node(state: JourneyState) -> dict[str, Any]:
|
||||
excluded = frozenset(state.get("excluded_actions", []))
|
||||
context = _decision_context_from_state(state, exclude=excluded)
|
||||
nba = decision_model.decide(context)
|
||||
needs_approval = nba is not None and requires_approval(
|
||||
context.filtered_offerings, nba.action
|
||||
)
|
||||
return {"nba": nba, "nba_requires_approval": needs_approval}
|
||||
|
||||
def handle_denial_node(state: JourneyState) -> dict[str, Any]:
|
||||
denied_action = state["denied_action"]
|
||||
assert denied_action is not None
|
||||
already_excluded = frozenset(state.get("excluded_actions", []))
|
||||
context = _decision_context_from_state(state, exclude=already_excluded)
|
||||
result = handle_denial(
|
||||
context,
|
||||
denied_action=denied_action,
|
||||
decision_model=decision_model,
|
||||
audit_sink=audit_sink,
|
||||
)
|
||||
needs_approval = result.nba is not None and requires_approval(
|
||||
context.filtered_offerings, result.nba.action
|
||||
)
|
||||
return {
|
||||
"nba": result.nba,
|
||||
"nba_requires_approval": needs_approval,
|
||||
"excluded_actions": [*already_excluded, denied_action],
|
||||
"denial_outcome": result.outcome.value,
|
||||
"denied_action": None,
|
||||
}
|
||||
|
||||
def route_entry(state: JourneyState) -> Literal["fresh", "denial_reentry"]:
|
||||
return "denial_reentry" if state.get("denied_action") else "fresh"
|
||||
|
||||
def route_after_denial(state: JourneyState) -> str:
|
||||
return "approval_gate" if state.get("nba") is not None else END
|
||||
|
||||
builder: StateGraph[JourneyState, None, JourneyState, JourneyState] = StateGraph(JourneyState)
|
||||
builder.add_node("resolve_profile", resolve_profile_node)
|
||||
builder.add_node("resolve_entitlement", resolve_entitlement_node)
|
||||
builder.add_node("filter_offerings", filter_offerings_node)
|
||||
builder.add_node("decide_nba", decide_nba_node)
|
||||
builder.add_node("approval_gate", approval_gate_node)
|
||||
builder.add_node("handle_denial", handle_denial_node)
|
||||
|
||||
builder.add_conditional_edges(
|
||||
START, route_entry, {"fresh": "resolve_profile", "denial_reentry": "handle_denial"}
|
||||
)
|
||||
builder.add_edge("resolve_profile", "resolve_entitlement")
|
||||
builder.add_edge("resolve_entitlement", "filter_offerings")
|
||||
builder.add_edge("filter_offerings", "decide_nba")
|
||||
builder.add_edge("decide_nba", "approval_gate")
|
||||
builder.add_conditional_edges(
|
||||
"handle_denial", route_after_denial, {"approval_gate": "approval_gate", END: END}
|
||||
)
|
||||
builder.add_edge("approval_gate", END)
|
||||
|
||||
return builder.compile(checkpointer=checkpointer)
|
||||
46
RI/src/emcp_bus/orchestrator/hitl_node.py
Normal file
46
RI/src/emcp_bus/orchestrator/hitl_node.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Human-in-the-loop interrupt node (EP-11-T05, README.md §26, integrates EP-14).
|
||||
|
||||
Pauses the journey graph (LangGraph's `interrupt`) when the NBA's target
|
||||
capability requires approval (`CapabilityManifest.approval.required`) --
|
||||
the same signal the Gateway/PDP (EP-05/EP-14) enforce independently at
|
||||
`tools/call` time, checked here *before* the Agent Runtime (EP-12) ever
|
||||
attempts dispatch, so a high-risk NBA doesn't even reach the point of a
|
||||
denied call.
|
||||
|
||||
No silent timeout: `interrupt()` suspends the graph and LangGraph persists
|
||||
that via the checkpointer (verified in
|
||||
tests/unit/test_orchestrator_graph.py) -- there is no code path here, or
|
||||
anywhere in this module, that turns "no answer yet" into an implicit
|
||||
approval. Resuming is the caller's job, via `Command(resume=...)` once
|
||||
EP-14's `ApprovalService` reports a grant -- see `graph.py`'s module
|
||||
docstring for how the two are wired together in a real deployment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langgraph.types import interrupt
|
||||
|
||||
from emcp_bus.orchestrator.state import JourneyState
|
||||
|
||||
|
||||
def requires_approval(offerings: list[Any], action: str) -> bool:
|
||||
return any(o.name == action and o.approval.required for o in offerings)
|
||||
|
||||
|
||||
def approval_gate_node(state: JourneyState) -> dict[str, Any]:
|
||||
nba = state.get("nba")
|
||||
if nba is None:
|
||||
return {"approval_status": "not_applicable"}
|
||||
if not state.get("nba_requires_approval", False):
|
||||
return {"approval_status": "not_required"}
|
||||
|
||||
decision = interrupt(
|
||||
{
|
||||
"approval_for": nba.action,
|
||||
"decision_id": nba.decision_id,
|
||||
"reason_codes": nba.reason_codes,
|
||||
}
|
||||
)
|
||||
return {"approval_status": "approved" if decision == "approved" else "denied"}
|
||||
45
RI/src/emcp_bus/orchestrator/nba_model.py
Normal file
45
RI/src/emcp_bus/orchestrator/nba_model.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""NBA/NBO contract + pluggable decision interface (EP-11-T03, README.md
|
||||
§6.13, ADR-024).
|
||||
|
||||
Field names/shape mirror README.md §6.13's dispatch example exactly
|
||||
(`decisionId`, `action`, `capabilityVersion`, `subjectRef`, `channel`,
|
||||
`agent`, `reasonCodes`, `policyContext`, `expiresAt`).
|
||||
|
||||
`NBADecisionModel` is a `Protocol` on purpose (ADR-024, EP-11-T03 acceptance
|
||||
criterion): swapping the reference rule-based implementation
|
||||
(`decisioning.RuleBasedNBADecisionModel`) for an AI ranking/recommendation
|
||||
model changes nothing about `DecisionContext` in or `NBADecision` out, and
|
||||
therefore nothing about EP-03/EP-04/EP-05's authorization surface -- the
|
||||
model only ever ranks/selects among `DecisionContext.filtered_offerings`,
|
||||
already intersected with MaximumEntitlement by EP-10 before this code ever
|
||||
sees it (Axiom 8/11).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from emcp_bus.orchestrator.decision_context import DecisionContext
|
||||
|
||||
|
||||
class NBADecision(BaseModel):
|
||||
decision_id: str
|
||||
action: str
|
||||
capability_version: str
|
||||
subject_ref: str
|
||||
channel: str
|
||||
agent: str
|
||||
reason_codes: list[str]
|
||||
policy_context: dict[str, Any]
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class NBADecisionModel(Protocol):
|
||||
def decide(self, context: DecisionContext) -> NBADecision | None:
|
||||
"""`None` when no offering survives `context.filtered_offerings` --
|
||||
the caller (the journey graph, EP-11-T02) must treat this as "end
|
||||
the journey," never as an excuse to look outside `filtered_offerings`."""
|
||||
...
|
||||
49
RI/src/emcp_bus/orchestrator/state.py
Normal file
49
RI/src/emcp_bus/orchestrator/state.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Journey state (EP-11-T02, README.md §6.12).
|
||||
|
||||
A `TypedDict` (LangGraph's expected state-schema shape) carrying the journey
|
||||
through the graph's fixed nodes: `resolve_profile -> resolve_entitlement ->
|
||||
filter_offerings -> decide_nba -> approval_gate` (`graph.py`), plus the
|
||||
denial-recalculation loop (EP-11-T04, `handle_denial_node`). `total=False`
|
||||
because every field is populated incrementally, one node at a time -- the
|
||||
graph's whole point is that a checkpoint mid-journey has some fields set and
|
||||
others not yet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
from emcp_bus.orchestrator.nba_model import NBADecision
|
||||
from emcp_bus.profile_intelligence.models import ProfileView
|
||||
from emcp_bus.registry.models import CapabilityManifest
|
||||
|
||||
|
||||
class JourneyState(TypedDict, total=False):
|
||||
# Inputs (set by the caller before the first `invoke`).
|
||||
client_id: str
|
||||
raw_subject_id: str
|
||||
channel: str
|
||||
|
||||
# Populated by resolve_profile.
|
||||
profile: ProfileView
|
||||
|
||||
# Populated by resolve_entitlement.
|
||||
allowed_tools: list[str]
|
||||
entitlement_version: str
|
||||
risk_ceiling: str
|
||||
|
||||
# Populated by filter_offerings.
|
||||
filtered_offerings: list[CapabilityManifest]
|
||||
|
||||
# Populated by decide_nba.
|
||||
nba: NBADecision | None
|
||||
nba_requires_approval: bool
|
||||
|
||||
# Populated by approval_gate (EP-11-T05).
|
||||
approval_status: str
|
||||
|
||||
# Denial-recalculation loop (EP-11-T04) -- set by the caller (the Agent
|
||||
# Runtime, EP-12) when re-invoking the graph after a real Gateway DENY.
|
||||
denied_action: str | None
|
||||
excluded_actions: list[str]
|
||||
denial_outcome: str | None
|
||||
Reference in New Issue
Block a user