M4 implementada e backlog validado

This commit is contained in:
Marcos Lohmann
2026-09-14 11:22:03 -03:00
parent c894c1604b
commit 2fe6d6d508
53 changed files with 3152 additions and 39 deletions

View 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,
)

View 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,
)

View File

@@ -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,
)

View File

@@ -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]