mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-11-06 03:14:12 +00:00
Compare commits
9 Commits
main
...
57df723a24
| Author | SHA1 | Date | |
|---|---|---|---|
| 57df723a24 | |||
| 6e43cc0158 | |||
| d271031198 | |||
| 34d33350ae | |||
| ad778f6889 | |||
| 762f6fb024 | |||
| f9c66b4792 | |||
| 560e79d21b | |||
| 23d32bbcfc |
64
Documentacao/IMPLEMENTACAO_WORKFLOWS_TRANSACIONAIS.md
Normal file
64
Documentacao/IMPLEMENTACAO_WORKFLOWS_TRANSACIONAIS.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Implementação — workflows transacionais determinísticos
|
||||||
|
|
||||||
|
## Entrega
|
||||||
|
|
||||||
|
Foi adicionada ao `agent_framework_oci` uma capacidade opcional para executar transações multi-etapas como workflows determinísticos compilados em LangGraph.
|
||||||
|
|
||||||
|
### Módulo novo
|
||||||
|
|
||||||
|
`libs/agent_framework/src/agent_framework/workflows/`
|
||||||
|
|
||||||
|
- `models.py`: contratos Pydantic e validação estrutural;
|
||||||
|
- `repository.py`: resolução de versão ativa e leitura de YAML imutável;
|
||||||
|
- `registry.py`: registro desacoplado de actions sync/async;
|
||||||
|
- `runtime.py`: compilação, cache e execução do StateGraph;
|
||||||
|
- `tool_executor.py`: integração com a política da tool;
|
||||||
|
- `__init__.py`: API pública.
|
||||||
|
|
||||||
|
### Política expandida
|
||||||
|
|
||||||
|
`ToolPolicy` agora aceita:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
execution:
|
||||||
|
mode: direct_tool | workflow | agent
|
||||||
|
workflow: nome_do_workflow
|
||||||
|
version: active | 1
|
||||||
|
```
|
||||||
|
|
||||||
|
O default permanece `direct_tool`, preservando compatibilidade.
|
||||||
|
|
||||||
|
### Configuração
|
||||||
|
|
||||||
|
Foram adicionados:
|
||||||
|
|
||||||
|
- `ENABLE_TRANSACTIONAL_WORKFLOWS=false`;
|
||||||
|
- `WORKFLOWS_PATH=./workflows`.
|
||||||
|
|
||||||
|
### Template
|
||||||
|
|
||||||
|
Inclui um exemplo completo de devolução de pedido com:
|
||||||
|
|
||||||
|
- confirmação e campos obrigatórios pela política;
|
||||||
|
- workflow YAML versionado;
|
||||||
|
- actions de domínio no backend;
|
||||||
|
- bifurcação determinística baseada no resultado da validação.
|
||||||
|
|
||||||
|
## Validação realizada
|
||||||
|
|
||||||
|
- `tests/unit/test_tool_policies.py`: 4 testes aprovados;
|
||||||
|
- compilação Python de framework, template e novos testes: aprovada;
|
||||||
|
- o teste funcional novo do LangGraph foi criado, mas não pôde ser executado neste container porque `langgraph` não está instalado no ambiente. A dependência já está declarada no `pyproject.toml` do framework.
|
||||||
|
|
||||||
|
## Escopo e segurança
|
||||||
|
|
||||||
|
Esta entrega cria o motor e a integração de política. Para operações críticas em produção ainda é necessário conectar:
|
||||||
|
|
||||||
|
- execution store persistente;
|
||||||
|
- idempotência de negócio nas actions/APIs;
|
||||||
|
- autorização por escopo;
|
||||||
|
- telemetria IC/NOC específica de workflow;
|
||||||
|
- compensação/Saga quando aplicável;
|
||||||
|
- estratégia corporativa de timeout e retry.
|
||||||
|
|
||||||
|
Esses itens foram explicitamente documentados para evitar a falsa impressão de que retry por si só garante segurança transacional.
|
||||||
987
Documentacao/Implementando_Basic_Auth.md
Normal file
987
Documentacao/Implementando_Basic_Auth.md
Normal file
@@ -0,0 +1,987 @@
|
|||||||
|
# Implementando Basic Auth
|
||||||
|
|
||||||
|
Para validar **todo o circuito com Basic Auth**, você precisa configurar três relações distintas:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cliente de teste
|
||||||
|
└─ Basic Auth A ─► Agent Gateway :8010
|
||||||
|
└─ Basic Auth B ─► Agent Backend :8000
|
||||||
|
└─ Basic Auth C ─► MCP Gateway :8300
|
||||||
|
```
|
||||||
|
|
||||||
|
Há um detalhe importante: no pacote atual, a autenticação Basic já funciona para chamadas **de entrada**, mas os clientes internos ainda não enviam Basic Auth:
|
||||||
|
|
||||||
|
* `Agent Gateway → Agent Backend` não envia credencial;
|
||||||
|
* `Agent Backend → MCP Gateway` envia apenas Bearer Token.
|
||||||
|
|
||||||
|
Portanto, para testar o circuito inteiro com Basic Auth, faça os dois pequenos ajustes de código descritos abaixo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Preparar o ambiente
|
||||||
|
|
||||||
|
Considere que o ZIP foi extraído em:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent_framework_oci_authentication_v2_1
|
||||||
|
```
|
||||||
|
|
||||||
|
Crie um único ambiente virtual para facilitar o teste:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
No Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Instale o framework e as dependências dos três componentes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -U pip
|
||||||
|
|
||||||
|
pip install -e ./libs/agent_framework
|
||||||
|
|
||||||
|
pip install \
|
||||||
|
-r ./Tuning-Performance/Authentication/agent_template_backend_authentication/requirements.txt \
|
||||||
|
-r ./apps/agent_gateway/requirements.txt \
|
||||||
|
-r ./apps/mcp_gateway/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirme a importação:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "from agent_framework.security import install_authentication; print('framework ok')"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Criar três pares de Client ID e Secret
|
||||||
|
|
||||||
|
Use credenciais diferentes para cada trecho. Para teste local:
|
||||||
|
|
||||||
|
| Fluxo | Client ID | Secret de teste |
|
||||||
|
| ----------------------- | -------------------- | --------------------------- |
|
||||||
|
| Cliente → Agent Gateway | `tia-test` | `TiaGateway-Test-2026!` |
|
||||||
|
| Agent Gateway → Backend | `agent-gateway-test` | `GatewayBackend-Test-2026!` |
|
||||||
|
| Backend → MCP Gateway | `agent-backend-test` | `BackendMcp-Test-2026!` |
|
||||||
|
|
||||||
|
Esses valores são apenas para ambiente local. Não os reutilize em produção.
|
||||||
|
|
||||||
|
## Gerar os hashes
|
||||||
|
|
||||||
|
O script está em:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tuning-Performance/Authentication/
|
||||||
|
agent_template_backend_authentication/
|
||||||
|
scripts/generate_secret_hash.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Execute:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python Tuning-Performance/Authentication/agent_template_backend_authentication/scripts/generate_secret_hash.py \
|
||||||
|
--secret 'TiaGateway-Test-2026!'
|
||||||
|
```
|
||||||
|
|
||||||
|
Depois:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python Tuning-Performance/Authentication/agent_template_backend_authentication/scripts/generate_secret_hash.py \
|
||||||
|
--secret 'GatewayBackend-Test-2026!'
|
||||||
|
```
|
||||||
|
|
||||||
|
E:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python Tuning-Performance/Authentication/agent_template_backend_authentication/scripts/generate_secret_hash.py \
|
||||||
|
--secret 'BackendMcp-Test-2026!'
|
||||||
|
```
|
||||||
|
|
||||||
|
Você receberá três valores semelhantes a:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pbkdf2_sha256:310000:<salt>:<digest>
|
||||||
|
```
|
||||||
|
|
||||||
|
Guarde-os temporariamente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HASH_CLIENT_GATEWAY='pbkdf2_sha256:310000:...'
|
||||||
|
HASH_GATEWAY_BACKEND='pbkdf2_sha256:310000:...'
|
||||||
|
HASH_BACKEND_MCP='pbkdf2_sha256:310000:...'
|
||||||
|
```
|
||||||
|
|
||||||
|
O hash muda a cada execução porque o salt é aleatório. Isso é esperado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Configurar o Agent Gateway
|
||||||
|
|
||||||
|
Entre no diretório:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/agent_gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Copie o exemplo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Adicione ao final do `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Entrada: cliente/TIA -> Agent Gateway
|
||||||
|
AGENT_GATEWAY_AUTH_ENABLED=true
|
||||||
|
AGENT_GATEWAY_AUTH_MODE=basic
|
||||||
|
AGENT_GATEWAY_AUTH_BASIC_CLIENT_ID=tia-test
|
||||||
|
AGENT_GATEWAY_AUTH_BASIC_SECRET_HASH=COLE_AQUI_HASH_CLIENT_GATEWAY
|
||||||
|
AGENT_GATEWAY_AUTH_BASIC_REALM=agent-gateway
|
||||||
|
|
||||||
|
AGENT_GATEWAY_AUTH_PUBLIC_PATHS=/health,/docs,/openapi.json,/redoc
|
||||||
|
AGENT_GATEWAY_AUTH_PUBLIC_PREFIXES=
|
||||||
|
|
||||||
|
# Saída: Agent Gateway -> Agent Backend
|
||||||
|
BACKEND_AUTH_MODE=basic
|
||||||
|
BACKEND_AUTH_CLIENT_ID=agent-gateway-test
|
||||||
|
BACKEND_AUTH_SECRET=GatewayBackend-Test-2026!
|
||||||
|
```
|
||||||
|
|
||||||
|
Não coloque aspas no `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
BACKEND_AUTH_SECRET=GatewayBackend-Test-2026!
|
||||||
|
```
|
||||||
|
|
||||||
|
O arquivo de backends já aponta o backend Contas para:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
contas:
|
||||||
|
url: http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Arquivo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/agent_gateway/config/backends.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Para este teste, mantenha apenas o backend `contas` ou force o backend no payload. Caso contrário, pedidos sobre ofertas e suporte podem ser roteados para portas em que nenhum backend está rodando.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Fazer o Agent Gateway enviar Basic Auth ao backend
|
||||||
|
|
||||||
|
Abra:
|
||||||
|
|
||||||
|
```text
|
||||||
|
libs/agent_framework/src/agent_framework/global_supervisor/client.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Substitua a classe `BackendClient` por uma versão que aceite autenticação Basic.
|
||||||
|
|
||||||
|
No início do arquivo, adicione:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
```
|
||||||
|
|
||||||
|
Altere o construtor:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BackendClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
timeout_seconds: float = 120.0,
|
||||||
|
basic_client_id: str | None = None,
|
||||||
|
basic_secret: str | None = None,
|
||||||
|
):
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
self.basic_client_id = basic_client_id
|
||||||
|
self.basic_secret = basic_secret
|
||||||
|
|
||||||
|
def _auth(self) -> httpx.BasicAuth | None:
|
||||||
|
if self.basic_client_id and self.basic_secret:
|
||||||
|
return httpx.BasicAuth(
|
||||||
|
username=self.basic_client_id,
|
||||||
|
password=self.basic_secret,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
No método `call_message`, troque:
|
||||||
|
|
||||||
|
```python
|
||||||
|
resp = await client.post(url, json=payload)
|
||||||
|
```
|
||||||
|
|
||||||
|
por:
|
||||||
|
|
||||||
|
```python
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
auth=self._auth(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
No método `health`, você pode manter `/health` público. Caso queira enviar autenticação também, use:
|
||||||
|
|
||||||
|
```python
|
||||||
|
resp = await client.get(url, auth=self._auth())
|
||||||
|
```
|
||||||
|
|
||||||
|
Agora abra:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/agent_gateway/app/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Adicione:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
```
|
||||||
|
|
||||||
|
Troque:
|
||||||
|
|
||||||
|
```python
|
||||||
|
backend_client = BackendClient(
|
||||||
|
timeout_seconds=settings.BACKEND_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
por:
|
||||||
|
|
||||||
|
```python
|
||||||
|
backend_client = BackendClient(
|
||||||
|
timeout_seconds=settings.BACKEND_TIMEOUT_SECONDS,
|
||||||
|
basic_client_id=os.getenv("BACKEND_AUTH_CLIENT_ID"),
|
||||||
|
basic_secret=os.getenv("BACKEND_AUTH_SECRET"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Isso implementa:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Agent Gateway → Agent Backend
|
||||||
|
Authorization: Basic base64(agent-gateway-test:GatewayBackend-Test-2026!)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Configurar o Agent Backend autenticado
|
||||||
|
|
||||||
|
Entre no diretório:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Tuning-Performance/Authentication/agent_template_backend_authentication
|
||||||
|
```
|
||||||
|
|
||||||
|
Copie o exemplo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajuste a seção de autenticação:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Entrada: Agent Gateway -> Agent Backend
|
||||||
|
AGENT_AUTH_ENABLED=true
|
||||||
|
AGENT_AUTH_MODE=basic
|
||||||
|
AGENT_AUTH_BASIC_CLIENT_ID=agent-gateway-test
|
||||||
|
AGENT_AUTH_BASIC_SECRET_HASH=COLE_AQUI_HASH_GATEWAY_BACKEND
|
||||||
|
AGENT_AUTH_BASIC_REALM=agent-contas
|
||||||
|
|
||||||
|
AGENT_AUTH_PUBLIC_PATHS=/health,/docs,/openapi.json,/redoc
|
||||||
|
AGENT_AUTH_PUBLIC_PREFIXES=
|
||||||
|
```
|
||||||
|
|
||||||
|
Para usar o MCP Gateway:
|
||||||
|
|
||||||
|
```env
|
||||||
|
MCP_GATEWAY_ENABLED=true
|
||||||
|
MCP_GATEWAY_URL=http://localhost:8300
|
||||||
|
MCP_GATEWAY_TIMEOUT_SECONDS=60
|
||||||
|
|
||||||
|
# Saída: Agent Backend -> MCP Gateway
|
||||||
|
MCP_GATEWAY_AUTH_MODE=basic
|
||||||
|
MCP_GATEWAY_BASIC_CLIENT_ID=agent-backend-test
|
||||||
|
MCP_GATEWAY_BASIC_SECRET=BackendMcp-Test-2026!
|
||||||
|
```
|
||||||
|
|
||||||
|
Para evitar dependências externas durante o primeiro teste, configure também:
|
||||||
|
|
||||||
|
```env
|
||||||
|
LLM_PROVIDER=mock
|
||||||
|
ENABLE_LANGFUSE=false
|
||||||
|
ENABLE_ANALYTICS=false
|
||||||
|
|
||||||
|
SESSION_REPOSITORY_PROVIDER=memory
|
||||||
|
MEMORY_REPOSITORY_PROVIDER=memory
|
||||||
|
CHECKPOINT_REPOSITORY_PROVIDER=memory
|
||||||
|
CACHE_PROVIDER=memory
|
||||||
|
USAGE_REPOSITORY_PROVIDER=memory
|
||||||
|
```
|
||||||
|
|
||||||
|
Os nomes exatos de alguns providers podem depender do arquivo de configuração atual do framework. Caso o `.env.example` já contenha valores locais ou mock, preserve-os.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Fazer o Backend enviar Basic Auth ao MCP Gateway
|
||||||
|
|
||||||
|
Abra:
|
||||||
|
|
||||||
|
```text
|
||||||
|
libs/agent_framework/src/agent_framework/gateways/mcp_gateway_client.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Substitua a implementação por:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class MCPGatewayClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
token: str | None = None,
|
||||||
|
timeout_seconds: int = 60,
|
||||||
|
auth_mode: str | None = None,
|
||||||
|
basic_client_id: str | None = None,
|
||||||
|
basic_secret: str | None = None,
|
||||||
|
):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.token = token
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
self.auth_mode = (auth_mode or "").strip().lower()
|
||||||
|
self.basic_client_id = basic_client_id
|
||||||
|
self.basic_secret = basic_secret
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
if (
|
||||||
|
self.auth_mode == "basic"
|
||||||
|
and self.basic_client_id
|
||||||
|
and self.basic_secret
|
||||||
|
):
|
||||||
|
raw = f"{self.basic_client_id}:{self.basic_secret}".encode("utf-8")
|
||||||
|
encoded = base64.b64encode(raw).decode("ascii")
|
||||||
|
return {"Authorization": f"Basic {encoded}"}
|
||||||
|
|
||||||
|
if self.token:
|
||||||
|
return {"Authorization": f"Bearer {self.token}"}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def list_tools(self) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=self.timeout_seconds
|
||||||
|
) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{self.base_url}/v1/tools",
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def invoke_tool(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
channel: str | None,
|
||||||
|
tool_name: str,
|
||||||
|
arguments: dict[str, Any] | None = None,
|
||||||
|
business_context: dict[str, Any] | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"channel": channel,
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"arguments": arguments or {},
|
||||||
|
"business_context": business_context or {},
|
||||||
|
"metadata": metadata or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=self.timeout_seconds
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/v1/tools/{tool_name}/invoke",
|
||||||
|
json=payload,
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
```
|
||||||
|
|
||||||
|
Agora abra:
|
||||||
|
|
||||||
|
```text
|
||||||
|
libs/agent_framework/src/agent_framework/mcp/tool_router.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Localize:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MCPGatewayClient(
|
||||||
|
base_url=getattr(
|
||||||
|
settings,
|
||||||
|
"MCP_GATEWAY_URL",
|
||||||
|
"http://localhost:8300",
|
||||||
|
),
|
||||||
|
token=getattr(settings, "MCP_GATEWAY_TOKEN", None),
|
||||||
|
timeout_seconds=getattr(
|
||||||
|
settings,
|
||||||
|
"MCP_GATEWAY_TIMEOUT_SECONDS",
|
||||||
|
settings.MCP_TOOL_TIMEOUT_SECONDS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Altere para:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MCPGatewayClient(
|
||||||
|
base_url=getattr(
|
||||||
|
settings,
|
||||||
|
"MCP_GATEWAY_URL",
|
||||||
|
"http://localhost:8300",
|
||||||
|
),
|
||||||
|
token=getattr(settings, "MCP_GATEWAY_TOKEN", None),
|
||||||
|
timeout_seconds=getattr(
|
||||||
|
settings,
|
||||||
|
"MCP_GATEWAY_TIMEOUT_SECONDS",
|
||||||
|
settings.MCP_TOOL_TIMEOUT_SECONDS,
|
||||||
|
),
|
||||||
|
auth_mode=getattr(
|
||||||
|
settings,
|
||||||
|
"MCP_GATEWAY_AUTH_MODE",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
basic_client_id=getattr(
|
||||||
|
settings,
|
||||||
|
"MCP_GATEWAY_BASIC_CLIENT_ID",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
basic_secret=getattr(
|
||||||
|
settings,
|
||||||
|
"MCP_GATEWAY_BASIC_SECRET",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Adicione estes campos em:
|
||||||
|
|
||||||
|
```text
|
||||||
|
libs/agent_framework/src/agent_framework/config/settings.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Próximo das configurações existentes de MCP Gateway:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MCP_GATEWAY_AUTH_MODE: str | None = None
|
||||||
|
MCP_GATEWAY_BASIC_CLIENT_ID: str | None = None
|
||||||
|
MCP_GATEWAY_BASIC_SECRET: str | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
Há também uma factory local em:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tuning-Performance/Authentication/
|
||||||
|
agent_template_backend_authentication/
|
||||||
|
app/mcp_gateway_client_factory.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajuste para:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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")
|
||||||
|
),
|
||||||
|
auth_mode=os.getenv("MCP_GATEWAY_AUTH_MODE"),
|
||||||
|
basic_client_id=os.getenv(
|
||||||
|
"MCP_GATEWAY_BASIC_CLIENT_ID"
|
||||||
|
),
|
||||||
|
basic_secret=os.getenv(
|
||||||
|
"MCP_GATEWAY_BASIC_SECRET"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Configurar o MCP Gateway
|
||||||
|
|
||||||
|
Entre no diretório:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/mcp_gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Crie `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Adicione:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Entrada: Agent Backend -> MCP Gateway
|
||||||
|
MCP_GATEWAY_AUTH_ENABLED=true
|
||||||
|
MCP_GATEWAY_AUTH_MODE=basic
|
||||||
|
MCP_GATEWAY_AUTH_BASIC_CLIENT_ID=agent-backend-test
|
||||||
|
MCP_GATEWAY_AUTH_BASIC_SECRET_HASH=COLE_AQUI_HASH_BACKEND_MCP
|
||||||
|
MCP_GATEWAY_AUTH_BASIC_REALM=mcp-gateway
|
||||||
|
|
||||||
|
MCP_GATEWAY_AUTH_PUBLIC_PATHS=/health,/ready,/docs,/openapi.json,/redoc
|
||||||
|
MCP_GATEWAY_AUTH_PUBLIC_PREFIXES=
|
||||||
|
|
||||||
|
MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Desabilitar o mecanismo Bearer legado
|
||||||
|
|
||||||
|
O MCP Gateway ainda possui um segundo mecanismo antigo, configurado dentro de:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/mcp_gateway/config/mcp_gateway.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Localize a seção:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Altere para:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
enabled: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Isso é necessário porque o novo middleware já faz a autenticação Basic. Caso o `auth_check()` legado continue habilitado, a requisição passará pelo Basic e depois será rejeitada por não possuir Bearer Token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Subir os componentes
|
||||||
|
|
||||||
|
Use quatro terminais.
|
||||||
|
|
||||||
|
## Terminal 1 — MCP Servers
|
||||||
|
|
||||||
|
O MCP Gateway precisa ter pelo menos um servidor MCP disponível para demonstrar uma chamada real.
|
||||||
|
|
||||||
|
Na raiz do projeto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
Suba o servidor telecom:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn mcp.servers.telecom_mcp_server.main:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port 8100 \
|
||||||
|
--reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Em outro terminal, caso queira também o retail:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn mcp.servers.retail_mcp_server.main:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port 8200 \
|
||||||
|
--reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Confira as URLs configuradas em:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/mcp_gateway/config/mcp_gateway.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Para execução local, devem apontar para:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://localhost:8100
|
||||||
|
```
|
||||||
|
|
||||||
|
e:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://localhost:8200
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminal 2 — MCP Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/mcp_gateway
|
||||||
|
source ../../.venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
Suba usando `--env-file`. Isso é importante porque o middleware lê variáveis com `os.getenv()`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port 8300 \
|
||||||
|
--reload \
|
||||||
|
--env-file .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Teste a saúde pública:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8300/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Teste um endpoint protegido sem credencial:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i http://localhost:8300/v1/tools
|
||||||
|
```
|
||||||
|
|
||||||
|
Esperado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/1.1 401 Unauthorized
|
||||||
|
```
|
||||||
|
|
||||||
|
Teste com Basic Auth:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i \
|
||||||
|
-u 'agent-backend-test:BackendMcp-Test-2026!' \
|
||||||
|
http://localhost:8300/v1/tools
|
||||||
|
```
|
||||||
|
|
||||||
|
Esperado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminal 3 — Agent Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Tuning-Performance/Authentication/agent_template_backend_authentication
|
||||||
|
source ../../../.venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
Suba:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port 8000 \
|
||||||
|
--reload \
|
||||||
|
--env-file .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Teste saúde:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Teste endpoint protegido sem credencial:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i http://localhost:8000/agents
|
||||||
|
```
|
||||||
|
|
||||||
|
Esperado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/1.1 401 Unauthorized
|
||||||
|
```
|
||||||
|
|
||||||
|
Teste com a credencial usada pelo Agent Gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i \
|
||||||
|
-u 'agent-gateway-test:GatewayBackend-Test-2026!' \
|
||||||
|
http://localhost:8000/agents
|
||||||
|
```
|
||||||
|
|
||||||
|
Esperado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Teste mensagem diretamente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/gateway/message \
|
||||||
|
-u 'agent-gateway-test:GatewayBackend-Test-2026!' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"channel": "web",
|
||||||
|
"agent_id": "telecom_contas",
|
||||||
|
"tenant_id": "default",
|
||||||
|
"payload": {
|
||||||
|
"text": "Quero consultar minha fatura",
|
||||||
|
"session_id": "teste-backend-001",
|
||||||
|
"user_id": "user-001",
|
||||||
|
"customer_id": "12345",
|
||||||
|
"message_id": "msg-001"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminal 4 — Agent Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/agent_gateway
|
||||||
|
source ../../.venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
Suba:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port 8010 \
|
||||||
|
--reload \
|
||||||
|
--env-file .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Teste saúde:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8010/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Teste endpoint protegido sem credencial:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i http://localhost:8010/backends
|
||||||
|
```
|
||||||
|
|
||||||
|
Esperado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/1.1 401 Unauthorized
|
||||||
|
```
|
||||||
|
|
||||||
|
Teste com a credencial externa:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i \
|
||||||
|
-u 'tia-test:TiaGateway-Test-2026!' \
|
||||||
|
http://localhost:8010/backends
|
||||||
|
```
|
||||||
|
|
||||||
|
Esperado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. Validar o circuito completo
|
||||||
|
|
||||||
|
Force o backend `contas` para evitar que o roteador selecione um backend não iniciado:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8010/gateway/message \
|
||||||
|
-u 'tia-test:TiaGateway-Test-2026!' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"channel": "web",
|
||||||
|
"backend_id": "contas",
|
||||||
|
"tenant_id": "default",
|
||||||
|
"agent_id": "telecom_contas",
|
||||||
|
"session_id": "circuito-basic-001",
|
||||||
|
"payload": {
|
||||||
|
"text": "Quero consultar minha fatura",
|
||||||
|
"session_id": "circuito-basic-001",
|
||||||
|
"user_id": "user-001",
|
||||||
|
"customer_id": "12345",
|
||||||
|
"message_id": "msg-circuito-001"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
O circuito esperado é:
|
||||||
|
|
||||||
|
```text
|
||||||
|
curl
|
||||||
|
│ Basic tia-test
|
||||||
|
▼
|
||||||
|
Agent Gateway :8010
|
||||||
|
│ Basic agent-gateway-test
|
||||||
|
▼
|
||||||
|
Agent Backend :8000
|
||||||
|
│ Basic agent-backend-test
|
||||||
|
▼
|
||||||
|
MCP Gateway :8300
|
||||||
|
▼
|
||||||
|
MCP Server :8100 ou :8200
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Como comprovar cada autenticação
|
||||||
|
|
||||||
|
Faça testes negativos em cada trecho.
|
||||||
|
|
||||||
|
## Secret externo incorreto
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i \
|
||||||
|
-u 'tia-test:senha-errada' \
|
||||||
|
http://localhost:8010/backends
|
||||||
|
```
|
||||||
|
|
||||||
|
Resultado esperado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
401 Unauthorized
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secret do gateway para backend incorreto
|
||||||
|
|
||||||
|
Altere temporariamente no `apps/agent_gateway/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
BACKEND_AUTH_SECRET=senha-errada
|
||||||
|
```
|
||||||
|
|
||||||
|
Reinicie o Agent Gateway e envie uma mensagem.
|
||||||
|
|
||||||
|
O gateway deverá retornar erro de backend, normalmente:
|
||||||
|
|
||||||
|
```text
|
||||||
|
502 Bad Gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
O erro interno será originado por um:
|
||||||
|
|
||||||
|
```text
|
||||||
|
401 Unauthorized
|
||||||
|
```
|
||||||
|
|
||||||
|
do Agent Backend.
|
||||||
|
|
||||||
|
## Secret do backend para MCP incorreto
|
||||||
|
|
||||||
|
Altere temporariamente:
|
||||||
|
|
||||||
|
```env
|
||||||
|
MCP_GATEWAY_BASIC_SECRET=senha-errada
|
||||||
|
```
|
||||||
|
|
||||||
|
Reinicie o backend e execute uma frase que acione uma ferramenta MCP.
|
||||||
|
|
||||||
|
O backend deverá registrar falha na chamada ao MCP Gateway com:
|
||||||
|
|
||||||
|
```text
|
||||||
|
401 Unauthorized
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Verificação rápida de portas
|
||||||
|
|
||||||
|
No Linux ou WSL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ss -lntp | grep -E ':8000|:8010|:8100|:8200|:8300'
|
||||||
|
```
|
||||||
|
|
||||||
|
No Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-NetTCPConnection -State Listen |
|
||||||
|
Where-Object LocalPort -in 8000,8010,8100,8200,8300 |
|
||||||
|
Sort-Object LocalPort
|
||||||
|
```
|
||||||
|
|
||||||
|
Você deverá ver:
|
||||||
|
|
||||||
|
```text
|
||||||
|
8000 Agent Backend
|
||||||
|
8010 Agent Gateway
|
||||||
|
8100 Telecom MCP Server
|
||||||
|
8200 Retail MCP Server
|
||||||
|
8300 MCP Gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
## Observação importante
|
||||||
|
|
||||||
|
O segredo original precisa existir no componente cliente:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TIA ou curl:
|
||||||
|
TiaGateway-Test-2026!
|
||||||
|
|
||||||
|
Agent Gateway:
|
||||||
|
GatewayBackend-Test-2026!
|
||||||
|
|
||||||
|
Agent Backend:
|
||||||
|
BackendMcp-Test-2026!
|
||||||
|
```
|
||||||
|
|
||||||
|
Os componentes servidores armazenam apenas os hashes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Agent Gateway:
|
||||||
|
hash de TiaGateway-Test-2026!
|
||||||
|
|
||||||
|
Agent Backend:
|
||||||
|
hash de GatewayBackend-Test-2026!
|
||||||
|
|
||||||
|
MCP Gateway:
|
||||||
|
hash de BackendMcp-Test-2026!
|
||||||
|
```
|
||||||
|
|
||||||
|
Em produção, os segredos originais e hashes devem vir de Vault ou Kubernetes Secret, não de arquivos `.env`.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Release Notes — Generic Deterministic Intent Shift v15
|
||||||
|
|
||||||
|
## Problema corrigido
|
||||||
|
|
||||||
|
A Route Stickiness podia preservar a intent anterior quando a nova mensagem correspondia a uma intent configurada no `routing.yaml`, mas a frase do usuário omitia conectores curtos presentes na keyword configurada.
|
||||||
|
|
||||||
|
Exemplo real de configuração:
|
||||||
|
|
||||||
|
- keyword: `qual é o meu plano`
|
||||||
|
- mensagem: `qual o meu plano`
|
||||||
|
|
||||||
|
A classificação determinística não reconhecia a nova intent e a continuity acabava mantendo a intent anterior.
|
||||||
|
|
||||||
|
## Correção
|
||||||
|
|
||||||
|
O `EnterpriseRouter` continua usando, nesta ordem:
|
||||||
|
|
||||||
|
1. match exato;
|
||||||
|
2. sequência completa de tokens com palavras inseridas (`ordered_tokens`);
|
||||||
|
3. sequência de tokens informativos tolerando a omissão de conectores curtos presentes na keyword (`ordered_content_tokens`).
|
||||||
|
|
||||||
|
A terceira estratégia ignora, somente no lado da keyword, tokens de até dois caracteres e exige pelo menos dois tokens informativos. Não há nomes de intents, agentes, domínios ou verbos de negócio hardcoded.
|
||||||
|
|
||||||
|
Assim, a solução é dirigida integralmente pelas intents carregadas do `routing.yaml` da aplicação.
|
||||||
|
|
||||||
|
## Precedência sobre Route Stickiness
|
||||||
|
|
||||||
|
Quando o candidato determinístico encontrado é diferente da intent ativa, ele preempta a stickiness e retorna:
|
||||||
|
|
||||||
|
- `route_stickiness_preempted: true`
|
||||||
|
- `previous_agent`
|
||||||
|
- `previous_intent`
|
||||||
|
- `keyword_match_strategy`
|
||||||
|
|
||||||
|
A continuity LLM não é chamada nesse caminho.
|
||||||
|
|
||||||
|
## Casos cobertos
|
||||||
|
|
||||||
|
### Mesmo agente, nova intent
|
||||||
|
|
||||||
|
`retail_order_tracking` -> `quero cancelar meu pedido` -> `retail_order_cancel`
|
||||||
|
|
||||||
|
### Mesmo agente, tools diferentes
|
||||||
|
|
||||||
|
`contas_invoice_query` -> `qual o meu plano` -> `contas_plan_information`
|
||||||
|
|
||||||
|
Mesmo que ambas as intents usem `faturas_agent`, as tools mudam de `consultar_faturas` para `consultar_plano`.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Route Stickiness — deterministic intent shift (v14)
|
||||||
|
|
||||||
|
## Problema corrigido
|
||||||
|
|
||||||
|
Uma keyword multi-token como `cancelar pedido` não era reconhecida em frases como `quero cancelar meu pedido`. O match legado usava substring literal; assim, a keyword genérica `pedido` podia manter `retail_order_tracking` e a continuidade reutilizava a intent anterior.
|
||||||
|
|
||||||
|
## Correção
|
||||||
|
|
||||||
|
O `EnterpriseRouter` agora possui um segundo estágio determinístico para keywords multi-token: ordered-token matching com até três tokens intermediários. Não há chamada adicional de LLM.
|
||||||
|
|
||||||
|
Exemplos reconhecidos pela keyword configurada `cancelar pedido`:
|
||||||
|
|
||||||
|
- `quero cancelar meu pedido`
|
||||||
|
- `quero cancelar o meu pedido`
|
||||||
|
- `pode cancelar esse pedido`
|
||||||
|
- `gostaria de cancelar meu pedido`
|
||||||
|
|
||||||
|
Quando esse match identifica uma intent diferente da ativa, ele preempta a route stickiness antes do LLM de continuidade.
|
||||||
|
|
||||||
|
Metadados de auditoria esperados:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "keyword",
|
||||||
|
"intent": "retail_order_cancel",
|
||||||
|
"metadata": {
|
||||||
|
"matched_keyword": "cancelar pedido",
|
||||||
|
"keyword_match_strategy": "ordered_tokens",
|
||||||
|
"route_stickiness_preempted": true,
|
||||||
|
"previous_intent": "retail_order_tracking"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custo de LLM
|
||||||
|
|
||||||
|
Para mudança explícita reconhecida deterministicamente, o classificador LLM de continuity não é chamado. Para mensagens sem sinal explícito, a Route Stickiness continua com o comportamento configurado.
|
||||||
|
|
||||||
|
## Regressão
|
||||||
|
|
||||||
|
Testes cobrem mudança `retail_order_tracking -> retail_order_cancel` no mesmo `orders_agent`, inclusive com palavras intermediárias. A suíte relacionada passou com 18 testes.
|
||||||
@@ -3,5 +3,8 @@ from __future__ import annotations
|
|||||||
# Compatibilidade local do template/backend.
|
# Compatibilidade local do template/backend.
|
||||||
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
|
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
|
||||||
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
|
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
|
||||||
|
from app.presentation import register_tool_renderers
|
||||||
|
|
||||||
|
register_tool_renderers()
|
||||||
|
|
||||||
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]
|
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .tool_renderers import register_tool_renderers
|
||||||
|
|
||||||
|
__all__ = ["register_tool_renderers"]
|
||||||
Binary file not shown.
@@ -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)
|
||||||
@@ -60,6 +60,18 @@ 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
|
||||||
|
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:
|
solicitar_troca:
|
||||||
map:
|
map:
|
||||||
session_key: session_id
|
session_key: session_id
|
||||||
|
|||||||
@@ -79,6 +79,25 @@ intents:
|
|||||||
- Quero saber sobre meu pacote de internet.
|
- Quero saber sobre meu pacote de internet.
|
||||||
- Tenho roaming internacional?
|
- 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
|
- name: retail_order_tracking
|
||||||
domain: retail
|
domain: retail
|
||||||
agent: orders_agent
|
agent: orders_agent
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ defaults:
|
|||||||
require_confirmation: false
|
require_confirmation: false
|
||||||
|
|
||||||
tool_policies:
|
tool_policies:
|
||||||
|
cancelar_pedido:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
requires: [order_id]
|
||||||
|
|
||||||
|
|
||||||
solicitar_troca:
|
solicitar_troca:
|
||||||
operation_type: transactional
|
operation_type: transactional
|
||||||
require_confirmation: true
|
require_confirmation: true
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ tools:
|
|||||||
- fatura
|
- fatura
|
||||||
- conta
|
- conta
|
||||||
- boleto
|
- boleto
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.invoice
|
||||||
consultar_pagamentos:
|
consultar_pagamentos:
|
||||||
description: Consulta histórico de pagamentos do cliente.
|
description: Consulta histórico de pagamentos do cliente.
|
||||||
mcp_server: telecom
|
mcp_server: telecom
|
||||||
@@ -28,6 +31,9 @@ tools:
|
|||||||
asset_id: string
|
asset_id: string
|
||||||
selection_keywords:
|
selection_keywords:
|
||||||
- plano
|
- plano
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.plan
|
||||||
listar_servicos:
|
listar_servicos:
|
||||||
description: Lista serviços ativos e adicionais VAS.
|
description: Lista serviços ativos e adicionais VAS.
|
||||||
mcp_server: telecom
|
mcp_server: telecom
|
||||||
@@ -49,6 +55,9 @@ tools:
|
|||||||
- consultar pedido
|
- consultar pedido
|
||||||
- status do pedido
|
- status do pedido
|
||||||
- pedido
|
- pedido
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.order
|
||||||
consultar_entrega:
|
consultar_entrega:
|
||||||
description: Consulta entrega e rastreamento do pedido.
|
description: Consulta entrega e rastreamento do pedido.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
@@ -61,6 +70,26 @@ tools:
|
|||||||
- rastreamento
|
- rastreamento
|
||||||
- transportadora
|
- transportadora
|
||||||
- previsão
|
- previsão
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.delivery
|
||||||
|
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:
|
solicitar_troca:
|
||||||
description: Simula abertura de solicitação de troca.
|
description: Simula abertura de solicitação de troca.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .tool_renderers import register_tool_renderers
|
||||||
|
|
||||||
|
__all__ = ["register_tool_renderers"]
|
||||||
Binary file not shown.
@@ -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)
|
||||||
@@ -60,6 +60,18 @@ 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
|
||||||
|
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:
|
solicitar_troca:
|
||||||
map:
|
map:
|
||||||
session_key: session_id
|
session_key: session_id
|
||||||
|
|||||||
@@ -79,6 +79,25 @@ intents:
|
|||||||
- Quero saber sobre meu pacote de internet.
|
- Quero saber sobre meu pacote de internet.
|
||||||
- Tenho roaming internacional?
|
- 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
|
- name: retail_order_tracking
|
||||||
domain: retail
|
domain: retail
|
||||||
agent: orders_agent
|
agent: orders_agent
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ defaults:
|
|||||||
require_confirmation: false
|
require_confirmation: false
|
||||||
|
|
||||||
tool_policies:
|
tool_policies:
|
||||||
|
cancelar_pedido:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
requires: [order_id]
|
||||||
|
|
||||||
|
|
||||||
solicitar_troca:
|
solicitar_troca:
|
||||||
operation_type: transactional
|
operation_type: transactional
|
||||||
require_confirmation: true
|
require_confirmation: true
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ tools:
|
|||||||
- fatura
|
- fatura
|
||||||
- conta
|
- conta
|
||||||
- boleto
|
- boleto
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.invoice
|
||||||
consultar_pagamentos:
|
consultar_pagamentos:
|
||||||
description: Consulta histórico de pagamentos do cliente.
|
description: Consulta histórico de pagamentos do cliente.
|
||||||
mcp_server: telecom
|
mcp_server: telecom
|
||||||
@@ -28,6 +31,9 @@ tools:
|
|||||||
asset_id: string
|
asset_id: string
|
||||||
selection_keywords:
|
selection_keywords:
|
||||||
- plano
|
- plano
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.plan
|
||||||
listar_servicos:
|
listar_servicos:
|
||||||
description: Lista serviços ativos e adicionais VAS.
|
description: Lista serviços ativos e adicionais VAS.
|
||||||
mcp_server: telecom
|
mcp_server: telecom
|
||||||
@@ -49,6 +55,9 @@ tools:
|
|||||||
- consultar pedido
|
- consultar pedido
|
||||||
- status do pedido
|
- status do pedido
|
||||||
- pedido
|
- pedido
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.order
|
||||||
consultar_entrega:
|
consultar_entrega:
|
||||||
description: Consulta entrega e rastreamento do pedido.
|
description: Consulta entrega e rastreamento do pedido.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
@@ -61,6 +70,26 @@ tools:
|
|||||||
- rastreamento
|
- rastreamento
|
||||||
- transportadora
|
- transportadora
|
||||||
- previsão
|
- previsão
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.delivery
|
||||||
|
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:
|
solicitar_troca:
|
||||||
description: Simula abertura de solicitação de troca.
|
description: Simula abertura de solicitação de troca.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Domain Requested LLM Composition
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Permitir que uma tool/workflow de domínio informe que o resultado operacional não deve ser devolvido diretamente ao usuário e precisa ser redigido pelo LLM oficial do agente, sem criar um gateway LLM dentro do domínio.
|
||||||
|
|
||||||
|
## Contrato
|
||||||
|
|
||||||
|
A tool pode devolver, em qualquer nível do resultado:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"requires_llm_composition": true,
|
||||||
|
"response_instruction": "Explique ao cliente a forma de devolução usando apenas os dados do workflow."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Também é aceito `response_instructions` como lista.
|
||||||
|
|
||||||
|
O `AgentRuntimeMixin` percorre recursivamente o resultado MCP. Quando a flag está ativa, `build_direct_mcp_answer()` retorna `None`; a resposta segue pelo LLM configurado no framework e recebe as evidências MCP no contexto normal do agente.
|
||||||
|
|
||||||
|
## Por que isso existe
|
||||||
|
|
||||||
|
Algumas operações, como pró-rata, possuem resultado determinístico e efeitos já concluídos, mas precisam de linguagem natural adequada ao canal. Antes, o domínio Contas possuía um gateway LLM próprio. Agora o domínio só declara a necessidade e a instrução; execução, credenciais, profiles, tracing e custos continuam no `agent_framework_oci`.
|
||||||
|
|
||||||
|
## Regras
|
||||||
|
|
||||||
|
- Não usar para decidir se uma transação deve ocorrer.
|
||||||
|
- Não usar para inventar valores ou protocolos.
|
||||||
|
- A instrução deve exigir que o LLM use somente as evidências retornadas pela tool/workflow.
|
||||||
|
- Pode coexistir com `requires_rag`; nesse caso o framework também executa RAG antes da composição.
|
||||||
51
Tuning-Performance/Domain_Requested_RAG/README.md
Normal file
51
Tuning-Performance/Domain_Requested_RAG/README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Domain-Requested RAG
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Permitir que uma tool/workflow de domínio declare que o resultado MCP **não é suficiente** para produzir a resposta final e que o agente deve recuperar conhecimento usando o `RagService` oficial do `agent_framework_oci`.
|
||||||
|
|
||||||
|
O domínio não instancia banco vetorial, embeddings, LLM ou cliente RAG. Ele apenas devolve no resultado:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"requires_rag": true,
|
||||||
|
"rag_queries": [
|
||||||
|
"Como cancelar o serviço Paramount+ no parceiro? Procedimento oficial de cancelamento."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fluxo
|
||||||
|
|
||||||
|
```text
|
||||||
|
Workflow/tool de domínio
|
||||||
|
|
|
||||||
|
| requires_rag + rag_queries
|
||||||
|
v
|
||||||
|
AgentRuntimeMixin
|
||||||
|
|
|
||||||
|
+-- impede direct MCP answer
|
||||||
|
+-- ignora SKIP_RAG_WHEN_MCP_SUFFICIENT para este resultado
|
||||||
|
+-- usa rag_query/rag_queries como query override
|
||||||
|
v
|
||||||
|
RagService
|
||||||
|
v
|
||||||
|
Vector/Graph store configurado no framework
|
||||||
|
v
|
||||||
|
LLM do agente com MCP evidence + RAG evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
## Regras
|
||||||
|
|
||||||
|
1. `requires_rag=false` ou ausente mantém o comportamento padrão.
|
||||||
|
2. `SKIP_RAG_WHEN_MCP_SUFFICIENT=true` continua válido para tools normais.
|
||||||
|
3. Quando `requires_rag=true`, o framework não usa `build_direct_mcp_answer()`.
|
||||||
|
4. `rag_query` aceita uma query; `rag_queries` aceita várias queries, preservadas e deduplicadas em ordem.
|
||||||
|
5. O domínio nunca executa `RagService` diretamente.
|
||||||
|
6. Guardrails de retrieval (`RAGSEC`, `RET_REL` etc.) continuam executando normalmente sobre o contexto recuperado.
|
||||||
|
|
||||||
|
## Exemplo Contas
|
||||||
|
|
||||||
|
No fluxo VAS Estratégico, após o cliente rejeitar a explicação e pedir cancelamento, a action devolve queries específicas por parceiro. O `VasAgent` usa o `RagService` do framework para recuperar o procedimento oficial e compor a resposta.
|
||||||
|
|
||||||
|
Isso substitui o comportamento legado em que o backend Contas possuía uma busca RAG própria dentro de `vas_strategic()`.
|
||||||
@@ -3,5 +3,8 @@ from __future__ import annotations
|
|||||||
# Compatibilidade local do template/backend.
|
# Compatibilidade local do template/backend.
|
||||||
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
|
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
|
||||||
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
|
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
|
||||||
|
from app.presentation import register_tool_renderers
|
||||||
|
|
||||||
|
register_tool_renderers()
|
||||||
|
|
||||||
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]
|
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .tool_renderers import register_tool_renderers
|
||||||
|
|
||||||
|
__all__ = ["register_tool_renderers"]
|
||||||
Binary file not shown.
@@ -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)
|
||||||
@@ -60,6 +60,18 @@ 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
|
||||||
|
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:
|
solicitar_troca:
|
||||||
map:
|
map:
|
||||||
session_key: session_id
|
session_key: session_id
|
||||||
|
|||||||
@@ -79,6 +79,25 @@ intents:
|
|||||||
- Quero saber sobre meu pacote de internet.
|
- Quero saber sobre meu pacote de internet.
|
||||||
- Tenho roaming internacional?
|
- 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
|
- name: retail_order_tracking
|
||||||
domain: retail
|
domain: retail
|
||||||
agent: orders_agent
|
agent: orders_agent
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ defaults:
|
|||||||
require_confirmation: false
|
require_confirmation: false
|
||||||
|
|
||||||
tool_policies:
|
tool_policies:
|
||||||
|
cancelar_pedido:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
requires: [order_id]
|
||||||
|
|
||||||
|
|
||||||
solicitar_troca:
|
solicitar_troca:
|
||||||
operation_type: transactional
|
operation_type: transactional
|
||||||
require_confirmation: true
|
require_confirmation: true
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ tools:
|
|||||||
- fatura
|
- fatura
|
||||||
- conta
|
- conta
|
||||||
- boleto
|
- boleto
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.invoice
|
||||||
consultar_pagamentos:
|
consultar_pagamentos:
|
||||||
description: Consulta histórico de pagamentos do cliente.
|
description: Consulta histórico de pagamentos do cliente.
|
||||||
mcp_server: telecom
|
mcp_server: telecom
|
||||||
@@ -28,6 +31,9 @@ tools:
|
|||||||
asset_id: string
|
asset_id: string
|
||||||
selection_keywords:
|
selection_keywords:
|
||||||
- plano
|
- plano
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.plan
|
||||||
listar_servicos:
|
listar_servicos:
|
||||||
description: Lista serviços ativos e adicionais VAS.
|
description: Lista serviços ativos e adicionais VAS.
|
||||||
mcp_server: telecom
|
mcp_server: telecom
|
||||||
@@ -49,6 +55,9 @@ tools:
|
|||||||
- consultar pedido
|
- consultar pedido
|
||||||
- status do pedido
|
- status do pedido
|
||||||
- pedido
|
- pedido
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.order
|
||||||
consultar_entrega:
|
consultar_entrega:
|
||||||
description: Consulta entrega e rastreamento do pedido.
|
description: Consulta entrega e rastreamento do pedido.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
@@ -61,6 +70,26 @@ tools:
|
|||||||
- rastreamento
|
- rastreamento
|
||||||
- transportadora
|
- transportadora
|
||||||
- previsão
|
- previsão
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.delivery
|
||||||
|
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:
|
solicitar_troca:
|
||||||
description: Simula abertura de solicitação de troca.
|
description: Simula abertura de solicitação de troca.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
|
|||||||
@@ -3,5 +3,8 @@ from __future__ import annotations
|
|||||||
# Compatibilidade local do template/backend.
|
# Compatibilidade local do template/backend.
|
||||||
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
|
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
|
||||||
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
|
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
|
||||||
|
from app.presentation import register_tool_renderers
|
||||||
|
|
||||||
|
register_tool_renderers()
|
||||||
|
|
||||||
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]
|
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .tool_renderers import register_tool_renderers
|
||||||
|
|
||||||
|
__all__ = ["register_tool_renderers"]
|
||||||
Binary file not shown.
@@ -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)
|
||||||
@@ -60,6 +60,18 @@ 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
|
||||||
|
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:
|
solicitar_troca:
|
||||||
map:
|
map:
|
||||||
session_key: session_id
|
session_key: session_id
|
||||||
|
|||||||
@@ -79,6 +79,25 @@ intents:
|
|||||||
- Quero saber sobre meu pacote de internet.
|
- Quero saber sobre meu pacote de internet.
|
||||||
- Tenho roaming internacional?
|
- 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
|
- name: retail_order_tracking
|
||||||
domain: retail
|
domain: retail
|
||||||
agent: orders_agent
|
agent: orders_agent
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ defaults:
|
|||||||
require_confirmation: false
|
require_confirmation: false
|
||||||
|
|
||||||
tool_policies:
|
tool_policies:
|
||||||
|
cancelar_pedido:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
requires: [order_id]
|
||||||
|
|
||||||
|
|
||||||
solicitar_troca:
|
solicitar_troca:
|
||||||
operation_type: transactional
|
operation_type: transactional
|
||||||
require_confirmation: true
|
require_confirmation: true
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ tools:
|
|||||||
- fatura
|
- fatura
|
||||||
- conta
|
- conta
|
||||||
- boleto
|
- boleto
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.invoice
|
||||||
consultar_pagamentos:
|
consultar_pagamentos:
|
||||||
description: Consulta histórico de pagamentos do cliente.
|
description: Consulta histórico de pagamentos do cliente.
|
||||||
mcp_server: telecom
|
mcp_server: telecom
|
||||||
@@ -28,6 +31,9 @@ tools:
|
|||||||
asset_id: string
|
asset_id: string
|
||||||
selection_keywords:
|
selection_keywords:
|
||||||
- plano
|
- plano
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.plan
|
||||||
listar_servicos:
|
listar_servicos:
|
||||||
description: Lista serviços ativos e adicionais VAS.
|
description: Lista serviços ativos e adicionais VAS.
|
||||||
mcp_server: telecom
|
mcp_server: telecom
|
||||||
@@ -49,6 +55,9 @@ tools:
|
|||||||
- consultar pedido
|
- consultar pedido
|
||||||
- status do pedido
|
- status do pedido
|
||||||
- pedido
|
- pedido
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.order
|
||||||
consultar_entrega:
|
consultar_entrega:
|
||||||
description: Consulta entrega e rastreamento do pedido.
|
description: Consulta entrega e rastreamento do pedido.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
@@ -61,6 +70,26 @@ tools:
|
|||||||
- rastreamento
|
- rastreamento
|
||||||
- transportadora
|
- transportadora
|
||||||
- previsão
|
- previsão
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.delivery
|
||||||
|
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:
|
solicitar_troca:
|
||||||
description: Simula abertura de solicitação de troca.
|
description: Simula abertura de solicitação de troca.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ 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
|
||||||
|
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:
|
solicitar_troca:
|
||||||
map:
|
map:
|
||||||
session_key: session_id
|
session_key: session_id
|
||||||
|
|||||||
@@ -79,6 +79,25 @@ intents:
|
|||||||
- Quero saber sobre meu pacote de internet.
|
- Quero saber sobre meu pacote de internet.
|
||||||
- Tenho roaming internacional?
|
- 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
|
- name: retail_order_tracking
|
||||||
domain: retail
|
domain: retail
|
||||||
agent: orders_agent
|
agent: orders_agent
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ defaults:
|
|||||||
require_confirmation: false
|
require_confirmation: false
|
||||||
|
|
||||||
tool_policies:
|
tool_policies:
|
||||||
|
cancelar_pedido:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
requires: [order_id]
|
||||||
|
|
||||||
|
|
||||||
solicitar_troca:
|
solicitar_troca:
|
||||||
operation_type: transactional
|
operation_type: transactional
|
||||||
require_confirmation: true
|
require_confirmation: true
|
||||||
|
|||||||
@@ -61,6 +61,23 @@ tools:
|
|||||||
- rastreamento
|
- rastreamento
|
||||||
- transportadora
|
- transportadora
|
||||||
- previsão
|
- previsão
|
||||||
|
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:
|
solicitar_troca:
|
||||||
description: Simula abertura de solicitação de troca.
|
description: Simula abertura de solicitação de troca.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
|
|||||||
21
Tuning-Performance/Offline_Workflow_Regression/README.md
Normal file
21
Tuning-Performance/Offline_Workflow_Regression/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Offline Workflow Regression
|
||||||
|
|
||||||
|
O backend de produção de `WorkflowRuntime` continua sendo **LangGraph**. A ausência do pacote `langgraph` em produção é erro de configuração.
|
||||||
|
|
||||||
|
Para builders restritos/offline, o runtime aceita `allow_deterministic_fallback=True`. Esse modo é deliberadamente opt-in e existe somente para exercitar a DSL do framework (actions, edges, condições, pause/resume e trace) quando a dependência externa não pode ser instalada. Ele não é selecionado automaticamente.
|
||||||
|
|
||||||
|
Exemplo de teste:
|
||||||
|
|
||||||
|
```python
|
||||||
|
runtime = WorkflowRuntime(
|
||||||
|
repository,
|
||||||
|
actions=registry,
|
||||||
|
allow_deterministic_fallback=True,
|
||||||
|
)
|
||||||
|
first = await runtime.arun("workflow", payload)
|
||||||
|
assert first.status == "PAUSED"
|
||||||
|
final = await runtime.aresume("workflow", first.execution_id, {"resposta": "SIM"})
|
||||||
|
assert final.status == "COMPLETED"
|
||||||
|
```
|
||||||
|
|
||||||
|
O objetivo é não transformar indisponibilidade de rede/PyPI em `pytest.skip`, sem mascarar o requisito de LangGraph do runtime de produção.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Implementação no framework
|
||||||
|
|
||||||
|
A evolução adiciona `WorkflowPause` e `WorkflowExpectedInput` ao modelo de workflow e mantém o LangGraph como detalhe interno de `WorkflowRuntime`.
|
||||||
|
|
||||||
|
O runtime aceita condições declarativas `all`, `any`, `not`, `eq`, `neq`, `exists`, `path/equals`, `path/not_equals` e `path/in`. Em um nó com `pause`, a action é executada primeiro e a interrupção ocorre em um nó técnico separado. Na retomada, `langgraph.types.Command(resume=...)` injeta o valor esperado e segue para `resume_from` sem repetir a action que precedeu o pause.
|
||||||
|
|
||||||
|
`FrameworkStateGraph` é a facade para aplicações que ainda precisam compor um grafo de agentes; templates oficiais devem usar a facade e não importar LangGraph diretamente.
|
||||||
|
|
||||||
|
## Preservação de estado em falhas posteriores
|
||||||
|
|
||||||
|
O `WorkflowRuntime` também preserva o último snapshot persistido do LangGraph quando uma action posterior falha. O resultado `FAILED` contém `output`, `state` e `trace` dos nodes que já terminaram com sucesso.
|
||||||
|
|
||||||
|
Isso é necessário para workflows transacionais: por exemplo, se um protocolo foi criado e uma chamada posterior falha, o chamador ainda recebe o `protocol_number` persistido e pode executar recuperação/idempotência sem repetir o primeiro side effect.
|
||||||
|
|
||||||
|
O runtime não transforma falha em sucesso e não reexecuta automaticamente a action; ele apenas preserva a evidência durável já existente no checkpointer.
|
||||||
34
Tuning-Performance/Pause_Resume_Workflow/README.md
Normal file
34
Tuning-Performance/Pause_Resume_Workflow/README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Pause/Resume Workflow — LangGraph encapsulado pelo Agent Framework OCI
|
||||||
|
|
||||||
|
Este exemplo demonstra uma capability genérica do `agent_framework_oci`: workflows determinísticos podem interromper a execução para obter uma resposta do cliente e retomar posteriormente pelo mesmo `execution_id`, sem que o domínio importe ou monte um `StateGraph`.
|
||||||
|
|
||||||
|
## Conceito
|
||||||
|
|
||||||
|
A aplicação declara o workflow em YAML. `WorkflowRuntime` transforma a definição em LangGraph internamente, utiliza o checkpointer configurado pelo framework e expõe somente:
|
||||||
|
|
||||||
|
- `arun(name, payload)` — inicia ou executa o workflow;
|
||||||
|
- `aresume(name, execution_id, value)` — retoma o workflow pausado;
|
||||||
|
- `WorkflowRunResult.status` — `PAUSED`, `COMPLETED` ou `FAILED`.
|
||||||
|
|
||||||
|
O `pause` é compilado em um nó separado da action anterior. Isto impede que uma action com efeito colateral seja executada novamente quando o cliente responde.
|
||||||
|
|
||||||
|
## Executar
|
||||||
|
|
||||||
|
A partir da raiz do exemplo, com as dependências do framework instaladas:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m app.demo
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## YAML
|
||||||
|
|
||||||
|
`workflows/confirmacao.v1.yaml` mostra `expected_input`, normalização, valores permitidos e `resume_from`.
|
||||||
|
|
||||||
|
## Persistência
|
||||||
|
|
||||||
|
O exemplo usa `MemorySaver` apenas para ser autocontido. Em aplicações reais use `create_langgraph_checkpointer(settings)`. Assim `execution_id` é o `thread_id` do LangGraph e a retomada sobrevive a processos/replicas conforme o provider configurado (por exemplo Autonomous Database).
|
||||||
|
|
||||||
|
## Regra arquitetural
|
||||||
|
|
||||||
|
Código de domínio não deve importar `langgraph.graph.StateGraph`. Para grafos de agentes use `FrameworkStateGraph`; para workflows determinísticos de negócio use `WorkflowRuntime`.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from agent_framework.workflows import FileWorkflowRepository, WorkflowActionRegistry, WorkflowRuntime
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def build_runtime(*, offline_test_fallback: bool = False) -> WorkflowRuntime:
|
||||||
|
actions = WorkflowActionRegistry()
|
||||||
|
|
||||||
|
async def preparar(params, state):
|
||||||
|
return {"assunto": params.get("assunto") or "operação"}
|
||||||
|
|
||||||
|
async def perguntar(params, state):
|
||||||
|
return {"mensagem": f"Deseja confirmar {params['assunto']}?"}
|
||||||
|
|
||||||
|
async def decidir(params, state):
|
||||||
|
return {
|
||||||
|
"mensagem": "Operação confirmada." if params["resposta"] == "SIM" else "Operação cancelada.",
|
||||||
|
"confirmado": params["resposta"] == "SIM",
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.register("preparar_operacao", preparar)
|
||||||
|
actions.register("montar_pergunta", perguntar)
|
||||||
|
actions.register("registrar_decisao", decidir)
|
||||||
|
|
||||||
|
checkpointer = None
|
||||||
|
if not offline_test_fallback:
|
||||||
|
# Produção/exemplo real continua usando LangGraph + checkpointer. O import
|
||||||
|
# fica aqui para que a regressão offline do repositório não dependa de rede.
|
||||||
|
from langgraph.checkpoint.memory import MemorySaver
|
||||||
|
checkpointer = MemorySaver()
|
||||||
|
|
||||||
|
return WorkflowRuntime(
|
||||||
|
FileWorkflowRepository(ROOT / "workflows"),
|
||||||
|
actions=actions,
|
||||||
|
checkpointer=checkpointer,
|
||||||
|
allow_deterministic_fallback=offline_test_fallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
runtime = build_runtime()
|
||||||
|
first = await runtime.arun("confirmacao", {"assunto": "a alteração do plano"})
|
||||||
|
print(first.model_dump(mode="json"))
|
||||||
|
assert first.status == "PAUSED"
|
||||||
|
resumed = await runtime.aresume("confirmacao", first.execution_id, {"resposta_usuario": "sim"})
|
||||||
|
print(resumed.model_dump(mode="json"))
|
||||||
|
assert resumed.status == "COMPLETED"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.demo import build_runtime
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pause_resume_does_not_repeat_previous_action():
|
||||||
|
# Regressão offline: exercita a mesma DSL/WorkflowRuntime sem exigir download
|
||||||
|
# de LangGraph no builder. Produção continua usando build_runtime() default.
|
||||||
|
runtime = build_runtime(offline_test_fallback=True)
|
||||||
|
first = await runtime.arun("confirmacao", {"assunto": "o cancelamento"})
|
||||||
|
assert first.status == "PAUSED"
|
||||||
|
assert first.pause["expected_input"]["key"] == "resposta_usuario"
|
||||||
|
before = [item for item in first.trace if item.get("action") == "preparar_operacao"]
|
||||||
|
assert len(before) == 1
|
||||||
|
resumed = await runtime.aresume("confirmacao", first.execution_id, {"resposta_usuario": "SIM"})
|
||||||
|
assert resumed.status == "COMPLETED"
|
||||||
|
after = [item for item in resumed.trace if item.get("action") == "preparar_operacao"]
|
||||||
|
assert len(after) == 1
|
||||||
|
assert resumed.state["vars"]["decidir"]["confirmado"] is True
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
version: 1
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: confirmacao
|
||||||
|
version: 1
|
||||||
|
start: preparar
|
||||||
|
nodes:
|
||||||
|
- id: preparar
|
||||||
|
action: preparar_operacao
|
||||||
|
input:
|
||||||
|
assunto: $.input.assunto
|
||||||
|
- id: perguntar
|
||||||
|
action: montar_pergunta
|
||||||
|
input:
|
||||||
|
assunto: $.vars.preparar.assunto
|
||||||
|
pause:
|
||||||
|
enabled: true
|
||||||
|
return_from: $.output.mensagem
|
||||||
|
expected_input:
|
||||||
|
key: resposta_usuario
|
||||||
|
allowed_values: [SIM, NAO]
|
||||||
|
normalize: upper_strip
|
||||||
|
resume_from: decidir
|
||||||
|
- id: decidir
|
||||||
|
action: registrar_decisao
|
||||||
|
input:
|
||||||
|
resposta: $.input.resposta_usuario
|
||||||
|
assunto: $.vars.preparar.assunto
|
||||||
|
edges:
|
||||||
|
- from: preparar
|
||||||
|
to: perguntar
|
||||||
|
- from: perguntar
|
||||||
|
to: END
|
||||||
|
- from: decidir
|
||||||
|
to: END
|
||||||
@@ -6,3 +6,5 @@ Variantes e documentos de referência para comparar funcionalidades e impacto de
|
|||||||
- `Route_Stickness`: continuidade de rota, handoff e políticas transacionais conversacionais.
|
- `Route_Stickness`: continuidade de rota, handoff e políticas transacionais conversacionais.
|
||||||
- `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_Pre_Validation`: pré-validação MCP side-effect-free antes da confirmação transacional; regras de elegibilidade permanecem no domínio.
|
||||||
|
|||||||
@@ -3,5 +3,8 @@ from __future__ import annotations
|
|||||||
# Compatibilidade local do template/backend.
|
# Compatibilidade local do template/backend.
|
||||||
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
|
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
|
||||||
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
|
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
|
||||||
|
from app.presentation import register_tool_renderers
|
||||||
|
|
||||||
|
register_tool_renderers()
|
||||||
|
|
||||||
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]
|
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .tool_renderers import register_tool_renderers
|
||||||
|
|
||||||
|
__all__ = ["register_tool_renderers"]
|
||||||
Binary file not shown.
@@ -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)
|
||||||
@@ -60,6 +60,18 @@ 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
|
||||||
|
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:
|
solicitar_troca:
|
||||||
map:
|
map:
|
||||||
session_key: session_id
|
session_key: session_id
|
||||||
|
|||||||
@@ -79,6 +79,25 @@ intents:
|
|||||||
- Quero saber sobre meu pacote de internet.
|
- Quero saber sobre meu pacote de internet.
|
||||||
- Tenho roaming internacional?
|
- 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
|
- name: retail_order_tracking
|
||||||
domain: retail
|
domain: retail
|
||||||
agent: orders_agent
|
agent: orders_agent
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ defaults:
|
|||||||
require_confirmation: false
|
require_confirmation: false
|
||||||
|
|
||||||
tool_policies:
|
tool_policies:
|
||||||
|
cancelar_pedido:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
requires: [order_id]
|
||||||
|
|
||||||
|
|
||||||
solicitar_troca:
|
solicitar_troca:
|
||||||
operation_type: transactional
|
operation_type: transactional
|
||||||
require_confirmation: true
|
require_confirmation: true
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ tools:
|
|||||||
- fatura
|
- fatura
|
||||||
- conta
|
- conta
|
||||||
- boleto
|
- boleto
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.invoice
|
||||||
consultar_pagamentos:
|
consultar_pagamentos:
|
||||||
description: Consulta histórico de pagamentos do cliente.
|
description: Consulta histórico de pagamentos do cliente.
|
||||||
mcp_server: telecom
|
mcp_server: telecom
|
||||||
@@ -28,6 +31,9 @@ tools:
|
|||||||
asset_id: string
|
asset_id: string
|
||||||
selection_keywords:
|
selection_keywords:
|
||||||
- plano
|
- plano
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.plan
|
||||||
listar_servicos:
|
listar_servicos:
|
||||||
description: Lista serviços ativos e adicionais VAS.
|
description: Lista serviços ativos e adicionais VAS.
|
||||||
mcp_server: telecom
|
mcp_server: telecom
|
||||||
@@ -49,6 +55,9 @@ tools:
|
|||||||
- consultar pedido
|
- consultar pedido
|
||||||
- status do pedido
|
- status do pedido
|
||||||
- pedido
|
- pedido
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.order
|
||||||
consultar_entrega:
|
consultar_entrega:
|
||||||
description: Consulta entrega e rastreamento do pedido.
|
description: Consulta entrega e rastreamento do pedido.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
@@ -61,6 +70,26 @@ tools:
|
|||||||
- rastreamento
|
- rastreamento
|
||||||
- transportadora
|
- transportadora
|
||||||
- previsão
|
- previsão
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.delivery
|
||||||
|
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:
|
solicitar_troca:
|
||||||
description: Simula abertura de solicitação de troca.
|
description: Simula abertura de solicitação de troca.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ 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
|
||||||
|
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:
|
solicitar_troca:
|
||||||
map:
|
map:
|
||||||
session_key: session_id
|
session_key: session_id
|
||||||
|
|||||||
@@ -79,6 +79,25 @@ intents:
|
|||||||
- Quero saber sobre meu pacote de internet.
|
- Quero saber sobre meu pacote de internet.
|
||||||
- Tenho roaming internacional?
|
- 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
|
- name: retail_order_tracking
|
||||||
domain: retail
|
domain: retail
|
||||||
agent: orders_agent
|
agent: orders_agent
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ defaults:
|
|||||||
require_confirmation: false
|
require_confirmation: false
|
||||||
|
|
||||||
tool_policies:
|
tool_policies:
|
||||||
|
cancelar_pedido:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
requires: [order_id]
|
||||||
|
|
||||||
|
|
||||||
solicitar_troca:
|
solicitar_troca:
|
||||||
operation_type: transactional
|
operation_type: transactional
|
||||||
require_confirmation: true
|
require_confirmation: true
|
||||||
|
|||||||
@@ -61,6 +61,23 @@ tools:
|
|||||||
- rastreamento
|
- rastreamento
|
||||||
- transportadora
|
- transportadora
|
||||||
- previsão
|
- previsão
|
||||||
|
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:
|
solicitar_troca:
|
||||||
description: Simula abertura de solicitação de troca.
|
description: Simula abertura de solicitação de troca.
|
||||||
mcp_server: retail
|
mcp_server: retail
|
||||||
|
|||||||
172
Tuning-Performance/Transaction_Evidence/README.md
Normal file
172
Tuning-Performance/Transaction_Evidence/README.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# Transaction Evidence / Evidência Operacional de Transação
|
||||||
|
|
||||||
|
Esta variante demonstra a funcionalidade **Transaction Evidence** do `agent_framework_oci`.
|
||||||
|
Ela preserva o resultado estruturado de transações executadas no estado/checkpoint do
|
||||||
|
LangGraph e o reutiliza, quando relevante, em turnos posteriores como evidência
|
||||||
|
operacional para composição de resposta e groundedness.
|
||||||
|
|
||||||
|
> Transaction Evidence **não é Long Term Memory**. É estado operacional associado à
|
||||||
|
> sessão/workflow e aos recursos envolvidos na transação.
|
||||||
|
|
||||||
|
## Problema que resolve
|
||||||
|
|
||||||
|
Sem esta funcionalidade, uma transação pode ocorrer corretamente em um turno:
|
||||||
|
|
||||||
|
```text
|
||||||
|
quero cancelar pedido
|
||||||
|
PED-1001
|
||||||
|
sim
|
||||||
|
→ protocolo CANCEL-2026-001
|
||||||
|
```
|
||||||
|
|
||||||
|
mas, no turno seguinte:
|
||||||
|
|
||||||
|
```text
|
||||||
|
quero meu pedido
|
||||||
|
```
|
||||||
|
|
||||||
|
o runtime pode consultar apenas `consultar_pedido`. Se a resposta mencionar o
|
||||||
|
cancelamento/protocolo visto no turno anterior, o judge de groundedness pode tratar
|
||||||
|
isso como informação não evidenciada.
|
||||||
|
|
||||||
|
Com Transaction Evidence, o resultado transacional confirmado fica estruturado em
|
||||||
|
`transaction_evidence` e pode aparecer como `relevant_transaction_evidence` quando
|
||||||
|
correlacionado ao recurso atual.
|
||||||
|
|
||||||
|
## Fluxo demonstrado
|
||||||
|
|
||||||
|
```text
|
||||||
|
cancelar_pedido(PED-1001)
|
||||||
|
↓
|
||||||
|
transação executada
|
||||||
|
↓
|
||||||
|
transaction_evidence
|
||||||
|
- tool_name
|
||||||
|
- arguments
|
||||||
|
- transaction_id
|
||||||
|
- status
|
||||||
|
- result/protocolo
|
||||||
|
↓
|
||||||
|
checkpoint da sessão
|
||||||
|
↓
|
||||||
|
novo turno: "quero meu pedido"
|
||||||
|
↓
|
||||||
|
consultar_pedido(PED-1001)
|
||||||
|
+
|
||||||
|
relevant_transaction_evidence(PED-1001)
|
||||||
|
↓
|
||||||
|
resposta + groundedness com evidência operacional
|
||||||
|
```
|
||||||
|
|
||||||
|
## Regras de segurança e correlação
|
||||||
|
|
||||||
|
A implementação do framework:
|
||||||
|
|
||||||
|
- registra apenas resultados transacionais efetivamente executados;
|
||||||
|
- mantém histórico bounded das evidências mais recentes;
|
||||||
|
- correlaciona por identificadores de recurso, como `order_id`, `invoice_id`,
|
||||||
|
`asset_id`, `resource_key` e outros campos `*_id`;
|
||||||
|
- evita reutilizar evidência de um recurso diferente;
|
||||||
|
- disponibiliza somente a evidência relevante do turno em
|
||||||
|
`relevant_transaction_evidence`;
|
||||||
|
- expõe a evidência relevante no metadata da resposta para diagnóstico;
|
||||||
|
- inclui a evidência relevante no contexto usado pelos judges/groundedness.
|
||||||
|
|
||||||
|
## Configuração
|
||||||
|
|
||||||
|
Não existe uma variável obrigatória `ENABLE_TRANSACTION_EVIDENCE` nesta versão.
|
||||||
|
A funcionalidade faz parte do runtime transacional do framework e depende da
|
||||||
|
persistência de estado/checkpoint já usada pelo agente.
|
||||||
|
|
||||||
|
Os principais pontos do exemplo são:
|
||||||
|
|
||||||
|
- `app/state.py`: campos `transaction_evidence`, `last_transaction_evidence` e
|
||||||
|
`relevant_transaction_evidence`;
|
||||||
|
- `app/workflows/agent_graph.py`: injeta evidência relevante no contexto dos judges;
|
||||||
|
- `app/main.py`: publica `transaction_evidence` no metadata da resposta;
|
||||||
|
- framework: `runtime/agent_runtime.py`: grava, correlaciona e materializa a evidência.
|
||||||
|
|
||||||
|
## Executar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Tuning-Performance/Transaction_Evidence/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
|
||||||
|
```
|
||||||
|
|
||||||
|
Use o frontend normalmente apontando para `http://localhost:8000`.
|
||||||
|
|
||||||
|
## Cenário de teste recomendado
|
||||||
|
|
||||||
|
Execute na mesma sessão:
|
||||||
|
|
||||||
|
```text
|
||||||
|
quero meu pedido
|
||||||
|
quero cancelar pedido
|
||||||
|
PED-1001
|
||||||
|
sim
|
||||||
|
quero meu pedido
|
||||||
|
```
|
||||||
|
|
||||||
|
Na última rodada, valide no JSON de resposta:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"route": "orders_agent",
|
||||||
|
"intent": "retail_order_tracking",
|
||||||
|
"transaction_evidence": [
|
||||||
|
{
|
||||||
|
"tool_name": "cancelar_pedido",
|
||||||
|
"status": "COMPLETED"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A estrutura exata do `result` depende da tool transacional, mas o protocolo retornado
|
||||||
|
pela transação deve estar presente na evidência relevante quando o recurso correlacionar.
|
||||||
|
|
||||||
|
## Resultado esperado
|
||||||
|
|
||||||
|
No último `quero meu pedido`:
|
||||||
|
|
||||||
|
1. a intent deve ser `retail_order_tracking`;
|
||||||
|
2. `consultar_pedido` deve ser executada normalmente;
|
||||||
|
3. a evidência da transação anterior de `PED-1001` deve ser recuperada;
|
||||||
|
4. a resposta pode mencionar o cancelamento/protocolo porque essa informação agora
|
||||||
|
está suportada por evidência operacional;
|
||||||
|
5. o judge de groundedness deve receber essa mesma evidência.
|
||||||
|
|
||||||
|
## Diferença para Long Term Memory
|
||||||
|
|
||||||
|
| Transaction Evidence | Long Term Memory |
|
||||||
|
|---|---|
|
||||||
|
| Estado operacional | Memória semântica/pessoal |
|
||||||
|
| Resultado de tools/transações | Preferências e fatos duráveis |
|
||||||
|
| Correlacionado a recursos | Correlacionado ao sujeito |
|
||||||
|
| Usado para grounding operacional | Usado para personalização/contexto |
|
||||||
|
| Curto/médio prazo de workflow | Longo prazo entre conversas |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## English summary
|
||||||
|
|
||||||
|
This variant demonstrates **Transaction Evidence**: executed transactional tool
|
||||||
|
outcomes are persisted as structured operational evidence in LangGraph state/checkpoint.
|
||||||
|
On later turns, the runtime correlates prior evidence with the current resource and
|
||||||
|
exposes it as `relevant_transaction_evidence` to both response composition and
|
||||||
|
judges/groundedness. It is operational workflow state, not Long Term Memory.
|
||||||
|
|
||||||
|
Recommended test sequence:
|
||||||
|
|
||||||
|
```text
|
||||||
|
show my order
|
||||||
|
cancel my order
|
||||||
|
PED-1001
|
||||||
|
yes
|
||||||
|
show my order
|
||||||
|
```
|
||||||
|
|
||||||
|
On the last turn, verify that the response metadata contains relevant transaction
|
||||||
|
evidence for the cancellation of `PED-1001`.
|
||||||
@@ -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,647 @@
|
|||||||
|
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", []),
|
||||||
|
"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,56 @@
|
|||||||
|
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_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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user