New features: Route Stickness, Handoff, Clarification, Read-Only/Transactional, Long Term Memory

This commit is contained in:
2026-08-03 08:57:02 -03:00
parent e684b0ecc3
commit 8e414e4e26
604 changed files with 38978 additions and 402 deletions

View File

@@ -0,0 +1,290 @@
# Route Stickiness, Handoff, Encerramento e Políticas MCP
## 1. Objetivo
Este material consolida o desenho de continuidade semântica de rota, transferência para atendimento humano, encerramento de sessão e proteção mínima de ferramentas MCP de consulta e transação no Agent Framework OCI.
As capacidades são complementares:
- **Route stickiness** decide se o turno permanece com o agente ativo ou volta ao Enterprise Router.
- **Handoff e encerramento** tratam ações globais de sessão sem delegá-las a agentes de domínio.
- **Políticas MCP** decidem se uma ferramenta já selecionada pode executar, com diferenciação entre `read_only` e `transactional`.
Todas são opcionais e preservam o comportamento anterior quando desabilitadas ou não configuradas.
## 2. Visão arquitetural
```text
Nova mensagem
|
+-- sessão já encerrada? -- sim --> rejeitar reutilização da sessão
|
+-- route stickiness habilitada --> classificador semântico leve
| |
| +-- CONTINUE + agente ativo --> agente ativo
| +-- ROUTE/baixa confiança/erro --> Enterprise Router
| +-- HUMAN_HANDOFF --> nó global human_handoff
| +-- END_SESSION --> nó global end_session
|
+-- route stickiness desabilitada --> Enterprise Router
|
v
agente de domínio
|
v
ferramenta MCP selecionada
|
v
política read-only/transacional
| |
permitida bloqueada
| |
v v
MCP Gateway/Server resposta segura
```
O classificador de continuidade não responde ao usuário, não escolhe outro agente, não executa ferramentas e não interpreta regras de negócio. O MCP Server continua sendo a autoridade final para autenticação, autorização, idempotência, validação e atomicidade.
## 3. Decisões de continuidade e sessão
| Decisão | Condição | Destino | Executa agente/MCP? |
|---|---|---|---|
| `CONTINUE` | A mensagem permanece no domínio do agente ativo e supera o threshold | agente ativo | sim, conforme o fluxo do agente |
| `ROUTE` | Mudança de assunto, dúvida, baixa confiança ou falha | Enterprise Router | somente após nova rota |
| `HUMAN_HANDOFF` | Solicitação de atendimento humano | nó global `human_handoff` | não |
| `END_SESSION` | Solicitação ou confirmação de encerramento | nó global `end_session` | não |
No primeiro turno, `CONTINUE` é normalizado para `ROUTE`, pois ainda não existe agente ativo. `HUMAN_HANDOFF` e `END_SESSION` podem ser identificados mesmo no primeiro turno.
### 3.1 Por que a decisão é semântica
Não são usadas regexes, listas de frases, pronomes ou palavras-chave específicas de idioma. O código mantém somente decisões técnicas:
- feature flag;
- existência de agente ativo;
- threshold de confiança;
- validação do contrato de saída;
- fallback em timeout, erro ou JSON inválido.
Isso evita manutenção de padrões linguísticos por domínio e mantém o comportamento multilíngue no perfil LLM.
## 4. Contratos globais de sessão
### 4.1 Handoff humano
O router produz:
```json
{
"route": "human_handoff",
"intent": "human_handoff",
"method": "continuity",
"handoff": true,
"metadata": {
"session_control": "HUMAN_HANDOFF",
"route_bypassed": true
}
}
```
O nó global define:
- `session_control=HUMAN_HANDOFF`;
- `human_handoff_requested=true`;
- `session_ended=false`;
- `next_state=HUMAN_HANDOFF_REQUESTED`.
O evento `session.human_handoff.requested` permite que a integração escolha fila, fornecedor e protocolo. O framework não presume uma plataforma humana específica.
### 4.2 Encerramento
O router produz:
```json
{
"route": "end_session",
"intent": "end_session",
"method": "continuity",
"metadata": {
"session_control": "END_SESSION",
"route_bypassed": true
}
}
```
O nó global define:
- `session_control=END_SESSION`;
- `session_ended=true`;
- `human_handoff_requested=false`;
- `next_state=SESSION_ENDED`.
O evento `session.end.requested` deve ser emitido antes da persistência. Para encerramento definitivo, `session_ended=true` precisa ser persistido e novas mensagens com a mesma `session_id` devem ser bloqueadas antes de guardrails, roteamento, agentes, RAG, judges ou MCP.
## 5. Políticas MCP read-only e transacionais
### 5.1 Responsabilidade
Depois que a rota e o agente selecionam uma ferramenta, o `MCPToolRouter` aplica a política imediatamente antes da chamada externa:
- `read_only`: consulta sem alteração de estado; por padrão não exige confirmação.
- `transactional`: operação que altera estado; pode exigir confirmação explícita e campos obrigatórios.
A classificação não cria outro roteador LLM e não substitui a allowlist atual de ferramentas por agente/intenção.
### 5.2 Configuração no backend
```text
templates/agent_template_backend/config/tool_policies.yaml
```
```dotenv
TOOL_POLICIES_PATH=./config/tool_policies.yaml
```
```yaml
version: 1
defaults:
operation_type: read_only
require_confirmation: false
tool_policies:
consultar_plano:
operation_type: read_only
alterar_plano:
operation_type: transactional
require_confirmation: true
requires: [new_plan_id]
cancelar_servico:
operation_type: transactional
require_confirmation: true
```
Uma transação confirmada deve receber um booleano literal:
```json
{
"new_plan_id": "CONTROLE_100",
"confirmed": true
}
```
Também é aceito `"confirmation": true`. Strings como `"true"` não confirmam a operação.
### 5.3 Compatibilidade
- Se `tool_policies.yaml` não existir, o framework preserva `tool_type`, `requires`, `confirmation_required` e `execution_policy` de `tools.yaml`.
- Tools antigas sem política executam como antes.
- Uma política explícita no arquivo novo prevalece para tipo e confirmação daquela tool.
- `tools.yaml` continua sendo a fonte de endpoint, schema, habilitação e cache.
- A política fica no backend, e não em `libs/agent_framework`, porque varia por aplicação e domínio.
## 6. Interação entre continuidade e transações
Route stickiness não autoriza transações. Mesmo quando `CONTINUE` mantém o agente ativo, toda ferramenta passa novamente pelo controle MCP.
Exemplo:
```text
Usuário: Quero mudar para o plano Controle 100.
Agente: Confirma a alteração para o Controle 100?
Usuário: Sim.
-> CONTINUE mantém product_agent
-> agente recupera a ação pendente
-> MCPToolRouter valida confirmation=true
-> alterar_plano executa
```
Em `HUMAN_HANDOFF` ou `END_SESSION`, nenhum agente de domínio ou MCP deve executar. Uma transação pendente deve ser invalidada ou mantida suspensa conforme política explícita da aplicação; nunca deve executar implicitamente após handoff ou encerramento.
## 7. Configuração da continuidade
```dotenv
ENABLE_ROUTE_STICKINESS=true
ROUTE_STICKINESS_LLM_PROFILE=route_continuity
ROUTE_STICKINESS_CONFIDENCE_THRESHOLD=0.90
ROUTE_STICKINESS_HISTORY_TURNS=2
ROUTE_STICKINESS_MAX_TOKENS=80
HUMAN_HANDOFF_MESSAGE=Vou encaminhar seu atendimento para uma pessoa.
END_SESSION_MESSAGE=Atendimento encerrado. Obrigado pelo contato.
```
```yaml
profiles:
route_continuity:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 80
timeout_seconds: 5
```
Use o menor modelo aprovado no ambiente OCI. O classificador recebe apenas agente ativo, capacidades derivadas das intents, intent/domínio anteriores, histórico recente limitado e mensagem atual. Não recebe RAG completo, resultados MCP integrais, prompt do agente ou regras de negócio.
## 8. Segurança e fallback
- Apenas decisões acima do threshold são aceitas.
- `CONTINUE` sem agente ativo vira `ROUTE`.
- Baixa confiança, timeout, erro ou JSON inválido voltam ao Enterprise Router.
- Handoff e encerramento não executam tools.
- Confirmação transacional exige booleano literal.
- A validação conversacional não substitui controles do MCP Server.
- Sessões encerradas devem ser bloqueadas na entrada.
- Retries de transações devem usar idempotência no serviço de destino.
## 9. Telemetria
Evento de continuidade:
```json
{
"decision": "CONTINUE",
"confidence": 0.97,
"active_agent": "product_agent",
"route_bypassed": true,
"profile_name": "route_continuity"
}
```
Campos recomendados:
- `route_decision.method`;
- `active_agent`;
- `route_bypassed`;
- `continuity_signal`;
- `session_control`;
- `human_handoff_requested`;
- `session_ended`;
- `tool_name`;
- `operation_type`;
- `policy_source`;
- `blocked_by_policy`.
## 10. Testes e benchmark
Casos mínimos:
1. continuidade com bypass;
2. mudança de domínio com retorno ao router;
3. baixa confiança, timeout e JSON inválido;
4. `CONTINUE` sem agente ativo;
5. handoff e encerramento no primeiro turno e em turnos posteriores;
6. bloqueio de mensagem após sessão encerrada;
7. consulta sem confirmação;
8. transação sem confirmação, com string e com booleano válido;
9. campo obrigatório ausente;
10. ausência de `tool_policies.yaml` usando comportamento legado.
```bash
PYTHONPATH=libs/agent_framework/src:templates/agent_template_backend python -m pytest -q
```
Para benchmark, compare a mesma conversa com a funcionalidade habilitada e desabilitada. Registre `route_bypassed`, chamadas ao `llm.router`, latência de `llm.route_continuity`, tokens por perfil e latência total p50/p95/p99. Só atribua ganho à stickiness quando houver `route_bypassed=true` e nenhuma geração do Enterprise Router no mesmo turno.
## 11. Critério de adoção
Adote route stickiness quando houver conversas multi-turno com retornos frequentes ao mesmo agente. Cadastre políticas apenas para ferramentas que precisem de comportamento adicional, começando pelas transações que exigem confirmação. Mantenha autorização, idempotência e regras de negócio no MCP Server para evitar duas fontes de verdade.

View File

@@ -0,0 +1,289 @@
# Route Stickiness, Handoff, Session End, and MCP Policies
## 1. Purpose
This document consolidates semantic route continuity, human handoff, session ending, and minimal protection for read-only and transactional MCP tools in the OCI Agent Framework.
The capabilities are complementary:
- **Route stickiness** decides whether a turn stays with the active agent or returns to the Enterprise Router.
- **Handoff and session end** handle global session actions outside domain agents.
- **MCP policies** decide whether an already selected tool may execute, distinguishing `read_only` from `transactional` operations.
All capabilities are optional and preserve previous behavior when disabled or not configured.
## 2. Architecture overview
```text
Incoming message
|
+-- session already ended? -- yes --> reject session reuse
|
+-- route stickiness enabled --> lightweight semantic classifier
| |
| +-- CONTINUE + active agent --> active agent
| +-- ROUTE/low confidence/error --> Enterprise Router
| +-- HUMAN_HANDOFF --> global human_handoff node
| +-- END_SESSION --> global end_session node
|
+-- route stickiness disabled --> Enterprise Router
|
v
domain agent
|
v
selected MCP tool
|
v
read-only/transactional policy
| |
allowed blocked
| |
v v
MCP Gateway/Server safe response
```
The continuity classifier does not answer users, choose another agent, execute tools, or interpret business rules. The MCP Server remains the final authority for authentication, authorization, idempotency, validation, and atomicity.
## 3. Continuity and session decisions
| Decision | Condition | Destination | Runs agent/MCP? |
|---|---|---|---|
| `CONTINUE` | The message remains in the active agent's domain and exceeds the threshold | active agent | yes, according to the agent flow |
| `ROUTE` | Topic change, uncertainty, low confidence, or failure | Enterprise Router | only after routing |
| `HUMAN_HANDOFF` | Human assistance requested | global `human_handoff` node | no |
| `END_SESSION` | Session ending requested or confirmed | global `end_session` node | no |
On the first turn, `CONTINUE` is normalized to `ROUTE` because no active agent exists. `HUMAN_HANDOFF` and `END_SESSION` may be detected on the first turn.
### 3.1 Why the decision is semantic
The implementation uses no regexes, phrase lists, pronoun lists, or language-specific keywords. Code retains only technical decisions:
- feature flag;
- presence of an active agent;
- confidence threshold;
- output-contract validation;
- fallback on timeout, error, or invalid JSON.
This avoids domain-specific language-pattern maintenance and keeps multilingual behavior in the LLM profile.
## 4. Global session contracts
### 4.1 Human handoff
The router returns:
```json
{
"route": "human_handoff",
"intent": "human_handoff",
"method": "continuity",
"handoff": true,
"metadata": {
"session_control": "HUMAN_HANDOFF",
"route_bypassed": true
}
}
```
The global node sets:
- `session_control=HUMAN_HANDOFF`;
- `human_handoff_requested=true`;
- `session_ended=false`;
- `next_state=HUMAN_HANDOFF_REQUESTED`.
The `session.human_handoff.requested` event lets the integration select the queue, provider, and protocol. The framework assumes no specific human-service platform.
### 4.2 Session end
The router returns:
```json
{
"route": "end_session",
"intent": "end_session",
"method": "continuity",
"metadata": {
"session_control": "END_SESSION",
"route_bypassed": true
}
}
```
The global node sets:
- `session_control=END_SESSION`;
- `session_ended=true`;
- `human_handoff_requested=false`;
- `next_state=SESSION_ENDED`.
The `session.end.requested` event should be emitted before persistence. For definitive closure, `session_ended=true` must be persisted, and new messages using the same `session_id` must be blocked before guardrails, routing, agents, RAG, judges, or MCP.
## 5. Read-only and transactional MCP policies
### 5.1 Responsibility
After routing and agent selection identify a tool, `MCPToolRouter` applies policy immediately before the external call:
- `read_only`: retrieves data without changing state and does not require confirmation by default.
- `transactional`: changes state and may require explicit confirmation and mandatory fields.
This classification adds no LLM router and does not replace the existing per-agent/per-intent tool allowlist.
### 5.2 Backend configuration
```text
templates/agent_template_backend/config/tool_policies.yaml
```
```dotenv
TOOL_POLICIES_PATH=./config/tool_policies.yaml
```
```yaml
version: 1
defaults:
operation_type: read_only
require_confirmation: false
tool_policies:
consultar_plano:
operation_type: read_only
alterar_plano:
operation_type: transactional
require_confirmation: true
requires: [new_plan_id]
cancelar_servico:
operation_type: transactional
require_confirmation: true
```
A confirmed transaction must receive a literal boolean:
```json
{
"new_plan_id": "CONTROLE_100",
"confirmed": true
}
```
`"confirmation": true` is also accepted. Strings such as `"true"` do not confirm an operation.
### 5.3 Compatibility
- If `tool_policies.yaml` is absent, the framework preserves `tool_type`, `requires`, `confirmation_required`, and `execution_policy` from `tools.yaml`.
- Legacy tools without policy execute as before.
- An explicit entry in the new file takes precedence for that tool's type and confirmation behavior.
- `tools.yaml` remains the source for endpoint, schema, enablement, and cache.
- Policy belongs in the backend rather than `libs/agent_framework` because it varies by application and domain.
## 6. Interaction between continuity and transactions
Route stickiness does not authorize transactions. Even when `CONTINUE` retains the active agent, every tool passes through MCP policy again.
Example:
```text
User: I want to switch to the Control 100 plan.
Agent: Do you confirm the change to Control 100?
User: Yes.
-> CONTINUE retains product_agent
-> agent retrieves the pending action
-> MCPToolRouter validates confirmation=true
-> alterar_plano executes
```
For `HUMAN_HANDOFF` or `END_SESSION`, no domain agent or MCP tool should execute. A pending transaction must be invalidated or remain suspended according to an explicit application policy; it must never execute implicitly after handoff or session end.
## 7. Continuity configuration
```dotenv
ENABLE_ROUTE_STICKINESS=true
ROUTE_STICKINESS_LLM_PROFILE=route_continuity
ROUTE_STICKINESS_CONFIDENCE_THRESHOLD=0.90
ROUTE_STICKINESS_HISTORY_TURNS=2
ROUTE_STICKINESS_MAX_TOKENS=80
HUMAN_HANDOFF_MESSAGE=I will transfer your request to a person.
END_SESSION_MESSAGE=The session has ended. Thank you for contacting us.
```
```yaml
profiles:
route_continuity:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 80
timeout_seconds: 5
```
Use the smallest approved model available in the OCI environment. The classifier receives only the active agent, capabilities derived from intents, previous intent/domain, limited recent history, and the current message. It receives no complete RAG context, full MCP results, agent prompt, or business rules.
## 8. Safety and fallback
- Only decisions above the configured threshold are accepted.
- `CONTINUE` without an active agent becomes `ROUTE`.
- Low confidence, timeout, error, or invalid JSON falls back to the Enterprise Router.
- Handoff and session ending execute no tools.
- Transaction confirmation requires a literal boolean.
- Conversational validation does not replace MCP Server controls.
- Ended sessions must be blocked at request entry.
- Transaction retries require idempotency in the destination service.
## 9. Telemetry
Continuity event:
```json
{
"decision": "CONTINUE",
"confidence": 0.97,
"active_agent": "product_agent",
"route_bypassed": true,
"profile_name": "route_continuity"
}
```
Recommended fields:
- `route_decision.method`;
- `active_agent`;
- `route_bypassed`;
- `continuity_signal`;
- `session_control`;
- `human_handoff_requested`;
- `session_ended`;
- `tool_name`;
- `operation_type`;
- `policy_source`;
- `blocked_by_policy`.
## 10. Tests and benchmark
Minimum scenarios:
1. continuity with router bypass;
2. domain change with router fallback;
3. low confidence, timeout, and invalid JSON;
4. `CONTINUE` without an active agent;
5. handoff and session end on first and subsequent turns;
6. rejection of messages after session end;
7. read-only call without confirmation;
8. transaction without confirmation, with a string, and with a valid boolean;
9. missing mandatory field;
10. absent `tool_policies.yaml` using legacy behavior.
```bash
PYTHONPATH=libs/agent_framework/src:templates/agent_template_backend python -m pytest -q
```
For benchmarking, compare the same conversation with the feature enabled and disabled. Record `route_bypassed`, calls to `llm.router`, `llm.route_continuity` latency, tokens per profile, and total p50/p95/p99 latency. Attribute gains to stickiness only when `route_bypassed=true` and no Enterprise Router generation occurs in the same turn.
## 11. Adoption criteria
Adopt route stickiness for multi-turn conversations that frequently remain with the same agent. Register policies only for tools requiring additional behavior, starting with transactions that require confirmation. Keep authorization, idempotency, and business rules in the MCP Server to avoid competing sources of truth.

View File

@@ -0,0 +1,207 @@
###############################################################################
# AI AGENT PLATFORM - CONFIGURAÇÃO ÚNICA
# Este arquivo é lido por Pydantic Settings no framework e no backend template.
###############################################################################
APP_NAME=ai-agent-template
APP_ENV=local
LOG_LEVEL=INFO
API_HOST=0.0.0.0
API_PORT=8000
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
###############################################################################
# LLM - OCI Generative AI como provider principal
###############################################################################
# Opções: mock, oci_openai, oci_sdk, openai_compatible
LLM_PROVIDER=oci_openai
LLM_TEMPERATURE=0.2
LLM_MAX_TOKENS=2048
LLM_TIMEOUT_SECONDS=120
# OCI OpenAI-compatible endpoint
OCI_GENAI_BASE_URL=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1
OCI_GENAI_MODEL=openai.gpt-4.1
OCI_GENAI_API_KEY=sk-ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6
OCI_GENAI_PROJECT_OCID=
# OCI SDK / signer / profiles
OCI_CONFIG_FILE=~/.oci/config
OCI_PROFILE=DEFAULT
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
OCI_REGION=us-chicago-1
###############################################################################
# Persistência
###############################################################################
# Opções: memory, autonomous, mongodb
SESSION_REPOSITORY_PROVIDER=sqlite
MEMORY_REPOSITORY_PROVIDER=sqlite
CHECKPOINT_REPOSITORY_PROVIDER=sqlite
SQLITE_DB_PATH=./data/agent_framework.db
# Autonomous Database
ADB_USER=admin
ADB_PASSWORD=fjhsdf04954hf
ADB_DSN=oradb23aidev_high
ADB_WALLET_LOCATION=/ORACLE/DEFAULT/Wallet_ORADB23aiDev
ADB_WALLET_PASSWORD=fjhsdf04954hf
ADB_TABLE_PREFIX=AGENTFW
# MongoDB - também pode representar Autonomous usando API compatível com Mongo, se habilitada no ambiente
MONGODB_URI=mongodb://mongo:mongopassword@localhost:27017
MONGODB_DATABASE=agent_platform
# Redis
REDIS_URL=redis://localhost:6379/0
ENABLE_REDIS_CACHE=false
###############################################################################
# RAG / Vector / Graph
###############################################################################
VECTOR_STORE_PROVIDER=sqlite
GRAPH_STORE_PROVIDER=sqlite
RAG_TOP_K=5
EMBEDDING_PROVIDER=mock
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json
###############################################################################
# Observabilidade
###############################################################################
ENABLE_LANGFUSE=true
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
LANGFUSE_PUBLIC_KEY=pk-lf-2f9da109-5b0f-4c78-b61d-9598ed787eba
LANGFUSE_SECRET_KEY=sk-lf-a4cb0cdd-f2ea-4468-9911-cebeb91ba944
LANGFUSE_HOST=http://localhost:3005
ENABLE_OTEL=false
OTEL_EXPORTER_OTLP_ENDPOINT=
OTEL_SERVICE_NAME=ai-agent-template
ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true
###############################################################################
# Analytics / Observer corporativo
###############################################################################
# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo.
ENABLE_ANALYTICS=false
# Providers aceitos: oci_streaming,pubsub,noop
ANALYTICS_PROVIDERS=pubsub
# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente.
AGENT_PUBSUB_TOPIC=
GCP_PUBSUB_TOPIC_PATH=
GCP_PROJECT_ID=
GCP_PUBSUB_TOPIC=
GCP_PUBSUB_TIMEOUT_SECONDS=30
# Credencial GCP segue padrão Google:
# GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
###############################################################################
# OCI Streaming
###############################################################################
ENABLE_OCI_STREAMING=false
OCI_STREAM_ENDPOINT=
OCI_STREAM_OCID=
OCI_STREAM_PARTITION_KEY=agent-events
###############################################################################
# Guardrails, Judges, Supervisor
###############################################################################
ENABLE_INPUT_GUARDRAILS=true
ENABLE_OUTPUT_GUARDRAILS=true
ENABLE_JUDGES=true
ENABLE_SUPERVISOR=true
ENABLE_OUTPUT_SUPERVISOR=true
ENABLE_PARALLEL_GUARDRAILS=true
GUARDRAILS_FAIL_FAST=true
OUTPUT_SUPERVISOR_MAX_RETRIES=3
GUARDRAILS_CONFIG_PATH=./config/guardrails.yaml
JUDGES_CONFIG_PATH=./config/judges.yaml
PROMPT_POLICY_PATH=./config/prompt_policy.yaml
###############################################################################
# Gateway de canais
###############################################################################
DEFAULT_CHANNEL=web
# embedded = backend may parse simple/native channel payloads.
# external = backend only accepts GatewayRequest normalized by an external Channel Gateway.
FRAMEWORK_CHANNEL_INPUT_MODE=embedded
ENABLE_VOICE_ADAPTER=true
ENABLE_WHATSAPP_ADAPTER=true
ENABLE_TEXT_ADAPTER=true
#################################################
# ENTERPRISE ROUTING
#################################################
# Arquivo YAML com intents, keywords, políticas de estado e fallback.
ROUTING_CONFIG_PATH=./config/routing.yaml
# true = usa LLM para classificar quando keywords/estado não resolverem.
# Em produção, costuma ser útil; em desenvolvimento, false evita custo e latência.
ENABLE_LLM_ROUTER=true
# Semantic route stickiness (optional).
# Uses a lightweight LLM profile to decide only CONTINUE vs ROUTE.
# There are no regexes or deterministic language rules.
ENABLE_ROUTE_STICKINESS=true
ROUTE_STICKINESS_LLM_PROFILE=route_continuity
ROUTE_STICKINESS_CONFIDENCE_THRESHOLD=0.90
ROUTE_STICKINESS_HISTORY_TURNS=2
ROUTE_STICKINESS_MAX_TOKENS=80
HUMAN_HANDOFF_MESSAGE=Vou encaminhar seu atendimento para uma pessoa.
END_SESSION_MESSAGE=Atendimento encerrado. Obrigado pelo contato.
SESSION_ALREADY_ENDED_MESSAGE=Este atendimento já foi encerrado. Inicie uma nova sessão para continuar.
###############################################################################
# MCP / Tools
###############################################################################
ENABLE_MCP_TOOLS=true
MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.yaml
TOOLS_CONFIG_PATH=./config/tools.yaml
TOOL_POLICIES_PATH=./config/tool_policies.yaml
MCP_TOOL_TIMEOUT_SECONDS=30
# router = EnterpriseRouter seleciona um agente; supervisor = pode acionar múltiplos agentes
ROUTING_MODE=router
# Usage/cost accounting
USAGE_REPOSITORY_PROVIDER=sqlite
IDENTITY_CONFIG_PATH=./config/identity.yaml
MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml
# -----------------------------------------------------------------------------
# ConversationSummaryMemory / compressão de contexto conversacional
# -----------------------------------------------------------------------------
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
MEMORY_CONTEXT_STRATEGY=summary
MEMORY_HISTORY_LIMIT=80
MEMORY_RECENT_MESSAGES_LIMIT=8
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
MEMORY_MAX_SUMMARY_CHARS=6000
MEMORY_SUMMARY_USE_LLM=true
MEMORY_INJECT_RECENT_MESSAGES=true
MEMORY_INJECT_SUMMARY=true
###############################################################################
# MCP Gateway
###############################################################################
# true = framework routes tool calls to the dedicated MCP Gateway.
# false = framework calls MCP servers directly from mcp_servers.yaml.
MCP_GATEWAY_ENABLED=true
MCP_GATEWAY_URL=http://localhost:8300
MCP_GATEWAY_TIMEOUT_SECONDS=60
# MCP_GATEWAY_TOKEN=
MCP_GATEWAY_AGENT_ID=telecom_contas
MCP_GATEWAY_TENANT_ID=default
###############################################################################
# LONG-TERM MEMORY
###############################################################################
ENABLE_LONG_TERM_MEMORY=true
LONG_TERM_MEMORY_PROVIDER=sqlite
LONG_TERM_MEMORY_SQLITE_PATH=./data/agent_framework.db
LONG_TERM_MEMORY_TABLE=agentfw_long_term_memory
# For Autonomous/Oracle, defaults to ${ADB_TABLE_PREFIX}_LONG_TERM_MEMORY
# LONG_TERM_MEMORY_ORACLE_TABLE=AGENTFW_LONG_TERM_MEMORY
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS=20
LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70
LONG_TERM_MEMORY_AUTO_EXTRACT=true
LONG_TERM_MEMORY_INJECT_CONTEXT=true

