mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-05 22:14:11 +00:00
M4 implementada e backlog validado
This commit is contained in:
0
RI/agents/example_agent/backends/__init__.py
Normal file
0
RI/agents/example_agent/backends/__init__.py
Normal file
42
RI/agents/example_agent/backends/finance_agent/__init__.py
Normal file
42
RI/agents/example_agent/backends/finance_agent/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Finance backend for the Modo A multiagent demo (EP-13-T05, ADR-024).
|
||||
|
||||
Same shape as `backends.sales_agent` -- a thin factory around `ExampleAgent`
|
||||
bound to the `finance-payments-agent` MCP Client identity (EP-01) instead.
|
||||
See `sales_agent/__init__.py`'s module docstring for why the two backends
|
||||
share everything except identity/prompt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from emcp_bus.audit.sink import AuditSink
|
||||
from example_agent.agent import ExampleAgent
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant for a finance/billing team. Use only the "
|
||||
"tools you are given -- never claim to have capabilities outside them, "
|
||||
"and never invent a tool name that wasn't offered to you. Executing a "
|
||||
"payment above the approval threshold always requires a human approval "
|
||||
"before it can complete -- never tell the customer it is done until the "
|
||||
"tool result confirms it."
|
||||
)
|
||||
|
||||
|
||||
def build_finance_agent(
|
||||
*,
|
||||
gateway_url: str,
|
||||
bearer_token: str,
|
||||
llm_client: AsyncOpenAI,
|
||||
model: str,
|
||||
audit_sink: AuditSink | None = None,
|
||||
) -> ExampleAgent:
|
||||
return ExampleAgent(
|
||||
gateway_url=gateway_url,
|
||||
bearer_token=bearer_token,
|
||||
llm_client=llm_client,
|
||||
model=model,
|
||||
agent_name="finance_agent",
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
audit_sink=audit_sink,
|
||||
)
|
||||
43
RI/agents/example_agent/backends/sales_agent/__init__.py
Normal file
43
RI/agents/example_agent/backends/sales_agent/__init__.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Sales backend for the Modo A multiagent demo (EP-13-T05, ADR-024).
|
||||
|
||||
A thin factory around the same `ExampleAgent` (EP-13-T01) every other
|
||||
example-agent test already uses -- bound to a Sales MCP Client identity
|
||||
(`sales-read-agent`/`sales-write-agent`, EP-01). Nothing about `ExampleAgent`
|
||||
itself is Sales-specific; only the bearer token and system prompt differ
|
||||
from `finance_agent`, which is the point (ADR-023: the security boundary is
|
||||
identity/credential, never code path or prompt text).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from emcp_bus.audit.sink import AuditSink
|
||||
from example_agent.agent import ExampleAgent
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant for a sales team. Use only the tools you are "
|
||||
"given -- never claim to have capabilities outside them, and never invent "
|
||||
"a tool name that wasn't offered to you. If the customer wants to pay an "
|
||||
"invoice or discuss a payment, tell them you will connect them with "
|
||||
"billing -- you cannot process payments yourself."
|
||||
)
|
||||
|
||||
|
||||
def build_sales_agent(
|
||||
*,
|
||||
gateway_url: str,
|
||||
bearer_token: str,
|
||||
llm_client: AsyncOpenAI,
|
||||
model: str,
|
||||
audit_sink: AuditSink | None = None,
|
||||
) -> ExampleAgent:
|
||||
return ExampleAgent(
|
||||
gateway_url=gateway_url,
|
||||
bearer_token=bearer_token,
|
||||
llm_client=llm_client,
|
||||
model=model,
|
||||
agent_name="sales_agent",
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
audit_sink=audit_sink,
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Reference `NBADecisionModel` for Modo B (EP-13-T06, `could`, ADR-024).
|
||||
|
||||
Same least-risk-tier-first selection rule as
|
||||
`emcp_bus.orchestrator.decisioning.RuleBasedNBADecisionModel` (EP-11-T03) --
|
||||
not a claim of optimality, just a simple, legible priority rule (see that
|
||||
module's own docstring). The one deliberate difference: `agent` here is
|
||||
always `context.client_id`, the real MCP Client whose entitlement produced
|
||||
`context.filtered_offerings` in the first place -- never a separate label
|
||||
from some mapping table. `AgentDispatcher` (EP-12-T01) resolves a credential
|
||||
*for* `nba.agent` via `AgentTokenProvider.token_for`, and the only
|
||||
credential this Orchestrator may ever dispatch a given offering with is the
|
||||
one belonging to the client whose entitlement actually authorized it (Axiom
|
||||
4/9) -- keeping `agent == client_id` makes that self-evidently true, with
|
||||
no separate table that could drift out of sync with which client resolved
|
||||
which offerings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from emcp_bus.orchestrator.decision_context import DecisionContext
|
||||
from emcp_bus.orchestrator.decisioning import DEFAULT_TTL
|
||||
from emcp_bus.orchestrator.nba_model import NBADecision
|
||||
|
||||
|
||||
class DomainRoutingNBADecisionModel:
|
||||
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=context.client_id,
|
||||
reason_codes=reason_codes,
|
||||
policy_context={
|
||||
"entitlementVersion": context.entitlement_version,
|
||||
"consentPurposes": sorted(context.runtime_context.consent_purposes),
|
||||
},
|
||||
expires_at=datetime.now(UTC) + DEFAULT_TTL,
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Modo B -- Service Orchestrator como unico cerebro de NBA/NBO (EP-13-T06,
|
||||
`could`, ADR-024, README.md §6.12).
|
||||
|
||||
Contrast with `emcp_bus.agent_runtime.global_supervisor` (Modo A, EP-13-T05):
|
||||
there, an external Global Supervisor decides routing and our Orchestrator
|
||||
never participates. Here, our own Orchestrator -- the real journey
|
||||
`StateGraph` (EP-11-T02), invoked once per relevant MCP Client for the same
|
||||
subject -- is the *only* thing that ever decides which backend acts; no
|
||||
external Global Supervisor is consulted at all. Each backend/agent still
|
||||
remains its own MCP Client with its own entitlement ceiling (ADR-008); this
|
||||
class only combines what each of them is independently allowed to offer.
|
||||
|
||||
Deliberately does **not** extend `emcp_bus.orchestrator.graph.JourneyState`/
|
||||
`build_journey_graph` to natively span multiple `client_id`s in one run --
|
||||
that graph is already real, tested, and used unmodified per client_id here
|
||||
(`README.md §6.12`'s fixed backbone: `resolve_profile -> resolve_entitlement
|
||||
-> filter_offerings -> decide_nba -> approval_gate`); the *combining* step
|
||||
across clients lives in this module instead, one layer above the graph,
|
||||
exactly where `AgentDispatcher`'s own docstring already says post-decision
|
||||
orchestration belongs ("one layer up"). A native multi-client journey graph
|
||||
is a natural, larger future extension of EP-11-T02 -- not attempted here to
|
||||
avoid risking an already-tested core component for a `could`-priority demo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.graph.state import CompiledStateGraph
|
||||
|
||||
from emcp_bus.agent_runtime.dispatcher import AgentDispatcher, DispatchResult
|
||||
from emcp_bus.orchestrator.nba_model import NBADecision
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModeBDecision:
|
||||
chosen: NBADecision
|
||||
considered: list[NBADecision]
|
||||
|
||||
|
||||
class ModeBOrchestrator:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
graph: CompiledStateGraph[Any, Any, Any, Any],
|
||||
dispatcher: AgentDispatcher,
|
||||
client_ids: list[str],
|
||||
) -> None:
|
||||
self._graph = graph
|
||||
self._dispatcher = dispatcher
|
||||
self._client_ids = client_ids
|
||||
|
||||
def decide(self, *, subject_id: str, channel: str) -> ModeBDecision | None:
|
||||
"""Invokes the real journey graph once per `client_ids` entry --
|
||||
each run independently resolves that client's own profile/
|
||||
entitlement/offerings (EP-11-T02's existing, unmodified nodes) --
|
||||
then combines the resulting `NBADecision`s. A `client_id` whose
|
||||
journey ends without a survivable offering (`nba is None`) simply
|
||||
contributes nothing to `considered`, never an error."""
|
||||
candidates: list[NBADecision] = []
|
||||
for client_id in self._client_ids:
|
||||
config: RunnableConfig = {
|
||||
"configurable": {"thread_id": f"mode-b-{subject_id}-{client_id}"}
|
||||
}
|
||||
result = self._graph.invoke(
|
||||
{"client_id": client_id, "raw_subject_id": subject_id, "channel": channel}, config
|
||||
)
|
||||
nba = result.get("nba")
|
||||
if nba is not None:
|
||||
candidates.append(nba)
|
||||
|
||||
chosen = self._combine(candidates)
|
||||
if chosen is None:
|
||||
return None
|
||||
return ModeBDecision(chosen=chosen, considered=candidates)
|
||||
|
||||
async def decide_and_dispatch(
|
||||
self, *, subject_id: str, channel: str, arguments: dict[str, Any] | None = None
|
||||
) -> DispatchResult | None:
|
||||
"""Decides, then dispatches `chosen` through the real
|
||||
`AgentDispatcher` (EP-12-T01) -- the Agent Runtime "apenas despacha
|
||||
o resultado" (EP-13-T06's own acceptance criterion): this method
|
||||
adds no authorization logic of its own, `dispatcher.dispatch` goes
|
||||
through the exact same Gateway/PDP reauthorization any other
|
||||
dispatch does."""
|
||||
decision = self.decide(subject_id=subject_id, channel=channel)
|
||||
if decision is None:
|
||||
return None
|
||||
return await self._dispatcher.dispatch(decision.chosen, arguments=arguments or {})
|
||||
|
||||
@staticmethod
|
||||
def _combine(candidates: list[NBADecision]) -> NBADecision | None:
|
||||
"""Reference combining rule (not a claim of optimality -- matching
|
||||
`RuleBasedNBADecisionModel`'s own stance, EP-11-T03): a pending
|
||||
Finance obligation always outranks a discretionary Sales offer for
|
||||
the same subject, when both exist. A real deployment would plug in
|
||||
whatever cross-domain ranking model it actually has (EP-11-T03's
|
||||
pluggable `NBADecisionModel` interface, applied one level up)."""
|
||||
if not candidates:
|
||||
return None
|
||||
finance_candidates = [c for c in candidates if c.action.startswith("finance.")]
|
||||
return finance_candidates[0] if finance_candidates else candidates[0]
|
||||
Reference in New Issue
Block a user