revisão e complementação da documentação do projeto

This commit is contained in:
Marcos Lohmann
2026-09-14 14:01:27 -03:00
parent 2fe6d6d508
commit 9a99ec6cc8
65 changed files with 6630 additions and 1854 deletions

View File

@@ -0,0 +1,125 @@
# Interface: Agent Runtime dispatch (`AgentDispatcher`, `GlobalSupervisor`, `ModeBOrchestrator`)
Contratos de despacho do Agent Runtime, para quem vai construir um novo backend de agente ou uma
nova integração multiagente seguindo o `docs/adr/ADR-024-posicionamento-multiagente.md`.
Implementação: [`src/emcp_bus/agent_runtime/dispatcher.py`](../../src/emcp_bus/agent_runtime/dispatcher.py),
[`agent_runtime/global_supervisor.py`](../../src/emcp_bus/agent_runtime/global_supervisor.py),
[`agent_runtime/handoff_graph.py`](../../src/emcp_bus/agent_runtime/handoff_graph.py),
[`agent_runtime/identity_resolver.py`](../../src/emcp_bus/agent_runtime/identity_resolver.py),
[`agents/example_agent/mode_b_orchestrator_driven/orchestrator.py`](../../agents/example_agent/mode_b_orchestrator_driven/orchestrator.py).
Ver [`AS-BUILT.md`](../AS-BUILT.md) (seções Agent Runtime, Modo A, Modo B).
## `AgentDispatcher` — o primitivo comum aos dois modos
```python
result = await dispatcher.dispatch(nba: NBADecision, arguments: dict[str, Any]) -> DispatchResult
```
`nba` (ver `orchestrator/nba_model.py`) carrega `agent` — o nome do agente cuja credencial deve
ser usada. `AgentDispatcher` nunca gera ou guarda credencial própria: obtém o token via
`AgentTokenProvider.token_for(nba.agent)`, tipicamente um `client_credentials` real contra o
mesmo IdP de qualquer outro MCP Client (`OIDCAgentTokenProvider`), e chama `nba.action` através
do Gateway real — a mesma reautorização de qualquer outro `tools/call` (EP-05/EP-03).
```python
DispatchResult(decision_id="...", action="finance.payment.execute", status="allowed" | "denied",
structured_content={...} | None, error_text: str | None)
```
Uma negação do Gateway/PDP vira `status="denied"` com `error_text` — `dispatch()` nunca
reinterpreta ou reexecuta em torno de uma negação; recálculo de NBA após DENY é responsabilidade
de uma camada acima (`orchestrator/fallback.handle_denial`).
## Modo A — `GlobalSupervisor` (governança-somente, ADR-024)
Para quando uma **plataforma de orquestração externa** decide qual agente atende a conversa; o
Service Orchestrator (EP-11) não participa.
```python
supervisor = GlobalSupervisor(backends={"sales_agent": ..., "finance_agent": ...})
result = await supervisor.route_and_run(
message, current_backend="sales_agent", router=keyword_router
)
# RoutingResult(backend_name, handed_off: bool, turn_result)
```
- `router: RouterFn` — `Callable[[str], str | None]`, escolhe um nome de backend a partir do
conteúdo da mensagem, ou `None` para permanecer no backend atual. `keyword_router` é a
implementação de referência (palavra-chave, deliberadamente simples) — **não é a fronteira de
segurança**; um roteamento errado, ou uma tentativa de manipular o roteamento via prompt, é
sempre recapturado pelo Gateway/PDP real no backend que efetivamente responde.
- `BackendAgent` — o único contrato que um backend precisa satisfazer:
`async def run_turn(message: str) -> object`. Cada `BackendAgent` resolve seu **próprio** MCP
Client/entitlement de forma completamente independente (token, `tools/list`, reautorização) —
`GlobalSupervisor` nunca lê, guarda ou repassa a credencial de um backend para outro.
- `GlobalSupervisor` nunca importa `emcp_bus.orchestrator` (verificado via análise de AST no
teste unitário correspondente) — prova mecânica de que o Modo A não usa NBA própria.
**Para integrar um novo backend de agente no Modo A:** implementar `BackendAgent.run_turn`,
registrar seu próprio `ClientRegistration`/`ClientProfile` (ver
[`identity-oidc.md`](identity-oidc.md)) como qualquer outro MCP Client, e adicioná-lo ao dict
`backends` do `GlobalSupervisor`.
## Modo B — `ModeBOrchestrator` (substituição completa, ADR-024)
Para quando o **nosso próprio Service Orchestrator** é o único cérebro de NBA/NBO — nenhum
Global Supervisor externo é consultado.
```python
orchestrator = ModeBOrchestrator(
graph=journey_graph,
dispatcher=dispatcher,
client_ids=["sales-read-agent", "finance-payments-agent"],
)
decision = orchestrator.decide(subject_id="subject:7f31c2", channel="chat")
# ModeBDecision(chosen: NBADecision, considered: list[NBADecision]) | None
result = await orchestrator.decide_and_dispatch(subject_id=..., channel=..., arguments={...})
```
`graph` é o journey `StateGraph` real do EP-11-T02 (`orchestrator/graph.py`,
`build_journey_graph`), invocado **sem modificação**, uma vez por `client_id` em `client_ids`,
para o mesmo `subject_id` — cada invocação resolve profile/entitlement/offerings daquele client
de forma independente. `_combine` é a regra de referência que decide entre os `NBADecision`s
resultantes (pendência de Finance sempre vence oferta de Sales) — não é uma reivindicação de
otimalidade, apenas a mesma postura de `RuleBasedNBADecisionModel` (EP-11-T03). Um `client_id`
cuja jornada termina sem oferta sobrevivente (`nba is None`) simplesmente não contribui a
`considered` — nunca um erro.
**Para estender a regra de combinação:** substituir `ModeBOrchestrator._combine` por um modelo
de ranking cross-domain real — o contrato de entrada/saída (`list[NBADecision]` →
`NBADecision | None`) não muda.
## `execute_handoff`/`build_handoff_payload` (EP-12-T03) — implementado, não usado pelos modos A/B
Primitivo de handoff cross-process com dois cuidados estruturalmente separados: minimização de
contexto (`build_handoff_payload`, só `carry_keys` sobrevive) e resolução de entitlement sempre
independente (`execute_handoff` despacha via o mesmo `AgentDispatcher`, que resolve a credencial
do agente destino do zero). **Achado real**: nem o Modo A (roteamento conversacional puro, sem
uma `NBADecision` de origem) nem o Modo B (já combina decisões e despacha o vencedor direto)
usam este primitivo — ver `AS-BUILT.md` para o detalhe. Continua correto e reutilizável para um
handoff *dentro* de uma jornada Orchestrator-driven que precise ceder controle a outro agente no
meio da execução.
## `IdentityResolver` (EP-12-T04) — nunca uma entrada de autorização
```python
resolver = IdentityResolver(IdentityResolverConfig(aliases={"msisdn": "customer_key"}))
resolver.resolve(
{"msisdn": "5511...", "amount": 100}
) # -> {"customer_key": "5511...", "amount": 100}
```
Normaliza nomes de campo entre canais para o mapeamento de parâmetros do Fabric/MCP — **nunca**
consultado pelo PDP (verificado via AST no teste unitário correspondente, mesma disciplina do
`GlobalSupervisor`). Um campo resolvido a partir de dado fornecido pelo caller nunca deve
influenciar uma decisão de autorização (a lição do `docs/adr/ADR-023-autorizacao-nunca-vem-do-payload.md`).
## Erros
| Situação | Exceção |
|---|---|
| `nba.agent` sem `AgentTokenProvider` configurado | `UnknownAgentError` |
| IdP rejeita a obtenção do token do agente | `AgentCredentialError` |
| `current_backend` não registrado no `GlobalSupervisor` | `UnknownBackendError` |
| `nba.agent` do handoff diverge de `payload.target_agent` | `ValueError` (`execute_handoff`) |
| Canal sem `ChannelAdapter` registrado | `UnsupportedChannelError` |