View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY agent_framework /agent_framework
COPY agent_template_backend /app
RUN pip install --no-cache-dir -e /agent_framework -r requirements.txt
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
# Agent Template Backend Enterprise
Este folder é uma cópia completa do `agent_template_backend`, sem cortes de
arquitetura. Ele mantém workflow, router, output supervisor, guardrails,
analytics, observer, MCP, memória, checkpoints e configurações.
A diferença é que a lógica de negócio dos agentes de exemplo foi removida da
execução e preservada comentada nos próprios arquivos:
- `app/agents/billing_agent.py`
- `app/agents/product_agent.py`
- `app/agents/orders_agent.py`
- `app/agents/support_agent.py`
## O que o desenvolvedor deve alterar
1. Escolher ou criar um agente em `app/agents/`.
2. Implementar o método `run()`.
3. Ajustar prompts e tools, se necessário.
4. Emitir ICs de negócio relevantes para a jornada.
5. Manter NOC/GRL nos pontos operacionais e de guardrails.
## O que já está integrado
- `AgentObserver`
- `observer.emit_ic()`
- `observer.emit_noc()`
- `observer.emit_grl()`
- `AnalyticsPublisher`
- OCI Streaming
- GCP Pub/Sub
- OutputSupervisor
- GuardrailPipeline com suporte a execução paralela/fail-fast no framework
- MCP Tool Router
- LangGraph
- Memory
- Checkpoint
- Langfuse / OpenTelemetry
## Exemplos adicionados
Veja `app/examples/`:
- `ic_examples.py`
- `noc_examples.py`
- `grl_examples.py`
- `mcp_examples.py`
- `observer_examples.py`
## Convenção rápida
- IC = evento de negócio / curadoria / informacional.
- NOC = evento operacional / saúde técnica.
- GRL = evento de guardrail / segurança / validação.

View File

@@ -0,0 +1,15 @@
# Agentes do Template Backend Enterprise
Os arquivos desta pasta preservam a estrutura real esperada pelo workflow, mas
não executam lógica de negócio pronta.
Cada agente mostra:
- como emitir IC;
- como emitir NOC;
- como emitir GRL;
- como coletar MCP via `_collect_tool_context()`;
- como recuperar RAG via `_retrieve_rag_context()`;
- onde chamar LLM/cache.
A implementação original do exemplo está comentada no fim de cada arquivo.

View File

@@ -0,0 +1,129 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class BillingAgent(AgentRuntimeMixin):
name = "billingAgent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
async def run(self, state):
await self._emit_ic(
"IC.BILLING_AGENT_STARTED",
state,
{"business_component": "faturas"},
component="agent.billing.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.BILLING_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.billing.mcp",
)
state["mcp_results"] = tool_context
clarification_message = self.transaction_clarification_message(state)
if clarification_message:
return {
"answer": f"[{self.__class__.__name__}] {clarification_message}",
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
confirmation_message = self.transaction_confirmation_message(state)
if confirmation_message:
result = {
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
"next_state": state.get("next_state"),
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
return result
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="BillingAgent")
if direct_answer:
return {
"answer": direct_answer,
"next_state": state.get("next_state") or "ACTIVE",
"mcp_results": tool_context,
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
**self.transaction_state_patch(state),
}
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.BILLING_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.billing.rag",
)
# Prepara ConversationSummaryMemory antes de montar o prompt.
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
await self.prepare_memory_context(state)
messages = self.build_messages(
state,
system_prompt=apply_agent_profile_prompt(
state,
"Você é um agente especialista em faturas. Responda com clareza, objetividade e sem sugerir ações não solicitadas. Use dados MCP quando disponíveis.",
),
mcp_results=tool_context,
rag_context=rag_context,
rag_metadata=rag_metadata,
)
answer = await self._invoke_llm_cached(state, "BillingAgent", messages)
result = {
"answer": f"[BillingAgent] {answer}",
"next_state": "BILLING_ACTIVE",
"mcp_results": tool_context,
"rag": rag_metadata,
"memory_context_metadata": state.get("memory_context_metadata"),
**self.transaction_state_patch(state),
}
await self._emit_ic(
"IC.BILLING_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
"memory_context": state.get("memory_context_metadata"),
},
component="agent.billing.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

View File

@@ -0,0 +1,129 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class OrdersAgent(AgentRuntimeMixin):
name = "orders_agent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
async def run(self, state):
await self._emit_ic(
"IC.ORDERS_AGENT_STARTED",
state,
{"business_component": "pedidos"},
component="agent.orders.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.ORDERS_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.orders.mcp",
)
state["mcp_results"] = tool_context
clarification_message = self.transaction_clarification_message(state)
if clarification_message:
return {
"answer": f"[{self.__class__.__name__}] {clarification_message}",
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
confirmation_message = self.transaction_confirmation_message(state)
if confirmation_message:
result = {
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
"next_state": state.get("next_state"),
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
return result
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="OrdersAgent")
if direct_answer:
return {
"answer": direct_answer,
"next_state": state.get("next_state") or "ACTIVE",
"mcp_results": tool_context,
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
**self.transaction_state_patch(state),
}
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.ORDERS_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.orders.rag",
)
# Prepara ConversationSummaryMemory antes de montar o prompt.
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
await self.prepare_memory_context(state)
messages = self.build_messages(
state,
system_prompt=apply_agent_profile_prompt(
state,
"Você é um agente de pedidos de varejo. Use dados de tools quando disponíveis.",
),
mcp_results=tool_context,
rag_context=rag_context,
rag_metadata=rag_metadata,
)
answer = await self._invoke_llm_cached(state, "OrdersAgent", messages)
result = {
"answer": f"[OrdersAgent] {answer}",
"next_state": "ORDER_ACTIVE",
"mcp_results": tool_context,
"rag": rag_metadata,
"memory_context_metadata": state.get("memory_context_metadata"),
**self.transaction_state_patch(state),
}
await self._emit_ic(
"IC.ORDERS_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
"memory_context": state.get("memory_context_metadata"),
},
component="agent.orders.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

View File

@@ -0,0 +1,129 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class ProductAgent(AgentRuntimeMixin):
name = "productAgent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
async def run(self, state):
await self._emit_ic(
"IC.PRODUCT_AGENT_STARTED",
state,
{"business_component": "produtos"},
component="agent.product.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.PRODUCT_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.product.mcp",
)
state["mcp_results"] = tool_context
clarification_message = self.transaction_clarification_message(state)
if clarification_message:
return {
"answer": f"[{self.__class__.__name__}] {clarification_message}",
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
confirmation_message = self.transaction_confirmation_message(state)
if confirmation_message:
result = {
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
"next_state": state.get("next_state"),
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
return result
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="ProductAgent")
if direct_answer:
return {
"answer": direct_answer,
"next_state": state.get("next_state") or "ACTIVE",
"mcp_results": tool_context,
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
**self.transaction_state_patch(state),
}
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.PRODUCT_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.product.rag",
)
# Prepara ConversationSummaryMemory antes de montar o prompt.
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
await self.prepare_memory_context(state)
messages = self.build_messages(
state,
system_prompt=apply_agent_profile_prompt(
state,
"Você é um agente especialista em produtos, planos e serviços. Explique sem fazer oferta proativa e sem executar ações sem confirmação. Use dados MCP quando disponíveis.",
),
mcp_results=tool_context,
rag_context=rag_context,
rag_metadata=rag_metadata,
)
answer = await self._invoke_llm_cached(state, "ProductAgent", messages)
result = {
"answer": f"[ProductAgent] {answer}",
"next_state": "PRODUCT_ACTIVE",
"mcp_results": tool_context,
"rag": rag_metadata,
"memory_context_metadata": state.get("memory_context_metadata"),
**self.transaction_state_patch(state),
}
await self._emit_ic(
"IC.PRODUCT_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
"memory_context": state.get("memory_context_metadata"),
},
component="agent.product.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
def apply_agent_profile_prompt(state: dict, default_prompt: str) -> str:
"""Adiciona o prefixo de prompt configurado para o agent_template selecionado.
Cada agent_id pode definir metadata.system_prefix em config/agents.yaml. Isso
mantém prompts isolados sem duplicar o código dos agentes especializados.
"""
profile = state.get("agent_profile") or (state.get("context") or {}).get("agent_profile") or {}
metadata = profile.get("metadata") or {}
prefix = (metadata.get("system_prefix") or "").strip()
if not prefix:
return default_prompt
return f"{prefix}\n\n{default_prompt}"

View File

@@ -0,0 +1,7 @@
from __future__ import annotations
# Compatibilidade local do template/backend.
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]

View File

@@ -0,0 +1,129 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class SupportAgent(AgentRuntimeMixin):
name = "support_agent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
async def run(self, state):
await self._emit_ic(
"IC.SUPPORT_AGENT_STARTED",
state,
{"business_component": "suporte"},
component="agent.support.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.SUPPORT_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.support.mcp",
)
state["mcp_results"] = tool_context
clarification_message = self.transaction_clarification_message(state)
if clarification_message:
return {
"answer": f"[{self.__class__.__name__}] {clarification_message}",
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
confirmation_message = self.transaction_confirmation_message(state)
if confirmation_message:
result = {
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
"next_state": state.get("next_state"),
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
return result
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="SupportAgent")
if direct_answer:
return {
"answer": direct_answer,
"next_state": state.get("next_state") or "ACTIVE",
"mcp_results": tool_context,
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
**self.transaction_state_patch(state),
}
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.SUPPORT_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.support.rag",
)
# Prepara ConversationSummaryMemory antes de montar o prompt.
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
await self.prepare_memory_context(state)
messages = self.build_messages(
state,
system_prompt=apply_agent_profile_prompt(
state,
"Você é um agente de suporte de varejo para troca, devolução e garantia.",
),
mcp_results=tool_context,
rag_context=rag_context,
rag_metadata=rag_metadata,
)
answer = await self._invoke_llm_cached(state, "SupportAgent", messages)
result = {
"answer": f"[SupportAgent] {answer}",
"next_state": "SUPPORT_ACTIVE",
"mcp_results": tool_context,
"rag": rag_metadata,
"memory_context_metadata": state.get("memory_context_metadata"),
**self.transaction_state_patch(state),
}
await self._emit_ic(
"IC.SUPPORT_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
"memory_context": state.get("memory_context_metadata"),
},
component="agent.support.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

View File

@@ -0,0 +1 @@
"""Exemplos de uso do template backend enterprise."""

View File

@@ -0,0 +1,37 @@
"""Exemplos de GRL.
GRL representa eventos de guardrails. Em regra, GRL.001..GRL.009 são emitidos
pelo pipeline de guardrails e pelo OutputSupervisor do framework. Use emissão
manual apenas para validações customizadas do agente.
"""
from typing import Any
async def exemplo_guardrail_observado(observer: Any, state: dict[str, Any], rail_code: str, reason: str) -> None:
await observer.emit_grl(
"OBSERVE",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"rail_code": rail_code,
"reason": reason,
},
component="examples.grl",
)
async def exemplo_guardrail_block(observer: Any, state: dict[str, Any], rail_code: str, reason: str) -> None:
await observer.emit_grl(
"004",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"rail_code": rail_code,
"reason": reason,
"action": "block",
},
component="examples.grl",
)

View File

@@ -0,0 +1,34 @@
"""Exemplos de IC - Item de Controle.
ICs representam eventos de negócio. Eles alimentam Informacional, Curadoria,
analytics, BigQuery ou qualquer publisher configurado no framework.
"""
from typing import Any
async def exemplo_fatura_consultada(observer: Any, state: dict[str, Any], invoice_id: str) -> None:
await observer.emit_ic(
"IC.FATURA_CONSULTADA",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"invoice_id": invoice_id,
},
component="examples.ic",
)
async def exemplo_acao_concluida(observer: Any, state: dict[str, Any], action_name: str, ok: bool) -> None:
await observer.emit_ic(
"IC.ACAO_CONCLUIDA",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"action_name": action_name,
"ok": ok,
},
component="examples.ic",
)

View File

@@ -0,0 +1,43 @@
"""Exemplos de MCP + IC.
O AgentRuntimeMixin já possui _collect_mcp_context(), mas este arquivo mostra o
padrão para chamadas explícitas ao tool_router quando necessário.
"""
from typing import Any
async def exemplo_chamada_mcp(tool_router: Any, observer: Any, state: dict[str, Any], tool_name: str, payload: dict[str, Any]) -> Any:
session_id = state.get("conversation_key") or state.get("session_id")
await observer.emit_ic(
"IC.MCP_TOOL_CALLED",
{
"session_id": session_id,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"tool_name": tool_name,
},
component="examples.mcp",
)
result = await tool_router.call(
tool_name,
payload,
business_context=(state.get("context") or {}).get("business_context") or {},
original_context=state.get("context") or {},
)
await observer.emit_ic(
"IC.TOOL_CALLED",
{
"session_id": session_id,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"tool_name": tool_name,
"ok": getattr(result, "ok", None),
},
component="examples.mcp",
)
return result

View File

@@ -0,0 +1,37 @@
"""Exemplos de NOC.
NOC representa telemetria operacional. O workflow do template já emite NOC.001,
NOC.005 e NOC.006. Estes exemplos mostram eventos adicionais que a squad pode
emitir em pontos críticos.
"""
from typing import Any
async def exemplo_api_invalida(observer: Any, state: dict[str, Any], api_url: str, status_code: int, latency_ms: int) -> None:
await observer.emit_noc(
"002",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"apiUrl": api_url,
"statusCode": status_code,
"latencyMs": latency_ms,
},
component="examples.noc",
)
async def exemplo_latencia_banco(observer: Any, state: dict[str, Any], resource_name: str, latency_ms: int) -> None:
await observer.emit_noc(
"003",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"resourceName": resource_name,
"latencyMs": latency_ms,
},
component="examples.noc",
)

View File

@@ -0,0 +1,28 @@
"""Resumo prático do Observer corporativo.
Use este arquivo como cola rápida para IC, NOC e GRL.
"""
from typing import Any
async def emitir_eventos_basicos(observer: Any, state: dict[str, Any]) -> None:
session_id = state.get("conversation_key") or state.get("session_id")
await observer.emit_ic(
"IC.EXEMPLO_NEGOCIO",
{"session_id": session_id, "agent_id": state.get("agent_id")},
component="examples.observer",
)
await observer.emit_noc(
"EXEMPLO_OPERACIONAL",
{"session_id": session_id, "agent_id": state.get("agent_id")},
component="examples.observer",
)
await observer.emit_grl(
"OBSERVE",
{"session_id": session_id, "agent_id": state.get("agent_id"), "rail_code": "CUSTOM"},
component="examples.observer",
)

View File

