new feature: mcp pre-validation
This commit is contained in:
@@ -7,3 +7,4 @@ Variantes e documentos de referência para comparar funcionalidades e impacto de
|
|||||||
- `Long_Term_Memory`: memória de longo prazo.
|
- `Long_Term_Memory`: memória de longo prazo.
|
||||||
- `Deterministic_Transactional_Workflow`: transações multi-etapas executadas por workflow LangGraph determinístico após clarification e confirmação.
|
- `Deterministic_Transactional_Workflow`: transações multi-etapas executadas por workflow LangGraph determinístico após clarification e confirmação.
|
||||||
- `Transaction_Evidence`: persistência e correlação de resultados transacionais como evidência operacional para turnos posteriores e groundedness.
|
- `Transaction_Evidence`: persistência e correlação de resultados transacionais como evidência operacional para turnos posteriores e groundedness.
|
||||||
|
- `Transaction_Pre_Validation`: pré-validação MCP side-effect-free antes da confirmação transacional; regras de elegibilidade permanecem no domínio.
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Transaction Pre-Validation / Pré-validação Transacional
|
||||||
|
|
||||||
|
Esta variante demonstra a capability genérica de **pré-validação MCP antes da confirmação**.
|
||||||
|
|
||||||
|
A regra de negócio continua no MCP. O framework apenas orquestra o contrato genérico:
|
||||||
|
|
||||||
|
```text
|
||||||
|
parâmetros completos
|
||||||
|
↓
|
||||||
|
MCP validator (read-only / side-effect-free)
|
||||||
|
↓
|
||||||
|
eligible?
|
||||||
|
├─ false → OUT_OF_SCOPE / NOT_ELIGIBLE → responde sem pedir confirmação
|
||||||
|
└─ true → AWAITING_CONFIRMATION → usuário confirma → tool transacional executa
|
||||||
|
```
|
||||||
|
|
||||||
|
Nenhum LLM adicional é usado pela pré-validação.
|
||||||
|
|
||||||
|
## Por que existe
|
||||||
|
|
||||||
|
Sem pre-validation, uma aplicação pode pedir confirmação para uma operação que o domínio já
|
||||||
|
sabe que é inválida. Exemplo: tentar cancelar um pedido já entregue ou contestar uma categoria
|
||||||
|
que não é elegível para contestação.
|
||||||
|
|
||||||
|
O framework **não contém essas regras de negócio**. Ele apenas consulta uma tool MCP declarada
|
||||||
|
na policy e interpreta o campo genérico `eligible`.
|
||||||
|
|
||||||
|
## Policy
|
||||||
|
|
||||||
|
`config/tool_policies.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tool_policies:
|
||||||
|
cancelar_pedido:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
requires: [order_id]
|
||||||
|
pre_validation:
|
||||||
|
enabled: true
|
||||||
|
tool: validar_cancelamento_pedido
|
||||||
|
fail_open: false
|
||||||
|
```
|
||||||
|
|
||||||
|
A tool `validar_cancelamento_pedido` é read-only/internal e deve ser side-effect-free.
|
||||||
|
|
||||||
|
## Contrato MCP esperado
|
||||||
|
|
||||||
|
Elegível:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"eligible": true,
|
||||||
|
"status": "ELIGIBLE",
|
||||||
|
"order_id": "PED-1001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Não elegível:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"eligible": false,
|
||||||
|
"status": "NOT_ELIGIBLE",
|
||||||
|
"order_id": "PED-ENTREGUE",
|
||||||
|
"reason": "Pedido já entregue não pode ser cancelado por esta operação."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cenário de teste
|
||||||
|
|
||||||
|
Suba o Retail MCP de exemplo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent_framework_oci/mcp/servers/retail_mcp_server
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 8200
|
||||||
|
```
|
||||||
|
|
||||||
|
Em outro terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent_framework_oci/Tuning-Performance/Transaction_Pre_Validation/agent_template_backend
|
||||||
|
pip install -e ../../../libs/agent_framework
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso elegível
|
||||||
|
|
||||||
|
```text
|
||||||
|
quero cancelar o pedido PED-1001
|
||||||
|
```
|
||||||
|
|
||||||
|
Esperado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
validar_cancelamento_pedido(PED-1001)
|
||||||
|
→ eligible=true
|
||||||
|
→ AWAITING_CONFIRMATION
|
||||||
|
→ nenhuma execução de cancelar_pedido ainda
|
||||||
|
```
|
||||||
|
|
||||||
|
Após `sim`, `cancelar_pedido` é executada.
|
||||||
|
|
||||||
|
### Caso não elegível
|
||||||
|
|
||||||
|
```text
|
||||||
|
quero cancelar o pedido PED-ENTREGUE
|
||||||
|
```
|
||||||
|
|
||||||
|
Esperado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
validar_cancelamento_pedido(PED-ENTREGUE)
|
||||||
|
→ eligible=false
|
||||||
|
→ OUT_OF_SCOPE
|
||||||
|
→ NÃO entra em AWAITING_CONFIRMATION
|
||||||
|
→ cancelar_pedido NÃO é executada
|
||||||
|
```
|
||||||
|
|
||||||
|
## Telemetria
|
||||||
|
|
||||||
|
Procure pelos eventos:
|
||||||
|
|
||||||
|
- `IC.TRANSACTION_PREVALIDATION_REQUESTED`
|
||||||
|
- `IC.TRANSACTION_PREVALIDATION_PASSED`
|
||||||
|
- `IC.TRANSACTION_PREVALIDATION_REJECTED`
|
||||||
|
- `IC.TRANSACTION_CONFIRMATION_REQUIRED` somente após pre-validation aprovada.
|
||||||
|
|
||||||
|
No estado/metadata, `transaction_pre_validation` registra o validator utilizado e o resultado.
|
||||||
|
|
||||||
|
## Fail-open x fail-closed
|
||||||
|
|
||||||
|
`fail_open: false` é o padrão recomendado para operações sensíveis: se o validator estiver
|
||||||
|
indisponível, a transação não avança para confirmação.
|
||||||
|
|
||||||
|
`fail_open: true` deve ser usado apenas quando o domínio aceitar explicitamente prosseguir sem
|
||||||
|
a pré-validação.
|
||||||
|
|
||||||
|
## Separação de responsabilidades
|
||||||
|
|
||||||
|
**Framework**: ordem das etapas, estado, confirmação, idempotência e telemetria.
|
||||||
|
|
||||||
|
**MCP/domínio**: regra de elegibilidade, consulta aos sistemas de registro e motivo da rejeição.
|
||||||
|
|
||||||
|
A capability é genérica: pode ser usada para cancelamento, contestação, devolução, troca,
|
||||||
|
suspensão ou qualquer outra operação transacional que possua uma precondição de domínio.
|
||||||
|
|
||||||
|
## Rejeição encerra o estado transacional
|
||||||
|
|
||||||
|
Quando o validator retorna `eligible: false`, o framework encerra imediatamente o latch transacional. Isso significa que `selected_tool_call`, `pending_tool_call` e `missing_parameters` são limpos, `confirmation_required=false`, `next_state=null` e `transaction_status=OUT_OF_SCOPE`.
|
||||||
|
|
||||||
|
O resultado da pré-validação também é propagado no estado/metadata como `transaction_pre_validation`, por exemplo:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"transaction_pre_validation": {
|
||||||
|
"tool_name": "contestar_cobranca",
|
||||||
|
"validator_tool": "validar_contestacao",
|
||||||
|
"eligible": false,
|
||||||
|
"status": "OUT_OF_SCOPE",
|
||||||
|
"terminal": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Assim, o turno seguinte volta ao roteamento normal e não permanece preso em `COLLECTING_*` ou `WAITING_*`. A rejeição não vira `transaction_evidence`, pois a operação de negócio não foi executada.
|
||||||
@@ -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_sdk
|
||||||
|
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
|
||||||
|
OCI_GENAI_MODEL=openai.gpt-4.1
|
||||||
|
OCI_GENAI_API_KEY=sk-ph3FgX6iP3fxAQCXb9IpPIDTadkeeYAWntUWhzcWysIM6zsS
|
||||||
|
OCI_GENAI_PROJECT_OCID=
|
||||||
|
|
||||||
|
#OCI_GENAI_BASE_URL=https://pegruagntaiatenddev.pe.inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com
|
||||||
|
#OCI_GENAI_MODEL=openai.gpt-4.1
|
||||||
|
#OCI_GENAI_API_KEY=
|
||||||
|
#OCI_GENAI_PROJECT_OCID=
|
||||||
|
|
||||||
|
|
||||||
|
# OCI_AUTH_MODE=config_file|instance_principal|resource_principal
|
||||||
|
OCI_AUTH_MODE=config_file
|
||||||
|
# OCI SDK / signer / profiles
|
||||||
|
OCI_CONFIG_FILE=~/.oci/config
|
||||||
|
OCI_PROFILE=LATINOAMERICA-Chicago
|
||||||
|
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaexpiw4a7dio64mkfv2t273s2hgdl6mgfvvyv7tycalnjlvpvfl3q
|
||||||
|
OCI_REGION=us-chicago-1
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Persistência
|
||||||
|
###############################################################################
|
||||||
|
# Opções: memory, autonomous, mongodb
|
||||||
|
SESSION_REPOSITORY_PROVIDER=autonomous
|
||||||
|
MEMORY_REPOSITORY_PROVIDER=autonomous
|
||||||
|
CHECKPOINT_REPOSITORY_PROVIDER=autonomous
|
||||||
|
|
||||||
|
# Autonomous Database
|
||||||
|
ADB_USER=admin
|
||||||
|
ADB_PASSWORD=Moniquinha19721972
|
||||||
|
ADB_DSN=oradb23ai_high
|
||||||
|
ADB_WALLET_LOCATION=/mnt/d/Dropbox/ORACLE/LatinoAmerica/Wallet_ORADB23ai
|
||||||
|
ADB_WALLET_PASSWORD=Moniquinha1972
|
||||||
|
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=autonomous
|
||||||
|
GRAPH_STORE_PROVIDER=autonomous
|
||||||
|
RAG_TOP_K=5
|
||||||
|
EMBEDDING_PROVIDER=oci
|
||||||
|
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
|
||||||
|
RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Observabilidade
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_LANGFUSE=true
|
||||||
|
# Opcional: verbose, compact
|
||||||
|
LANGFUSE_TRACE_MODE=compact
|
||||||
|
# Nome customizado do trace pai, ex.: backoffice.checklist.workflow ou backoffice.emulador.workflow
|
||||||
|
LANGFUSE_COMPACT_VISIBLE_EVENT_PREFIXES=AGA.,NOC., IC.
|
||||||
|
LANGFUSE_COMPACT_SUPPRESSED_PREFIXES=llm.chat_completion
|
||||||
|
LANGFUSE_IGNORE_HEALTHCHECKS=true
|
||||||
|
LANGFUSE_IGNORED_PATHS=/health,/ready,/metrics
|
||||||
|
LANGFUSE_PUBLIC_KEY=pk-lf-4a1e3921-5158-4fd3-a16d-7a77549fb312
|
||||||
|
LANGFUSE_SECRET_KEY=sk-lf-efc6fd59-c5ec-4858-b6ec-4aa129734915
|
||||||
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
|
ENABLE_OTEL=false
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
|
OTEL_SERVICE_NAME=ai-agent-template
|
||||||
|
ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true
|
||||||
|
ENABLE_LANGFUSE_ANALYTICS_PUBLISHER=false
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# 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=oci_streaming
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# MCP / Tools
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_MCP_TOOLS=true
|
||||||
|
MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.yaml
|
||||||
|
TOOLS_CONFIG_PATH=./config/tools.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=autonomous
|
||||||
|
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
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# 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
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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}"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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
|
||||||
|
from app.presentation import register_tool_renderers
|
||||||
|
|
||||||
|
register_tool_renderers()
|
||||||
|
|
||||||
|
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Exemplos de uso do template backend enterprise."""
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -0,0 +1,648 @@
|
|||||||
|
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.channels.interruption import classify_processing_interruption, evaluate_interruption
|
||||||
|
from agent_framework.channels.transcription import fix_whole_utterance_transcription
|
||||||
|
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 sessions.upsert(session)
|
||||||
|
|
||||||
|
fixed_message_text = fix_whole_utterance_transcription(msg.text)
|
||||||
|
if fixed_message_text != msg.text:
|
||||||
|
await telemetry.event(
|
||||||
|
"channel.transcription.fixed",
|
||||||
|
{
|
||||||
|
"session_id": agent_session_id,
|
||||||
|
"original_text": msg.text,
|
||||||
|
"fixed_text": fixed_message_text,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
interruption = evaluate_interruption(
|
||||||
|
payload=payload,
|
||||||
|
message_text=fixed_message_text,
|
||||||
|
session_metadata=session.metadata,
|
||||||
|
terminal_fallback_text=getattr(settings, "POST_FINALIZE_REPLAY_MESSAGE", ""),
|
||||||
|
)
|
||||||
|
if interruption.action == "classify":
|
||||||
|
prior_history = await memory.list(agent_session_id)
|
||||||
|
prior_user_text = ""
|
||||||
|
for prior in reversed(prior_history):
|
||||||
|
role = getattr(prior, "role", None)
|
||||||
|
content = getattr(prior, "content", "")
|
||||||
|
if str(role or "") == "user" and str(content or "").strip():
|
||||||
|
prior_user_text = str(content).strip()
|
||||||
|
break
|
||||||
|
regenerate = await classify_processing_interruption(
|
||||||
|
llm,
|
||||||
|
original_agent=interruption.replay_text,
|
||||||
|
original_client=prior_user_text,
|
||||||
|
supplement_client=interruption.text,
|
||||||
|
)
|
||||||
|
await telemetry.event(
|
||||||
|
"channel.processing_interruption.classified",
|
||||||
|
{
|
||||||
|
"session_id": agent_session_id,
|
||||||
|
"regenerate": regenerate,
|
||||||
|
"profile_name": "processing_interruption_classifier",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if regenerate:
|
||||||
|
interruption.action = "process"
|
||||||
|
interruption.reason = "classifier_result_1"
|
||||||
|
else:
|
||||||
|
interruption.action = "replay"
|
||||||
|
interruption.reason = "classifier_result_0"
|
||||||
|
|
||||||
|
if interruption.action == "replay":
|
||||||
|
response = ChannelResponse(
|
||||||
|
channel=msg.channel,
|
||||||
|
session_id=agent_session_id,
|
||||||
|
text=interruption.replay_text,
|
||||||
|
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,
|
||||||
|
"replay": True,
|
||||||
|
"replay_reason": interruption.reason,
|
||||||
|
"is_interruptible": interruption.is_interruptible,
|
||||||
|
"framework_short_circuit": True,
|
||||||
|
"terminal_status": interruption.terminal_status,
|
||||||
|
"llm_called": False,
|
||||||
|
"tools_called": False,
|
||||||
|
"guardrails_called": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rendered = await gateway.render(response)
|
||||||
|
await telemetry.event("gateway.message.replayed", {"session_id": agent_session_id, "reason": interruption.reason})
|
||||||
|
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
effective_text = interruption.text
|
||||||
|
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=effective_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": effective_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"],
|
||||||
|
# Chave estável de LTM. Nunca use session_id como identidade de longo prazo.
|
||||||
|
"long_term_memory_subject_key": business_context.customer_key or session.user_id,
|
||||||
|
"customer_key": business_context.customer_key,
|
||||||
|
"user_id": session.user_id,
|
||||||
|
"business_context": business_context.model_dump(),
|
||||||
|
"user_text": effective_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"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
terminal_status = str(result.get("terminal_status") or "").strip()
|
||||||
|
session_ended = bool(result.get("session_ended")) or bool(terminal_status)
|
||||||
|
session.metadata = {
|
||||||
|
**(session.metadata or {}),
|
||||||
|
"last_assistant_text": answer,
|
||||||
|
"last_assistant_is_interruptible": bool(result.get("is_interruptible", True)),
|
||||||
|
"last_route": result.get("route"),
|
||||||
|
"last_intent": result.get("intent"),
|
||||||
|
"conversation_closed": session_ended,
|
||||||
|
"terminal_status": terminal_status or ("resolvido" if session_ended else ""),
|
||||||
|
"terminal_replay_text": answer if session_ended else "",
|
||||||
|
}
|
||||||
|
await sessions.upsert(session)
|
||||||
|
|
||||||
|
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"),
|
||||||
|
"transaction_evidence": result.get("relevant_transaction_evidence", []),
|
||||||
|
"transaction_pre_validation": result.get("transaction_pre_validation"),
|
||||||
|
"business_context": business_context.model_dump(),
|
||||||
|
"identity_missing": missing_identity_keys,
|
||||||
|
"judges": result.get("judge_results"),
|
||||||
|
"guardrails": result.get("guardrail_decisions"),
|
||||||
|
"long_term_memory": {
|
||||||
|
"subject_key": business_context.customer_key or session.user_id,
|
||||||
|
"loaded": result.get("long_term_memories", []),
|
||||||
|
"context": result.get("long_term_memory_context", ""),
|
||||||
|
"load_error": result.get("long_term_memory_load_error"),
|
||||||
|
"write_result": result.get("long_term_memory_write_result", {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
"long_term_memory": {
|
||||||
|
"enabled": getattr(settings, "ENABLE_LONG_TERM_MEMORY", False),
|
||||||
|
"provider": getattr(settings, "LONG_TERM_MEMORY_PROVIDER", None),
|
||||||
|
"sqlite_path": getattr(settings, "LONG_TERM_MEMORY_SQLITE_PATH", None),
|
||||||
|
"table": getattr(settings, "LONG_TERM_MEMORY_TABLE", None),
|
||||||
|
"auto_extract": getattr(settings, "LONG_TERM_MEMORY_AUTO_EXTRACT", None),
|
||||||
|
"inject_context": getattr(settings, "LONG_TERM_MEMORY_INJECT_CONTEXT", None),
|
||||||
|
},
|
||||||
|
"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": effective_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()
|
||||||
@@ -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")),
|
||||||
|
)
|
||||||
@@ -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})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .tool_renderers import register_tool_renderers
|
||||||
|
|
||||||
|
__all__ = ["register_tool_renderers"]
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agent_framework.presentation import register_tool_response_renderer
|
||||||
|
|
||||||
|
|
||||||
|
def _money_brl(value: Any) -> str:
|
||||||
|
try:
|
||||||
|
return f"{float(value):.2f}".replace(".", ",")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def render_telecom_invoice(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:
|
||||||
|
return f"[{agent_label}] Fatura consultada: {result}."
|
||||||
|
|
||||||
|
|
||||||
|
def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:
|
||||||
|
plano = result.get("plano")
|
||||||
|
if plano is None:
|
||||||
|
return None
|
||||||
|
parts = [f"[{agent_label}] Seu plano é {plano}"]
|
||||||
|
internet_gb = result.get("internet_gb")
|
||||||
|
status = result.get("status")
|
||||||
|
if internet_gb is not None:
|
||||||
|
parts.append(f"com {internet_gb} GB")
|
||||||
|
if status is not None:
|
||||||
|
parts.append(f"status {status}")
|
||||||
|
return ", ".join(parts) + "."
|
||||||
|
|
||||||
|
|
||||||
|
def render_retail_order(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:
|
||||||
|
order_id = result.get("order_id")
|
||||||
|
status = result.get("status")
|
||||||
|
if order_id is None or status is None:
|
||||||
|
return None
|
||||||
|
lines = [f"[{agent_label}] Pedido {order_id}: status {status}."]
|
||||||
|
total = result.get("valor_total")
|
||||||
|
if total is not None:
|
||||||
|
lines.append(f"Valor total: R$ {_money_brl(total)}.")
|
||||||
|
items = result.get("itens") or []
|
||||||
|
rendered_items: list[str] = []
|
||||||
|
if isinstance(items, list):
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
value = item.get("descricao") or item.get("nome") or item.get("sku")
|
||||||
|
else:
|
||||||
|
value = item
|
||||||
|
if value not in (None, ""):
|
||||||
|
rendered_items.append(str(value))
|
||||||
|
if rendered_items:
|
||||||
|
lines.append("Itens: " + "; ".join(rendered_items) + ".")
|
||||||
|
return " ".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_retail_delivery(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:
|
||||||
|
order_id = result.get("order_id")
|
||||||
|
transportadora = result.get("transportadora")
|
||||||
|
codigo = result.get("codigo_rastreio")
|
||||||
|
previsao = result.get("previsao_entrega")
|
||||||
|
if any(v is None for v in (order_id, transportadora, codigo, previsao)):
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
f"[{agent_label}] Entrega do pedido {order_id}: transportadora {transportadora}, "
|
||||||
|
f"rastreio {codigo}, previsão {previsao}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_tool_renderers() -> None:
|
||||||
|
register_tool_response_renderer("telecom.invoice", render_telecom_invoice)
|
||||||
|
register_tool_response_renderer("telecom.plan", render_telecom_plan)
|
||||||
|
register_tool_response_renderer("retail.order", render_retail_order)
|
||||||
|
register_tool_response_renderer("retail.delivery", render_retail_delivery)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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
|
||||||
|
transaction_pre_validation: dict[str, Any]
|
||||||
|
transaction_evidence: list[dict[str, Any]]
|
||||||
|
last_transaction_evidence: dict[str, Any]
|
||||||
|
relevant_transaction_evidence: list[dict[str, Any]]
|
||||||
|
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]
|
||||||
|
long_term_memory_subject_key: str
|
||||||
|
long_term_memory_load_error: str
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import devolucao # noqa: F401
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Actions de domínio permanecem no agente; o runtime está no framework."""
|
||||||
|
from agent_framework.workflows import workflow_action
|
||||||
|
|
||||||
|
|
||||||
|
@workflow_action("validar_pedido")
|
||||||
|
async def validar_pedido(params: dict, state: dict) -> dict:
|
||||||
|
return {"valid": bool(params.get("order_id"))}
|
||||||
|
|
||||||
|
|
||||||
|
@workflow_action("registrar_devolucao")
|
||||||
|
async def registrar_devolucao(params: dict, state: dict) -> dict:
|
||||||
|
# Substitua pela chamada real ao serviço/MCP e use chave idempotente.
|
||||||
|
return {"protocol": f"DEV-{params['order_id']}", "status": "REQUESTED"}
|
||||||
@@ -0,0 +1,892 @@
|
|||||||
|
from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer
|
||||||
|
from agent_framework.workflows import END, START, FrameworkStateGraph
|
||||||
|
|
||||||
|
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 = FrameworkStateGraph(AgentState)
|
||||||
|
builder.add_node("input_guardrails", self._node("input_guardrails", self.input_guardrails))
|
||||||
|
builder.add_node("load_long_term_memory", self._node("load_long_term_memory", self.load_long_term_memory))
|
||||||
|
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": "load_long_term_memory"},
|
||||||
|
)
|
||||||
|
builder.add_edge("load_long_term_memory", "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", [])
|
||||||
|
relevant_transaction_evidence = list(state.get("relevant_transaction_evidence") or [])
|
||||||
|
judge_context["transaction_evidence"] = relevant_transaction_evidence
|
||||||
|
current_evidence = list(state.get("mcp_results", []) or [])
|
||||||
|
current_evidence.extend(relevant_transaction_evidence)
|
||||||
|
judge_context["evidence"] = current_evidence 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 load_long_term_memory(self, state):
|
||||||
|
"""Carrega LTM antes do roteamento e mantém o resultado no estado.
|
||||||
|
|
||||||
|
A carga explícita evita depender apenas do agente selecionado para realizar
|
||||||
|
a recuperação e facilita o diagnóstico de identidade/namespace.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
memories = await self.long_term_memory_manager.load(state)
|
||||||
|
serialized = []
|
||||||
|
context_lines = []
|
||||||
|
for item in memories or []:
|
||||||
|
if hasattr(item, "model_dump"):
|
||||||
|
data = item.model_dump(mode="json")
|
||||||
|
elif hasattr(item, "__dict__"):
|
||||||
|
data = dict(item.__dict__)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
data = dict(item)
|
||||||
|
else:
|
||||||
|
data = {"value": str(item)}
|
||||||
|
serialized.append(data)
|
||||||
|
key = data.get("key") or data.get("memory_key") or data.get("category") or "memory"
|
||||||
|
value = data.get("value") or data.get("memory_value")
|
||||||
|
if value not in (None, ""):
|
||||||
|
context_lines.append(f"- {key}: {value}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"long_term_memories": serialized,
|
||||||
|
"long_term_memory_context": "\n".join(context_lines),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
await self.telemetry.event(
|
||||||
|
"long_term_memory.load.failed",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"subject_key": state.get("long_term_memory_subject_key"),
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"long_term_memories": [],
|
||||||
|
"long_term_memory_context": "",
|
||||||
|
"long_term_memory_load_error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def persist_long_term_memory(self, state):
|
||||||
|
try:
|
||||||
|
result = await self.long_term_memory_manager.persist_turn(state)
|
||||||
|
await self.telemetry.event(
|
||||||
|
"long_term_memory.persist.completed",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"subject_key": state.get("long_term_memory_subject_key"),
|
||||||
|
"result": result,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {"long_term_memory_write_result": result}
|
||||||
|
except Exception as exc:
|
||||||
|
await self.telemetry.event(
|
||||||
|
"long_term_memory.persist.failed",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"subject_key": state.get("long_term_memory_subject_key"),
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {"long_term_memory_write_result": {"saved": 0, "error": str(exc)}}
|
||||||
|
|
||||||
|
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", []),
|
||||||
|
"transaction_evidence": state.get("relevant_transaction_evidence", []),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
input:
|
||||||
|
- code: MSK
|
||||||
|
enabled: true
|
||||||
|
- code: VLOOP
|
||||||
|
enabled: true
|
||||||
|
output:
|
||||||
|
- code: REVPREC
|
||||||
|
enabled: true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
judges:
|
||||||
|
- name: response_quality
|
||||||
|
enabled: true
|
||||||
|
threshold: 0.7
|
||||||
|
- name: groundedness
|
||||||
|
enabled: true
|
||||||
|
threshold: 0.6
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
input:
|
||||||
|
- code: MSK
|
||||||
|
enabled: true
|
||||||
|
- code: VLOOP
|
||||||
|
enabled: true
|
||||||
|
output:
|
||||||
|
- code: REVPREC
|
||||||
|
enabled: true
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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
|
||||||
|
validar_cancelamento_pedido:
|
||||||
|
map:
|
||||||
|
resource_key: order_id
|
||||||
|
|
||||||
|
cancelar_pedido:
|
||||||
|
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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# 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_cancel
|
||||||
|
domain: retail
|
||||||
|
agent: orders_agent
|
||||||
|
description: Cancelamento explícito de pedido ou compra.
|
||||||
|
priority: 20
|
||||||
|
mcp_tools:
|
||||||
|
- consultar_pedido
|
||||||
|
- cancelar_pedido
|
||||||
|
keywords:
|
||||||
|
- cancelar pedido
|
||||||
|
- cancelamento do pedido
|
||||||
|
- cancelar a compra
|
||||||
|
- cancelar compra
|
||||||
|
examples:
|
||||||
|
- Quero cancelar meu pedido.
|
||||||
|
- Cancele o pedido.
|
||||||
|
- Quero cancelar a compra.
|
||||||
|
|
||||||
|
- 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?
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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:
|
||||||
|
cancelar_pedido:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
requires: [order_id]
|
||||||
|
pre_validation:
|
||||||
|
enabled: true
|
||||||
|
tool: validar_cancelamento_pedido
|
||||||
|
fail_open: false
|
||||||
|
|
||||||
|
|
||||||
|
solicitar_troca:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
|
||||||
|
solicitar_devolucao:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
requires: [order_id, reason]
|
||||||
|
execution:
|
||||||
|
mode: workflow
|
||||||
|
workflow: devolucao_pedido
|
||||||
|
version: active
|
||||||
|
|
||||||
|
# Exemplo para uma operação real que só pode executar após confirmação:
|
||||||
|
# cancelar_servico:
|
||||||
|
# operation_type: transactional
|
||||||
|
# require_confirmation: true
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.invoice
|
||||||
|
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
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.plan
|
||||||
|
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
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.order
|
||||||
|
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
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.delivery
|
||||||
|
validar_cancelamento_pedido:
|
||||||
|
description: Pre-valida cancelamento do pedido sem executar efeitos transacionais.
|
||||||
|
mcp_server: retail
|
||||||
|
enabled: true
|
||||||
|
tool_type: internal
|
||||||
|
confirmation_required: false
|
||||||
|
requires: [order_id]
|
||||||
|
args_schema:
|
||||||
|
order_id: string
|
||||||
|
target_tool: string
|
||||||
|
selection_keywords: []
|
||||||
|
|
||||||
|
cancelar_pedido:
|
||||||
|
description: Simula o cancelamento de um pedido de varejo.
|
||||||
|
mcp_server: retail
|
||||||
|
enabled: true
|
||||||
|
tool_type: action
|
||||||
|
requires:
|
||||||
|
- order_id
|
||||||
|
confirmation_required: true
|
||||||
|
args_schema:
|
||||||
|
order_id: string
|
||||||
|
selection_keywords:
|
||||||
|
- cancelar pedido
|
||||||
|
- cancelamento do pedido
|
||||||
|
- cancelar compra
|
||||||
|
- cancelar a compra
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Teste e diagnóstico de Long-Term Memory
|
||||||
|
|
||||||
|
## O que foi corrigido
|
||||||
|
|
||||||
|
1. A LTM agora é carregada explicitamente antes do roteamento.
|
||||||
|
2. O estado recebe uma chave estável em `long_term_memory_subject_key`, baseada em `business_context.customer_key` e, como fallback, `user_id`.
|
||||||
|
3. O resultado de carga e persistência aparece em `metadata.long_term_memory` da resposta.
|
||||||
|
4. `/health` informa a configuração efetiva de LTM carregada pelo processo.
|
||||||
|
5. Falhas de leitura e gravação geram eventos `long_term_memory.load.failed` e `long_term_memory.persist.failed`.
|
||||||
|
|
||||||
|
## Teste
|
||||||
|
|
||||||
|
Primeira sessão:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8000/gateway/message \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"channel":"web",
|
||||||
|
"payload":{
|
||||||
|
"text":"Meu nome preferido é Cris e minha linguagem preferida é Python.",
|
||||||
|
"session_id":"ltm-session-001",
|
||||||
|
"user_id":"ltm-user-001",
|
||||||
|
"customer_id":"ltm-customer-001"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifique na resposta:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"long_term_memory": {
|
||||||
|
"subject_key": "ltm-customer-001",
|
||||||
|
"write_result": {
|
||||||
|
"saved": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nova sessão, mesma identidade:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8000/gateway/message \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"channel":"web",
|
||||||
|
"payload":{
|
||||||
|
"text":"Qual é meu nome preferido e qual linguagem eu prefiro?",
|
||||||
|
"session_id":"ltm-session-002",
|
||||||
|
"user_id":"ltm-user-001",
|
||||||
|
"customer_id":"ltm-customer-001"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Na segunda resposta, confira:
|
||||||
|
|
||||||
|
- `metadata.long_term_memory.subject_key` igual à primeira chamada;
|
||||||
|
- `metadata.long_term_memory.loaded` com registros;
|
||||||
|
- `metadata.long_term_memory.context` preenchido;
|
||||||
|
- ausência de `load_error`.
|
||||||
|
|
||||||
|
## Diagnóstico rápido
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
A seção `long_term_memory` deve mostrar:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "sqlite",
|
||||||
|
"sqlite_path": "./data/agent_framework.db",
|
||||||
|
"table": "agentfw_long_term_memory",
|
||||||
|
"auto_extract": true,
|
||||||
|
"inject_context": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Execute o backend com o diretório do projeto como diretório de trabalho. Como o caminho SQLite é relativo, iniciar a aplicação em outro diretório pode criar ou consultar outro arquivo `./data/agent_framework.db`.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
compileall app: OK
|
||||||
|
Arquivos de exemplos IC/NOC/GRL adicionados.
|
||||||
|
Agentes preservam implementação original comentada.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
version: 1
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: devolucao_pedido
|
||||||
|
version: 1
|
||||||
|
start: validar_pedido
|
||||||
|
nodes:
|
||||||
|
- id: validar_pedido
|
||||||
|
action: validar_pedido
|
||||||
|
input:
|
||||||
|
order_id: $.input.order_id
|
||||||
|
- id: registrar_devolucao
|
||||||
|
action: registrar_devolucao
|
||||||
|
retry: 1
|
||||||
|
input:
|
||||||
|
order_id: $.input.order_id
|
||||||
|
reason: $.input.reason
|
||||||
|
edges:
|
||||||
|
- from: validar_pedido
|
||||||
|
to: registrar_devolucao
|
||||||
|
when:
|
||||||
|
path: $.nodes.validar_pedido.valid
|
||||||
|
equals: true
|
||||||
|
- from: validar_pedido
|
||||||
|
to: END
|
||||||
|
when:
|
||||||
|
path: $.nodes.validar_pedido.valid
|
||||||
|
equals: false
|
||||||
|
- from: registrar_devolucao
|
||||||
|
to: END
|
||||||
Binary file not shown.
Binary file not shown.
@@ -13,6 +13,14 @@ class WorkflowExecutionPolicy(BaseModel):
|
|||||||
version: int | Literal["active"] = "active"
|
version: int | Literal["active"] = "active"
|
||||||
|
|
||||||
|
|
||||||
|
class ToolPreValidationPolicy(BaseModel):
|
||||||
|
"""Optional MCP business pre-validation executed before user confirmation."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
tool: str | None = None
|
||||||
|
fail_open: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ToolPolicy(BaseModel):
|
class ToolPolicy(BaseModel):
|
||||||
"""Política de execução aplicada antes da chamada MCP ou workflow."""
|
"""Política de execução aplicada antes da chamada MCP ou workflow."""
|
||||||
|
|
||||||
@@ -20,6 +28,7 @@ class ToolPolicy(BaseModel):
|
|||||||
require_confirmation: bool = False
|
require_confirmation: bool = False
|
||||||
requires: list[str] = Field(default_factory=list)
|
requires: list[str] = Field(default_factory=list)
|
||||||
execution: WorkflowExecutionPolicy = Field(default_factory=WorkflowExecutionPolicy)
|
execution: WorkflowExecutionPolicy = Field(default_factory=WorkflowExecutionPolicy)
|
||||||
|
pre_validation: ToolPreValidationPolicy = Field(default_factory=ToolPreValidationPolicy)
|
||||||
|
|
||||||
|
|
||||||
class ToolPolicyRegistry:
|
class ToolPolicyRegistry:
|
||||||
@@ -55,11 +64,18 @@ class ToolPolicyRegistry:
|
|||||||
execution_raw = raw.get("execution") or {}
|
execution_raw = raw.get("execution") or {}
|
||||||
base_execution = base.execution.model_dump()
|
base_execution = base.execution.model_dump()
|
||||||
base_execution.update(execution_raw)
|
base_execution.update(execution_raw)
|
||||||
|
pre_validation_raw = raw.get("pre_validation") or {}
|
||||||
|
base_pre_validation = base.pre_validation.model_dump()
|
||||||
|
if isinstance(pre_validation_raw, bool):
|
||||||
|
base_pre_validation["enabled"] = pre_validation_raw
|
||||||
|
elif isinstance(pre_validation_raw, dict):
|
||||||
|
base_pre_validation.update(pre_validation_raw)
|
||||||
return ToolPolicy(
|
return ToolPolicy(
|
||||||
operation_type=operation_type,
|
operation_type=operation_type,
|
||||||
require_confirmation=bool(confirmation),
|
require_confirmation=bool(confirmation),
|
||||||
requires=list(raw.get("requires", base.requires) or []),
|
requires=list(raw.get("requires", base.requires) or []),
|
||||||
execution=WorkflowExecutionPolicy.model_validate(base_execution),
|
execution=WorkflowExecutionPolicy.model_validate(base_execution),
|
||||||
|
pre_validation=ToolPreValidationPolicy.model_validate(base_pre_validation),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get(self, tool_name: str) -> ToolPolicy | None:
|
def get(self, tool_name: str) -> ToolPolicy | None:
|
||||||
|
|||||||
@@ -83,12 +83,14 @@ class MCPToolRouter:
|
|||||||
required.extend(explicit.requires)
|
required.extend(explicit.requires)
|
||||||
source = "tool_policies.yaml"
|
source = "tool_policies.yaml"
|
||||||
execution = explicit.execution.model_dump() if explicit is not None else {"mode": "direct_tool", "workflow": None, "version": "active"}
|
execution = explicit.execution.model_dump() if explicit is not None else {"mode": "direct_tool", "workflow": None, "version": "active"}
|
||||||
|
pre_validation = explicit.pre_validation.model_dump() if explicit is not None else {"enabled": False, "tool": None, "fail_open": False}
|
||||||
return {
|
return {
|
||||||
"operation_type": operation_type,
|
"operation_type": operation_type,
|
||||||
"require_confirmation": confirmation_required,
|
"require_confirmation": confirmation_required,
|
||||||
"requires": list(dict.fromkeys(required)),
|
"requires": list(dict.fromkeys(required)),
|
||||||
"policy_source": source,
|
"policy_source": source,
|
||||||
"execution": execution,
|
"execution": execution,
|
||||||
|
"pre_validation": pre_validation,
|
||||||
}
|
}
|
||||||
|
|
||||||
def validate_execution_policy(
|
def validate_execution_policy(
|
||||||
|
|||||||
Binary file not shown.
@@ -713,6 +713,83 @@ class AgentRuntimeMixin:
|
|||||||
"policy_source": "tools.yaml",
|
"policy_source": "tools.yaml",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _run_transaction_pre_validation(
|
||||||
|
self,
|
||||||
|
state: dict[str, Any],
|
||||||
|
*,
|
||||||
|
tool_name: str,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
policy: dict[str, Any],
|
||||||
|
emit_events: bool = True,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Execute an optional domain-owned MCP pre-validation before confirmation.
|
||||||
|
|
||||||
|
The framework knows only the generic contract ``eligible``. Business rules
|
||||||
|
remain in the configured MCP validator tool. No LLM is used here.
|
||||||
|
"""
|
||||||
|
cfg = policy.get("pre_validation") if isinstance(policy, dict) else None
|
||||||
|
if not isinstance(cfg, dict) or not cfg.get("enabled"):
|
||||||
|
return None
|
||||||
|
validator = str(cfg.get("tool") or "").strip()
|
||||||
|
if not validator:
|
||||||
|
return None
|
||||||
|
validation_args = dict(arguments or {})
|
||||||
|
validation_args.pop("confirmed", None)
|
||||||
|
validation_args["target_tool"] = tool_name
|
||||||
|
if emit_events:
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.TRANSACTION_PREVALIDATION_REQUESTED",
|
||||||
|
state,
|
||||||
|
{"tool_name": tool_name, "validator_tool": validator},
|
||||||
|
component="agent_runtime.tool_policy",
|
||||||
|
)
|
||||||
|
result = await self._call_mcp_tool(validator, validation_args, state)
|
||||||
|
payload = result.get("result") if isinstance(result, dict) and isinstance(result.get("result"), dict) else result
|
||||||
|
eligible = payload.get("eligible") if isinstance(payload, dict) else None
|
||||||
|
if eligible is True:
|
||||||
|
state["transaction_pre_validation"] = {
|
||||||
|
"tool_name": tool_name, "validator_tool": validator, "eligible": True, "result": result
|
||||||
|
}
|
||||||
|
if emit_events:
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.TRANSACTION_PREVALIDATION_PASSED", state,
|
||||||
|
{"tool_name": tool_name, "validator_tool": validator},
|
||||||
|
component="agent_runtime.tool_policy",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
transport_failed = isinstance(result, dict) and result.get("ok") is False and eligible is None
|
||||||
|
if transport_failed and bool(cfg.get("fail_open")):
|
||||||
|
return None
|
||||||
|
status = str((payload or {}).get("status") or ("PREVALIDATION_ERROR" if transport_failed else "OUT_OF_SCOPE"))
|
||||||
|
state["transaction_pre_validation"] = {
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"validator_tool": validator,
|
||||||
|
"eligible": False,
|
||||||
|
"status": status,
|
||||||
|
"error": (payload or {}).get("error") if isinstance(payload, dict) else None,
|
||||||
|
"terminal": True,
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
# A rejeição da pré-validação encerra o latch transacional imediatamente.
|
||||||
|
# A regra de negócio permanece no MCP; o framework apenas materializa o
|
||||||
|
# resultado genérico de elegibilidade e garante que o próximo turno volte
|
||||||
|
# ao roteamento normal, sem herdar COLLECTING_/WAITING_.
|
||||||
|
self._finish_active_transaction(state, "OUT_OF_SCOPE", result=result)
|
||||||
|
state["next_state"] = None
|
||||||
|
state["confirmation_required"] = False
|
||||||
|
state["confirmation_received"] = False
|
||||||
|
if emit_events:
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.TRANSACTION_PREVALIDATION_REJECTED", state,
|
||||||
|
{"tool_name": tool_name, "validator_tool": validator, "status": status, "error": (payload or {}).get("error")},
|
||||||
|
component="agent_runtime.tool_policy",
|
||||||
|
)
|
||||||
|
enriched = dict(result or {})
|
||||||
|
enriched["pre_validation"] = True
|
||||||
|
enriched["target_tool"] = tool_name
|
||||||
|
enriched["transaction_status"] = "OUT_OF_SCOPE"
|
||||||
|
return enriched
|
||||||
|
|
||||||
def _validate_tool_execution_policy(self, tool_name: str, arguments: dict[str, Any]) -> tuple[bool, str | None]:
|
def _validate_tool_execution_policy(self, tool_name: str, arguments: dict[str, Any]) -> tuple[bool, str | None]:
|
||||||
"""Aplica a mesma política central usada pelo MCPToolRouter."""
|
"""Aplica a mesma política central usada pelo MCPToolRouter."""
|
||||||
router = getattr(self, "tool_router", None)
|
router = getattr(self, "tool_router", None)
|
||||||
@@ -1608,6 +1685,7 @@ class AgentRuntimeMixin:
|
|||||||
"tool_policy_result", "missing_parameters", "next_state", "pending_domain_workflow", "pending_tool_clarification",
|
"tool_policy_result", "missing_parameters", "next_state", "pending_domain_workflow", "pending_tool_clarification",
|
||||||
"business_workflows_executed", "active_transaction", "last_transaction",
|
"business_workflows_executed", "active_transaction", "last_transaction",
|
||||||
"transaction_evidence", "last_transaction_evidence", "relevant_transaction_evidence",
|
"transaction_evidence", "last_transaction_evidence", "relevant_transaction_evidence",
|
||||||
|
"transaction_pre_validation",
|
||||||
)
|
)
|
||||||
return {key: state.get(key) for key in keys if key in state}
|
return {key: state.get(key) for key in keys if key in state}
|
||||||
|
|
||||||
@@ -2017,6 +2095,12 @@ class AgentRuntimeMixin:
|
|||||||
state, tool_name=tool_name, arguments=arguments, status="COLLECTING_PARAMETERS"
|
state, tool_name=tool_name, arguments=arguments, status="COLLECTING_PARAMETERS"
|
||||||
)
|
)
|
||||||
state["missing_parameters"] = []
|
state["missing_parameters"] = []
|
||||||
|
pre_validation_result = await self._run_transaction_pre_validation(
|
||||||
|
state, tool_name=tool_name, arguments=arguments, policy=policy, emit_events=emit_events
|
||||||
|
)
|
||||||
|
if pre_validation_result is not None:
|
||||||
|
return [pre_validation_result]
|
||||||
|
|
||||||
if policy.get("require_confirmation"):
|
if policy.get("require_confirmation"):
|
||||||
waiting_state = self._waiting_state_name(state)
|
waiting_state = self._waiting_state_name(state)
|
||||||
state.update({
|
state.update({
|
||||||
@@ -2190,6 +2274,13 @@ class AgentRuntimeMixin:
|
|||||||
})
|
})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
pre_validation_result = await self._run_transaction_pre_validation(
|
||||||
|
state, tool_name=selected_action, arguments=action_args, policy=policy, emit_events=emit_events
|
||||||
|
)
|
||||||
|
if pre_validation_result is not None:
|
||||||
|
results.append(pre_validation_result)
|
||||||
|
return results
|
||||||
|
|
||||||
if policy.get("require_confirmation"):
|
if policy.get("require_confirmation"):
|
||||||
state.update({
|
state.update({
|
||||||
"pending_tool_call": selected,
|
"pending_tool_call": selected,
|
||||||
|
|||||||
Binary file not shown.
@@ -17,6 +17,10 @@ TOOLS = {
|
|||||||
"description": "Consulta entrega e rastreamento do pedido.",
|
"description": "Consulta entrega e rastreamento do pedido.",
|
||||||
"input_schema": {"order_id": "string"},
|
"input_schema": {"order_id": "string"},
|
||||||
},
|
},
|
||||||
|
"validar_cancelamento_pedido": {
|
||||||
|
"description": "Pre-valida se o pedido pode ser cancelado, sem efeitos colaterais.",
|
||||||
|
"input_schema": {"order_id": "string", "target_tool": "string"},
|
||||||
|
},
|
||||||
"cancelar_pedido": {
|
"cancelar_pedido": {
|
||||||
"description": "Simula o cancelamento de um pedido de varejo.",
|
"description": "Simula o cancelamento de um pedido de varejo.",
|
||||||
"input_schema": {"order_id": "string"},
|
"input_schema": {"order_id": "string"},
|
||||||
@@ -81,6 +85,23 @@ async def call_tool(call: ToolCall):
|
|||||||
{"data": "2026-05-29", "descricao": "Em trânsito para o centro de distribuição"},
|
{"data": "2026-05-29", "descricao": "Em trânsito para o centro de distribuição"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
elif name == "validar_cancelamento_pedido":
|
||||||
|
order_id = str(args.get("order_id") or "PED-1001").upper()
|
||||||
|
if order_id in {"123", "PED-ENTREGUE"}:
|
||||||
|
result = {
|
||||||
|
"eligible": False,
|
||||||
|
"status": "NOT_ELIGIBLE",
|
||||||
|
"order_id": order_id,
|
||||||
|
"reason": "Pedido já entregue não pode ser cancelado por esta operação.",
|
||||||
|
"metadata": {"side_effect_free": True},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
result = {
|
||||||
|
"eligible": True,
|
||||||
|
"status": "ELIGIBLE",
|
||||||
|
"order_id": order_id,
|
||||||
|
"metadata": {"side_effect_free": True},
|
||||||
|
}
|
||||||
elif name == "cancelar_pedido":
|
elif name == "cancelar_pedido":
|
||||||
result = {
|
result = {
|
||||||
"protocolo": "CANCEL-2026-001",
|
"protocolo": "CANCEL-2026-001",
|
||||||
|
|||||||
207
agent_framework_oci/templates/agent_template_backend/.env
Normal file
207
agent_framework_oci/templates/agent_template_backend/.env
Normal 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_sdk
|
||||||
|
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
|
||||||
|
OCI_GENAI_MODEL=openai.gpt-4.1
|
||||||
|
OCI_GENAI_API_KEY=sk-ph3FgX6iP3fxAQCXb9IpPIDTadkeeYAWntUWhzcWysIM6zsS
|
||||||
|
OCI_GENAI_PROJECT_OCID=
|
||||||
|
|
||||||
|
#OCI_GENAI_BASE_URL=https://pegruagntaiatenddev.pe.inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com
|
||||||
|
#OCI_GENAI_MODEL=openai.gpt-4.1
|
||||||
|
#OCI_GENAI_API_KEY=
|
||||||
|
#OCI_GENAI_PROJECT_OCID=
|
||||||
|
|
||||||
|
|
||||||
|
# OCI_AUTH_MODE=config_file|instance_principal|resource_principal
|
||||||
|
OCI_AUTH_MODE=config_file
|
||||||
|
# OCI SDK / signer / profiles
|
||||||
|
OCI_CONFIG_FILE=~/.oci/config
|
||||||
|
OCI_PROFILE=LATINOAMERICA-Chicago
|
||||||
|
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaexpiw4a7dio64mkfv2t273s2hgdl6mgfvvyv7tycalnjlvpvfl3q
|
||||||
|
OCI_REGION=us-chicago-1
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Persistência
|
||||||
|
###############################################################################
|
||||||
|
# Opções: memory, autonomous, mongodb
|
||||||
|
SESSION_REPOSITORY_PROVIDER=autonomous
|
||||||
|
MEMORY_REPOSITORY_PROVIDER=autonomous
|
||||||
|
CHECKPOINT_REPOSITORY_PROVIDER=autonomous
|
||||||
|
|
||||||
|
# Autonomous Database
|
||||||
|
ADB_USER=admin
|
||||||
|
ADB_PASSWORD=Moniquinha19721972
|
||||||
|
ADB_DSN=oradb23ai_high
|
||||||
|
ADB_WALLET_LOCATION=/mnt/d/Dropbox/ORACLE/LatinoAmerica/Wallet_ORADB23ai
|
||||||
|
ADB_WALLET_PASSWORD=Moniquinha1972
|
||||||
|
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=autonomous
|
||||||
|
GRAPH_STORE_PROVIDER=autonomous
|
||||||
|
RAG_TOP_K=5
|
||||||
|
EMBEDDING_PROVIDER=oci
|
||||||
|
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
|
||||||
|
RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Observabilidade
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_LANGFUSE=true
|
||||||
|
# Opcional: verbose, compact
|
||||||
|
LANGFUSE_TRACE_MODE=compact
|
||||||
|
# Nome customizado do trace pai, ex.: backoffice.checklist.workflow ou backoffice.emulador.workflow
|
||||||
|
LANGFUSE_COMPACT_VISIBLE_EVENT_PREFIXES=AGA.,NOC., IC.
|
||||||
|
LANGFUSE_COMPACT_SUPPRESSED_PREFIXES=llm.chat_completion
|
||||||
|
LANGFUSE_IGNORE_HEALTHCHECKS=true
|
||||||
|
LANGFUSE_IGNORED_PATHS=/health,/ready,/metrics
|
||||||
|
LANGFUSE_PUBLIC_KEY=pk-lf-4a1e3921-5158-4fd3-a16d-7a77549fb312
|
||||||
|
LANGFUSE_SECRET_KEY=sk-lf-efc6fd59-c5ec-4858-b6ec-4aa129734915
|
||||||
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
|
ENABLE_OTEL=false
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
|
OTEL_SERVICE_NAME=ai-agent-template
|
||||||
|
ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true
|
||||||
|
ENABLE_LANGFUSE_ANALYTICS_PUBLISHER=false
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# 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=oci_streaming
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# MCP / Tools
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_MCP_TOOLS=true
|
||||||
|
MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.yaml
|
||||||
|
TOOLS_CONFIG_PATH=./config/tools.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=autonomous
|
||||||
|
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
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# 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
|
||||||
Binary file not shown.
Binary file not shown.
@@ -461,6 +461,7 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
|
|||||||
"mcp_tools": result.get("mcp_tools"),
|
"mcp_tools": result.get("mcp_tools"),
|
||||||
"mcp_results": result.get("mcp_results"),
|
"mcp_results": result.get("mcp_results"),
|
||||||
"transaction_evidence": result.get("relevant_transaction_evidence", []),
|
"transaction_evidence": result.get("relevant_transaction_evidence", []),
|
||||||
|
"transaction_pre_validation": result.get("transaction_pre_validation"),
|
||||||
"business_context": business_context.model_dump(),
|
"business_context": business_context.model_dump(),
|
||||||
"identity_missing": missing_identity_keys,
|
"identity_missing": missing_identity_keys,
|
||||||
"judges": result.get("judge_results"),
|
"judges": result.get("judge_results"),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class AgentState(TypedDict, total=False):
|
|||||||
selected_tool_call: dict[str, Any]
|
selected_tool_call: dict[str, Any]
|
||||||
pending_tool_call: dict[str, Any]
|
pending_tool_call: dict[str, Any]
|
||||||
transaction_status: str
|
transaction_status: str
|
||||||
|
transaction_pre_validation: dict[str, Any]
|
||||||
transaction_evidence: list[dict[str, Any]]
|
transaction_evidence: list[dict[str, Any]]
|
||||||
last_transaction_evidence: dict[str, Any]
|
last_transaction_evidence: dict[str, Any]
|
||||||
relevant_transaction_evidence: list[dict[str, Any]]
|
relevant_transaction_evidence: list[dict[str, Any]]
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ mcp_parameter_mapping:
|
|||||||
mensagem.
|
mensagem.
|
||||||
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
||||||
group: 1
|
group: 1
|
||||||
|
validar_cancelamento_pedido:
|
||||||
|
map:
|
||||||
|
resource_key: order_id
|
||||||
|
|
||||||
cancelar_pedido:
|
cancelar_pedido:
|
||||||
map:
|
map:
|
||||||
session_key: session_id
|
session_key: session_id
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ tool_policies:
|
|||||||
operation_type: transactional
|
operation_type: transactional
|
||||||
require_confirmation: true
|
require_confirmation: true
|
||||||
requires: [order_id]
|
requires: [order_id]
|
||||||
|
pre_validation:
|
||||||
|
enabled: true
|
||||||
|
tool: validar_cancelamento_pedido
|
||||||
|
fail_open: false
|
||||||
|
|
||||||
|
|
||||||
solicitar_troca:
|
solicitar_troca:
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ tools:
|
|||||||
response:
|
response:
|
||||||
mode: renderer
|
mode: renderer
|
||||||
renderer: retail.delivery
|
renderer: retail.delivery
|
||||||
|
validar_cancelamento_pedido:
|
||||||
|
description: Pre-valida cancelamento do pedido sem executar efeitos transacionais.
|
||||||
|
mcp_server: retail
|
||||||
|
enabled: true
|
||||||
|
tool_type: internal
|
||||||
|
confirmation_required: false
|
||||||
|
requires: [order_id]
|
||||||
|
args_schema:
|
||||||
|
order_id: string
|
||||||
|
target_tool: string
|
||||||
|
selection_keywords: []
|
||||||
|
|
||||||
cancelar_pedido:
|
cancelar_pedido:
|
||||||
description: Simula o cancelamento de um pedido de varejo.
|
description: Simula o cancelamento de um pedido de varejo.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class AgentState(TypedDict, total=False):
|
|||||||
selected_tool_call: dict[str, Any]
|
selected_tool_call: dict[str, Any]
|
||||||
pending_tool_call: dict[str, Any]
|
pending_tool_call: dict[str, Any]
|
||||||
transaction_status: str
|
transaction_status: str
|
||||||
|
transaction_pre_validation: dict[str, Any]
|
||||||
confirmation_required: bool
|
confirmation_required: bool
|
||||||
confirmation_received: bool
|
confirmation_received: bool
|
||||||
tool_policy_result: dict[str, Any]
|
tool_policy_result: dict[str, Any]
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ mcp_parameter_mapping:
|
|||||||
mensagem.
|
mensagem.
|
||||||
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
||||||
group: 1
|
group: 1
|
||||||
|
validar_cancelamento_pedido:
|
||||||
|
map:
|
||||||
|
resource_key: order_id
|
||||||
|
|
||||||
cancelar_pedido:
|
cancelar_pedido:
|
||||||
map:
|
map:
|
||||||
session_key: session_id
|
session_key: session_id
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ tool_policies:
|
|||||||
operation_type: transactional
|
operation_type: transactional
|
||||||
require_confirmation: true
|
require_confirmation: true
|
||||||
requires: [order_id]
|
requires: [order_id]
|
||||||
|
pre_validation:
|
||||||
|
enabled: true
|
||||||
|
tool: validar_cancelamento_pedido
|
||||||
|
fail_open: false
|
||||||
|
|
||||||
|
|
||||||
solicitar_troca:
|
solicitar_troca:
|
||||||
|
|||||||
@@ -61,6 +61,18 @@ tools:
|
|||||||
- rastreamento
|
- rastreamento
|
||||||
- transportadora
|
- transportadora
|
||||||
- previsão
|
- previsão
|
||||||
|
validar_cancelamento_pedido:
|
||||||
|
description: Pre-valida cancelamento do pedido sem executar efeitos transacionais.
|
||||||
|
mcp_server: retail
|
||||||
|
enabled: true
|
||||||
|
tool_type: internal
|
||||||
|
confirmation_required: false
|
||||||
|
requires: [order_id]
|
||||||
|
args_schema:
|
||||||
|
order_id: string
|
||||||
|
target_tool: string
|
||||||
|
selection_keywords: []
|
||||||
|
|
||||||
cancelar_pedido:
|
cancelar_pedido:
|
||||||
description: Simula o cancelamento de um pedido de varejo.
|
description: Simula o cancelamento de um pedido de varejo.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -379,3 +379,192 @@ def test_transaction_evidence_is_correlated_by_resource_identifier():
|
|||||||
current = [{"ok": True, "tool_name": "consultar_pedido", "result": {"order_id": "123"}}]
|
current = [{"ok": True, "tool_name": "consultar_pedido", "result": {"order_id": "123"}}]
|
||||||
relevant = runtime.transaction_evidence_for_turn(state, current)
|
relevant = runtime.transaction_evidence_for_turn(state, current)
|
||||||
assert [item["transaction_id"] for item in relevant] == ["tx-123"]
|
assert [item["transaction_id"] for item in relevant] == ["tx-123"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_policy_registry_reads_pre_validation(tmp_path: Path):
|
||||||
|
config = tmp_path / "tool_policies.yaml"
|
||||||
|
config.write_text("""version: 1
|
||||||
|
defaults:
|
||||||
|
operation_type: read_only
|
||||||
|
require_confirmation: false
|
||||||
|
tool_policies:
|
||||||
|
contestar_cobranca:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
requires: [subject, valor]
|
||||||
|
pre_validation:
|
||||||
|
enabled: true
|
||||||
|
tool: validar_contestacao
|
||||||
|
fail_open: false
|
||||||
|
""", encoding="utf-8")
|
||||||
|
policy = ToolPolicyRegistry(str(config)).get("contestar_cobranca")
|
||||||
|
assert policy is not None
|
||||||
|
assert policy.pre_validation.enabled is True
|
||||||
|
assert policy.pre_validation.tool == "validar_contestacao"
|
||||||
|
assert policy.pre_validation.fail_open is False
|
||||||
|
|
||||||
|
|
||||||
|
class _PreValidationRouter(_ContestPolicyRouter):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
from types import SimpleNamespace
|
||||||
|
self.registry = SimpleNamespace(
|
||||||
|
tools={"contestar_cobranca": object(), "validar_contestacao": object()},
|
||||||
|
get_tool=lambda name: SimpleNamespace(
|
||||||
|
selection_keywords=["contestar", "não contratei", "nao contratei"] if name == "contestar_cobranca" else []
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_execution_policy(self, tool_name, arguments=None):
|
||||||
|
if tool_name == "contestar_cobranca":
|
||||||
|
return {
|
||||||
|
"operation_type": "transactional",
|
||||||
|
"require_confirmation": True,
|
||||||
|
"requires": ["subject", "valor"],
|
||||||
|
"policy_source": "test",
|
||||||
|
"pre_validation": {"enabled": True, "tool": "validar_contestacao", "fail_open": False},
|
||||||
|
}
|
||||||
|
return {"operation_type": "internal", "require_confirmation": False, "requires": [], "policy_source": "test", "pre_validation": {"enabled": False}}
|
||||||
|
|
||||||
|
|
||||||
|
class _PreValidationRuntime(AgentRuntimeMixin):
|
||||||
|
def __init__(self, eligible: bool):
|
||||||
|
self.tool_router = _PreValidationRouter()
|
||||||
|
self.calls = []
|
||||||
|
self.eligible = eligible
|
||||||
|
|
||||||
|
async def _call_mcp_tool(self, tool_name, arguments, state):
|
||||||
|
self.calls.append((tool_name, dict(arguments)))
|
||||||
|
if tool_name == "validar_contestacao":
|
||||||
|
payload = ({"eligible": True, "status": "ELIGIBLE"} if self.eligible else {
|
||||||
|
"eligible": False,
|
||||||
|
"status": "OUT_OF_SCOPE",
|
||||||
|
"category": "plano",
|
||||||
|
"error": "item não elegível para contestação",
|
||||||
|
})
|
||||||
|
return {"ok": True, "tool_name": tool_name, "result": payload}
|
||||||
|
return {"ok": True, "tool_name": tool_name, "result": {"status": "OPENED"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pre_validation_rejects_before_confirmation_without_executing_transaction():
|
||||||
|
runtime = _PreValidationRuntime(eligible=False)
|
||||||
|
state = {
|
||||||
|
"user_text": "quero contestar TIM CTRL no valor de R$ 71,99",
|
||||||
|
"sanitized_input": "quero contestar TIM CTRL no valor de R$ 71,99",
|
||||||
|
"mcp_tools": ["contestar_cobranca"],
|
||||||
|
"route": "contestacao_agent",
|
||||||
|
"intent": "contas_contestation",
|
||||||
|
"context": {"tool_arguments": {"subject": "TIM CTRL Redes Sociais 8.0", "valor": 71.99}},
|
||||||
|
}
|
||||||
|
result = await runtime.execute_tools_for_intent(state)
|
||||||
|
assert [name for name, _ in runtime.calls] == ["validar_contestacao"]
|
||||||
|
assert result[-1]["pre_validation"] is True
|
||||||
|
assert result[-1]["transaction_status"] == "OUT_OF_SCOPE"
|
||||||
|
assert state["transaction_status"] == "OUT_OF_SCOPE"
|
||||||
|
assert state.get("pending_tool_call") in ({}, None)
|
||||||
|
assert state["confirmation_required"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pre_validation_passes_then_waits_for_confirmation():
|
||||||
|
runtime = _PreValidationRuntime(eligible=True)
|
||||||
|
state = {
|
||||||
|
"user_text": "quero contestar serviço X no valor de R$ 10,00",
|
||||||
|
"sanitized_input": "quero contestar serviço X no valor de R$ 10,00",
|
||||||
|
"mcp_tools": ["contestar_cobranca"],
|
||||||
|
"route": "contestacao_agent",
|
||||||
|
"intent": "contas_contestation",
|
||||||
|
"context": {"tool_arguments": {"subject": "serviço X", "valor": 10.0}},
|
||||||
|
}
|
||||||
|
result = await runtime.execute_tools_for_intent(state)
|
||||||
|
assert [name for name, _ in runtime.calls] == ["validar_contestacao"]
|
||||||
|
assert result[-1]["awaiting_confirmation"] is True
|
||||||
|
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
|
||||||
|
assert state["pending_tool_call"]["tool_name"] == "contestar_cobranca"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pre_validation_runs_after_last_required_parameter_before_confirmation():
|
||||||
|
runtime = _PreValidationRuntime(eligible=False)
|
||||||
|
state = {
|
||||||
|
"user_text": "R$ 71,99",
|
||||||
|
"sanitized_input": "R$ 71,99",
|
||||||
|
"route": "contestacao_agent",
|
||||||
|
"intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
|
||||||
|
"transaction_status": "COLLECTING_PARAMETERS",
|
||||||
|
"selected_tool_call": {
|
||||||
|
"tool_name": "contestar_cobranca",
|
||||||
|
"arguments": {"subject": "TIM CTRL Redes Sociais 8.0", "motivo": "não contratei"},
|
||||||
|
},
|
||||||
|
"context": {"tool_arguments": {"valor": 71.99}},
|
||||||
|
}
|
||||||
|
result = await runtime.execute_tools_for_intent(state, tools=[])
|
||||||
|
assert [name for name, _ in runtime.calls] == ["validar_contestacao"]
|
||||||
|
assert runtime.calls[0][1]["subject"] == "TIM CTRL Redes Sociais 8.0"
|
||||||
|
assert runtime.calls[0][1]["valor"] == 71.99
|
||||||
|
assert result[-1]["pre_validation"] is True
|
||||||
|
assert state["transaction_status"] == "OUT_OF_SCOPE"
|
||||||
|
assert state["confirmation_required"] is False
|
||||||
|
assert not state.get("pending_tool_call")
|
||||||
|
|
||||||
|
|
||||||
|
def test_transaction_state_patch_exposes_prevalidation_and_terminal_lifecycle():
|
||||||
|
runtime = _PreValidationRuntime(eligible=False)
|
||||||
|
state = {
|
||||||
|
"transaction_status": "OUT_OF_SCOPE",
|
||||||
|
"next_state": None,
|
||||||
|
"selected_tool_call": {},
|
||||||
|
"pending_tool_call": {},
|
||||||
|
"confirmation_required": False,
|
||||||
|
"confirmation_received": False,
|
||||||
|
"transaction_pre_validation": {
|
||||||
|
"tool_name": "contestar_cobranca",
|
||||||
|
"validator_tool": "validar_contestacao",
|
||||||
|
"eligible": False,
|
||||||
|
"status": "OUT_OF_SCOPE",
|
||||||
|
"terminal": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
patch = runtime.transaction_state_patch(state)
|
||||||
|
assert patch["transaction_pre_validation"]["eligible"] is False
|
||||||
|
assert patch["transaction_pre_validation"]["status"] == "OUT_OF_SCOPE"
|
||||||
|
assert patch["next_state"] is None
|
||||||
|
assert patch["transaction_status"] == "OUT_OF_SCOPE"
|
||||||
|
assert patch["confirmation_required"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pre_validation_rejection_clears_collecting_latches_and_is_exposed_in_patch():
|
||||||
|
runtime = _PreValidationRuntime(eligible=False)
|
||||||
|
state = {
|
||||||
|
"user_text": "R$ 71,99",
|
||||||
|
"sanitized_input": "R$ 71,99",
|
||||||
|
"route": "contestacao_agent",
|
||||||
|
"intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
|
||||||
|
"next_state": "COLLECTING_CONTESTACAO_PARAMETERS",
|
||||||
|
"transaction_status": "COLLECTING_PARAMETERS",
|
||||||
|
"selected_tool_call": {
|
||||||
|
"tool_name": "contestar_cobranca",
|
||||||
|
"arguments": {"subject": "TIM CTRL Redes Sociais 8.0", "motivo": "não contratei"},
|
||||||
|
},
|
||||||
|
"pending_tool_call": {},
|
||||||
|
"missing_parameters": ["valor"],
|
||||||
|
"confirmation_required": False,
|
||||||
|
"context": {"tool_arguments": {"valor": 71.99}},
|
||||||
|
}
|
||||||
|
result = await runtime.execute_tools_for_intent(state, tools=[])
|
||||||
|
assert result[-1]["pre_validation"] is True
|
||||||
|
assert state["transaction_status"] == "OUT_OF_SCOPE"
|
||||||
|
assert state["next_state"] is None
|
||||||
|
assert state["active_transaction"] is None
|
||||||
|
assert state["selected_tool_call"] == {}
|
||||||
|
assert state["pending_tool_call"] == {}
|
||||||
|
assert state["missing_parameters"] == []
|
||||||
|
assert state["confirmation_required"] is False
|
||||||
|
assert state["confirmation_received"] is False
|
||||||
|
assert state["transaction_pre_validation"]["eligible"] is False
|
||||||
|
assert state["transaction_pre_validation"]["status"] == "OUT_OF_SCOPE"
|
||||||
|
assert state["transaction_pre_validation"]["terminal"] is True
|
||||||
|
patch = runtime.transaction_state_patch(state)
|
||||||
|
assert patch["transaction_pre_validation"] == state["transaction_pre_validation"]
|
||||||
|
assert patch["next_state"] is None
|
||||||
|
|||||||
Reference in New Issue
Block a user