@@ -0,0 +1,532 @@
from __future__ import annotations
import logging
from uuid import uuid4
import time
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from agent_framework.channels.base import ChannelResponse
from agent_framework.channels.gateway import ChannelGateway
from agent_framework.config.agent_registry import AgentProfileRegistry
from agent_framework.config.settings import settings
from agent_framework.analytics.factory import create_analytics_publisher
from agent_framework.observer import configure as configure_global_observer
from agent_framework.llm.providers import create_llm
from agent_framework.memory.message_history import create_memory
from agent_framework.memory.summary_memory import create_conversation_summary_memory
from agent_framework.mcp.tool_router import create_mcp_tool_router
from agent_framework.models.identity import AgentIdentity
from agent_framework.identity import IdentityResolver, BusinessContext
from agent_framework.models.session import ChatMessage, SessionContext
from agent_framework.observability.telemetry import Telemetry
from agent_framework.observability.context import set_observability_context, clear_observability_context
from agent_framework.repositories.session_repository import create_session_repository
from agent_framework.checkpoints.checkpoint_repository import create_checkpoint_repository
from agent_framework.cache.cache import create_cache
from agent_framework.billing.usage_repository import create_usage_repository
from agent_framework.sse.events import SSEHub
from app.workflows.agent_graph import AgentWorkflow
from app.observability.telemetry_observer import TelemetryBackedAgentObserver
logging.basicConfig(level=settings.LOG_LEVEL)
logger = logging.getLogger("agent_template_backend")
app = FastAPI(title="Agent Template Backend FIRST-ready")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",")],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
telemetry = Telemetry(settings)
usage_repository = create_usage_repository(settings)
llm = create_llm(settings, telemetry=telemetry, usage_repository=usage_repository)
memory = create_memory(settings)
summary_memory = create_conversation_summary_memory(settings, message_history=memory, llm=llm, telemetry=telemetry)
sessions = create_session_repository(settings)
checkpoints = create_checkpoint_repository(settings)
cache = create_cache(settings, telemetry=telemetry)
gateway = ChannelGateway(input_mode=settings.FRAMEWORK_CHANNEL_INPUT_MODE)
analytics = create_analytics_publisher(settings)
observer = TelemetryBackedAgentObserver(telemetry=telemetry)
configure_global_observer({
"enabled": getattr(settings, "ENABLE_ANALYTICS", False),
"providers": getattr(settings, "ANALYTICS_PROVIDERS", "oci_streaming"),
"topic_path": getattr(settings, "GCP_PUBSUB_TOPIC_PATH", None) or getattr(settings, "AGENT_PUBSUB_TOPIC", None),
})
tool_router = create_mcp_tool_router(settings, telemetry=telemetry)
identity_resolver = IdentityResolver.from_yaml(settings.IDENTITY_CONFIG_PATH)
agent_profiles = AgentProfileRegistry(settings)
sse_hub = SSEHub(settings, telemetry=telemetry)
workflow = AgentWorkflow(llm, memory, telemetry, analytics, settings, observer=observer, tool_router=tool_router, summary_memory=summary_memory)
logger.info("LLM provider carregado: %s", llm.__class__.__name__)
logger.info("Langfuse habilitado: %s host=%s", telemetry.is_enabled(), settings.LANGFUSE_HOST)
logger.info("Analytics habilitado: %s providers=%s", getattr(settings, "ENABLE_ANALYTICS", False), getattr(settings, "ANALYTICS_PROVIDERS", ""))
logger.info("Agentes disponíveis: %s", [p.agent_id for p in agent_profiles.list_profiles()])
logger.info("Framework channel input mode: %s", gateway.input_mode)
@app.middleware("http")
async def observability_context_middleware(request: Request, call_next):
clear_observability_context()
request_id = request.headers.get("x-request-id") or str(uuid4())
set_observability_context(
request_id=request_id,
channel=request.headers.get("x-channel") or "http",
ura_call_id=request.headers.get("x-ura-call-id"),
)
started = time.time()
try:
response = await call_next(request)
response.headers["x-request-id"] = request_id
await telemetry.event("http.request.completed", {
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": int((time.time() - started) * 1000),
}, kind="http")
return response
except Exception as exc:
await telemetry.event("http.request.failed", {
"method": request.method,
"path": request.url.path,
"error": str(exc),
"duration_ms": int((time.time() - started) * 1000),
}, kind="http")
raise
finally:
clear_observability_context()
class GatewayRequest(BaseModel):
channel: str = "web"
payload: dict
agent_id: str | None = None
tenant_id: str | None = None
def _metadata_value(payload: dict, key: str):
metadata = payload.get("metadata")
if isinstance(metadata, dict):
return metadata.get(key)
return None
def _extract_workflow_id(payload: dict) -> str | None:
return (
payload.get("workflow_id")
or payload.get("workflowId")
or _metadata_value(payload, "workflow_id")
or _metadata_value(payload, "workflowId")
)
def _format_root_span_name(template: str | None, values: dict) -> str:
template = template or "agent.gateway_message"
try:
return template.format(**{k: v or "unknown" for k, v in values.items()})
except Exception:
logger.warning("LANGFUSE_ROOT_SPAN_NAME inválido: %s", template)
return "agent.gateway_message"
def _resolve_identity(req: GatewayRequest, msg) -> tuple[AgentIdentity, dict, BusinessContext, list[str]]:
payload = req.payload or {}
context = dict(msg.context or {})
tenant_id = req.tenant_id or payload.get("tenant_id") or context.get("tenant_id") or "default"
agent_id = req.agent_id or payload.get("agent_id") or context.get("agent_id") or agent_profiles.default_agent_id
profile = agent_profiles.get(agent_id)
# 1) Identidade técnica do framework: isola tenant/agente/sessão.
context.update({"tenant_id": tenant_id, "agent_id": profile.agent_id, "agent_profile": profile.__dict__})
identity = AgentIdentity.from_context(context, session_id=msg.session_id)
# 2) Identidade de negócio: chaves canônicas vindas do front/canal.
# Estas chaves são estáveis na sessão e seguem até agentes e MCP Router.
previous_business_context = context.get("business_context") or context.get("identity") or {}
business_context = identity_resolver.resolve(
{**payload, **context},
session_id=identity.conversation_key(),
previous=previous_business_context,
)
missing_identity_keys = identity_resolver.validate(business_context)
context.update({
"business_context": business_context.model_dump(),
"business_keys": business_context.to_context_dict(),
"identity_missing": missing_identity_keys,
"conversation_key": identity.conversation_key(),
"original_session_id": msg.session_id,
})
return identity, context, business_context, missing_identity_keys
async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False) -> dict:
try:
msg = await gateway.normalize(req.channel, req.payload)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
payload = req.payload or {}
identity, normalized_context, business_context, missing_identity_keys = _resolve_identity(req, msg)
agent_session_id = identity.conversation_key()
message_id = payload.get("message_id") or str(uuid4())
workflow_id = _extract_workflow_id(payload)
set_observability_context(
session_id=agent_session_id,
user_id=msg.user_id,
tenant_id=identity.tenant_id,
agent_id=identity.agent_id,
channel=msg.channel,
message_id=message_id,
workflow_id=workflow_id,
ura_call_id=payload.get("ura_call_id") or normalized_context.get("ura_call_id") or business_context.interaction_key,
)
stream = sse_hub.stream_for(agent_session_id)
async with stream.lock:
await sse_hub.emit(agent_session_id, "flow.start", {"session_id": agent_session_id, "message_id": message_id, "agent_id": identity.agent_id}) if emit_sse else None
session = await sessions.get(agent_session_id)
if not session:
context_fields = {
k: v
for k, v in normalized_context.items()
if k in SessionContext.model_fields
and k not in {"tenant_id", "agent_id", "session_id", "user_id", "channel", "channel_id"}
}
session = SessionContext(
tenant_id=identity.tenant_id,
agent_id=identity.agent_id,
session_id=agent_session_id,
user_id=msg.user_id,
channel=msg.channel,
channel_id=msg.channel_id,
**context_fields,
)
session.tenant_id = identity.tenant_id
session.agent_id = identity.agent_id
session.channel = msg.channel
session.channel_id = msg.channel_id or session.channel_id
await sessions.upsert(session)
session.metadata = {
**(session.metadata or {}),
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"original_context": normalized_context,
}
await sse_hub.emit(agent_session_id, "session.upserted", {"session_id": agent_session_id, "business_context": business_context.model_dump()}) if emit_sse else None
await memory.append(
agent_session_id,
ChatMessage(
role="user",
content=msg.text,
metadata={
**normalized_context,
"agent_id": identity.agent_id,
"tenant_id": identity.tenant_id,
"message_id": message_id,
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
},
),
)
await sse_hub.emit(agent_session_id, "message.received", {"session_id": agent_session_id, "role": "user"}) if emit_sse else None
history = [m.model_dump(mode="json") for m in await memory.list(agent_session_id)]
cms_input = {
"channel": req.channel,
"tenant_id": req.tenant_id,
"agent_id": req.agent_id,
"payload": payload,
}
trace_context = {
"text": msg.text,
"channel": msg.channel,
"channel_id": msg.channel_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"conversation_key": agent_session_id,
"workflow_id": workflow_id,
"message_id": message_id,
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
}
root_span_name = _format_root_span_name(
getattr(settings, "LANGFUSE_ROOT_SPAN_NAME", "agent.gateway_message"),
{
"workflow_id": workflow_id,
"channel": msg.channel,
"agent_id": identity.agent_id,
"tenant_id": identity.tenant_id,
},
)
root_tags = ["agent-template", msg.channel, f"agent:{identity.agent_id}", f"tenant:{identity.tenant_id}"]
if workflow_id:
root_tags.append(f"workflow:{workflow_id}")
async with telemetry.span(
root_span_name,
session_id=agent_session_id,
user_id=session.user_id,
channel=msg.channel,
workflow_id=workflow_id,
input=cms_input,
tags=root_tags,
_root_span=True,
) as root_span:
await telemetry.event("gateway.message.received", trace_context)
await sse_hub.emit(agent_session_id, "workflow.started", trace_context) if emit_sse else None
result = await workflow.ainvoke(
{
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"session_id": agent_session_id,
"conversation_key": agent_session_id,
"workflow_id": workflow_id,
"agent_profile": normalized_context["agent_profile"],
"user_text": msg.text,
"history": history,
"context": {
**normalized_context,
"session": session.model_dump(mode="json"),
"original_session_id": msg.session_id,
"session_id": agent_session_id,
"conversation_key": agent_session_id,
"workflow_id": workflow_id,
"user_id": session.user_id,
"channel": msg.channel,
"message_id": message_id,
"business_context": business_context.model_dump(),
"business_keys": business_context.to_context_dict(),
"identity_missing": missing_identity_keys,
},
}
)
await checkpoints.put(agent_session_id, {"state": result, "message_id": message_id})
await sse_hub.emit(agent_session_id, "workflow.completed", {"session_id": agent_session_id, "route": result.get("route"), "intent": result.get("intent")}) if emit_sse else None
answer = result.get("final_answer") or result.get("answer") or ""
await memory.append(
agent_session_id,
ChatMessage(
role="assistant",
content=answer,
metadata={
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"message_id": f"assistant-{message_id}",
"route": result.get("route"),
"intent": result.get("intent"),
"route_decision": result.get("route_decision"),
"judges": result.get("judge_results"),
},
),
)
await telemetry.event(
"gateway.message.responded",
{
"session_id": agent_session_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"route": result.get("route"),
"intent": result.get("intent"),
"answer_chars": len(answer),
},
)
response = ChannelResponse(
channel=msg.channel,
session_id=agent_session_id,
text=answer,
metadata={
"channel_id": msg.channel_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"original_session_id": msg.session_id,
"conversation_key": agent_session_id,
"workflow_id": workflow_id,
"message_id": message_id,
"route": result.get("route"),
"intent": result.get("intent"),
"route_decision": result.get("route_decision"),
"domain": result.get("domain"),
"mcp_tools": result.get("mcp_tools"),
"mcp_results": result.get("mcp_results"),
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"judges": result.get("judge_results"),
"guardrails": result.get("guardrail_decisions"),
},
)
rendered = await gateway.render(response)
root_span.set_output(rendered)
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
await sse_hub.emit(agent_session_id, "flow.end", {"session_id": agent_session_id, "message_id": message_id}) if emit_sse else None
return rendered
@app.get("/health")
async def health():
return {
"status": "ok",
"llm_provider": settings.LLM_PROVIDER,
"llm_class": llm.__class__.__name__,
"langfuse_enabled": telemetry.is_enabled(),
"agents": [p.agent_id for p in agent_profiles.list_profiles()],
"default_agent_id": agent_profiles.default_agent_id,
"routing_mode": settings.ROUTING_MODE,
"sse_enabled": settings.ENABLE_SSE,
"session_repository": settings.SESSION_REPOSITORY_PROVIDER,
"memory_repository": settings.MEMORY_REPOSITORY_PROVIDER,
"checkpoint_repository": settings.CHECKPOINT_REPOSITORY_PROVIDER,
"usage_repository": settings.USAGE_REPOSITORY_PROVIDER,
"identity_config_path": settings.IDENTITY_CONFIG_PATH,
"mcp_parameter_mapping_path": settings.MCP_PARAMETER_MAPPING_PATH,
"framework_channel_input_mode": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
"legacy_channel_gateway_mode": settings.CHANNEL_GATEWAY_MODE,
}
@app.get("/agents")
async def list_agents():
return {"default_agent_id": agent_profiles.default_agent_id, "agents": [p.__dict__ for p in agent_profiles.list_profiles()]}
@app.get("/debug/env")
async def debug_env():
return {
"APP_ENV": settings.APP_ENV,
"LLM_PROVIDER": settings.LLM_PROVIDER,
"ENABLE_LANGFUSE": settings.ENABLE_LANGFUSE,
"LANGFUSE_HOST": settings.LANGFUSE_HOST,
"TELEMETRY_ENABLED": telemetry.is_enabled(),
"SQLITE_DB_PATH": settings.SQLITE_DB_PATH,
"SESSION_REPOSITORY_PROVIDER": settings.SESSION_REPOSITORY_PROVIDER,
"MEMORY_REPOSITORY_PROVIDER": settings.MEMORY_REPOSITORY_PROVIDER,
"CHECKPOINT_REPOSITORY_PROVIDER": settings.CHECKPOINT_REPOSITORY_PROVIDER,
"AGENTS_CONFIG_PATH": settings.AGENTS_CONFIG_PATH,
"ROUTING_CONFIG_PATH": settings.ROUTING_CONFIG_PATH,
"ROUTING_MODE": settings.ROUTING_MODE,
"FRAMEWORK_CHANNEL_INPUT_MODE": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
"CHANNEL_GATEWAY_MODE": settings.CHANNEL_GATEWAY_MODE,
}
@app.get("/test-llm")
async def test_llm():
async with telemetry.span("debug.test_llm", input={"message": "Diga apenas OK"}):
answer = await llm.ainvoke([
{"role": "system", "content": "Responda de forma curta."},
{"role": "user", "content": "Diga apenas OK"},
])
telemetry.flush()
return {"provider": llm.__class__.__name__, "answer": answer}
@app.post("/debug/route")
async def debug_route(req: GatewayRequest):
msg = await gateway.normalize(req.channel, req.payload)
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
state = {
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"session_id": msg.session_id or "debug-session",
"conversation_key": identity.conversation_key(),
"agent_profile": context["agent_profile"],
"user_text": msg.text,
"sanitized_input": msg.text,
"history": [],
"context": {**context, "session": context.get("session", {}), "channel": msg.channel, "business_context": business_context.model_dump()},
}
if settings.ROUTING_MODE == "supervisor":
plan = await workflow.supervisor.route_plan(state)
return {"mode": "supervisor", "route": "supervisor_agent", "agents": plan.agents, "intent": plan.intent, "confidence": plan.confidence, "reason": plan.reason, "metadata": plan.metadata}
decision = await workflow.router.route(state)
data = decision.model_dump(mode="json")
data["mode"] = "router"
return data
@app.post("/debug/identity")
async def debug_identity(req: GatewayRequest):
msg = await gateway.normalize(req.channel, req.payload)
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
return {
"technical_identity": {
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"conversation_key": identity.conversation_key(),
"original_session_id": msg.session_id,
},
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"context_keys": sorted(context.keys()),
}
@app.get("/debug/usage")
async def debug_usage(tenant_id: str | None = None, session_id: str | None = None):
return await usage_repository.summarize(tenant_id=tenant_id, session_id=session_id)
@app.get("/debug/mcp/tools")
async def debug_mcp_tools():
return {"enabled": tool_router.enabled, "tools": tool_router.describe_tools()}
@app.post("/debug/mcp/call/{tool_name}")
async def debug_mcp_call(tool_name: str, arguments: dict | None = None):
arguments = arguments or {}
ctx = arguments.get("business_context") or arguments.get("identity") or {}
result = await tool_router.call(
tool_name,
arguments,
business_context=ctx,
original_context=arguments,
)
return result.model_dump(mode="json")
@app.post("/gateway/message")
async def gateway_message(req: GatewayRequest):
return await _process_gateway_message(req, emit_sse=False)
@app.post("/gateway/message/sse")
async def gateway_message_sse(req: GatewayRequest):
return await _process_gateway_message(req, emit_sse=True)
@app.get("/gateway/events/{session_id}")
async def gateway_events(session_id: str, request: Request):
last = request.headers.get("last-event-id") or request.query_params.get("last_event_id") or "0"
return StreamingResponse(
sse_hub.subscribe(session_id, int(last)),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)
@app.get("/sessions/{session_id}/messages")
async def get_session_messages(session_id: str, limit: int = 50):
return {"session_id": session_id, "messages": [m.model_dump(mode="json") for m in await memory.list(session_id, limit)]}
@app.get("/sessions/{session_id}/checkpoint")
async def get_session_checkpoint(session_id: str):
return {"session_id": session_id, "checkpoint": await checkpoints.get_latest(session_id)}
@app.on_event("shutdown")
async def shutdown():
telemetry.shutdown()

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
import os
from agent_framework.gateways import MCPGatewayClient
def build_mcp_gateway_client() -> MCPGatewayClient | None:
if os.getenv("MCP_GATEWAY_ENABLED", "true").lower() != "true":
return None
return MCPGatewayClient(
base_url=os.getenv("MCP_GATEWAY_URL", "http://localhost:8300"),
token=os.getenv("MCP_GATEWAY_TOKEN") or None,
timeout_seconds=int(os.getenv("MCP_GATEWAY_TIMEOUT_SECONDS", "60")),
)

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
"""Observer adapter that emits IC/NOC/GRL through framework Telemetry only.
This avoids a second Langfuse root trace created by AgentObserver ->
AnalyticsPublisher while preserving the events inside the active request span.
"""
from datetime import datetime, timezone
from typing import Any
def _normalize_ic_code(code: str) -> str:
code = str(code or "UNKNOWN").strip()
return code if code.startswith(("IC.", "AGA.", "NOC.", "GRL.")) else f"IC.{code}"
def _normalize_noc_code(code: str) -> str:
code = str(code or "UNKNOWN").strip()
return code if code.startswith("NOC.") else f"NOC.{code}"
def _normalize_grl_code(code: str) -> str:
code = str(code or "UNKNOWN").strip()
return code if code.startswith("GRL.") else f"GRL.{code}"
def _kind_for(event_type: str) -> str:
if event_type.startswith(("IC.", "AGA.")):
return "ic"
if event_type.startswith("NOC."):
return "noc"
if event_type.startswith("GRL."):
return "grl"
return "event"
class TelemetryBackedAgentObserver:
"""Drop-in subset of AgentObserver backed by Telemetry.event.
Do not publish through AnalyticsPublisher here. Analytics publishing may be
configured with a Langfuse provider, and that path creates an extra root
trace for business events such as IC.AGENT_COMPLETED/NOC.006. Telemetry.event
uses the active span/trace context, so these events appear inside the single
request trace.
"""
def __init__(self, telemetry: Any, *, source: str = "agent_framework") -> None:
self.telemetry = telemetry
self.source = source
async def emit(
self,
event_type: str,
payload: dict[str, Any] | None = None,
*,
metadata: dict[str, Any] | None = None,
source: str | None = None,
) -> dict[str, Any]:
body = dict(payload or {})
meta = dict(metadata or {})
body.setdefault("tag", event_type)
event = {
"eventType": event_type,
"source": source or self.source,
"eventDate": datetime.now(timezone.utc).isoformat(),
"body": body,
"metadata": meta,
}
try:
await self.telemetry.event(event_type, event, kind=_kind_for(event_type))
except TypeError:
# Compatibility with older Telemetry.event signatures.
await self.telemetry.event(event_type, event)
return event
async def emit_ic(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
return await self.emit(_normalize_ic_code(code), payload, metadata={**metadata, "ic": True})
async def emit_noc(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
return await self.emit(_normalize_noc_code(code), payload, metadata={**metadata, "noc": True})
async def emit_grl(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
return await self.emit(_normalize_grl_code(code), payload, metadata={**metadata, "grl": True})

View File

@@ -0,0 +1,51 @@
from typing import Any, TypedDict
class AgentState(TypedDict, total=False):
tenant_id: str
agent_id: str
session_id: str
conversation_key: str
workflow_id: str
agent_profile: dict[str, Any]
user_text: str
sanitized_input: str
route: str
intent: str
route_decision: dict[str, Any]
answer: str
final_answer: str
history: list[dict[str, Any]]
context: dict[str, Any]
guardrail_decisions: list[dict[str, Any]]
judge_results: list[dict[str, Any]]
next_state: str
domain: str
mcp_tools: list[str]
mcp_results: list[dict[str, Any]]
available_mcp_tools: list[str]
selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any]
transaction_status: str
confirmation_required: bool
confirmation_received: bool
tool_policy_result: dict[str, Any]
missing_parameters: list[str]
supervisor_plan: dict[str, Any]
supervisor_results: list[dict[str, Any]]
active_agent: str
route_bypassed: bool
continuity_signal: dict[str, Any]
session_control: str
session_ended: bool
human_handoff_requested: bool
blocked: bool
supervisor_action: str
supervisor_guidance: str
supervisor_attempt: int
supervisor_handover_reason: str
output_supervisor_results: list[dict[str, Any]]
output_guardrails_already_applied: bool
long_term_memories: list[dict[str, Any]]
long_term_memory_context: str
long_term_memory_write_result: dict[str, Any]

View File

@@ -0,0 +1,816 @@
from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer
from langgraph.graph import END, START, StateGraph
from agent_framework.guardrails.pipeline import GuardrailPipeline
from agent_framework.guardrails.output_supervisor import OutputSupervisor
from agent_framework.guardrails.rail_action import RailAction
from agent_framework.guardrails.rail_result import RailResult
from agent_framework.judges.judge import JudgePipeline
from agent_framework.routing.enterprise_router import EnterpriseRouter
from agent_framework.supervisor.supervisor import Supervisor
from agent_framework.observability.workflow_events import WorkflowTelemetry
from agent_framework.observability.guardrail_events import GuardrailTelemetry
from agent_framework.observability.judge_events import JudgeTelemetry
from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry
from agent_framework.observability.observer import AgentObserver
from app.agents.billing_agent import BillingAgent
from app.agents.product_agent import ProductAgent
from app.agents.orders_agent import OrdersAgent
from app.agents.support_agent import SupportAgent
from app.state import AgentState
from agent_framework.rag.rag_service import RagService
from agent_framework.rag.embedding_provider import create_embedding_provider
from agent_framework.cache.cache import create_cache
from agent_framework.memory.long_term_memory import create_long_term_memory_manager
class LegacyOutputGuardrailRail:
"""Adapter: reutiliza GuardrailPipeline.run_output dentro do OutputSupervisor novo.
O framework antigo retornava decisões allowed=True/False. O OutputSupervisor
corporativo trabalha com RailAction (allow/sanitize/retry/block/handover).
Este adapter evita reescrever todos os rails agora e mantém compatibilidade.
"""
code = "LEGACY_OUTPUT_GUARDRAILS"
def __init__(self, pipeline: GuardrailPipeline):
self.pipeline = pipeline
async def evaluate(self, candidate: str, context: dict):
final, decisions = await self.pipeline.run_output(candidate, context)
serialized = [d.model_dump() for d in decisions]
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
if blocked:
first = blocked[0]
code = (getattr(first, "code", "") or "").upper()
action = RailAction.RETRY if code in {"REVPREC", "CMP", "SCO", "GND"} else RailAction.BLOCK
return RailResult(
code=code or self.code,
action=action,
reason=getattr(first, "reason", "Resposta bloqueada por guardrail de saída"),
guidance=getattr(first, "reason", "Regerar resposta seguindo as políticas de saída."),
sanitized_text=final,
metadata={"legacy_decisions": serialized},
)
if final != candidate:
return RailResult(
code=self.code,
action=RailAction.SANITIZE,
reason="Resposta sanitizada por guardrail de saída legado.",
sanitized_text=final,
metadata={"legacy_decisions": serialized},
)
return RailResult(
code=self.code,
action=RailAction.ALLOW,
reason="Resposta aprovada pelos guardrails de saída legados.",
sanitized_text=final,
metadata={"legacy_decisions": serialized},
)
class AgentWorkflow:
"""Workflow principal com dois modos de roteamento.
Modos suportados por configuração:
ROUTING_MODE=router
input_guardrails -> routing_decision/EnterpriseRouter -> 1 agente -> output_guardrails
ROUTING_MODE=supervisor
input_guardrails -> routing_decision/Supervisor -> supervisor_agent -> N agentes -> consolidação
Em ambos os modos, memória/checkpoint/session usam tenant_id:agent_id:session_id.
"""
def __init__(self, llm, memory, telemetry, analytics, settings, observer: AgentObserver | None = None, tool_router=None, summary_memory=None):
self.llm = llm
self.memory = memory
self.telemetry = telemetry
self.analytics = analytics
self.observer = observer or AgentObserver(analytics=analytics)
self.settings = settings
self.tool_router = tool_router
self.summary_memory = summary_memory
self.long_term_memory_manager = create_long_term_memory_manager(settings, telemetry=telemetry)
self.guardrails = GuardrailPipeline(
observer=self.observer,
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
)
self.output_supervisor_engine = OutputSupervisor(
rails=[LegacyOutputGuardrailRail(self.guardrails)],
observer=self.observer,
max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)),
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
)
self.judges = JudgePipeline()
self.supervisor = Supervisor()
self.workflow_telemetry = WorkflowTelemetry(telemetry)
self.guardrail_telemetry = GuardrailTelemetry(telemetry)
self.judge_telemetry = JudgeTelemetry(telemetry)
self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry)
self.cache = create_cache(settings)
self.embedding_provider = create_embedding_provider(settings)
self.rag_service = RagService(settings, embedding_provider=self.embedding_provider, telemetry=telemetry)
self.router = EnterpriseRouter(settings, llm=llm, telemetry=telemetry)
agent_kwargs = {"telemetry": telemetry, "tool_router": getattr(self, "tool_router", None), "rag_service": self.rag_service, "cache": self.cache, "settings": settings, "observer": self.observer, "memory": memory, "summary_memory": summary_memory}
self.billing = BillingAgent(llm, **agent_kwargs)
self.product = ProductAgent(llm, **agent_kwargs)
self.orders = OrdersAgent(llm, **agent_kwargs)
self.support = SupportAgent(llm, **agent_kwargs)
# The existing agent constructors intentionally keep their stable API.
# Long-term memory is injected as a runtime capability after creation.
for agent in (self.billing, self.product, self.orders, self.support):
agent.long_term_memory_manager = self.long_term_memory_manager
self.graph = self._build_graph()
def _node(self, name, fn):
async def _wrapped(state):
async with self.langgraph_telemetry.node(name, state):
return await fn(state)
return _wrapped
def _build_graph(self):
builder = StateGraph(AgentState)
builder.add_node("input_guardrails", self._node("input_guardrails", self.input_guardrails))
builder.add_node("routing_decision", self._node("routing_decision", self.routing_decision))
builder.add_node("billing_agent", self._node("billing_agent", self.billing_agent))
builder.add_node("product_agent", self._node("product_agent", self.product_agent))
builder.add_node("orders_agent", self._node("orders_agent", self.orders_agent))
builder.add_node("support_agent", self._node("support_agent", self.support_agent))
builder.add_node("handoff", self._node("handoff", self.handoff))
builder.add_node("human_handoff", self._node("human_handoff", self.human_handoff))
builder.add_node("end_session", self._node("end_session", self.end_session))
builder.add_node("supervisor_agent", self._node("supervisor_agent", self.supervisor_agent))
builder.add_node("output_supervisor", self._node("output_supervisor", self.output_supervisor))
builder.add_node("output_guardrails", self._node("output_guardrails", self.output_guardrails))
builder.add_node("judge", self._node("judge", self.judge))
builder.add_node("supervisor_review", self._node("supervisor_review", self.supervisor_review))
builder.add_node("persist_long_term_memory", self._node("persist_long_term_memory", self.persist_long_term_memory))
builder.add_node("persist", self._node("persist", self.persist))
builder.add_edge(START, "input_guardrails")
builder.add_conditional_edges(
"input_guardrails",
self._after_input_guardrails,
{"blocked": "persist", "continue": "routing_decision"},
)
builder.add_conditional_edges(
"routing_decision",
lambda s: s.get("route", "billing_agent"),
{
"billing_agent": "billing_agent",
"product_agent": "product_agent",
"orders_agent": "orders_agent",
"support_agent": "support_agent",
"handoff": "handoff",
"human_handoff": "human_handoff",
"end_session": "end_session",
"supervisor_agent": "supervisor_agent",
},
)
builder.add_edge("billing_agent", "output_supervisor")
builder.add_edge("product_agent", "output_supervisor")
builder.add_edge("orders_agent", "output_supervisor")
builder.add_edge("support_agent", "output_supervisor")
builder.add_edge("handoff", "output_supervisor")
builder.add_edge("human_handoff", "output_supervisor")
builder.add_edge("end_session", "output_supervisor")
builder.add_edge("supervisor_agent", "output_supervisor")
builder.add_edge("output_supervisor", "output_guardrails")
builder.add_edge("output_guardrails", "judge")
builder.add_edge("judge", "supervisor_review")
builder.add_edge("supervisor_review", "persist_long_term_memory")
builder.add_edge("persist_long_term_memory", "persist")
builder.add_edge("persist", END)
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
def _after_input_guardrails(self, state):
return "blocked" if state.get("blocked") else "continue"
async def input_guardrails(self, state):
if state.get("session_ended") is True:
answer = str(getattr(
self.settings,
"SESSION_ALREADY_ENDED_MESSAGE",
"Este atendimento já foi encerrado. Inicie uma nova sessão para continuar.",
))
await self.telemetry.event(
"session.message.rejected_after_end",
{"session_id": state.get("conversation_key") or state.get("session_id")},
)
return {
"answer": answer,
"final_answer": answer,
"blocked": True,
"session_control": "END_SESSION",
"session_ended": True,
"next_state": "SESSION_ENDED",
}
async with self.telemetry.span(
"workflow.input_guardrails",
session_id=state.get("conversation_key") or state.get("session_id"),
input=state.get("user_text"),
):
history_texts = [m.get("content", "") for m in state.get("history", [])]
await self.observer.emit_grl(
"001",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "input",
},
component="workflow.input_guardrails.start",
)
sanitized, decisions = await self.guardrails.run_input(
state["user_text"],
{
**(state.get("context") or {}),
"history_texts": history_texts,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"agent_profile": state.get("agent_profile") or {},
},
)
for _decision in decisions:
await self.guardrail_telemetry.evaluated("input", _decision)
await self.observer.emit_grl(
"002" if _decision.allowed else "004",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "input",
"rail_code": getattr(_decision, "code", None),
"allowed": bool(_decision.allowed),
"reason": getattr(_decision, "reason", None),
},
component="workflow.input_guardrails.decision",
)
if not _decision.allowed:
await self.guardrail_telemetry.blocked("input", _decision)
await self.telemetry.event(
"guardrails.input.completed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"decisions": [d.model_dump() for d in decisions],
},
)
await self.observer.emit_grl(
"009",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "input",
"blocked": any(not d.allowed for d in decisions),
"decision_count": len(decisions),
},
component="workflow.input_guardrails.final",
)
if any(not d.allowed for d in decisions):
return {
"sanitized_input": sanitized,
"answer": "Não consegui seguir com essa mensagem por regra de segurança.",
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.",
"guardrail_decisions": [d.model_dump() for d in decisions],
"route": "blocked",
"blocked": True,
}
return {
"sanitized_input": sanitized,
"guardrail_decisions": [d.model_dump() for d in decisions],
"blocked": False,
}
async def routing_decision(self, state):
mode = getattr(self.settings, "ROUTING_MODE", "router")
async with self.telemetry.span(
"workflow.routing_decision",
session_id=state.get("conversation_key") or state.get("session_id"),
input={
"mode": mode,
"text": state.get("sanitized_input") or state.get("user_text"),
"previous_state": state.get("next_state"),
},
):
if mode == "supervisor":
plan = await self.supervisor.route_plan(state)
await self.langgraph_telemetry.edge("routing_decision", "supervisor_agent", state, {"method": "supervisor", "intent": plan.intent, "confidence": plan.confidence})
return {
"route": "supervisor_agent",
"intent": plan.intent,
"supervisor_plan": {
"agents": plan.agents,
"intent": plan.intent,
"confidence": plan.confidence,
"reason": plan.reason,
"metadata": plan.metadata,
},
"route_decision": {
"route": "supervisor_agent",
"agent": "supervisor",
"intent": plan.intent,
"confidence": plan.confidence,
"reason": plan.reason,
"method": "supervisor",
"metadata": plan.metadata,
},
}
decision = await self.router.route(state)
await self.langgraph_telemetry.edge("routing_decision", decision.route, state, {"method": getattr(decision, "method", None), "intent": decision.intent, "confidence": decision.confidence})
await self.observer.emit_ic(
"ROUTE_SELECTED",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": decision.route,
"intent": decision.intent,
"confidence": decision.confidence,
"method": getattr(decision, "method", None),
},
component="workflow.routing_decision",
)
return {
"route": decision.route,
"intent": decision.intent,
"route_decision": decision.model_dump(mode="json"),
"domain": decision.domain,
"mcp_tools": decision.mcp_tools,
"next_state": decision.next_state,
"active_agent": decision.agent,
"route_bypassed": decision.method == "continuity",
"session_control": (decision.metadata or {}).get("session_control", ""),
"human_handoff_requested": (decision.metadata or {}).get("session_control") == "HUMAN_HANDOFF",
"session_ended": (decision.metadata or {}).get("session_control") == "END_SESSION",
"continuity_signal": {
"decision": (decision.metadata or {}).get("continuity_decision"),
"confidence": decision.confidence if decision.method == "continuity" else None,
"reason": decision.reason if decision.method == "continuity" else None,
"profile": (decision.metadata or {}).get("continuity_profile"),
} if decision.method == "continuity" else {},
}
async def billing_agent(self, state):
async with self.telemetry.span(
"workflow.agent.billing",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.billing.run(state)
async def product_agent(self, state):
async with self.telemetry.span(
"workflow.agent.product",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.product.run(state)
async def orders_agent(self, state):
async with self.telemetry.span(
"workflow.agent.orders",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.orders.run(state)
async def support_agent(self, state):
async with self.telemetry.span(
"workflow.agent.support",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.support.run(state)
async def supervisor_agent(self, state):
"""Executa um ou mais agentes no modo supervisor e consolida a resposta.
Este nó mantém o desenho de supervisor sem obrigar o restante do workflow
a conhecer quantos agentes foram acionados. Cada execução especializada
recebe o mesmo estado, mas com route/active_agent atualizados.
"""
plan = state.get("supervisor_plan") or {}
agents = plan.get("agents") or ["billing_agent"]
handlers = {
"billing_agent": self.billing.run,
"product_agent": self.product.run,
"orders_agent": self.orders.run,
"support_agent": self.support.run,
}
partials = []
mcp_results = []
async with self.telemetry.span(
"workflow.supervisor_agent",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"agents": agents, "intent": state.get("intent")},
):
for agent_name in agents:
handler = handlers.get(agent_name)
if handler is None:
continue
child_state = {**state, "route": agent_name, "active_agent": agent_name}
result = await handler(child_state)
partials.append({"agent": agent_name, "answer": result.get("answer", "")})
mcp_results.extend(result.get("mcp_results") or [])
if len(partials) == 1:
answer = partials[0]["answer"]
else:
joined = "\n\n".join(f"{p['agent']}: {p['answer']}" for p in partials)
answer = (
"[Supervisor] Consolidação de múltiplos agentes acionados.\n"
f"{joined}"
)
return {
"answer": answer,
"supervisor_results": partials,
"mcp_results": mcp_results,
"next_state": "SUPERVISOR_ACTIVE",
}
async def handoff(self, state):
async with self.telemetry.span("workflow.handoff", session_id=state.get("session_id")):
target = (state.get("route_decision") or {}).get("metadata", {}).get("target_agent")
answer = (
"Vou redirecionar sua solicitação para o especialista correto. "
f"Destino sugerido: {target or 'agente especializado'}."
)
return {"answer": answer}
async def human_handoff(self, state):
session_id = state.get("conversation_key") or state.get("session_id")
async with self.telemetry.span("workflow.human_handoff", session_id=session_id):
answer = str(getattr(self.settings, "HUMAN_HANDOFF_MESSAGE", "Vou encaminhar seu atendimento para uma pessoa."))
await self.telemetry.event(
"session.human_handoff.requested",
{
"session_id": session_id,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"reason": (state.get("route_decision") or {}).get("reason"),
},
)
return {
"answer": answer,
"session_control": "HUMAN_HANDOFF",
"human_handoff_requested": True,
"session_ended": False,
"next_state": "HUMAN_HANDOFF_REQUESTED",
}
async def end_session(self, state):
session_id = state.get("conversation_key") or state.get("session_id")
async with self.telemetry.span("workflow.end_session", session_id=session_id):
answer = str(getattr(self.settings, "END_SESSION_MESSAGE", "Atendimento encerrado. Obrigado pelo contato."))
await self.telemetry.event(
"session.end.requested",
{
"session_id": session_id,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"reason": (state.get("route_decision") or {}).get("reason"),
},
)
return {
"answer": answer,
"session_control": "END_SESSION",
"session_ended": True,
"human_handoff_requested": False,
"next_state": "SESSION_ENDED",
}
async def output_supervisor(self, state):
"""Valida a resposta candidata com o OutputSupervisor corporativo.
Este nó não substitui o roteador/supervisor multiagente. Ele roda após o
agente gerar `answer` e antes dos judges/persistência, produzindo campos
supervisor_* no state e eventos GRL.001..GRL.009 via AgentObserver.
"""
if not bool(getattr(self.settings, "ENABLE_OUTPUT_SUPERVISOR", True)):
return {
"output_guardrails_already_applied": False,
"supervisor_action": "disabled",
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}
candidate = state.get("answer") or ""
context = {
**(state.get("context") or {}),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"session_id": state.get("conversation_key") or state.get("session_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}
async with self.telemetry.span(
"workflow.output_supervisor",
session_id=state.get("conversation_key") or state.get("session_id"),
input=candidate,
):
decision = await self.output_supervisor_engine.evaluate(candidate, context)
action = decision.action.value
await self.telemetry.event(
"output_supervisor.completed",
{
"session_id": context["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"action": action,
"approved": decision.approved,
"guidance": decision.guidance,
},
)
await self.observer.emit_ic(
"IC.OUTPUT_SUPERVISOR_COMPLETED",
{
"session_id": context["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"action": action,
"approved": decision.approved,
"result_count": len(decision.results),
},
component="workflow.output_supervisor",
)
if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
final_answer = decision.candidate
elif decision.action == RailAction.HANDOVER:
final_answer = "Vou encaminhar seu atendimento para continuidade com um especialista."
else:
final_answer = decision.fallback_message
return {
"answer": final_answer,
"final_answer": final_answer,
"supervisor_action": action,
"supervisor_guidance": decision.guidance,
"supervisor_attempt": int(state.get("supervisor_attempt", 0)) + (1 if decision.action == RailAction.RETRY else 0),
"supervisor_handover_reason": decision.handover_reason,
"output_supervisor_results": [
{
"code": r.code,
"action": r.action.value,
"reason": r.reason,
"guidance": r.guidance,
"metadata": r.metadata,
}
for r in decision.results
],
"output_guardrails_already_applied": True,
"guardrail_decisions": state.get("guardrail_decisions", [])
+ [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])],
}
async def output_guardrails(self, state):
if state.get("output_guardrails_already_applied"):
return {"final_answer": state.get("final_answer") or state.get("answer") or ""}
async with self.telemetry.span(
"workflow.output_guardrails",
session_id=state.get("conversation_key") or state.get("session_id"),
input=state.get("answer"),
):
await self.observer.emit_grl(
"001",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "output",
"route": state.get("route"),
"intent": state.get("intent"),
},
component="workflow.output_guardrails.start",
)
final, decisions = await self.guardrails.run_output(
state["answer"], state.get("context", {})
)
for _decision in decisions:
await self.guardrail_telemetry.evaluated("output", _decision)
await self.observer.emit_grl(
"002" if _decision.allowed else "004",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "output",
"rail_code": getattr(_decision, "code", None),
"allowed": bool(_decision.allowed),
"reason": getattr(_decision, "reason", None),
},
component="workflow.output_guardrails.decision",
)
if not _decision.allowed:
await self.guardrail_telemetry.blocked("output", _decision)
await self.telemetry.event(
"guardrails.output.completed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"decisions": [d.model_dump() for d in decisions],
},
)
await self.observer.emit_grl(
"009",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "output",
"blocked": any(not d.allowed for d in decisions),
"decision_count": len(decisions),
},
component="workflow.output_guardrails.final",
)
return {
"final_answer": final,
"guardrail_decisions": state.get("guardrail_decisions", [])
+ [d.model_dump() for d in decisions],
}
async def judge(self, state):
async with self.telemetry.span(
"workflow.judge",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"question": state.get("user_text"), "answer": state.get("final_answer")},
):
judge_context = dict(state.get("context", {}) or {})
judge_context["mcp_results"] = state.get("mcp_results", [])
judge_context["evidence"] = state.get("mcp_results", []) or judge_context.get("evidence")
judge_context["route"] = state.get("route")
judge_context["intent"] = state.get("intent")
# Judge sampling must see the finalized transaction state. These
# fields are populated by the agent/tool runtime before this node.
for key in (
"transaction_status",
"confirmation_required",
"confirmation_received",
"tool_policy_result",
"selected_tool_call",
"pending_tool_call",
):
judge_context[key] = state.get(key)
judge_context["transactional_tools"] = [
result.get("tool_name")
for result in state.get("mcp_results", [])
if isinstance(result, dict)
and (
(result.get("metadata") or {}).get("operation_type") == "transactional"
or result.get("awaiting_confirmation")
or result.get("transaction_status")
)
]
results = await self.judges.evaluate_all(
state["user_text"], state["final_answer"], judge_context
)
for _result in results:
await self.judge_telemetry.evaluated(_result)
await self.telemetry.event(
"judges.completed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"results": [r.model_dump() for r in results],
},
)
return {"judge_results": [r.model_dump() for r in results]}
async def supervisor_review(self, state):
async with self.telemetry.span(
"workflow.supervisor_review",
session_id=state.get("conversation_key") or state.get("session_id"),
input=state.get("final_answer"),
):
ok, answer = await self.supervisor.review(
state["final_answer"], state.get("context", {})
)
await self.telemetry.event(
"supervisor.review.completed",
{"session_id": state.get("session_id"), "approved": ok},
)
return {"final_answer": answer if ok else answer}
async def persist_long_term_memory(self, state):
result = await self.long_term_memory_manager.persist_turn(state)
return {"long_term_memory_write_result": result}
async def persist(self, state):
async with self.telemetry.span(
"workflow.persist",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"route": state.get("route"), "intent": state.get("intent")},
):
await self.observer.emit_ic(
"AGENT_COMPLETED",
{
"session_id": state.get("conversation_key") or state["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"route_decision": state.get("route_decision"),
"judges": state.get("judge_results", []),
"mcp_tools": state.get("mcp_tools", []),
"mcp_results": state.get("mcp_results", []),
},
)
await self.observer.emit_noc(
"006",
{
"session_id": state.get("conversation_key") or state["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"answer_chars": len(state.get("final_answer") or ""),
},
component="workflow.persist",
)
await self.telemetry.event(
"agent.completed",
{
"session_id": state.get("conversation_key") or state["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"answer_chars": len(state.get("final_answer") or ""),
},
)
return state
async def ainvoke(self, state):
thread_id = state.get("conversation_key") or state["session_id"]
config = {"configurable": {"thread_id": thread_id}}
async with self.telemetry.span(
"workflow.langgraph.ainvoke",
session_id=state.get("conversation_key") or state.get("session_id"),
user_id=state.get("context", {}).get("user_id"),
input={"user_text": state.get("user_text")},
tags=["langgraph", "agent-workflow", f"routing-mode:{getattr(self.settings, 'ROUTING_MODE', 'router')}",],
):
await self.workflow_telemetry.started("agent_workflow", state)
await self.observer.emit_noc(
"001",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"channel_id": (state.get("context") or {}).get("channel"),
"message_id": (state.get("context") or {}).get("message_id"),
"ura_call_id": (state.get("context") or {}).get("ura_call_id"),
},
component="workflow.ainvoke",
)
await self.observer.emit_ic(
"AGENT_STARTED",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"channel_id": (state.get("context") or {}).get("channel"),
"message_id": (state.get("context") or {}).get("message_id"),
"user_text_chars": len(state.get("user_text") or ""),
},
component="workflow.ainvoke",
)
try:
result = await self.graph.ainvoke(state, config=config)
await self.workflow_telemetry.completed("agent_workflow", result)
return result
except Exception as exc:
await self.workflow_telemetry.failed("agent_workflow", exc)
await self.observer.emit_noc(
"005",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"error": str(exc),
"exception_type": exc.__class__.__name__,
},
component="workflow.ainvoke",
)
raise

View File

@@ -0,0 +1,33 @@
default_agent_id: telecom_contas
agents:
- agent_id: telecom_contas
name: Agente Telecom Contas
description: Template de atendimento para faturas, produtos e suporte de telecom.
prompt_policy_path: ./config/agents/telecom_contas/prompt_policy.yaml
routing_config_path: ./config/routing.yaml
guardrails_config_path: ./config/agents/telecom_contas/guardrails.yaml
judges_config_path: ./config/agents/telecom_contas/judges.yaml
mcp_servers_config_path: ./config/mcp_servers.yaml
tools_config_path: ./config/tools.yaml
metadata:
domain: telecom
system_prefix: |
Você está executando o agent_template telecom_contas.
Use somente políticas, memória, checkpoints, guardrails e judges deste agent_id.
Não misture histórico ou decisões de outros agentes.
- agent_id: retail_orders
name: Agente Retail Pedidos
description: Template de varejo para pedidos, produtos, troca/devolução e garantia.
prompt_policy_path: ./config/agents/retail_orders/prompt_policy.yaml
routing_config_path: ./config/routing.yaml
guardrails_config_path: ./config/agents/retail_orders/guardrails.yaml
judges_config_path: ./config/agents/retail_orders/judges.yaml
mcp_servers_config_path: ./config/mcp_servers.yaml
tools_config_path: ./config/tools.yaml
metadata:
domain: retail
system_prefix: |
Você está executando o agent_template retail_orders.
Use somente políticas, memória, checkpoints, guardrails e judges deste agent_id.
Não misture histórico ou decisões de outros agentes.

View File

@@ -0,0 +1,8 @@
input:
- code: MSK
enabled: true
- code: VLOOP
enabled: true
output:
- code: REVPREC
enabled: true

View File

@@ -0,0 +1,7 @@
judges:
- name: response_quality
enabled: true
threshold: 0.7
- name: groundedness
enabled: true
threshold: 0.6

View File

@@ -0,0 +1,6 @@
id: retail_orders_prompt_policy
version: 1
description: Prompt base isolado do agente de varejo/pedidos.
system_prefix: |
Você é um agente corporativo de varejo especializado em pedidos, entrega, troca, devolução e garantia.
Seja claro, objetivo e não use regras de negócio de telecom neste agente.

View File

@@ -0,0 +1,8 @@
input:
- code: MSK
enabled: true
- code: VLOOP
enabled: true
output:
- code: REVPREC
enabled: true

View File

@@ -0,0 +1,20 @@
enabled: true
fail_closed: true
profile: judge
judges:
- name: response_quality
enabled: true
threshold: 0.7
- name: groundedness
enabled: true
threshold: 0.6
- name: sentiment
enabled: true
fail_on_negative: false
- name: tone
enabled: true
fail_closed: true

View File

@@ -0,0 +1,6 @@
id: telecom_contas_prompt_policy
version: 1
description: Prompt base isolado do agente de telecom/contas.
system_prefix: |
Você é um agente corporativo de atendimento telecom especializado em faturas, produtos, VAS e suporte.
Seja claro, objetivo e não prometa execução operacional sem ferramenta ou confirmação válida.

View File

@@ -0,0 +1,12 @@
input:
- code: MSK
enabled: true
- code: VLOOP
enabled: true
output:
- code: REVPREC
enabled: true
- code: PINJ
enabled: true
- code: DLEX_OUT
enabled: true

View File

@@ -0,0 +1,55 @@
identity:
version: "2"
required:
- session_key
keys:
customer_key:
description: Cliente/assinante/consumidor canônico.
sources:
- business_context.customer_key
- customer_key
- msisdn
- customer_id
- user_id
- ani
- from
contract_key:
description: Contrato, conta, fatura, pedido ou asset principal.
sources:
- business_context.contract_key
- contract_key
- invoice_id
- current_invoice_number
- order_id
- pedido_id
- asset_id
interaction_key:
description: Chave externa da interação/call/chat vinda do canal.
sources:
- business_context.interaction_key
- interaction_key
- ura_call_id
- call_id
- message_id
account_key:
description: Conta de cobrança/conta comercial.
sources:
- business_context.account_key
- account_key
- account_id
- billing_account_id
resource_key:
description: Recurso/linha/produto/asset específico.
sources:
- business_context.resource_key
- resource_key
- asset_id
- product_id
- sku
session_key:
description: Sessão técnica estável já escopada por tenant e agente.
sources:
- business_context.session_key
- session_key
- conversation_key
- session_id

View File

@@ -0,0 +1,18 @@
enabled: true
fail_closed: true
profile: judge
judges:
- name: response_quality
enabled: true
threshold: 0.7
- name: groundedness
enabled: true
threshold: 0.6
- name: sentiment
enabled: true
fail_on_negative: false
- name: tone
enabled: true
fail_closed: true
sample_rate: 0.25
always_run_for_transactional: true

View File

@@ -0,0 +1,92 @@
mcp_parameter_mapping:
defaults:
use_mock: true
tools:
consultar_fatura:
map:
customer_key: msisdn
contract_key: invoice_id
interaction_key: ura_call_id
session_key: session_id
extract:
mes_referencia:
from: message
type: int
strategy: month_name_pt
description: 'Extrair mês citado na mensagem. janeiro=1, fevereiro=2, março=3,
abril=4, maio=5, junho=6, julho=7, agosto=8, setembro=9, outubro=10, novembro=11,
dezembro=12.
'
consultar_pagamentos:
map:
customer_key: msisdn
interaction_key: ura_call_id
session_key: session_id
consultar_plano:
map:
customer_key: msisdn
resource_key: asset_id
contract_key: asset_id
session_key: session_id
listar_servicos:
map:
customer_key: msisdn
session_key: session_id
consultar_pedido:
map:
customer_key: customer_id
session_key: session_id
extract:
order_id:
from: message
type: string
strategy: hybrid
description: Extraia somente o identificador do pedido informado explicitamente
pelo usuário. Retorne null quando não houver identificador de pedido na
mensagem.
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
group: 1
consultar_entrega:
map:
session_key: session_id
extract:
order_id:
from: message
type: string
strategy: hybrid
description: Extraia somente o identificador do pedido informado explicitamente
pelo usuário. Retorne null quando não houver identificador de pedido na
mensagem.
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
group: 1
solicitar_troca:
map:
session_key: session_id
defaults:
reason: Solicitação aberta pelo atendimento conversacional.
extract:
order_id:
from: message
type: string
strategy: hybrid
description: Extraia somente o identificador do pedido informado explicitamente
pelo usuário. Retorne null quando não houver identificador de pedido na
mensagem.
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
group: 1
solicitar_devolucao:
map:
session_key: session_id
defaults:
reason: Solicitação aberta pelo atendimento conversacional.
extract:
order_id:
from: message
type: string
strategy: hybrid
description: Extraia somente o identificador do pedido informado explicitamente
pelo usuário. Retorne null quando não houver identificador de pedido na
mensagem.
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
group: 1

View File

@@ -0,0 +1,12 @@
servers:
telecom:
transport: http
endpoint: http://telecom-mcp:8100/mcp
enabled: true
description: MCP Server Telecom via docker-compose.
retail:
transport: http
endpoint: http://retail-mcp:8200/mcp
enabled: true
description: MCP Server Retail via docker-compose.

View File

@@ -0,0 +1,30 @@
# MCP servers registry.
# transport=http keeps the legacy framework mock contract:
# GET <endpoint>/tools/list
# POST <endpoint>/tools/call
# transport=fastmcp uses official MCP Streamable HTTP, typically endpoint http://host:port/mcp
# transport=sse uses official MCP SSE, typically endpoint http://host:port/sse
servers:
# telecom:
# enabled: true
# transport: fastmcp
# endpoint: http://localhost:8001/mcp
# description: Telecom FastMCP server using official MCP protocol
#
# retail:
# enabled: true
# transport: fastmcp
# endpoint: http://localhost:8002/mcp
# description: Retail FastMCP server using official MCP protocol
telecom:
enabled: true
transport: http
endpoint: http://localhost:8100/mcp
description: Telecom legacy HTTP mock MCP server
retail:
enabled: true
transport: http
endpoint: http://localhost:8200/mcp
description: Retail legacy HTTP mock MCP server

View File

@@ -0,0 +1,19 @@
tone:
style: "claro, objetivo, empático"
forbidden_phrases:
- "procure atendimento humano"
vocabulary:
preferred:
fatura: "fatura"
contestacao: "contestação"
intents:
billing_agent:
- fatura
- boleto
- cobrança
- segunda via
product_agent:
- plano
- produto
- oferta
- serviço

View File

@@ -0,0 +1,128 @@
# Roteamento enterprise configurável com MCP-aware intents.
router:
# mode também pode ser definido por variável de ambiente ROUTING_MODE.
# Valores: router | supervisor
mode: router
fallback_agent: billing_agent
confidence_threshold: 0.65
allow_handoff: true
state_policies:
- state: WAITING_BILLING_CONFIRMATION
agent: billing_agent
description: Mantém mensagens curtas como "sim" ou "não" no fluxo de fatura.
- state: WAITING_PRODUCT_CONFIRMATION
agent: product_agent
description: Mantém confirmações no fluxo de produtos/serviços.
- state: WAITING_ORDER_CONFIRMATION
agent: orders_agent
description: Mantém confirmações no fluxo de pedidos.
- state: WAITING_SUPPORT_CONFIRMATION
agent: support_agent
description: Mantém confirmações no fluxo de suporte retail.
- state: COLLECTING_BILLING_PARAMETERS
agent: billing_agent
description: Mantém a coleta de parâmetros no fluxo de faturamento.
- state: COLLECTING_PRODUCT_PARAMETERS
agent: product_agent
description: Mantém a coleta de parâmetros no fluxo de produtos e serviços.
- state: COLLECTING_ORDER_PARAMETERS
agent: orders_agent
description: Mantém a coleta de parâmetros no fluxo de pedidos.
- state: COLLECTING_SUPPORT_PARAMETERS
agent: support_agent
description: Mantém a coleta de parâmetros no fluxo transacional de suporte retail.
intents:
- name: billing_invoice_explanation
domain: telecom
agent: billing_agent
description: Dúvidas sobre fatura, cobrança, vencimento, segunda via, contestação e valores.
priority: 10
mcp_tools:
- consultar_fatura
- consultar_pagamentos
keywords:
- fatura
- conta
- cobrança
- boleto
- vencimento
- segunda via
- contestar
- valor alto
- invoice
examples:
- Minha fatura veio alta.
- Quero entender uma cobrança.
- Preciso da segunda via da conta.
- name: product_services_information
domain: telecom
agent: product_agent
description: Dúvidas sobre plano, pacote, produto, serviço, VAS, internet, roaming e benefícios.
priority: 20
mcp_tools:
- consultar_plano
- listar_servicos
keywords:
- plano
- serviço
- pacote
- internet
- roaming
- vas
- benefício
- assinatura
examples:
- Quais serviços estão ativos no meu plano?
- Quero saber sobre meu pacote de internet.
- Tenho roaming internacional?
- name: retail_order_tracking
domain: retail
agent: orders_agent
description: Consulta de pedido, entrega, rastreamento, atraso e status de compra.
priority: 30
mcp_tools:
- consultar_pedido
- consultar_entrega
keywords:
- pedido
- entrega
- rastreio
- rastreamento
- encomenda
- compra
- atraso
- correios
examples:
- Meu pedido não chegou.
- Quero rastrear minha entrega.
- Qual é o status da minha compra?
- name: retail_support_exchange_return
domain: retail
agent: support_agent
description: Suporte, troca, devolução, garantia e problema com produto.
priority: 25
mcp_tools:
- consultar_pedido
- solicitar_troca
- solicitar_devolucao
keywords:
- solicitar devolução
- devolver pedido
- solicitar troca
- troca
- devolução
- devolver
- garantia
- defeito
- produto quebrado
- suporte
- arrependimento
examples:
- Quero trocar um produto.
- Meu produto veio com defeito.
- Como faço uma devolução?

View File

@@ -0,0 +1,21 @@
version: 1
# Arquivo opcional da aplicação. A ausência mantém o comportamento dos
# templates anteriores e as políticas legadas declaradas em tools.yaml.
defaults:
operation_type: read_only
require_confirmation: false
tool_policies:
solicitar_troca:
operation_type: transactional
require_confirmation: true
solicitar_devolucao:
operation_type: transactional
require_confirmation: true
# Exemplo para uma operação real que só pode executar após confirmação:
# cancelar_servico:
# operation_type: transactional
# require_confirmation: true

View File

@@ -0,0 +1,101 @@
tools:
consultar_fatura:
description: Consulta dados resumidos de fatura por msisdn/invoice_id.
mcp_server: telecom
enabled: true
args_schema:
msisdn: string
invoice_id: string
selection_keywords:
- fatura
- conta
- boleto
consultar_pagamentos:
description: Consulta histórico de pagamentos do cliente.
mcp_server: telecom
enabled: true
args_schema:
msisdn: string
selection_keywords:
- pagamento
- pagamentos
consultar_plano:
description: Consulta plano ativo e atributos comerciais.
mcp_server: telecom
enabled: true
args_schema:
msisdn: string
asset_id: string
selection_keywords:
- plano
listar_servicos:
description: Lista serviços ativos e adicionais VAS.
mcp_server: telecom
enabled: true
args_schema:
msisdn: string
selection_keywords:
- serviços
- servicos
- vas
consultar_pedido:
description: Consulta pedido de varejo por order_id/customer_id.
mcp_server: retail
enabled: true
args_schema:
order_id: string
customer_id: string
selection_keywords:
- consultar pedido
- status do pedido
- pedido
consultar_entrega:
description: Consulta entrega e rastreamento do pedido.
mcp_server: retail
enabled: true
args_schema:
order_id: string
selection_keywords:
- entrega
- rastreio
- rastreamento
- transportadora
- previsão
solicitar_troca:
description: Simula abertura de solicitação de troca.
mcp_server: retail
enabled: true
tool_type: action
requires:
- order_id
- reason
confirmation_required: true
args_schema:
order_id: string
reason: string
selection_keywords:
- solicitar troca
- trocar
- troca
- defeito
- quebrado
solicitar_devolucao:
description: Simula abertura de solicitação de devolução.
mcp_server: retail
enabled: true
tool_type: action
requires:
- order_id
- reason
confirmation_required: true
args_schema:
order_id: string
reason: string
selection_keywords:
- solicitar devolução
- solicitar devolucao
- devolver pedido
- devolver
- devolução
- devolucao
- arrependimento

View File

@@ -0,0 +1,95 @@
# Atualização do Template Backend — Analytics, Observer, NOC/GRL e OutputSupervisor
Esta versão do `agent_template_backend` foi atualizada para consumir as novidades transportadas para o `agent_framework`.
## 1. Analytics e Pub/Sub
O backend não chama mais diretamente apenas o publisher antigo de eventos. Agora ele cria um `AnalyticsPublisher`:
```python
from agent_framework.analytics.factory import create_analytics_publisher
from agent_framework.observability.observer import AgentObserver
analytics = create_analytics_publisher(settings)
observer = AgentObserver(analytics=analytics)
```
Com isso, o mesmo backend pode publicar em:
- OCI Streaming
- GCP Pub/Sub
- CompositePublisher, quando `ANALYTICS_PROVIDERS=oci_streaming,pubsub`
- Noop, quando analytics estiver desligado
## 2. Configuração mínima
```env
ENABLE_ANALYTICS=true
ANALYTICS_PROVIDERS=pubsub
GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-name>
GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
```
Para publicar simultaneamente em OCI Streaming e GCP Pub/Sub:
```env
ENABLE_ANALYTICS=true
ANALYTICS_PROVIDERS=oci_streaming,pubsub
ENABLE_OCI_STREAMING=true
OCI_STREAM_ENDPOINT=<endpoint>
OCI_STREAM_OCID=<stream-ocid>
GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-name>
```
## 3. Observer corporativo
O workflow recebeu emissão automática dos principais eventos corporativos:
- `NOC.001`: início do workflow
- `NOC.005`: exceção fatal no workflow
- `NOC.006`: fim do workflow antes da resposta final
- `IC.AGENT_COMPLETED`: evento informacional de conclusão
- `GRL.001` a `GRL.009`: emitidos pelo `OutputSupervisor`
## 4. OutputSupervisor
Foi inserido um novo nó LangGraph:
```text
agent -> output_supervisor -> output_guardrails -> judge -> supervisor_review -> persist
```
O `OutputSupervisor` não substitui o supervisor de roteamento. Ele valida a saída candidata do agente usando o contrato corporativo:
- `allow`
- `sanitize`
- `retry`
- `block`
- `handover`
- `observe`
Para compatibilidade com os guardrails já existentes, o template inclui o adapter `LegacyOutputGuardrailRail`, que converte decisões antigas `allowed=True/False` para `RailAction`.
## 5. Campos adicionados ao AgentState
```python
supervisor_action: str
supervisor_guidance: str
supervisor_attempt: int
supervisor_handover_reason: str
output_supervisor_results: list[dict]
output_guardrails_already_applied: bool
```
## 6. Arquivos alterados
- `agent_template_backend/app/main.py`
- `agent_template_backend/app/workflows/agent_graph.py`
- `agent_template_backend/app/state.py`
- `agent_template_backend/.env`
- `agent_template_backend/requirements.txt`
- `agent_framework/src/agent_framework/config/settings.py`
## 7. Observação importante
O `OutputSupervisor` roda os guardrails de saída por meio do adapter legado e marca `output_guardrails_already_applied=True`. Assim o nó `output_guardrails` permanece no grafo para compatibilidade, mas evita reexecutar a mesma validação quando o supervisor já aplicou os rails.

View File

@@ -0,0 +1,45 @@
# Como usar IC, NOC e GRL no Template Backend
## IC — Item de Controle
Use IC para registrar eventos de negócio relevantes.
```python
await observer.emit_ic(
"IC.FATURA_CONSULTADA",
{"session_id": session_id, "invoice_id": invoice_id},
component="billing_agent",
)
```
## NOC — Evento operacional
Use NOC para saúde técnica, latência, erros e checkpoints operacionais.
```python
await observer.emit_noc(
"003",
{"session_id": session_id, "resourceName": "ADB", "latencyMs": 120},
component="repository",
)
```
## GRL — Evento de guardrail
Normalmente o framework emite GRL automaticamente. Use manualmente apenas para
rails customizados dentro do agente.
```python
await observer.emit_grl(
"OBSERVE",
{"session_id": session_id, "rail_code": "CUSTOM_POLICY"},
component="custom_rail",
)
```
## Onde já existe no template
- `app/workflows/agent_graph.py` emite IC/NOC no ciclo do workflow.
- `app/agents/runtime.py` emite IC para MCP/tools.
- `app/agents/*_agent.py` contém exemplos dentro do método `run()`.
- `app/examples/` contém exemplos isolados.

View File

@@ -0,0 +1,48 @@
# Backends atualizados para ConversationSummaryMemory
Esta versão dos backends foi compatibilizada com a versão do framework que adiciona `ConversationSummaryMemory`.
## O que mudou
- `app/main.py` agora inicializa `create_conversation_summary_memory(...)` junto com `create_memory(...)`.
- `AgentWorkflow` recebe `summary_memory` e repassa para os agentes.
- Os agentes não montam mais prompts manuais para o LLM; agora usam `build_messages()` do framework.
- Antes da chamada ao LLM, os agentes executam `await self.prepare_memory_context(state)`.
- Quando habilitado por `.env`, o prompt passa a receber:
- resumo acumulado da conversa;
- últimas mensagens completas;
- mensagem atual;
- BusinessContext;
- MCP results;
- RAG context e metadata.
## Configuração
```env
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
MEMORY_CONTEXT_STRATEGY=summary
MEMORY_HISTORY_LIMIT=80
MEMORY_RECENT_MESSAGES_LIMIT=8
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
MEMORY_MAX_SUMMARY_CHARS=6000
MEMORY_SUMMARY_USE_LLM=true
MEMORY_INJECT_RECENT_MESSAGES=true
MEMORY_INJECT_SUMMARY=true
```
## Backends alterados
- `backoffice_convertido_framework`
- `agent_template_backend`
- `agent_template_backend_day_zero`
## Observação importante
Estes backends esperam que o pacote `agent_framework` instalado/conectado seja a versão com os módulos:
- `agent_framework.memory.summary_memory`
- `agent_framework.memory.summary_store`
- `AgentRuntimeMixin.prepare_memory_context()`
- `AgentRuntimeMixin.build_messages()` com injeção de memória
Use junto com o ZIP `agent_framework_conversation_summary_memory.zip`.

View File

@@ -0,0 +1,14 @@
# Exemplos implementados no template
Este projeto entrega as capacidades transversais habilitadas como referência:
- route stickiness semântica com o perfil `route_continuity`;
- decisões `CONTINUE`, `ROUTE`, `HUMAN_HANDOFF` e `END_SESSION`;
- nós globais `human_handoff` e `end_session`;
- persistência de `active_agent`, `route_bypassed`, `continuity_signal` e controle de sessão;
- rejeição de novas mensagens depois de `session_ended=true`;
- políticas MCP `read_only` e `transactional` no backend;
- exemplo `solicitar_devolucao` com `require_confirmation: true`.
Para confirmar a transação, envie `confirmed: true` ou `confirmation: true` como booleano. Handoff e encerramento não chamam agentes de domínio nem MCP.

View File

@@ -0,0 +1,84 @@
# FRAMEWORK_CHANNEL_INPUT_MODE
This backend setting controls what kind of channel input the Agent Framework backend accepts.
It replaces the ambiguous use of `CHANNEL_GATEWAY_MODE` inside the backend.
## Values
```env
FRAMEWORK_CHANNEL_INPUT_MODE=embedded
```
The backend may use internal channel adapters to interpret simple/native channel payloads. This is useful for demos, labs, local frontend, curl tests, and simple environments.
```env
FRAMEWORK_CHANNEL_INPUT_MODE=external
```
The backend accepts only a normalized `GatewayRequest` produced by an external Channel Gateway. It does not parse native WhatsApp, Voice, Teams, or other channel payloads.
## Recommended enterprise setup
In the external channel gateway service:
```env
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
```
In this backend:
```env
FRAMEWORK_CHANNEL_INPUT_MODE=external
```
Flow:
```text
External channel / browser / customer adapter
channel_gateway:7000
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
↓ GatewayRequest
agent_template_backend:8000
FRAMEWORK_CHANNEL_INPUT_MODE=external
LangGraph / Agents / MCP / Guardrails
```
## Valid direct request to backend in external mode
```bash
curl -s -X POST "http://localhost:8000/gateway/message" \
-H "Content-Type: application/json" \
-d '{
"channel": "web",
"tenant_id": "default",
"agent_id": "telecom_contas",
"payload": {
"message": "Quero consultar minha fatura",
"session_id": "backend-external-ok-001"
}
}' | jq
```
## Invalid direct request to backend in external mode
```bash
curl -i -s -X POST "http://localhost:8000/gateway/message" \
-H "Content-Type: application/json" \
-d '{
"message": "Quero consultar minha fatura",
"session_id": "raw-payload-error-001"
}'
```
Expected result: HTTP 422.
## Legacy compatibility
`CHANNEL_GATEWAY_MODE` is still present as a legacy alias for older environments, but new deployments should use:
```env
FRAMEWORK_CHANNEL_INPUT_MODE=embedded|external
```

View File

@@ -0,0 +1,127 @@
# Guardrails paralelos fail-fast e Observer IC
## O que foi implementado
### 1. ParallelRailExecutor
Arquivo principal:
```text
agent_framework/src/agent_framework/guardrails/parallel_executor.py
```
Também foi criado um alias de compatibilidade:
```text
agent_framework/src/agent_framework/guardrails/executor.py
```
Esse alias evita erro quando algum código antigo importar:
```python
from agent_framework.guardrails.executor import ParallelRailExecutor
```
### 2. Execução paralela no GuardrailPipeline
Arquivo alterado:
```text
agent_framework/src/agent_framework/guardrails/pipeline.py
```
O pipeline continua retornando o contrato antigo:
```python
(texto_final, list[RailDecision])
```
mas internamente pode executar rails em paralelo com fail-fast.
### 3. Execução paralela no OutputSupervisor
Arquivo alterado:
```text
agent_framework/src/agent_framework/guardrails/output_supervisor.py
```
O `OutputSupervisor` agora usa `ParallelRailExecutor` quando habilitado.
### 4. Configuração
Novas configurações:
```env
ENABLE_PARALLEL_GUARDRAILS=true
GUARDRAILS_FAIL_FAST=true
```
Também foram adicionadas em:
```text
agent_framework/src/agent_framework/config/settings.py
.env
.env.example
agent_template_backend/.env
agent_template_backend_day_zero/.env
```
### 5. Observer IC
O `AgentObserver` já tinha `emit_ic()`.
Foi complementada a API global compatível com FIRST/TIM:
```python
from agent_framework.observer import ic, aic, noc, anoc, grl, agrl
```
Exemplos:
```python
ic("AGENT_COMPLETED", data={"session_id": "..."})
await aic("MCP_TOOL_CALLED", data={"tool_name": "consultar_fatura"})
```
### 6. ICs automáticos no template backend
O backend emite agora:
```text
IC.AGENT_STARTED
IC.ROUTE_SELECTED
IC.MCP_TOOL_CALLED
IC.TOOL_CALLED
IC.AGENT_COMPLETED
```
Além dos eventos já existentes:
```text
NOC.001
NOC.005
NOC.006
GRL.001 ... GRL.009
```
## Validações executadas
Foram executadas validações locais com `PYTHONPATH=agent_framework/src`:
```bash
python3 -m compileall -q agent_framework/src/agent_framework agent_template_backend/app agent_template_backend_day_zero/app
```
Smoke tests executados:
```text
1. Import de ParallelRailExecutor via agent_framework.guardrails
2. Import de ParallelRailExecutor via agent_framework.guardrails.executor
3. Execução fail-fast: FastBlock cancela SlowAllow
4. GuardrailPipeline paralelo retorna RailDecision legado
5. OutputSupervisor paralelo retorna RailAction.BLOCK
6. API global observer.ic/noc/grl/aic/anoc/agrl
```
Observação: o import completo do `agent_template_backend.app.workflows.agent_graph` depende de `langgraph`, que não está instalado no sandbox de validação. O arquivo foi validado por `compileall`, e a dependência já consta em `agent_template_backend/requirements.txt`.

View File

@@ -0,0 +1,42 @@
# Implementação IC/NOC/GRL preservando lógica existente
Esta versão mantém a lógica original dos agentes do `agent_template_backend` e adiciona observabilidade corporativa.
## IC adicionados nos agentes
Cada agente agora emite eventos de negócio sem alterar a resposta final:
- `IC.BILLING_AGENT_STARTED` / `IC.BILLING_AGENT_COMPLETED`
- `IC.ORDERS_AGENT_STARTED` / `IC.ORDERS_AGENT_COMPLETED`
- `IC.PRODUCT_AGENT_STARTED` / `IC.PRODUCT_AGENT_COMPLETED`
- `IC.SUPPORT_AGENT_STARTED` / `IC.SUPPORT_AGENT_COMPLETED`
- `IC.<AGENT>_MCP_CONTEXT_COLLECTED` quando houver dados MCP
- `IC.<AGENT>_RAG_CONTEXT_RETRIEVED` quando RAG estiver habilitado
O mixin `AgentRuntimeMixin` também emite:
- `IC.MCP_TOOL_CALLED` antes da chamada MCP
- `IC.TOOL_CALLED` após a chamada MCP
## NOC
O workflow já emite eventos operacionais principais:
- `NOC.001` no início da execução
- `NOC.005` em exceção fatal
- `NOC.006` na persistência/finalização
## GRL
O backend agora também exemplifica emissão GRL no workflow:
- `GRL.001` início do pipeline de guardrails
- `GRL.002` decisão allow
- `GRL.004` decisão block
- `GRL.009` decisão final agregada
Quando `OutputSupervisor` está habilitado, ele continua sendo o principal mecanismo corporativo de supervisão de saída.
## Garantia
A lógica original dos agentes não foi substituída por stubs. As chamadas LLM, MCP, RAG, cache e os retornos originais foram preservados.

View File

@@ -0,0 +1,5 @@
# Langfuse single trace observer fix
This backend now uses `TelemetryBackedAgentObserver` instead of publishing IC/NOC/GRL through `AgentObserver(analytics=...)`.
Why: when analytics includes the Langfuse provider, observer events such as `IC.AGENT_COMPLETED` and `NOC.006` may create a second root trace with little detail. Emitting those events through `Telemetry.event(...)` keeps them inside the active request/workflow trace.

View File

@@ -0,0 +1,62 @@
# Validação da versão com IC/NOC/GRL
Validações executadas nesta geração:
1. `python -m compileall -q agent_template_backend/app`
- Resultado: OK.
2. Smoke test dos agentes com LLM fake e Observer fake:
- `BillingAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
- `OrdersAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
- `ProductAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
- `SupportAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
3. Verificação de regressão:
- Nenhum agente retorna `Template Enterprise ativo`.
- A lógica LLM/MCP/RAG/cache existente foi preservada.
## Eventos adicionados
### IC
Nos agentes:
- `IC.BILLING_AGENT_STARTED`
- `IC.BILLING_MCP_CONTEXT_COLLECTED`
- `IC.BILLING_RAG_CONTEXT_RETRIEVED`
- `IC.BILLING_AGENT_COMPLETED`
- `IC.ORDERS_AGENT_STARTED`
- `IC.ORDERS_MCP_CONTEXT_COLLECTED`
- `IC.ORDERS_RAG_CONTEXT_RETRIEVED`
- `IC.ORDERS_AGENT_COMPLETED`
- `IC.PRODUCT_AGENT_STARTED`
- `IC.PRODUCT_MCP_CONTEXT_COLLECTED`
- `IC.PRODUCT_RAG_CONTEXT_RETRIEVED`
- `IC.PRODUCT_AGENT_COMPLETED`
- `IC.SUPPORT_AGENT_STARTED`
- `IC.SUPPORT_MCP_CONTEXT_COLLECTED`
- `IC.SUPPORT_RAG_CONTEXT_RETRIEVED`
- `IC.SUPPORT_AGENT_COMPLETED`
No runtime MCP:
- `IC.MCP_TOOL_CALLED`
- `IC.TOOL_CALLED`
### NOC
Já integrados no workflow:
- `NOC.001` início da execução
- `NOC.005` erro fatal
- `NOC.006` finalização/persistência
### GRL
No workflow de guardrails:
- `GRL.001` início da avaliação
- `GRL.002` allow
- `GRL.004` block
- `GRL.009` decisão final

View File

@@ -0,0 +1,3 @@
compileall app: OK
Arquivos de exemplos IC/NOC/GRL adicionados.
Agentes preservam implementação original comentada.

View File

@@ -0,0 +1,80 @@
profiles:
default:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
max_tokens: 2048
supervisor:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
route_continuity:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 80
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
guardrail:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 600
grl:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
judge:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 800
rag_rewriter:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 300
rag_compressor:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 1200
rag_generation:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.1
max_tokens: 1800
summary_memory:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.1
max_tokens: 1200
noc:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
billing_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
product_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
backoffice_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
mcp_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 80
timeout_seconds: 5

View File

@@ -0,0 +1,23 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
pydantic>=2.8.0
pydantic-settings>=2.4.0
python-dotenv>=1.0.1
langgraph>=0.2.60
langchain-core>=0.3.0
openai>=1.60.0
oci>=2.130.0
oracledb>=2.4.0
pymongo>=4.8.0
redis>=5.0.0
PyYAML>=6.0.2
langfuse>=3.0.0
httpx>=0.27.0
opentelemetry-api>=1.27.0
opentelemetry-sdk>=1.27.0
opentelemetry-exporter-otlp-proto-http>=1.27.0
pytest>=8.0.0
pytest-asyncio>=0.23.0
google-cloud-pubsub>=2.28.0

View File

@@ -0,0 +1,29 @@
import asyncio
import tempfile
from types import SimpleNamespace
from agent_framework.memory.long_term_memory import create_long_term_memory_manager
async def main():
with tempfile.TemporaryDirectory() as d:
settings = SimpleNamespace(
ENABLE_LONG_TERM_MEMORY=True,
LONG_TERM_MEMORY_PROVIDER='sqlite',
LONG_TERM_MEMORY_SQLITE_PATH=f'{d}/memory.db',
LONG_TERM_MEMORY_TABLE='agentfw_long_term_memory',
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS=20,
LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70,
LONG_TERM_MEMORY_AUTO_EXTRACT=True,
)
manager = create_long_term_memory_manager(settings)
first = {'tenant_id':'default','agent_id':'memory_test','session_id':'a','user_text':'Me chame de Cris. Minha linguagem preferida é Python. Meu projeto atual se chama Atlas.','context':{'business_context':{'customer_key':'MEM-001'}}}
assert (await manager.persist_turn(first))['saved'] >= 3
second = {'tenant_id':'default','agent_id':'memory_test','session_id':'b','context':{'business_context':{'customer_key':'MEM-001'}}}
values = {item.key:item.value for item in await manager.load(second)}
assert values['preferred_name'].lower() == 'cris'
assert values['preferred_language'].lower() == 'python'
assert values['current_project'].lower() == 'atlas'
isolated = {'tenant_id':'default','agent_id':'memory_test','session_id':'c','context':{'business_context':{'customer_key':'MEM-002'}}}
assert await manager.load(isolated) == []
print('OK: persistência, recuperação entre sessões e isolamento validados')
asyncio.run(main())

View File

@@ -0,0 +1,202 @@
###############################################################################
# AI AGENT PLATFORM - CONFIGURAÇÃO ÚNICA
# Este arquivo é lido por Pydantic Settings no framework e no backend template.
###############################################################################
APP_NAME=ai-agent-template
APP_ENV=local
LOG_LEVEL=INFO
API_HOST=0.0.0.0
API_PORT=8000
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
###############################################################################
# LLM - OCI Generative AI como provider principal
###############################################################################
# Opções: mock, oci_openai, oci_sdk, openai_compatible
LLM_PROVIDER=oci_openai
LLM_TEMPERATURE=0.2
LLM_MAX_TOKENS=2048
LLM_TIMEOUT_SECONDS=120
# OCI OpenAI-compatible endpoint
OCI_GENAI_BASE_URL=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1
OCI_GENAI_MODEL=openai.gpt-4.1
OCI_GENAI_API_KEY=sk-ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6
OCI_GENAI_PROJECT_OCID=
# OCI SDK / signer / profiles
OCI_CONFIG_FILE=~/.oci/config
OCI_PROFILE=DEFAULT
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
OCI_REGION=us-chicago-1
###############################################################################
# Persistência
###############################################################################
# Opções: memory, autonomous, mongodb
SESSION_REPOSITORY_PROVIDER=sqlite
MEMORY_REPOSITORY_PROVIDER=sqlite
CHECKPOINT_REPOSITORY_PROVIDER=sqlite
SQLITE_DB_PATH=./data/agent_framework.db
# Autonomous Database
ADB_USER=admin
ADB_PASSWORD=fjhsdf04954hf
ADB_DSN=oradb23aidev_high
ADB_WALLET_LOCATION=/ORACLE/DEFAULT/Wallet_ORADB23aiDev
ADB_WALLET_PASSWORD=fjhsdf04954hf
ADB_TABLE_PREFIX=AGENTFW
# MongoDB - também pode representar Autonomous usando API compatível com Mongo, se habilitada no ambiente
MONGODB_URI=mongodb://mongo:mongopassword@localhost:27017
MONGODB_DATABASE=agent_platform
# Redis
REDIS_URL=redis://localhost:6379/0
ENABLE_REDIS_CACHE=false
###############################################################################
# RAG / Vector / Graph
###############################################################################
VECTOR_STORE_PROVIDER=sqlite
GRAPH_STORE_PROVIDER=sqlite
RAG_TOP_K=5
EMBEDDING_PROVIDER=mock
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json
###############################################################################
# Observabilidade
###############################################################################
ENABLE_LANGFUSE=true
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
LANGFUSE_PUBLIC_KEY=pk-lf-2f9da109-5b0f-4c78-b61d-9598ed787eba
LANGFUSE_SECRET_KEY=sk-lf-a4cb0cdd-f2ea-4468-9911-cebeb91ba944
LANGFUSE_HOST=http://localhost:3005
ENABLE_OTEL=false
OTEL_EXPORTER_OTLP_ENDPOINT=
OTEL_SERVICE_NAME=ai-agent-template
ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true
###############################################################################
# Analytics / Observer corporativo
###############################################################################
# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo.
ENABLE_ANALYTICS=false
# Providers aceitos: oci_streaming,pubsub,noop
ANALYTICS_PROVIDERS=pubsub
# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente.
AGENT_PUBSUB_TOPIC=
GCP_PUBSUB_TOPIC_PATH=
GCP_PROJECT_ID=
GCP_PUBSUB_TOPIC=
GCP_PUBSUB_TIMEOUT_SECONDS=30
# Credencial GCP segue padrão Google:
# GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
###############################################################################
# OCI Streaming
###############################################################################
ENABLE_OCI_STREAMING=false
OCI_STREAM_ENDPOINT=
OCI_STREAM_OCID=
OCI_STREAM_PARTITION_KEY=agent-events
###############################################################################
# Guardrails, Judges, Supervisor
###############################################################################
ENABLE_INPUT_GUARDRAILS=true
ENABLE_OUTPUT_GUARDRAILS=true
ENABLE_JUDGES=true
ENABLE_SUPERVISOR=true
ENABLE_OUTPUT_SUPERVISOR=true
ENABLE_PARALLEL_GUARDRAILS=true
GUARDRAILS_FAIL_FAST=true
OUTPUT_SUPERVISOR_MAX_RETRIES=3
GUARDRAILS_CONFIG_PATH=./config/guardrails.yaml
JUDGES_CONFIG_PATH=./config/judges.yaml
PROMPT_POLICY_PATH=./config/prompt_policy.yaml
###############################################################################
# Gateway de canais
###############################################################################
DEFAULT_CHANNEL=web
ENABLE_VOICE_ADAPTER=true
ENABLE_WHATSAPP_ADAPTER=true
ENABLE_TEXT_ADAPTER=true
#################################################
# ENTERPRISE ROUTING
#################################################
# Arquivo YAML com intents, keywords, políticas de estado e fallback.
ROUTING_CONFIG_PATH=./config/routing.yaml
# true = usa LLM para classificar quando keywords/estado não resolverem.
# Em produção, costuma ser útil; em desenvolvimento, false evita custo e latência.
ENABLE_LLM_ROUTER=true
# Continuidade semântica, handoff humano e encerramento global.
ENABLE_ROUTE_STICKINESS=true
ROUTE_STICKINESS_LLM_PROFILE=route_continuity
ROUTE_STICKINESS_CONFIDENCE_THRESHOLD=0.90
ROUTE_STICKINESS_HISTORY_TURNS=2
ROUTE_STICKINESS_MAX_TOKENS=80
HUMAN_HANDOFF_MESSAGE=Vou encaminhar seu atendimento para uma pessoa.
END_SESSION_MESSAGE=Atendimento encerrado. Obrigado pelo contato.
SESSION_ALREADY_ENDED_MESSAGE=Este atendimento já foi encerrado. Inicie uma nova sessão para continuar.
###############################################################################
# MCP / Tools
###############################################################################
ENABLE_MCP_TOOLS=true
MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.yaml
TOOLS_CONFIG_PATH=./config/tools.yaml
TOOL_POLICIES_PATH=./config/tool_policies.yaml
MCP_TOOL_TIMEOUT_SECONDS=30
# router = EnterpriseRouter seleciona um agente; supervisor = pode acionar múltiplos agentes
ROUTING_MODE=router
# Usage/cost accounting
USAGE_REPOSITORY_PROVIDER=sqlite
IDENTITY_CONFIG_PATH=./config/identity.yaml
MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml
# -----------------------------------------------------------------------------
# ConversationSummaryMemory / compressão de contexto conversacional
# -----------------------------------------------------------------------------
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
MEMORY_CONTEXT_STRATEGY=summary
MEMORY_HISTORY_LIMIT=80
MEMORY_RECENT_MESSAGES_LIMIT=8
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
MEMORY_MAX_SUMMARY_CHARS=6000
MEMORY_SUMMARY_USE_LLM=true
MEMORY_INJECT_RECENT_MESSAGES=true
MEMORY_INJECT_SUMMARY=true
###############################################################################
# MCP Gateway
###############################################################################
# true = framework routes tool calls to the dedicated MCP Gateway.
# false = framework calls MCP servers directly from mcp_servers.yaml.
MCP_GATEWAY_ENABLED=true
MCP_GATEWAY_URL=http://localhost:8300
MCP_GATEWAY_TIMEOUT_SECONDS=60
# MCP_GATEWAY_TOKEN=
MCP_GATEWAY_AGENT_ID=telecom_contas
MCP_GATEWAY_TENANT_ID=default
###############################################################################
# LONG-TERM MEMORY
###############################################################################
ENABLE_LONG_TERM_MEMORY=true
LONG_TERM_MEMORY_PROVIDER=sqlite
LONG_TERM_MEMORY_SQLITE_PATH=./data/agent_framework.db
LONG_TERM_MEMORY_TABLE=agentfw_long_term_memory
# For Autonomous/Oracle, defaults to ${ADB_TABLE_PREFIX}_LONG_TERM_MEMORY
# LONG_TERM_MEMORY_ORACLE_TABLE=AGENTFW_LONG_TERM_MEMORY
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS=20
LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70
LONG_TERM_MEMORY_AUTO_EXTRACT=true
LONG_TERM_MEMORY_INJECT_CONTEXT=true

View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY agent_framework /agent_framework
COPY agent_template_backend_day_zero /app
RUN pip install --no-cache-dir -e /agent_framework -r requirements.txt
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,89 @@
# agent_template_backend_day_zero
Este folder é uma cópia do `agent_template_backend`, porém transformada em um template **Day Zero**.
A ideia é o desenvolvedor começar um agente novo sem apagar manualmente exemplos de negócio.
## O que foi mantido
Foi mantida a estrutura original do backend:
- `app/main.py`
- `app/workflows/agent_graph.py`
- `app/state.py`
- `app/agents/runtime.py`
- `app/agents/prompting.py`
- configurações em `config/`
- integração com `agent_framework`
- Analytics / Observer
- NOC / GRL
- OutputSupervisor
- MCP Router
- RAG
- cache
- memória
- checkpoints
- Langfuse / OTEL
## O que foi comentado
As implementações de exemplo dos agentes foram comentadas nos arquivos:
- `app/agents/billing_agent.py`
- `app/agents/product_agent.py`
- `app/agents/orders_agent.py`
- `app/agents/support_agent.py`
Cada arquivo contém:
1. um esqueleto funcional mínimo;
2. comentários `TODO` para o desenvolvedor;
3. a implementação original comentada no final do arquivo.
## Como desenvolver um novo agente
1. Escolha qual classe vai reutilizar inicialmente, por exemplo `BillingAgent`.
2. Edite o método `run()`.
3. Ajuste o prompt em `apply_agent_profile_prompt(...)`.
4. Descomente MCP se precisar de tools:
```python
# tool_context = await self._collect_tool_context(state)
```
5. Descomente RAG se precisar de base de conhecimento:
```python
# rag_context, rag_metadata = await self._retrieve_rag_context(state)
```
6. Ajuste o retorno:
```python
return {
"answer": answer,
"next_state": "MEU_ESTADO",
}
```
## O que o desenvolvedor normalmente altera
- `app/agents/*.py`
- `config/routing.yaml`
- `config/agents.yaml`
- `config/tools.yaml`
- `config/mcp_servers.yaml`
- `config/mcp_parameter_mapping.yaml`
- `.env`
## O que normalmente não deve ser alterado no início
- `app/main.py`
- `app/workflows/agent_graph.py`
- `app/state.py`
- `app/agents/runtime.py`
Esses arquivos são o esqueleto de execução usando o framework.
# Política opcional de tools
O arquivo `config/tool_policies.yaml` classifica tools como `read_only` ou `transactional`. Para uma transação real, ative `require_confirmation: true`; chamadas sem `confirmed: true` ou `confirmation: true` serão bloqueadas antes do MCP. A ausência do arquivo preserva o comportamento de templates anteriores.

View File

@@ -0,0 +1,12 @@
# Desenvolvimento de agentes
Os arquivos `billing_agent.py`, `product_agent.py`, `orders_agent.py` e `support_agent.py` foram mantidos com os mesmos nomes do template completo para o workflow continuar compatível.
A implementação de negócio original está comentada no final de cada arquivo.
Para criar seu agente:
1. Edite o método `run()` da classe desejada.
2. Use o bloco comentado como referência.
3. Depois, ajuste o roteamento em `config/routing.yaml`.
4. Se quiser renomear classes/arquivos, atualize também os imports em `app/workflows/agent_graph.py`.

View File

@@ -0,0 +1,129 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class BillingAgent(AgentRuntimeMixin):
name = "billingAgent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
async def run(self, state):
await self._emit_ic(
"IC.BILLING_AGENT_STARTED",
state,
{"business_component": "faturas"},
component="agent.billing.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.BILLING_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.billing.mcp",
)
state["mcp_results"] = tool_context
clarification_message = self.transaction_clarification_message(state)
if clarification_message:
return {
"answer": f"[{self.__class__.__name__}] {clarification_message}",
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
confirmation_message = self.transaction_confirmation_message(state)
if confirmation_message:
result = {
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
"next_state": state.get("next_state"),
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
return result
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="BillingAgent")
if direct_answer:
return {
"answer": direct_answer,
"next_state": state.get("next_state") or "ACTIVE",
"mcp_results": tool_context,
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
**self.transaction_state_patch(state),
}
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.BILLING_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.billing.rag",
)
# Prepara ConversationSummaryMemory antes de montar o prompt.
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
await self.prepare_memory_context(state)
messages = self.build_messages(
state,
system_prompt=apply_agent_profile_prompt(
state,
"Você é um agente especialista em faturas. Responda com clareza, objetividade e sem sugerir ações não solicitadas. Use dados MCP quando disponíveis.",
),
mcp_results=tool_context,
rag_context=rag_context,
rag_metadata=rag_metadata,
)
answer = await self._invoke_llm_cached(state, "BillingAgent", messages)
result = {
"answer": f"[BillingAgent] {answer}",
"next_state": "BILLING_ACTIVE",
"mcp_results": tool_context,
"rag": rag_metadata,
"memory_context_metadata": state.get("memory_context_metadata"),
**self.transaction_state_patch(state),
}
await self._emit_ic(
"IC.BILLING_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
"memory_context": state.get("memory_context_metadata"),
},
component="agent.billing.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

View File

@@ -0,0 +1,129 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class OrdersAgent(AgentRuntimeMixin):
name = "orders_agent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
async def run(self, state):
await self._emit_ic(
"IC.ORDERS_AGENT_STARTED",
state,
{"business_component": "pedidos"},
component="agent.orders.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.ORDERS_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.orders.mcp",
)
state["mcp_results"] = tool_context
clarification_message = self.transaction_clarification_message(state)
if clarification_message:
return {
"answer": f"[{self.__class__.__name__}] {clarification_message}",
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
confirmation_message = self.transaction_confirmation_message(state)
if confirmation_message:
result = {
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
"next_state": state.get("next_state"),
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
return result
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="OrdersAgent")
if direct_answer:
return {
"answer": direct_answer,
"next_state": state.get("next_state") or "ACTIVE",
"mcp_results": tool_context,
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
**self.transaction_state_patch(state),
}
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.ORDERS_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.orders.rag",
)
# Prepara ConversationSummaryMemory antes de montar o prompt.
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
await self.prepare_memory_context(state)
messages = self.build_messages(
state,
system_prompt=apply_agent_profile_prompt(
state,
"Você é um agente de pedidos de varejo. Use dados de tools quando disponíveis.",
),
mcp_results=tool_context,
rag_context=rag_context,
rag_metadata=rag_metadata,
)
answer = await self._invoke_llm_cached(state, "OrdersAgent", messages)
result = {
"answer": f"[OrdersAgent] {answer}",
"next_state": "ORDER_ACTIVE",
"mcp_results": tool_context,
"rag": rag_metadata,
"memory_context_metadata": state.get("memory_context_metadata"),
**self.transaction_state_patch(state),
}
await self._emit_ic(
"IC.ORDERS_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
"memory_context": state.get("memory_context_metadata"),
},
component="agent.orders.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

View File

@@ -0,0 +1,129 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class ProductAgent(AgentRuntimeMixin):
name = "productAgent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
async def run(self, state):
await self._emit_ic(
"IC.PRODUCT_AGENT_STARTED",
state,
{"business_component": "produtos"},
component="agent.product.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.PRODUCT_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.product.mcp",
)
state["mcp_results"] = tool_context
clarification_message = self.transaction_clarification_message(state)
if clarification_message:
return {
"answer": f"[{self.__class__.__name__}] {clarification_message}",
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
confirmation_message = self.transaction_confirmation_message(state)
if confirmation_message:
result = {
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
"next_state": state.get("next_state"),
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
return result
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="ProductAgent")
if direct_answer:
return {
"answer": direct_answer,
"next_state": state.get("next_state") or "ACTIVE",
"mcp_results": tool_context,
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
**self.transaction_state_patch(state),
}
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.PRODUCT_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.product.rag",
)
# Prepara ConversationSummaryMemory antes de montar o prompt.
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
await self.prepare_memory_context(state)
messages = self.build_messages(
state,
system_prompt=apply_agent_profile_prompt(
state,
"Você é um agente especialista em produtos, planos e serviços. Explique sem fazer oferta proativa e sem executar ações sem confirmação. Use dados MCP quando disponíveis.",
),
mcp_results=tool_context,
rag_context=rag_context,
rag_metadata=rag_metadata,
)
answer = await self._invoke_llm_cached(state, "ProductAgent", messages)
result = {
"answer": f"[ProductAgent] {answer}",
"next_state": "PRODUCT_ACTIVE",
"mcp_results": tool_context,
"rag": rag_metadata,
"memory_context_metadata": state.get("memory_context_metadata"),
**self.transaction_state_patch(state),
}
await self._emit_ic(
"IC.PRODUCT_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
"memory_context": state.get("memory_context_metadata"),
},
component="agent.product.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
def apply_agent_profile_prompt(state: dict, default_prompt: str) -> str:
"""Adiciona o prefixo de prompt configurado para o agent_template selecionado.
Cada agent_id pode definir metadata.system_prefix em config/agents.yaml. Isso
mantém prompts isolados sem duplicar o código dos agentes especializados.
"""
profile = state.get("agent_profile") or (state.get("context") or {}).get("agent_profile") or {}
metadata = profile.get("metadata") or {}
prefix = (metadata.get("system_prefix") or "").strip()
if not prefix:
return default_prompt
return f"{prefix}\n\n{default_prompt}"

View File

@@ -0,0 +1,7 @@
from __future__ import annotations
# Compatibilidade local do template/backend.
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]

View File

@@ -0,0 +1,129 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class SupportAgent(AgentRuntimeMixin):
name = "support_agent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
async def run(self, state):
await self._emit_ic(
"IC.SUPPORT_AGENT_STARTED",
state,
{"business_component": "suporte"},
component="agent.support.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.SUPPORT_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.support.mcp",
)
state["mcp_results"] = tool_context
clarification_message = self.transaction_clarification_message(state)
if clarification_message:
return {
"answer": f"[{self.__class__.__name__}] {clarification_message}",
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
confirmation_message = self.transaction_confirmation_message(state)
if confirmation_message:
result = {
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
"next_state": state.get("next_state"),
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
return result
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="SupportAgent")
if direct_answer:
return {
"answer": direct_answer,
"next_state": state.get("next_state") or "ACTIVE",
"mcp_results": tool_context,
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
**self.transaction_state_patch(state),
}
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.SUPPORT_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.support.rag",
)
# Prepara ConversationSummaryMemory antes de montar o prompt.
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
await self.prepare_memory_context(state)
messages = self.build_messages(
state,
system_prompt=apply_agent_profile_prompt(
state,
"Você é um agente de suporte de varejo para troca, devolução e garantia.",
),
mcp_results=tool_context,
rag_context=rag_context,
rag_metadata=rag_metadata,
)
answer = await self._invoke_llm_cached(state, "SupportAgent", messages)
result = {
"answer": f"[SupportAgent] {answer}",
"next_state": "SUPPORT_ACTIVE",
"mcp_results": tool_context,
"rag": rag_metadata,
"memory_context_metadata": state.get("memory_context_metadata"),
**self.transaction_state_patch(state),
}
await self._emit_ic(
"IC.SUPPORT_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
"memory_context": state.get("memory_context_metadata"),
},
component="agent.support.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

View File

@@ -0,0 +1,532 @@
from __future__ import annotations
import logging
from uuid import uuid4
import time
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from agent_framework.channels.base import ChannelResponse
from agent_framework.channels.gateway import ChannelGateway
from agent_framework.config.agent_registry import AgentProfileRegistry
from agent_framework.config.settings import settings
from agent_framework.analytics.factory import create_analytics_publisher
from agent_framework.observer import configure as configure_global_observer
from agent_framework.llm.providers import create_llm
from agent_framework.memory.message_history import create_memory
from agent_framework.memory.summary_memory import create_conversation_summary_memory
from agent_framework.mcp.tool_router import create_mcp_tool_router
from agent_framework.models.identity import AgentIdentity
from agent_framework.identity import IdentityResolver, BusinessContext
from agent_framework.models.session import ChatMessage, SessionContext
from agent_framework.observability.telemetry import Telemetry
from agent_framework.observability.context import set_observability_context, clear_observability_context
from agent_framework.repositories.session_repository import create_session_repository
from agent_framework.checkpoints.checkpoint_repository import create_checkpoint_repository
from agent_framework.cache.cache import create_cache
from agent_framework.billing.usage_repository import create_usage_repository
from agent_framework.sse.events import SSEHub
from app.workflows.agent_graph import AgentWorkflow
from app.observability.telemetry_observer import TelemetryBackedAgentObserver
logging.basicConfig(level=settings.LOG_LEVEL)
logger = logging.getLogger("agent_template_backend")
app = FastAPI(title="Agent Template Backend FIRST-ready")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",")],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
telemetry = Telemetry(settings)
usage_repository = create_usage_repository(settings)
llm = create_llm(settings, telemetry=telemetry, usage_repository=usage_repository)
memory = create_memory(settings)
summary_memory = create_conversation_summary_memory(settings, message_history=memory, llm=llm, telemetry=telemetry)
sessions = create_session_repository(settings)
checkpoints = create_checkpoint_repository(settings)
cache = create_cache(settings, telemetry=telemetry)
gateway = ChannelGateway(input_mode=settings.FRAMEWORK_CHANNEL_INPUT_MODE)
analytics = create_analytics_publisher(settings)
observer = TelemetryBackedAgentObserver(telemetry=telemetry)
configure_global_observer({
"enabled": getattr(settings, "ENABLE_ANALYTICS", False),
"providers": getattr(settings, "ANALYTICS_PROVIDERS", "oci_streaming"),
"topic_path": getattr(settings, "GCP_PUBSUB_TOPIC_PATH", None) or getattr(settings, "AGENT_PUBSUB_TOPIC", None),
})
tool_router = create_mcp_tool_router(settings, telemetry=telemetry)
identity_resolver = IdentityResolver.from_yaml(settings.IDENTITY_CONFIG_PATH)
agent_profiles = AgentProfileRegistry(settings)
sse_hub = SSEHub(settings, telemetry=telemetry)
workflow = AgentWorkflow(llm, memory, telemetry, analytics, settings, observer=observer, tool_router=tool_router, summary_memory=summary_memory)
logger.info("LLM provider carregado: %s", llm.__class__.__name__)
logger.info("Langfuse habilitado: %s host=%s", telemetry.is_enabled(), settings.LANGFUSE_HOST)
logger.info("Analytics habilitado: %s providers=%s", getattr(settings, "ENABLE_ANALYTICS", False), getattr(settings, "ANALYTICS_PROVIDERS", ""))
logger.info("Agentes disponíveis: %s", [p.agent_id for p in agent_profiles.list_profiles()])
logger.info("Framework channel input mode: %s", gateway.input_mode)
@app.middleware("http")
async def observability_context_middleware(request: Request, call_next):
clear_observability_context()
request_id = request.headers.get("x-request-id") or str(uuid4())
set_observability_context(
request_id=request_id,
channel=request.headers.get("x-channel") or "http",
ura_call_id=request.headers.get("x-ura-call-id"),
)
started = time.time()
try:
response = await call_next(request)
response.headers["x-request-id"] = request_id
await telemetry.event("http.request.completed", {
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": int((time.time() - started) * 1000),
}, kind="http")
return response
except Exception as exc:
await telemetry.event("http.request.failed", {
"method": request.method,
"path": request.url.path,
"error": str(exc),
"duration_ms": int((time.time() - started) * 1000),
}, kind="http")
raise
finally:
clear_observability_context()
class GatewayRequest(BaseModel):
channel: str = "web"
payload: dict
agent_id: str | None = None
tenant_id: str | None = None
def _metadata_value(payload: dict, key: str):
metadata = payload.get("metadata")
if isinstance(metadata, dict):
return metadata.get(key)
return None
def _extract_workflow_id(payload: dict) -> str | None:
return (
payload.get("workflow_id")
or payload.get("workflowId")
or _metadata_value(payload, "workflow_id")
or _metadata_value(payload, "workflowId")
)
def _format_root_span_name(template: str | None, values: dict) -> str:
template = template or "agent.gateway_message"
try:
return template.format(**{k: v or "unknown" for k, v in values.items()})
except Exception:
logger.warning("LANGFUSE_ROOT_SPAN_NAME inválido: %s", template)
return "agent.gateway_message"
def _resolve_identity(req: GatewayRequest, msg) -> tuple[AgentIdentity, dict, BusinessContext, list[str]]:
payload = req.payload or {}
context = dict(msg.context or {})
tenant_id = req.tenant_id or payload.get("tenant_id") or context.get("tenant_id") or "default"
agent_id = req.agent_id or payload.get("agent_id") or context.get("agent_id") or agent_profiles.default_agent_id
profile = agent_profiles.get(agent_id)
# 1) Identidade técnica do framework: isola tenant/agente/sessão.
context.update({"tenant_id": tenant_id, "agent_id": profile.agent_id, "agent_profile": profile.__dict__})
identity = AgentIdentity.from_context(context, session_id=msg.session_id)
# 2) Identidade de negócio: chaves canônicas vindas do front/canal.
# Estas chaves são estáveis na sessão e seguem até agentes e MCP Router.
previous_business_context = context.get("business_context") or context.get("identity") or {}
business_context = identity_resolver.resolve(
{**payload, **context},
session_id=identity.conversation_key(),
previous=previous_business_context,
)
missing_identity_keys = identity_resolver.validate(business_context)
context.update({
"business_context": business_context.model_dump(),
"business_keys": business_context.to_context_dict(),
"identity_missing": missing_identity_keys,
"conversation_key": identity.conversation_key(),
"original_session_id": msg.session_id,
})
return identity, context, business_context, missing_identity_keys
async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False) -> dict:
try:
msg = await gateway.normalize(req.channel, req.payload)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
payload = req.payload or {}
identity, normalized_context, business_context, missing_identity_keys = _resolve_identity(req, msg)
agent_session_id = identity.conversation_key()
message_id = payload.get("message_id") or str(uuid4())
workflow_id = _extract_workflow_id(payload)
set_observability_context(
session_id=agent_session_id,
user_id=msg.user_id,
tenant_id=identity.tenant_id,
agent_id=identity.agent_id,
channel=msg.channel,
message_id=message_id,
workflow_id=workflow_id,
ura_call_id=payload.get("ura_call_id") or normalized_context.get("ura_call_id") or business_context.interaction_key,
)
stream = sse_hub.stream_for(agent_session_id)
async with stream.lock:
await sse_hub.emit(agent_session_id, "flow.start", {"session_id": agent_session_id, "message_id": message_id, "agent_id": identity.agent_id}) if emit_sse else None
session = await sessions.get(agent_session_id)
if not session:
context_fields = {
k: v
for k, v in normalized_context.items()
if k in SessionContext.model_fields
and k not in {"tenant_id", "agent_id", "session_id", "user_id", "channel", "channel_id"}
}
session = SessionContext(
tenant_id=identity.tenant_id,
agent_id=identity.agent_id,
session_id=agent_session_id,
user_id=msg.user_id,
channel=msg.channel,
channel_id=msg.channel_id,
**context_fields,
)
session.tenant_id = identity.tenant_id
session.agent_id = identity.agent_id
session.channel = msg.channel
session.channel_id = msg.channel_id or session.channel_id
await sessions.upsert(session)
session.metadata = {
**(session.metadata or {}),
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"original_context": normalized_context,
}
await sse_hub.emit(agent_session_id, "session.upserted", {"session_id": agent_session_id, "business_context": business_context.model_dump()}) if emit_sse else None
await memory.append(
agent_session_id,
ChatMessage(
role="user",
content=msg.text,
metadata={
**normalized_context,
"agent_id": identity.agent_id,
"tenant_id": identity.tenant_id,
"message_id": message_id,
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
},
),
)
await sse_hub.emit(agent_session_id, "message.received", {"session_id": agent_session_id, "role": "user"}) if emit_sse else None
history = [m.model_dump(mode="json") for m in await memory.list(agent_session_id)]
cms_input = {
"channel": req.channel,
"tenant_id": req.tenant_id,
"agent_id": req.agent_id,
"payload": payload,
}
trace_context = {
"text": msg.text,
"channel": msg.channel,
"channel_id": msg.channel_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"conversation_key": agent_session_id,
"workflow_id": workflow_id,
"message_id": message_id,
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
}
root_span_name = _format_root_span_name(
getattr(settings, "LANGFUSE_ROOT_SPAN_NAME", "agent.gateway_message"),
{
"workflow_id": workflow_id,
"channel": msg.channel,
"agent_id": identity.agent_id,
"tenant_id": identity.tenant_id,
},
)
root_tags = ["agent-template", msg.channel, f"agent:{identity.agent_id}", f"tenant:{identity.tenant_id}"]
if workflow_id:
root_tags.append(f"workflow:{workflow_id}")
async with telemetry.span(
root_span_name,
session_id=agent_session_id,
user_id=session.user_id,
channel=msg.channel,
workflow_id=workflow_id,
input=cms_input,
tags=root_tags,
_root_span=True,
) as root_span:
await telemetry.event("gateway.message.received", trace_context)
await sse_hub.emit(agent_session_id, "workflow.started", trace_context) if emit_sse else None
result = await workflow.ainvoke(
{
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"session_id": agent_session_id,
"conversation_key": agent_session_id,
"workflow_id": workflow_id,
"agent_profile": normalized_context["agent_profile"],
"user_text": msg.text,
"history": history,
"context": {
**normalized_context,
"session": session.model_dump(mode="json"),
"original_session_id": msg.session_id,
"session_id": agent_session_id,
"conversation_key": agent_session_id,
"workflow_id": workflow_id,
"user_id": session.user_id,
"channel": msg.channel,
"message_id": message_id,
"business_context": business_context.model_dump(),
"business_keys": business_context.to_context_dict(),
"identity_missing": missing_identity_keys,
},
}
)
await checkpoints.put(agent_session_id, {"state": result, "message_id": message_id})
await sse_hub.emit(agent_session_id, "workflow.completed", {"session_id": agent_session_id, "route": result.get("route"), "intent": result.get("intent")}) if emit_sse else None
answer = result.get("final_answer") or result.get("answer") or ""
await memory.append(
agent_session_id,
ChatMessage(
role="assistant",
content=answer,
metadata={
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"message_id": f"assistant-{message_id}",
"route": result.get("route"),
"intent": result.get("intent"),
"route_decision": result.get("route_decision"),
"judges": result.get("judge_results"),
},
),
)
await telemetry.event(
"gateway.message.responded",
{
"session_id": agent_session_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"route": result.get("route"),
"intent": result.get("intent"),
"answer_chars": len(answer),
},
)
response = ChannelResponse(
channel=msg.channel,
session_id=agent_session_id,
text=answer,
metadata={
"channel_id": msg.channel_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"original_session_id": msg.session_id,
"conversation_key": agent_session_id,
"workflow_id": workflow_id,
"message_id": message_id,
"route": result.get("route"),
"intent": result.get("intent"),
"route_decision": result.get("route_decision"),
"domain": result.get("domain"),
"mcp_tools": result.get("mcp_tools"),
"mcp_results": result.get("mcp_results"),
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"judges": result.get("judge_results"),
"guardrails": result.get("guardrail_decisions"),
},
)
rendered = await gateway.render(response)
root_span.set_output(rendered)
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
await sse_hub.emit(agent_session_id, "flow.end", {"session_id": agent_session_id, "message_id": message_id}) if emit_sse else None
return rendered
@app.get("/health")
async def health():
return {
"status": "ok",
"llm_provider": settings.LLM_PROVIDER,
"llm_class": llm.__class__.__name__,
"langfuse_enabled": telemetry.is_enabled(),
"agents": [p.agent_id for p in agent_profiles.list_profiles()],
"default_agent_id": agent_profiles.default_agent_id,
"routing_mode": settings.ROUTING_MODE,
"sse_enabled": settings.ENABLE_SSE,
"session_repository": settings.SESSION_REPOSITORY_PROVIDER,
"memory_repository": settings.MEMORY_REPOSITORY_PROVIDER,
"checkpoint_repository": settings.CHECKPOINT_REPOSITORY_PROVIDER,
"usage_repository": settings.USAGE_REPOSITORY_PROVIDER,
"identity_config_path": settings.IDENTITY_CONFIG_PATH,
"mcp_parameter_mapping_path": settings.MCP_PARAMETER_MAPPING_PATH,
"framework_channel_input_mode": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
"legacy_channel_gateway_mode": settings.CHANNEL_GATEWAY_MODE,
}
@app.get("/agents")
async def list_agents():
return {"default_agent_id": agent_profiles.default_agent_id, "agents": [p.__dict__ for p in agent_profiles.list_profiles()]}
@app.get("/debug/env")
async def debug_env():
return {
"APP_ENV": settings.APP_ENV,
"LLM_PROVIDER": settings.LLM_PROVIDER,
"ENABLE_LANGFUSE": settings.ENABLE_LANGFUSE,
"LANGFUSE_HOST": settings.LANGFUSE_HOST,
"TELEMETRY_ENABLED": telemetry.is_enabled(),
"SQLITE_DB_PATH": settings.SQLITE_DB_PATH,
"SESSION_REPOSITORY_PROVIDER": settings.SESSION_REPOSITORY_PROVIDER,
"MEMORY_REPOSITORY_PROVIDER": settings.MEMORY_REPOSITORY_PROVIDER,
"CHECKPOINT_REPOSITORY_PROVIDER": settings.CHECKPOINT_REPOSITORY_PROVIDER,
"AGENTS_CONFIG_PATH": settings.AGENTS_CONFIG_PATH,
"ROUTING_CONFIG_PATH": settings.ROUTING_CONFIG_PATH,
"ROUTING_MODE": settings.ROUTING_MODE,
"FRAMEWORK_CHANNEL_INPUT_MODE": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
"CHANNEL_GATEWAY_MODE": settings.CHANNEL_GATEWAY_MODE,
}
@app.get("/test-llm")
async def test_llm():
async with telemetry.span("debug.test_llm", input={"message": "Diga apenas OK"}):
answer = await llm.ainvoke([
{"role": "system", "content": "Responda de forma curta."},
{"role": "user", "content": "Diga apenas OK"},
])
telemetry.flush()
return {"provider": llm.__class__.__name__, "answer": answer}
@app.post("/debug/route")
async def debug_route(req: GatewayRequest):
msg = await gateway.normalize(req.channel, req.payload)
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
state = {
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"session_id": msg.session_id or "debug-session",
"conversation_key": identity.conversation_key(),
"agent_profile": context["agent_profile"],
"user_text": msg.text,
"sanitized_input": msg.text,
"history": [],
"context": {**context, "session": context.get("session", {}), "channel": msg.channel, "business_context": business_context.model_dump()},
}
if settings.ROUTING_MODE == "supervisor":
plan = await workflow.supervisor.route_plan(state)
return {"mode": "supervisor", "route": "supervisor_agent", "agents": plan.agents, "intent": plan.intent, "confidence": plan.confidence, "reason": plan.reason, "metadata": plan.metadata}
decision = await workflow.router.route(state)
data = decision.model_dump(mode="json")
data["mode"] = "router"
return data
@app.post("/debug/identity")
async def debug_identity(req: GatewayRequest):
msg = await gateway.normalize(req.channel, req.payload)
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
return {
"technical_identity": {
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"conversation_key": identity.conversation_key(),
"original_session_id": msg.session_id,
},
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"context_keys": sorted(context.keys()),
}
@app.get("/debug/usage")
async def debug_usage(tenant_id: str | None = None, session_id: str | None = None):
return await usage_repository.summarize(tenant_id=tenant_id, session_id=session_id)
@app.get("/debug/mcp/tools")
async def debug_mcp_tools():
return {"enabled": tool_router.enabled, "tools": tool_router.describe_tools()}
@app.post("/debug/mcp/call/{tool_name}")
async def debug_mcp_call(tool_name: str, arguments: dict | None = None):
arguments = arguments or {}
ctx = arguments.get("business_context") or arguments.get("identity") or {}
result = await tool_router.call(
tool_name,
arguments,
business_context=ctx,
original_context=arguments,
)
return result.model_dump(mode="json")
@app.post("/gateway/message")
async def gateway_message(req: GatewayRequest):
return await _process_gateway_message(req, emit_sse=False)
@app.post("/gateway/message/sse")
async def gateway_message_sse(req: GatewayRequest):
return await _process_gateway_message(req, emit_sse=True)
@app.get("/gateway/events/{session_id}")
async def gateway_events(session_id: str, request: Request):
last = request.headers.get("last-event-id") or request.query_params.get("last_event_id") or "0"
return StreamingResponse(
sse_hub.subscribe(session_id, int(last)),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)
@app.get("/sessions/{session_id}/messages")
async def get_session_messages(session_id: str, limit: int = 50):
return {"session_id": session_id, "messages": [m.model_dump(mode="json") for m in await memory.list(session_id, limit)]}
@app.get("/sessions/{session_id}/checkpoint")
async def get_session_checkpoint(session_id: str):
return {"session_id": session_id, "checkpoint": await checkpoints.get_latest(session_id)}
@app.on_event("shutdown")
async def shutdown():
telemetry.shutdown()

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
import os
from agent_framework.gateways import MCPGatewayClient
def build_mcp_gateway_client() -> MCPGatewayClient | None:
if os.getenv("MCP_GATEWAY_ENABLED", "true").lower() != "true":
return None
return MCPGatewayClient(
base_url=os.getenv("MCP_GATEWAY_URL", "http://localhost:8300"),
token=os.getenv("MCP_GATEWAY_TOKEN") or None,
timeout_seconds=int(os.getenv("MCP_GATEWAY_TIMEOUT_SECONDS", "60")),
)

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
"""Observer adapter that emits IC/NOC/GRL through framework Telemetry only.
This avoids a second Langfuse root trace created by AgentObserver ->
AnalyticsPublisher while preserving the events inside the active request span.
"""
from datetime import datetime, timezone
from typing import Any
def _normalize_ic_code(code: str) -> str:
code = str(code or "UNKNOWN").strip()
return code if code.startswith(("IC.", "AGA.", "NOC.", "GRL.")) else f"IC.{code}"
def _normalize_noc_code(code: str) -> str:
code = str(code or "UNKNOWN").strip()
return code if code.startswith("NOC.") else f"NOC.{code}"
def _normalize_grl_code(code: str) -> str:
code = str(code or "UNKNOWN").strip()
return code if code.startswith("GRL.") else f"GRL.{code}"
def _kind_for(event_type: str) -> str:
if event_type.startswith(("IC.", "AGA.")):
return "ic"
if event_type.startswith("NOC."):
return "noc"
if event_type.startswith("GRL."):
return "grl"
return "event"
class TelemetryBackedAgentObserver:
"""Drop-in subset of AgentObserver backed by Telemetry.event.
Do not publish through AnalyticsPublisher here. Analytics publishing may be
configured with a Langfuse provider, and that path creates an extra root
trace for business events such as IC.AGENT_COMPLETED/NOC.006. Telemetry.event
uses the active span/trace context, so these events appear inside the single
request trace.
"""
def __init__(self, telemetry: Any, *, source: str = "agent_framework") -> None:
self.telemetry = telemetry
self.source = source
async def emit(
self,
event_type: str,
payload: dict[str, Any] | None = None,
*,
metadata: dict[str, Any] | None = None,
source: str | None = None,
) -> dict[str, Any]:
body = dict(payload or {})
meta = dict(metadata or {})
body.setdefault("tag", event_type)
event = {
"eventType": event_type,
"source": source or self.source,
"eventDate": datetime.now(timezone.utc).isoformat(),
"body": body,
"metadata": meta,
}
try:
await self.telemetry.event(event_type, event, kind=_kind_for(event_type))
except TypeError:
# Compatibility with older Telemetry.event signatures.
await self.telemetry.event(event_type, event)
return event
async def emit_ic(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
return await self.emit(_normalize_ic_code(code), payload, metadata={**metadata, "ic": True})
async def emit_noc(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
return await self.emit(_normalize_noc_code(code), payload, metadata={**metadata, "noc": True})
async def emit_grl(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
return await self.emit(_normalize_grl_code(code), payload, metadata={**metadata, "grl": True})

View File

@@ -0,0 +1,51 @@
from typing import Any, TypedDict
class AgentState(TypedDict, total=False):
tenant_id: str
agent_id: str
session_id: str
conversation_key: str
workflow_id: str
agent_profile: dict[str, Any]
user_text: str
sanitized_input: str
route: str
intent: str
route_decision: dict[str, Any]
answer: str
final_answer: str
history: list[dict[str, Any]]
context: dict[str, Any]
guardrail_decisions: list[dict[str, Any]]
judge_results: list[dict[str, Any]]
next_state: str
domain: str
mcp_tools: list[str]
mcp_results: list[dict[str, Any]]
available_mcp_tools: list[str]
selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any]
transaction_status: str
confirmation_required: bool
confirmation_received: bool
tool_policy_result: dict[str, Any]
missing_parameters: list[str]
supervisor_plan: dict[str, Any]
supervisor_results: list[dict[str, Any]]
active_agent: str
route_bypassed: bool
continuity_signal: dict[str, Any]
session_control: str
session_ended: bool
human_handoff_requested: bool
blocked: bool
supervisor_action: str
supervisor_guidance: str
supervisor_attempt: int
supervisor_handover_reason: str
output_supervisor_results: list[dict[str, Any]]
output_guardrails_already_applied: bool
long_term_memories: list[dict[str, Any]]
long_term_memory_context: str
long_term_memory_write_result: dict[str, Any]

View File

@@ -0,0 +1,816 @@
from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer
from langgraph.graph import END, START, StateGraph
from agent_framework.guardrails.pipeline import GuardrailPipeline
from agent_framework.guardrails.output_supervisor import OutputSupervisor
from agent_framework.guardrails.rail_action import RailAction
from agent_framework.guardrails.rail_result import RailResult
from agent_framework.judges.judge import JudgePipeline
from agent_framework.routing.enterprise_router import EnterpriseRouter
from agent_framework.supervisor.supervisor import Supervisor
from agent_framework.observability.workflow_events import WorkflowTelemetry
from agent_framework.observability.guardrail_events import GuardrailTelemetry
from agent_framework.observability.judge_events import JudgeTelemetry
from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry
from agent_framework.observability.observer import AgentObserver
from app.agents.billing_agent import BillingAgent
from app.agents.product_agent import ProductAgent
from app.agents.orders_agent import OrdersAgent
from app.agents.support_agent import SupportAgent
from app.state import AgentState
from agent_framework.rag.rag_service import RagService
from agent_framework.rag.embedding_provider import create_embedding_provider
from agent_framework.cache.cache import create_cache
from agent_framework.memory.long_term_memory import create_long_term_memory_manager
class LegacyOutputGuardrailRail:
"""Adapter: reutiliza GuardrailPipeline.run_output dentro do OutputSupervisor novo.
O framework antigo retornava decisões allowed=True/False. O OutputSupervisor
corporativo trabalha com RailAction (allow/sanitize/retry/block/handover).
Este adapter evita reescrever todos os rails agora e mantém compatibilidade.
"""
code = "LEGACY_OUTPUT_GUARDRAILS"
def __init__(self, pipeline: GuardrailPipeline):
self.pipeline = pipeline
async def evaluate(self, candidate: str, context: dict):
final, decisions = await self.pipeline.run_output(candidate, context)
serialized = [d.model_dump() for d in decisions]
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
if blocked:
first = blocked[0]
code = (getattr(first, "code", "") or "").upper()
action = RailAction.RETRY if code in {"REVPREC", "CMP", "SCO", "GND"} else RailAction.BLOCK
return RailResult(
code=code or self.code,
action=action,
reason=getattr(first, "reason", "Resposta bloqueada por guardrail de saída"),
guidance=getattr(first, "reason", "Regerar resposta seguindo as políticas de saída."),
sanitized_text=final,
metadata={"legacy_decisions": serialized},
)
if final != candidate:
return RailResult(
code=self.code,
action=RailAction.SANITIZE,
reason="Resposta sanitizada por guardrail de saída legado.",
sanitized_text=final,
metadata={"legacy_decisions": serialized},
)
return RailResult(
code=self.code,
action=RailAction.ALLOW,
reason="Resposta aprovada pelos guardrails de saída legados.",
sanitized_text=final,
metadata={"legacy_decisions": serialized},
)
class AgentWorkflow:
"""Workflow principal com dois modos de roteamento.
Modos suportados por configuração:
ROUTING_MODE=router
input_guardrails -> routing_decision/EnterpriseRouter -> 1 agente -> output_guardrails
ROUTING_MODE=supervisor
input_guardrails -> routing_decision/Supervisor -> supervisor_agent -> N agentes -> consolidação
Em ambos os modos, memória/checkpoint/session usam tenant_id:agent_id:session_id.
"""
def __init__(self, llm, memory, telemetry, analytics, settings, observer: AgentObserver | None = None, tool_router=None, summary_memory=None):
self.llm = llm
self.memory = memory
self.telemetry = telemetry
self.analytics = analytics
self.observer = observer or AgentObserver(analytics=analytics)
self.settings = settings
self.tool_router = tool_router
self.summary_memory = summary_memory
self.long_term_memory_manager = create_long_term_memory_manager(settings, telemetry=telemetry)
self.guardrails = GuardrailPipeline(
observer=self.observer,
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
)
self.output_supervisor_engine = OutputSupervisor(
rails=[LegacyOutputGuardrailRail(self.guardrails)],
observer=self.observer,
max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)),
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
)
self.judges = JudgePipeline()
self.supervisor = Supervisor()
self.workflow_telemetry = WorkflowTelemetry(telemetry)
self.guardrail_telemetry = GuardrailTelemetry(telemetry)
self.judge_telemetry = JudgeTelemetry(telemetry)
self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry)
self.cache = create_cache(settings)
self.embedding_provider = create_embedding_provider(settings)
self.rag_service = RagService(settings, embedding_provider=self.embedding_provider, telemetry=telemetry)
self.router = EnterpriseRouter(settings, llm=llm, telemetry=telemetry)
agent_kwargs = {"telemetry": telemetry, "tool_router": getattr(self, "tool_router", None), "rag_service": self.rag_service, "cache": self.cache, "settings": settings, "observer": self.observer, "memory": memory, "summary_memory": summary_memory}
self.billing = BillingAgent(llm, **agent_kwargs)
self.product = ProductAgent(llm, **agent_kwargs)
self.orders = OrdersAgent(llm, **agent_kwargs)
self.support = SupportAgent(llm, **agent_kwargs)
# The existing agent constructors intentionally keep their stable API.
# Long-term memory is injected as a runtime capability after creation.
for agent in (self.billing, self.product, self.orders, self.support):
agent.long_term_memory_manager = self.long_term_memory_manager
self.graph = self._build_graph()
def _node(self, name, fn):
async def _wrapped(state):
async with self.langgraph_telemetry.node(name, state):
return await fn(state)
return _wrapped
def _build_graph(self):
builder = StateGraph(AgentState)
builder.add_node("input_guardrails", self._node("input_guardrails", self.input_guardrails))
builder.add_node("routing_decision", self._node("routing_decision", self.routing_decision))
builder.add_node("billing_agent", self._node("billing_agent", self.billing_agent))
builder.add_node("product_agent", self._node("product_agent", self.product_agent))
builder.add_node("orders_agent", self._node("orders_agent", self.orders_agent))
builder.add_node("support_agent", self._node("support_agent", self.support_agent))
builder.add_node("handoff", self._node("handoff", self.handoff))
builder.add_node("human_handoff", self._node("human_handoff", self.human_handoff))
builder.add_node("end_session", self._node("end_session", self.end_session))
builder.add_node("supervisor_agent", self._node("supervisor_agent", self.supervisor_agent))
builder.add_node("output_supervisor", self._node("output_supervisor", self.output_supervisor))
builder.add_node("output_guardrails", self._node("output_guardrails", self.output_guardrails))
builder.add_node("judge", self._node("judge", self.judge))
builder.add_node("supervisor_review", self._node("supervisor_review", self.supervisor_review))
builder.add_node("persist_long_term_memory", self._node("persist_long_term_memory", self.persist_long_term_memory))
builder.add_node("persist", self._node("persist", self.persist))
builder.add_edge(START, "input_guardrails")
builder.add_conditional_edges(
"input_guardrails",
self._after_input_guardrails,
{"blocked": "persist", "continue": "routing_decision"},
)
builder.add_conditional_edges(
"routing_decision",
lambda s: s.get("route", "billing_agent"),
{
"billing_agent": "billing_agent",
"product_agent": "product_agent",
"orders_agent": "orders_agent",
"support_agent": "support_agent",
"handoff": "handoff",
"human_handoff": "human_handoff",
"end_session": "end_session",
"supervisor_agent": "supervisor_agent",
},
)
builder.add_edge("billing_agent", "output_supervisor")
builder.add_edge("product_agent", "output_supervisor")
builder.add_edge("orders_agent", "output_supervisor")
builder.add_edge("support_agent", "output_supervisor")
builder.add_edge("handoff", "output_supervisor")
builder.add_edge("human_handoff", "output_supervisor")
builder.add_edge("end_session", "output_supervisor")
builder.add_edge("supervisor_agent", "output_supervisor")
builder.add_edge("output_supervisor", "output_guardrails")
builder.add_edge("output_guardrails", "judge")
builder.add_edge("judge", "supervisor_review")
builder.add_edge("supervisor_review", "persist_long_term_memory")
builder.add_edge("persist_long_term_memory", "persist")
builder.add_edge("persist", END)
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
def _after_input_guardrails(self, state):
return "blocked" if state.get("blocked") else "continue"
async def input_guardrails(self, state):
if state.get("session_ended") is True:
answer = str(getattr(
self.settings,
"SESSION_ALREADY_ENDED_MESSAGE",
"Este atendimento já foi encerrado. Inicie uma nova sessão para continuar.",
))
await self.telemetry.event(
"session.message.rejected_after_end",
{"session_id": state.get("conversation_key") or state.get("session_id")},
)
return {
"answer": answer,
"final_answer": answer,
"blocked": True,
"session_control": "END_SESSION",
"session_ended": True,
"next_state": "SESSION_ENDED",
}
async with self.telemetry.span(
"workflow.input_guardrails",
session_id=state.get("conversation_key") or state.get("session_id"),
input=state.get("user_text"),
):
history_texts = [m.get("content", "") for m in state.get("history", [])]
await self.observer.emit_grl(
"001",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "input",
},
component="workflow.input_guardrails.start",
)
sanitized, decisions = await self.guardrails.run_input(
state["user_text"],
{
**(state.get("context") or {}),
"history_texts": history_texts,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"agent_profile": state.get("agent_profile") or {},
},
)
for _decision in decisions:
await self.guardrail_telemetry.evaluated("input", _decision)
await self.observer.emit_grl(
"002" if _decision.allowed else "004",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "input",
"rail_code": getattr(_decision, "code", None),
"allowed": bool(_decision.allowed),
"reason": getattr(_decision, "reason", None),
},
component="workflow.input_guardrails.decision",
)
if not _decision.allowed:
await self.guardrail_telemetry.blocked("input", _decision)
await self.telemetry.event(
"guardrails.input.completed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"decisions": [d.model_dump() for d in decisions],
},
)
await self.observer.emit_grl(
"009",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "input",
"blocked": any(not d.allowed for d in decisions),
"decision_count": len(decisions),
},
component="workflow.input_guardrails.final",
)
if any(not d.allowed for d in decisions):
return {
"sanitized_input": sanitized,
"answer": "Não consegui seguir com essa mensagem por regra de segurança.",
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.",
"guardrail_decisions": [d.model_dump() for d in decisions],
"route": "blocked",
"blocked": True,
}
return {
"sanitized_input": sanitized,
"guardrail_decisions": [d.model_dump() for d in decisions],
"blocked": False,
}
async def routing_decision(self, state):
mode = getattr(self.settings, "ROUTING_MODE", "router")
async with self.telemetry.span(
"workflow.routing_decision",
session_id=state.get("conversation_key") or state.get("session_id"),
input={
"mode": mode,
"text": state.get("sanitized_input") or state.get("user_text"),
"previous_state": state.get("next_state"),
},
):
if mode == "supervisor":
plan = await self.supervisor.route_plan(state)
await self.langgraph_telemetry.edge("routing_decision", "supervisor_agent", state, {"method": "supervisor", "intent": plan.intent, "confidence": plan.confidence})
return {
"route": "supervisor_agent",
"intent": plan.intent,
"supervisor_plan": {
"agents": plan.agents,
"intent": plan.intent,
"confidence": plan.confidence,
"reason": plan.reason,
"metadata": plan.metadata,
},
"route_decision": {
"route": "supervisor_agent",
"agent": "supervisor",
"intent": plan.intent,
"confidence": plan.confidence,
"reason": plan.reason,
"method": "supervisor",
"metadata": plan.metadata,
},
}
decision = await self.router.route(state)
await self.langgraph_telemetry.edge("routing_decision", decision.route, state, {"method": getattr(decision, "method", None), "intent": decision.intent, "confidence": decision.confidence})
await self.observer.emit_ic(
"ROUTE_SELECTED",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": decision.route,
"intent": decision.intent,
"confidence": decision.confidence,
"method": getattr(decision, "method", None),
},
component="workflow.routing_decision",
)
return {
"route": decision.route,
"intent": decision.intent,
"route_decision": decision.model_dump(mode="json"),
"domain": decision.domain,
"mcp_tools": decision.mcp_tools,
"next_state": decision.next_state,
"active_agent": decision.agent,
"route_bypassed": decision.method == "continuity",
"session_control": (decision.metadata or {}).get("session_control", ""),
"human_handoff_requested": (decision.metadata or {}).get("session_control") == "HUMAN_HANDOFF",
"session_ended": (decision.metadata or {}).get("session_control") == "END_SESSION",
"continuity_signal": {
"decision": (decision.metadata or {}).get("continuity_decision"),
"confidence": decision.confidence if decision.method == "continuity" else None,
"reason": decision.reason if decision.method == "continuity" else None,
"profile": (decision.metadata or {}).get("continuity_profile"),
} if decision.method == "continuity" else {},
}
async def billing_agent(self, state):
async with self.telemetry.span(
"workflow.agent.billing",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.billing.run(state)
async def product_agent(self, state):
async with self.telemetry.span(
"workflow.agent.product",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.product.run(state)
async def orders_agent(self, state):
async with self.telemetry.span(
"workflow.agent.orders",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.orders.run(state)
async def support_agent(self, state):
async with self.telemetry.span(
"workflow.agent.support",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.support.run(state)
async def supervisor_agent(self, state):
"""Executa um ou mais agentes no modo supervisor e consolida a resposta.
Este nó mantém o desenho de supervisor sem obrigar o restante do workflow
a conhecer quantos agentes foram acionados. Cada execução especializada
recebe o mesmo estado, mas com route/active_agent atualizados.
"""
plan = state.get("supervisor_plan") or {}
agents = plan.get("agents") or ["billing_agent"]
handlers = {
"billing_agent": self.billing.run,
"product_agent": self.product.run,
"orders_agent": self.orders.run,
"support_agent": self.support.run,
}
partials = []
mcp_results = []
async with self.telemetry.span(
"workflow.supervisor_agent",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"agents": agents, "intent": state.get("intent")},
):
for agent_name in agents:
handler = handlers.get(agent_name)
if handler is None:
continue
child_state = {**state, "route": agent_name, "active_agent": agent_name}
result = await handler(child_state)
partials.append({"agent": agent_name, "answer": result.get("answer", "")})
mcp_results.extend(result.get("mcp_results") or [])
if len(partials) == 1:
answer = partials[0]["answer"]
else:
joined = "\n\n".join(f"{p['agent']}: {p['answer']}" for p in partials)
answer = (
"[Supervisor] Consolidação de múltiplos agentes acionados.\n"
f"{joined}"
)
return {
"answer": answer,
"supervisor_results": partials,
"mcp_results": mcp_results,
"next_state": "SUPERVISOR_ACTIVE",
}
async def handoff(self, state):
async with self.telemetry.span("workflow.handoff", session_id=state.get("session_id")):
target = (state.get("route_decision") or {}).get("metadata", {}).get("target_agent")
answer = (
"Vou redirecionar sua solicitação para o especialista correto. "
f"Destino sugerido: {target or 'agente especializado'}."
)
return {"answer": answer}
async def human_handoff(self, state):
session_id = state.get("conversation_key") or state.get("session_id")
async with self.telemetry.span("workflow.human_handoff", session_id=session_id):
answer = str(getattr(self.settings, "HUMAN_HANDOFF_MESSAGE", "Vou encaminhar seu atendimento para uma pessoa."))
await self.telemetry.event(
"session.human_handoff.requested",
{
"session_id": session_id,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"reason": (state.get("route_decision") or {}).get("reason"),
},
)
return {
"answer": answer,
"session_control": "HUMAN_HANDOFF",
"human_handoff_requested": True,
"session_ended": False,
"next_state": "HUMAN_HANDOFF_REQUESTED",
}
async def end_session(self, state):
session_id = state.get("conversation_key") or state.get("session_id")
async with self.telemetry.span("workflow.end_session", session_id=session_id):
answer = str(getattr(self.settings, "END_SESSION_MESSAGE", "Atendimento encerrado. Obrigado pelo contato."))
await self.telemetry.event(
"session.end.requested",
{
"session_id": session_id,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"reason": (state.get("route_decision") or {}).get("reason"),
},
)
return {
"answer": answer,
"session_control": "END_SESSION",
"session_ended": True,
"human_handoff_requested": False,
"next_state": "SESSION_ENDED",
}
async def output_supervisor(self, state):
"""Valida a resposta candidata com o OutputSupervisor corporativo.
Este nó não substitui o roteador/supervisor multiagente. Ele roda após o
agente gerar `answer` e antes dos judges/persistência, produzindo campos
supervisor_* no state e eventos GRL.001..GRL.009 via AgentObserver.
"""
if not bool(getattr(self.settings, "ENABLE_OUTPUT_SUPERVISOR", True)):
return {
"output_guardrails_already_applied": False,
"supervisor_action": "disabled",
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}
candidate = state.get("answer") or ""
context = {
**(state.get("context") or {}),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"session_id": state.get("conversation_key") or state.get("session_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}
async with self.telemetry.span(
"workflow.output_supervisor",
session_id=state.get("conversation_key") or state.get("session_id"),
input=candidate,
):
decision = await self.output_supervisor_engine.evaluate(candidate, context)
action = decision.action.value
await self.telemetry.event(
"output_supervisor.completed",
{
"session_id": context["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"action": action,
"approved": decision.approved,
"guidance": decision.guidance,
},
)
await self.observer.emit_ic(
"IC.OUTPUT_SUPERVISOR_COMPLETED",
{
"session_id": context["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"action": action,
"approved": decision.approved,
"result_count": len(decision.results),
},
component="workflow.output_supervisor",
)
if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
final_answer = decision.candidate
elif decision.action == RailAction.HANDOVER:
final_answer = "Vou encaminhar seu atendimento para continuidade com um especialista."
else:
final_answer = decision.fallback_message
return {
"answer": final_answer,
"final_answer": final_answer,
"supervisor_action": action,
"supervisor_guidance": decision.guidance,
"supervisor_attempt": int(state.get("supervisor_attempt", 0)) + (1 if decision.action == RailAction.RETRY else 0),
"supervisor_handover_reason": decision.handover_reason,
"output_supervisor_results": [
{
"code": r.code,
"action": r.action.value,
"reason": r.reason,
"guidance": r.guidance,
"metadata": r.metadata,
}
for r in decision.results
],
"output_guardrails_already_applied": True,
"guardrail_decisions": state.get("guardrail_decisions", [])
+ [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])],
}
async def output_guardrails(self, state):
if state.get("output_guardrails_already_applied"):
return {"final_answer": state.get("final_answer") or state.get("answer") or ""}
async with self.telemetry.span(
"workflow.output_guardrails",
session_id=state.get("conversation_key") or state.get("session_id"),
input=state.get("answer"),
):
await self.observer.emit_grl(
"001",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "output",
"route": state.get("route"),
"intent": state.get("intent"),
},
component="workflow.output_guardrails.start",
)
final, decisions = await self.guardrails.run_output(
state["answer"], state.get("context", {})
)
for _decision in decisions:
await self.guardrail_telemetry.evaluated("output", _decision)
await self.observer.emit_grl(
"002" if _decision.allowed else "004",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "output",
"rail_code": getattr(_decision, "code", None),
"allowed": bool(_decision.allowed),
"reason": getattr(_decision, "reason", None),
},
component="workflow.output_guardrails.decision",
)
if not _decision.allowed:
await self.guardrail_telemetry.blocked("output", _decision)
await self.telemetry.event(
"guardrails.output.completed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"decisions": [d.model_dump() for d in decisions],
},
)
await self.observer.emit_grl(
"009",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "output",
"blocked": any(not d.allowed for d in decisions),
"decision_count": len(decisions),
},
component="workflow.output_guardrails.final",
)
return {
"final_answer": final,
"guardrail_decisions": state.get("guardrail_decisions", [])
+ [d.model_dump() for d in decisions],
}
async def judge(self, state):
async with self.telemetry.span(
"workflow.judge",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"question": state.get("user_text"), "answer": state.get("final_answer")},
):
judge_context = dict(state.get("context", {}) or {})
judge_context["mcp_results"] = state.get("mcp_results", [])
judge_context["evidence"] = state.get("mcp_results", []) or judge_context.get("evidence")
judge_context["route"] = state.get("route")
judge_context["intent"] = state.get("intent")
# Judge sampling must see the finalized transaction state. These
# fields are populated by the agent/tool runtime before this node.
for key in (
"transaction_status",
"confirmation_required",
"confirmation_received",
"tool_policy_result",
"selected_tool_call",
"pending_tool_call",
):
judge_context[key] = state.get(key)
judge_context["transactional_tools"] = [
result.get("tool_name")
for result in state.get("mcp_results", [])
if isinstance(result, dict)
and (
(result.get("metadata") or {}).get("operation_type") == "transactional"
or result.get("awaiting_confirmation")
or result.get("transaction_status")
)
]
results = await self.judges.evaluate_all(
state["user_text"], state["final_answer"], judge_context
)
for _result in results:
await self.judge_telemetry.evaluated(_result)
await self.telemetry.event(
"judges.completed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"results": [r.model_dump() for r in results],
},
)
return {"judge_results": [r.model_dump() for r in results]}
async def supervisor_review(self, state):
async with self.telemetry.span(
"workflow.supervisor_review",
session_id=state.get("conversation_key") or state.get("session_id"),
input=state.get("final_answer"),
):
ok, answer = await self.supervisor.review(
state["final_answer"], state.get("context", {})
)
await self.telemetry.event(
"supervisor.review.completed",
{"session_id": state.get("session_id"), "approved": ok},
)
return {"final_answer": answer if ok else answer}
async def persist_long_term_memory(self, state):
result = await self.long_term_memory_manager.persist_turn(state)
return {"long_term_memory_write_result": result}
async def persist(self, state):
async with self.telemetry.span(
"workflow.persist",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"route": state.get("route"), "intent": state.get("intent")},
):
await self.observer.emit_ic(
"AGENT_COMPLETED",
{
"session_id": state.get("conversation_key") or state["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"route_decision": state.get("route_decision"),
"judges": state.get("judge_results", []),
"mcp_tools": state.get("mcp_tools", []),
"mcp_results": state.get("mcp_results", []),
},
)
await self.observer.emit_noc(
"006",
{
"session_id": state.get("conversation_key") or state["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"answer_chars": len(state.get("final_answer") or ""),
},
component="workflow.persist",
)
await self.telemetry.event(
"agent.completed",
{
"session_id": state.get("conversation_key") or state["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"answer_chars": len(state.get("final_answer") or ""),
},
)
return state
async def ainvoke(self, state):
thread_id = state.get("conversation_key") or state["session_id"]
config = {"configurable": {"thread_id": thread_id}}
async with self.telemetry.span(
"workflow.langgraph.ainvoke",
session_id=state.get("conversation_key") or state.get("session_id"),
user_id=state.get("context", {}).get("user_id"),
input={"user_text": state.get("user_text")},
tags=["langgraph", "agent-workflow", f"routing-mode:{getattr(self.settings, 'ROUTING_MODE', 'router')}",],
):
await self.workflow_telemetry.started("agent_workflow", state)
await self.observer.emit_noc(
"001",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"channel_id": (state.get("context") or {}).get("channel"),
"message_id": (state.get("context") or {}).get("message_id"),
"ura_call_id": (state.get("context") or {}).get("ura_call_id"),
},
component="workflow.ainvoke",
)
await self.observer.emit_ic(
"AGENT_STARTED",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"channel_id": (state.get("context") or {}).get("channel"),
"message_id": (state.get("context") or {}).get("message_id"),
"user_text_chars": len(state.get("user_text") or ""),
},
component="workflow.ainvoke",
)
try:
result = await self.graph.ainvoke(state, config=config)
await self.workflow_telemetry.completed("agent_workflow", result)
return result
except Exception as exc:
await self.workflow_telemetry.failed("agent_workflow", exc)
await self.observer.emit_noc(
"005",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"error": str(exc),
"exception_type": exc.__class__.__name__,
},
component="workflow.ainvoke",
)
raise

View File

@@ -0,0 +1,38 @@
# ============================================================================
# DAY ZERO
# Este arquivo foi copiado do agent_template_backend original.
# Ajuste os exemplos abaixo para o domínio do seu novo agente.
# ============================================================================
default_agent_id: telecom_contas
agents:
- agent_id: telecom_contas
name: Agente Telecom Contas
description: Template de atendimento para faturas, produtos e suporte de telecom.
prompt_policy_path: ./config/agents/telecom_contas/prompt_policy.yaml
routing_config_path: ./config/routing.yaml
guardrails_config_path: ./config/agents/telecom_contas/guardrails.yaml
judges_config_path: ./config/agents/telecom_contas/judges.yaml
mcp_servers_config_path: ./config/mcp_servers.yaml
tools_config_path: ./config/tools.yaml
metadata:
domain: telecom
system_prefix: |
Você está executando o agent_template telecom_contas.
Use somente políticas, memória, checkpoints, guardrails e judges deste agent_id.
Não misture histórico ou decisões de outros agentes.
- agent_id: retail_orders
name: Agente Retail Pedidos
description: Template de varejo para pedidos, produtos, troca/devolução e garantia.
prompt_policy_path: ./config/agents/retail_orders/prompt_policy.yaml
routing_config_path: ./config/routing.yaml
guardrails_config_path: ./config/agents/retail_orders/guardrails.yaml
judges_config_path: ./config/agents/retail_orders/judges.yaml
mcp_servers_config_path: ./config/mcp_servers.yaml
tools_config_path: ./config/tools.yaml
metadata:
domain: retail
system_prefix: |
Você está executando o agent_template retail_orders.
Use somente políticas, memória, checkpoints, guardrails e judges deste agent_id.
Não misture histórico ou decisões de outros agentes.

View File

@@ -0,0 +1,8 @@
input:
- code: MSK
enabled: true
- code: VLOOP
enabled: true
output:
- code: REVPREC
enabled: true

View File

@@ -0,0 +1,7 @@
judges:
- name: response_quality
enabled: true
threshold: 0.7
- name: groundedness
enabled: true
threshold: 0.6

View File

@@ -0,0 +1,6 @@
id: retail_orders_prompt_policy
version: 1
description: Prompt base isolado do agente de varejo/pedidos.
system_prefix: |
Você é um agente corporativo de varejo especializado em pedidos, entrega, troca, devolução e garantia.
Seja claro, objetivo e não use regras de negócio de telecom neste agente.

View File

@@ -0,0 +1,8 @@
input:
- code: MSK
enabled: true
- code: VLOOP
enabled: true
output:
- code: REVPREC
enabled: true

View File

@@ -0,0 +1,20 @@
enabled: true
fail_closed: true
profile: judge
judges:
- name: response_quality
enabled: true
threshold: 0.7
- name: groundedness
enabled: true
threshold: 0.6
- name: sentiment
enabled: true
fail_on_negative: false
- name: tone
enabled: true
fail_closed: true

View File

@@ -0,0 +1,6 @@
id: telecom_contas_prompt_policy
version: 1
description: Prompt base isolado do agente de telecom/contas.
system_prefix: |
Você é um agente corporativo de atendimento telecom especializado em faturas, produtos, VAS e suporte.
Seja claro, objetivo e não prometa execução operacional sem ferramenta ou confirmação válida.

View File

@@ -0,0 +1,8 @@
input:
- code: MSK
enabled: true
- code: VLOOP
enabled: true
output:
- code: REVPREC
enabled: true

View File

@@ -0,0 +1,55 @@
identity:
version: "2"
required:
- session_key
keys:
customer_key:
description: Cliente/assinante/consumidor canônico.
sources:
- business_context.customer_key
- customer_key
- msisdn
- customer_id
- user_id
- ani
- from
contract_key:
description: Contrato, conta, fatura, pedido ou asset principal.
sources:
- business_context.contract_key
- contract_key
- invoice_id
- current_invoice_number
- order_id
- pedido_id
- asset_id
interaction_key:
description: Chave externa da interação/call/chat vinda do canal.
sources:
- business_context.interaction_key
- interaction_key
- ura_call_id
- call_id
- message_id
account_key:
description: Conta de cobrança/conta comercial.
sources:
- business_context.account_key
- account_key
- account_id
- billing_account_id
resource_key:
description: Recurso/linha/produto/asset específico.
sources:
- business_context.resource_key
- resource_key
- asset_id
- product_id
- sku
session_key:
description: Sessão técnica estável já escopada por tenant e agente.
sources:
- business_context.session_key
- session_key
- conversation_key
- session_id

View File

@@ -0,0 +1,18 @@
enabled: true
fail_closed: true
profile: judge
judges:
- name: response_quality
enabled: true
threshold: 0.7
- name: groundedness
enabled: true
threshold: 0.6
- name: sentiment
enabled: true
fail_on_negative: false
- name: tone
enabled: true
fail_closed: true
sample_rate: 0.25
always_run_for_transactional: true

View File

@@ -0,0 +1,92 @@
mcp_parameter_mapping:
defaults:
use_mock: true
tools:
consultar_fatura:
map:
customer_key: msisdn
contract_key: invoice_id
interaction_key: ura_call_id
session_key: session_id
extract:
mes_referencia:
from: message
type: int
strategy: month_name_pt
description: 'Extrair mês citado na mensagem. janeiro=1, fevereiro=2, março=3,
abril=4, maio=5, junho=6, julho=7, agosto=8, setembro=9, outubro=10, novembro=11,
dezembro=12.
'
consultar_pagamentos:
map:
customer_key: msisdn
interaction_key: ura_call_id
session_key: session_id
consultar_plano:
map:
customer_key: msisdn
resource_key: asset_id
contract_key: asset_id
session_key: session_id
listar_servicos:
map:
customer_key: msisdn
session_key: session_id
consultar_pedido:
map:
customer_key: customer_id
session_key: session_id
extract:
order_id:
from: message
type: string
strategy: hybrid
description: Extraia somente o identificador do pedido informado explicitamente
pelo usuário. Retorne null quando não houver identificador de pedido na
mensagem.
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
group: 1
consultar_entrega:
map:
session_key: session_id
extract:
order_id:
from: message
type: string
strategy: hybrid
description: Extraia somente o identificador do pedido informado explicitamente
pelo usuário. Retorne null quando não houver identificador de pedido na
mensagem.
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
group: 1
solicitar_troca:
map:
session_key: session_id
defaults:
reason: Solicitação aberta pelo atendimento conversacional.
extract:
order_id:
from: message
type: string
strategy: hybrid
description: Extraia somente o identificador do pedido informado explicitamente
pelo usuário. Retorne null quando não houver identificador de pedido na
mensagem.
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
group: 1
solicitar_devolucao:
map:
session_key: session_id
defaults:
reason: Solicitação aberta pelo atendimento conversacional.
extract:
order_id:
from: message
type: string
strategy: hybrid
description: Extraia somente o identificador do pedido informado explicitamente
pelo usuário. Retorne null quando não houver identificador de pedido na
mensagem.
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
group: 1

View File

@@ -0,0 +1,12 @@
servers:
telecom:
transport: http
endpoint: http://telecom-mcp:8100/mcp
enabled: true
description: MCP Server Telecom via docker-compose.
retail:
transport: http
endpoint: http://retail-mcp:8200/mcp
enabled: true
description: MCP Server Retail via docker-compose.

View File

@@ -0,0 +1,30 @@
# MCP servers registry.
# transport=http keeps the legacy framework mock contract:
# GET <endpoint>/tools/list
# POST <endpoint>/tools/call
# transport=fastmcp uses official MCP Streamable HTTP, typically endpoint http://host:port/mcp
# transport=sse uses official MCP SSE, typically endpoint http://host:port/sse
servers:
# telecom:
# enabled: true
# transport: fastmcp
# endpoint: http://localhost:8001/mcp
# description: Telecom FastMCP server using official MCP protocol
#
# retail:
# enabled: true
# transport: fastmcp
# endpoint: http://localhost:8002/mcp
# description: Retail FastMCP server using official MCP protocol
telecom:
enabled: true
transport: http
endpoint: http://localhost:8100/mcp
description: Telecom legacy HTTP mock MCP server
retail:
enabled: true
transport: http
endpoint: http://localhost:8200/mcp
description: Retail legacy HTTP mock MCP server

View File

@@ -0,0 +1,19 @@
tone:
style: "claro, objetivo, empático"
forbidden_phrases:
- "procure atendimento humano"
vocabulary:
preferred:
fatura: "fatura"
contestacao: "contestação"
intents:
billing_agent:
- fatura
- boleto
- cobrança
- segunda via
product_agent:
- plano
- produto
- oferta
- serviço

View File

@@ -0,0 +1,128 @@
# Roteamento enterprise configurável com MCP-aware intents.
router:
# mode também pode ser definido por variável de ambiente ROUTING_MODE.
# Valores: router | supervisor
mode: router
fallback_agent: billing_agent
confidence_threshold: 0.65
allow_handoff: true
state_policies:
- state: WAITING_BILLING_CONFIRMATION
agent: billing_agent
description: Mantém mensagens curtas como "sim" ou "não" no fluxo de fatura.
- state: WAITING_PRODUCT_CONFIRMATION
agent: product_agent
description: Mantém confirmações no fluxo de produtos/serviços.
- state: WAITING_ORDER_CONFIRMATION
agent: orders_agent
description: Mantém confirmações no fluxo de pedidos.
- state: WAITING_SUPPORT_CONFIRMATION
agent: support_agent
description: Mantém confirmações no fluxo de suporte retail.
- state: COLLECTING_BILLING_PARAMETERS
agent: billing_agent
description: Mantém a coleta de parâmetros no fluxo de faturamento.
- state: COLLECTING_PRODUCT_PARAMETERS
agent: product_agent
description: Mantém a coleta de parâmetros no fluxo de produtos e serviços.
- state: COLLECTING_ORDER_PARAMETERS
agent: orders_agent
description: Mantém a coleta de parâmetros no fluxo de pedidos.
- state: COLLECTING_SUPPORT_PARAMETERS
agent: support_agent
description: Mantém a coleta de parâmetros no fluxo transacional de suporte retail.
intents:
- name: billing_invoice_explanation
domain: telecom
agent: billing_agent
description: Dúvidas sobre fatura, cobrança, vencimento, segunda via, contestação e valores.
priority: 10
mcp_tools:
- consultar_fatura
- consultar_pagamentos
keywords:
- fatura
- conta
- cobrança
- boleto
- vencimento
- segunda via
- contestar
- valor alto
- invoice
examples:
- Minha fatura veio alta.
- Quero entender uma cobrança.
- Preciso da segunda via da conta.
- name: product_services_information
domain: telecom
agent: product_agent
description: Dúvidas sobre plano, pacote, produto, serviço, VAS, internet, roaming e benefícios.
priority: 20
mcp_tools:
- consultar_plano
- listar_servicos
keywords:
- plano
- serviço
- pacote
- internet
- roaming
- vas
- benefício
- assinatura
examples:
- Quais serviços estão ativos no meu plano?
- Quero saber sobre meu pacote de internet.
- Tenho roaming internacional?
- name: retail_order_tracking
domain: retail
agent: orders_agent
description: Consulta de pedido, entrega, rastreamento, atraso e status de compra.
priority: 30
mcp_tools:
- consultar_pedido
- consultar_entrega
keywords:
- pedido
- entrega
- rastreio
- rastreamento
- encomenda
- compra
- atraso
- correios
examples:
- Meu pedido não chegou.
- Quero rastrear minha entrega.
- Qual é o status da minha compra?
- name: retail_support_exchange_return
domain: retail
agent: support_agent
description: Suporte, troca, devolução, garantia e problema com produto.
priority: 25
mcp_tools:
- consultar_pedido
- solicitar_troca
- solicitar_devolucao
keywords:
- solicitar devolução
- devolver pedido
- solicitar troca
- troca
- devolução
- devolver
- garantia
- defeito
- produto quebrado
- suporte
- arrependimento
examples:
- Quero trocar um produto.
- Meu produto veio com defeito.
- Como faço uma devolução?

View File

@@ -0,0 +1,21 @@
version: 1
# Arquivo opcional da aplicação. A ausência mantém o comportamento dos
# templates anteriores e as políticas legadas declaradas em tools.yaml.
defaults:
operation_type: read_only
require_confirmation: false
tool_policies:
solicitar_troca:
operation_type: transactional
require_confirmation: true
solicitar_devolucao:
operation_type: transactional
require_confirmation: true
# Exemplo para uma operação real que só pode executar após confirmação:
# cancelar_servico:
# operation_type: transactional
# require_confirmation: true

View File

@@ -0,0 +1,101 @@
tools:
consultar_fatura:
description: Consulta dados resumidos de fatura por msisdn/invoice_id.
mcp_server: telecom
enabled: true
args_schema:
msisdn: string
invoice_id: string
selection_keywords:
- fatura
- conta
- boleto
consultar_pagamentos:
description: Consulta histórico de pagamentos do cliente.
mcp_server: telecom
enabled: true
args_schema:
msisdn: string
selection_keywords:
- pagamento
- pagamentos
consultar_plano:
description: Consulta plano ativo e atributos comerciais.
mcp_server: telecom
enabled: true
args_schema:
msisdn: string
asset_id: string
selection_keywords:
- plano
listar_servicos:
description: Lista serviços ativos e adicionais VAS.
mcp_server: telecom
enabled: true
args_schema:
msisdn: string
selection_keywords:
- serviços
- servicos
- vas
consultar_pedido:
description: Consulta pedido de varejo por order_id/customer_id.
mcp_server: retail
enabled: true
args_schema:
order_id: string
customer_id: string
selection_keywords:
- consultar pedido
- status do pedido
- pedido
consultar_entrega:
description: Consulta entrega e rastreamento do pedido.
mcp_server: retail
enabled: true
args_schema:
order_id: string
selection_keywords:
- entrega
- rastreio
- rastreamento
- transportadora
- previsão
solicitar_troca:
description: Simula abertura de solicitação de troca.
mcp_server: retail
enabled: true
tool_type: action
requires:
- order_id
- reason
confirmation_required: true
args_schema:
order_id: string
reason: string
selection_keywords:
- solicitar troca
- trocar
- troca
- defeito
- quebrado
solicitar_devolucao:
description: Simula abertura de solicitação de devolução.
mcp_server: retail
enabled: true
tool_type: action
requires:
- order_id
- reason
confirmation_required: true
args_schema:
order_id: string
reason: string
selection_keywords:
- solicitar devolução
- solicitar devolucao
- devolver pedido
- devolver
- devolução
- devolucao
- arrependimento

View File

@@ -0,0 +1,95 @@
# Atualização do Template Backend — Analytics, Observer, NOC/GRL e OutputSupervisor
Esta versão do `agent_template_backend` foi atualizada para consumir as novidades transportadas para o `agent_framework`.
## 1. Analytics e Pub/Sub
O backend não chama mais diretamente apenas o publisher antigo de eventos. Agora ele cria um `AnalyticsPublisher`:
```python
from agent_framework.analytics.factory import create_analytics_publisher
from agent_framework.observability.observer import AgentObserver
analytics = create_analytics_publisher(settings)
observer = AgentObserver(analytics=analytics)
```
Com isso, o mesmo backend pode publicar em:
- OCI Streaming
- GCP Pub/Sub
- CompositePublisher, quando `ANALYTICS_PROVIDERS=oci_streaming,pubsub`
- Noop, quando analytics estiver desligado
## 2. Configuração mínima
```env
ENABLE_ANALYTICS=true
ANALYTICS_PROVIDERS=pubsub
GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-name>
GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
```
Para publicar simultaneamente em OCI Streaming e GCP Pub/Sub:
```env
ENABLE_ANALYTICS=true
ANALYTICS_PROVIDERS=oci_streaming,pubsub
ENABLE_OCI_STREAMING=true
OCI_STREAM_ENDPOINT=<endpoint>
OCI_STREAM_OCID=<stream-ocid>
GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-name>
```
## 3. Observer corporativo
O workflow recebeu emissão automática dos principais eventos corporativos:
- `NOC.001`: início do workflow
- `NOC.005`: exceção fatal no workflow
- `NOC.006`: fim do workflow antes da resposta final
- `IC.AGENT_COMPLETED`: evento informacional de conclusão
- `GRL.001` a `GRL.009`: emitidos pelo `OutputSupervisor`
## 4. OutputSupervisor
Foi inserido um novo nó LangGraph:
```text
agent -> output_supervisor -> output_guardrails -> judge -> supervisor_review -> persist
```
O `OutputSupervisor` não substitui o supervisor de roteamento. Ele valida a saída candidata do agente usando o contrato corporativo:
- `allow`
- `sanitize`
- `retry`
- `block`
- `handover`
- `observe`
Para compatibilidade com os guardrails já existentes, o template inclui o adapter `LegacyOutputGuardrailRail`, que converte decisões antigas `allowed=True/False` para `RailAction`.
## 5. Campos adicionados ao AgentState
```python
supervisor_action: str
supervisor_guidance: str
supervisor_attempt: int
supervisor_handover_reason: str
output_supervisor_results: list[dict]
output_guardrails_already_applied: bool
```
## 6. Arquivos alterados
- `agent_template_backend/app/main.py`
- `agent_template_backend/app/workflows/agent_graph.py`
- `agent_template_backend/app/state.py`
- `agent_template_backend/.env`
- `agent_template_backend/requirements.txt`
- `agent_framework/src/agent_framework/config/settings.py`
## 7. Observação importante
O `OutputSupervisor` roda os guardrails de saída por meio do adapter legado e marca `output_guardrails_already_applied=True`. Assim o nó `output_guardrails` permanece no grafo para compatibilidade, mas evita reexecutar a mesma validação quando o supervisor já aplicou os rails.

View File

@@ -0,0 +1,48 @@
# Backends atualizados para ConversationSummaryMemory
Esta versão dos backends foi compatibilizada com a versão do framework que adiciona `ConversationSummaryMemory`.
## O que mudou
- `app/main.py` agora inicializa `create_conversation_summary_memory(...)` junto com `create_memory(...)`.
- `AgentWorkflow` recebe `summary_memory` e repassa para os agentes.
- Os agentes não montam mais prompts manuais para o LLM; agora usam `build_messages()` do framework.
- Antes da chamada ao LLM, os agentes executam `await self.prepare_memory_context(state)`.
- Quando habilitado por `.env`, o prompt passa a receber:
- resumo acumulado da conversa;
- últimas mensagens completas;
- mensagem atual;
- BusinessContext;
- MCP results;
- RAG context e metadata.
## Configuração
```env
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
MEMORY_CONTEXT_STRATEGY=summary
MEMORY_HISTORY_LIMIT=80
MEMORY_RECENT_MESSAGES_LIMIT=8
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
MEMORY_MAX_SUMMARY_CHARS=6000
MEMORY_SUMMARY_USE_LLM=true
MEMORY_INJECT_RECENT_MESSAGES=true
MEMORY_INJECT_SUMMARY=true
```
## Backends alterados
- `backoffice_convertido_framework`
- `agent_template_backend`
- `agent_template_backend_day_zero`
## Observação importante
Estes backends esperam que o pacote `agent_framework` instalado/conectado seja a versão com os módulos:
- `agent_framework.memory.summary_memory`
- `agent_framework.memory.summary_store`
- `AgentRuntimeMixin.prepare_memory_context()`
- `AgentRuntimeMixin.build_messages()` com injeção de memória
Use junto com o ZIP `agent_framework_conversation_summary_memory.zip`.

View File

@@ -0,0 +1,60 @@
# Como usar o `agent_template_backend_day_zero`
Este template é uma cópia do backend completo, mas com a lógica dos agentes de exemplo comentada.
## Fluxo mantido
```text
Gateway / Canal
-> AgentWorkflow
-> Input Guardrails
-> Router / Supervisor Router
-> Agente
-> OutputSupervisor
-> Output Guardrails
-> Judges
-> Persistência
```
## Onde escrever código
O ponto principal é o método `run()` dos agentes em `app/agents/`.
A estrutura esperada pelo workflow é:
```python
async def run(self, state):
...
return {
"answer": answer,
"next_state": "MEU_ESTADO"
}
```
## Como usar MCP
Dentro de `run()`:
```python
tool_context = await self._collect_tool_context(state)
```
## Como usar RAG
Dentro de `run()`:
```python
rag_context, rag_metadata = await self._retrieve_rag_context(state)
```
## Como chamar o LLM com cache/telemetria
```python
answer = await self._invoke_llm_cached(state, "MeuAgente", messages)
```
## Como ajustar roteamento
Edite `config/routing.yaml`.
O arquivo original foi mantido para servir de referência, mas as intents devem ser adaptadas para o domínio do novo agente.

View File

@@ -0,0 +1,13 @@
# Exemplos implementados no template Day Zero
O Day Zero preserva seu conteúdo simplificado, mas possui o mesmo conjunto transversal do template completo:
- route stickiness semântica com o perfil `route_continuity`;
- decisões `CONTINUE`, `ROUTE`, `HUMAN_HANDOFF` e `END_SESSION`;
- nós globais `human_handoff` e `end_session`;
- persistência de `active_agent`, `route_bypassed`, `continuity_signal` e controle de sessão;
- rejeição de novas mensagens depois de `session_ended=true`;
- políticas MCP `read_only` e `transactional` no backend;
- exemplo `solicitar_devolucao` com `require_confirmation: true`.
Para confirmar a transação, envie `confirmed: true` ou `confirmation: true` como booleano. Substitua os agentes e ferramentas de exemplo sem remover os controles transversais.

View File

@@ -0,0 +1,84 @@
# FRAMEWORK_CHANNEL_INPUT_MODE
This backend setting controls what kind of channel input the Agent Framework backend accepts.
It replaces the ambiguous use of `CHANNEL_GATEWAY_MODE` inside the backend.
## Values
```env
FRAMEWORK_CHANNEL_INPUT_MODE=embedded
```
The backend may use internal channel adapters to interpret simple/native channel payloads. This is useful for demos, labs, local frontend, curl tests, and simple environments.
```env
FRAMEWORK_CHANNEL_INPUT_MODE=external
```
The backend accepts only a normalized `GatewayRequest` produced by an external Channel Gateway. It does not parse native WhatsApp, Voice, Teams, or other channel payloads.
## Recommended enterprise setup
In the external channel gateway service:
```env
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
```
In this backend:
```env
FRAMEWORK_CHANNEL_INPUT_MODE=external
```
Flow:
```text
External channel / browser / customer adapter
channel_gateway:7000
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
↓ GatewayRequest
agent_template_backend:8000
FRAMEWORK_CHANNEL_INPUT_MODE=external
LangGraph / Agents / MCP / Guardrails
```
## Valid direct request to backend in external mode
```bash
curl -s -X POST "http://localhost:8000/gateway/message" \
-H "Content-Type: application/json" \
-d '{
"channel": "web",
"tenant_id": "default",
"agent_id": "telecom_contas",
"payload": {
"message": "Quero consultar minha fatura",
"session_id": "backend-external-ok-001"
}
}' | jq
```
## Invalid direct request to backend in external mode
```bash
curl -i -s -X POST "http://localhost:8000/gateway/message" \
-H "Content-Type: application/json" \
-d '{
"message": "Quero consultar minha fatura",
"session_id": "raw-payload-error-001"
}'
```
Expected result: HTTP 422.
## Legacy compatibility
`CHANNEL_GATEWAY_MODE` is still present as a legacy alias for older environments, but new deployments should use:
```env
FRAMEWORK_CHANNEL_INPUT_MODE=embedded|external
```

View File

@@ -0,0 +1,5 @@
# Langfuse single trace observer fix
This backend now uses `TelemetryBackedAgentObserver` instead of publishing IC/NOC/GRL through `AgentObserver(analytics=...)`.
Why: when analytics includes the Langfuse provider, observer events such as `IC.AGENT_COMPLETED` and `NOC.006` may create a second root trace with little detail. Emitting those events through `Telemetry.event(...)` keeps them inside the active request/workflow trace.

View File

@@ -0,0 +1,80 @@
profiles:
default:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
max_tokens: 2048
supervisor:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
route_continuity:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 80
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
guardrail:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 600
grl:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
judge:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 800
rag_rewriter:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 300
rag_compressor:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 1200
rag_generation:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.1
max_tokens: 1800
summary_memory:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.1
max_tokens: 1200
noc:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
billing_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
product_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
backoffice_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
mcp_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 80
timeout_seconds: 5

View File

@@ -0,0 +1,23 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
pydantic>=2.8.0
pydantic-settings>=2.4.0
python-dotenv>=1.0.1
langgraph>=0.2.60
langchain-core>=0.3.0
openai>=1.60.0
oci>=2.130.0
oracledb>=2.4.0
pymongo>=4.8.0
redis>=5.0.0
PyYAML>=6.0.2
langfuse>=3.0.0
httpx>=0.27.0
opentelemetry-api>=1.27.0
opentelemetry-sdk>=1.27.0
opentelemetry-exporter-otlp-proto-http>=1.27.0
pytest>=8.0.0
pytest-asyncio>=0.23.0
google-cloud-pubsub>=2.28.0

View File

@@ -0,0 +1,29 @@
import asyncio
import tempfile
from types import SimpleNamespace
from agent_framework.memory.long_term_memory import create_long_term_memory_manager
async def main():
with tempfile.TemporaryDirectory() as d:
settings = SimpleNamespace(
ENABLE_LONG_TERM_MEMORY=True,
LONG_TERM_MEMORY_PROVIDER='sqlite',
LONG_TERM_MEMORY_SQLITE_PATH=f'{d}/memory.db',
LONG_TERM_MEMORY_TABLE='agentfw_long_term_memory',
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS=20,
LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70,
LONG_TERM_MEMORY_AUTO_EXTRACT=True,
)
manager = create_long_term_memory_manager(settings)
first = {'tenant_id':'default','agent_id':'memory_test','session_id':'a','user_text':'Me chame de Cris. Minha linguagem preferida é Python. Meu projeto atual se chama Atlas.','context':{'business_context':{'customer_key':'MEM-001'}}}
assert (await manager.persist_turn(first))['saved'] >= 3
second = {'tenant_id':'default','agent_id':'memory_test','session_id':'b','context':{'business_context':{'customer_key':'MEM-001'}}}
values = {item.key:item.value for item in await manager.load(second)}
assert values['preferred_name'].lower() == 'cris'
assert values['preferred_language'].lower() == 'python'
assert values['current_project'].lower() == 'atlas'
isolated = {'tenant_id':'default','agent_id':'memory_test','session_id':'c','context':{'business_context':{'customer_key':'MEM-002'}}}
assert await manager.load(isolated) == []
print('OK: persistência, recuperação entre sessões e isolamento validados')
asyncio.run(main())

View File

@@ -0,0 +1,15 @@
# Template 2 — Retail/E-commerce: Pedidos + Suporte
Este template demonstra outro uso do mesmo framework, com dois agentes diferentes:
- `OrdersAgent`: status de pedido, entrega, troca, devolução e rastreamento.
- `SupportAgent`: problemas de acesso, cadastro, pagamento, cupom e atendimento geral.
A ideia é mostrar que o framework não é dependente de telecom. O desenvolvedor troca apenas:
- intents em `routing.yaml`;
- prompts dos agentes;
- tools de negócio;
- estados do workflow.
O core de LangGraph, guardrails, judges, supervisor, Langfuse, OCI Generative AI e sessão permanece igual.

Some files were not shown because too many files have changed in this diff Show More