mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-09-07 10:13:46 +00:00
new feature: External guardrails/judges
This commit is contained in:
@@ -207,3 +207,8 @@ 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
|
||||
|
||||
# Optional agent/deployment observability contract mapping.
|
||||
# Keep disabled in the generic framework; agents may enable their own YAML mapping.
|
||||
OBSERVABILITY_CODE_MAPPING_ENABLED=false
|
||||
OBSERVABILITY_CODE_MAPPING_PATH=
|
||||
|
||||
11
Tuning-Performance/External_Guardrails_Judges/README.md
Normal file
11
Tuning-Performance/External_Guardrails_Judges/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# External Guardrails / Judges
|
||||
|
||||
Este exemplo parte do `agent_template_backend` e demonstra a composição de componentes nativos com políticas pertencentes ao agente.
|
||||
|
||||
- `type: external` ativa import dinâmico somente para o componente declarado.
|
||||
- Guardrail/judge síncrono roda em worker thread via `asyncio.to_thread`.
|
||||
- Implementação `async` roda concorrente no event loop.
|
||||
- O framework não importa `app.extensions.*` por padrão.
|
||||
- Use códigos/names próprios do domínio; não sobrescreva semanticamente o genérico sem deixar a substituição explícita no YAML.
|
||||
|
||||
Veja também `agent_framework_oci/docs/EXTERNAL_GUARDRAILS_JUDGES.md` e `docs/EXTERNAL_GUARDRAILS_JUDGES.md` no Contas.
|
||||
@@ -0,0 +1,195 @@
|
||||
###############################################################################
|
||||
# AI AGENT PLATFORM - CONFIGURAÇÃO ÚNICA
|
||||
# Este arquivo é lido por Pydantic Settings no framework e no backend template.
|
||||
###############################################################################
|
||||
|
||||
APP_NAME=ai-agent-template
|
||||
APP_ENV=local
|
||||
LOG_LEVEL=INFO
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
###############################################################################
|
||||
# LLM - OCI Generative AI como provider principal
|
||||
###############################################################################
|
||||
# Opções: mock, oci_openai, oci_sdk, openai_compatible
|
||||
LLM_PROVIDER=oci_openai
|
||||
LLM_TEMPERATURE=0.2
|
||||
LLM_MAX_TOKENS=2048
|
||||
LLM_TIMEOUT_SECONDS=120
|
||||
|
||||
# OCI OpenAI-compatible endpoint
|
||||
OCI_GENAI_BASE_URL=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1
|
||||
OCI_GENAI_MODEL=openai.gpt-4.1
|
||||
OCI_GENAI_API_KEY=sk-ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6
|
||||
OCI_GENAI_PROJECT_OCID=
|
||||
|
||||
# OCI SDK / signer / profiles
|
||||
OCI_CONFIG_FILE=~/.oci/config
|
||||
OCI_PROFILE=DEFAULT
|
||||
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
OCI_REGION=us-chicago-1
|
||||
|
||||
###############################################################################
|
||||
# Persistência
|
||||
###############################################################################
|
||||
# Opções: memory, autonomous, mongodb
|
||||
SESSION_REPOSITORY_PROVIDER=sqlite
|
||||
MEMORY_REPOSITORY_PROVIDER=sqlite
|
||||
CHECKPOINT_REPOSITORY_PROVIDER=sqlite
|
||||
SQLITE_DB_PATH=./data/agent_framework.db
|
||||
|
||||
# Autonomous Database
|
||||
ADB_USER=admin
|
||||
ADB_PASSWORD=fjhsdf04954hf
|
||||
ADB_DSN=oradb23aidev_high
|
||||
ADB_WALLET_LOCATION=/ORACLE/DEFAULT/Wallet_ORADB23aiDev
|
||||
ADB_WALLET_PASSWORD=fjhsdf04954hf
|
||||
ADB_TABLE_PREFIX=AGENTFW
|
||||
|
||||
# MongoDB - também pode representar Autonomous usando API compatível com Mongo, se habilitada no ambiente
|
||||
MONGODB_URI=mongodb://mongo:mongopassword@localhost:27017
|
||||
MONGODB_DATABASE=agent_platform
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
ENABLE_REDIS_CACHE=false
|
||||
|
||||
###############################################################################
|
||||
# RAG / Vector / Graph
|
||||
###############################################################################
|
||||
VECTOR_STORE_PROVIDER=sqlite
|
||||
GRAPH_STORE_PROVIDER=sqlite
|
||||
RAG_TOP_K=5
|
||||
EMBEDDING_PROVIDER=mock
|
||||
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
|
||||
RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json
|
||||
|
||||
###############################################################################
|
||||
# Observabilidade
|
||||
###############################################################################
|
||||
ENABLE_LANGFUSE=true
|
||||
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
|
||||
LANGFUSE_PUBLIC_KEY=pk-lf-2f9da109-5b0f-4c78-b61d-9598ed787eba
|
||||
LANGFUSE_SECRET_KEY=sk-lf-a4cb0cdd-f2ea-4468-9911-cebeb91ba944
|
||||
LANGFUSE_HOST=http://localhost:3005
|
||||
ENABLE_OTEL=false
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
OTEL_SERVICE_NAME=ai-agent-template
|
||||
ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true
|
||||
|
||||
###############################################################################
|
||||
# Analytics / Observer corporativo
|
||||
###############################################################################
|
||||
# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo.
|
||||
ENABLE_ANALYTICS=false
|
||||
# Providers aceitos: oci_streaming,pubsub,noop
|
||||
ANALYTICS_PROVIDERS=pubsub
|
||||
# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente.
|
||||
AGENT_PUBSUB_TOPIC=
|
||||
GCP_PUBSUB_TOPIC_PATH=
|
||||
GCP_PROJECT_ID=
|
||||
GCP_PUBSUB_TOPIC=
|
||||
GCP_PUBSUB_TIMEOUT_SECONDS=30
|
||||
# Credencial GCP segue padrão Google:
|
||||
# GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
|
||||
|
||||
###############################################################################
|
||||
# OCI Streaming
|
||||
###############################################################################
|
||||
ENABLE_OCI_STREAMING=false
|
||||
OCI_STREAM_ENDPOINT=
|
||||
OCI_STREAM_OCID=
|
||||
OCI_STREAM_PARTITION_KEY=agent-events
|
||||
|
||||
###############################################################################
|
||||
# Guardrails, Judges, Supervisor
|
||||
###############################################################################
|
||||
ENABLE_INPUT_GUARDRAILS=true
|
||||
ENABLE_OUTPUT_GUARDRAILS=true
|
||||
ENABLE_JUDGES=true
|
||||
ENABLE_SUPERVISOR=true
|
||||
ENABLE_OUTPUT_SUPERVISOR=true
|
||||
ENABLE_PARALLEL_GUARDRAILS=true
|
||||
GUARDRAILS_FAIL_FAST=true
|
||||
OUTPUT_SUPERVISOR_MAX_RETRIES=3
|
||||
GUARDRAILS_CONFIG_PATH=./config/guardrails.yaml
|
||||
JUDGES_CONFIG_PATH=./config/judges.yaml
|
||||
PROMPT_POLICY_PATH=./config/prompt_policy.yaml
|
||||
|
||||
###############################################################################
|
||||
# Gateway de canais
|
||||
###############################################################################
|
||||
DEFAULT_CHANNEL=web
|
||||
# embedded = backend may parse simple/native channel payloads.
|
||||
# external = backend only accepts GatewayRequest normalized by an external Channel Gateway.
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE=embedded
|
||||
ENABLE_VOICE_ADAPTER=true
|
||||
ENABLE_WHATSAPP_ADAPTER=true
|
||||
ENABLE_TEXT_ADAPTER=true
|
||||
|
||||
#################################################
|
||||
# ENTERPRISE ROUTING
|
||||
#################################################
|
||||
# Arquivo YAML com intents, keywords, políticas de estado e fallback.
|
||||
ROUTING_CONFIG_PATH=./config/routing.yaml
|
||||
# true = usa LLM para classificar quando keywords/estado não resolverem.
|
||||
# Em produção, costuma ser útil; em desenvolvimento, false evita custo e latência.
|
||||
ENABLE_LLM_ROUTER=true
|
||||
|
||||
###############################################################################
|
||||
# MCP / Tools
|
||||
###############################################################################
|
||||
ENABLE_MCP_TOOLS=true
|
||||
MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.yaml
|
||||
TOOLS_CONFIG_PATH=./config/tools.yaml
|
||||
TOOL_POLICIES_PATH=./config/tool_policies.yaml
|
||||
MCP_TOOL_TIMEOUT_SECONDS=30
|
||||
|
||||
# router = EnterpriseRouter seleciona um agente; supervisor = pode acionar múltiplos agentes
|
||||
ROUTING_MODE=router
|
||||
|
||||
# Usage/cost accounting
|
||||
USAGE_REPOSITORY_PROVIDER=sqlite
|
||||
IDENTITY_CONFIG_PATH=./config/identity.yaml
|
||||
MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# ConversationSummaryMemory / compressão de contexto conversacional
|
||||
# -----------------------------------------------------------------------------
|
||||
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
|
||||
MEMORY_CONTEXT_STRATEGY=summary
|
||||
MEMORY_HISTORY_LIMIT=80
|
||||
MEMORY_RECENT_MESSAGES_LIMIT=8
|
||||
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
|
||||
MEMORY_MAX_SUMMARY_CHARS=6000
|
||||
MEMORY_SUMMARY_USE_LLM=true
|
||||
MEMORY_INJECT_RECENT_MESSAGES=true
|
||||
MEMORY_INJECT_SUMMARY=true
|
||||
|
||||
###############################################################################
|
||||
# MCP Gateway
|
||||
###############################################################################
|
||||
# true = framework routes tool calls to the dedicated MCP Gateway.
|
||||
# false = framework calls MCP servers directly from mcp_servers.yaml.
|
||||
MCP_GATEWAY_ENABLED=true
|
||||
MCP_GATEWAY_URL=http://localhost:8300
|
||||
MCP_GATEWAY_TIMEOUT_SECONDS=60
|
||||
# MCP_GATEWAY_TOKEN=
|
||||
MCP_GATEWAY_AGENT_ID=telecom_contas
|
||||
MCP_GATEWAY_TENANT_ID=default
|
||||
|
||||
###############################################################################
|
||||
# LONG-TERM MEMORY
|
||||
###############################################################################
|
||||
ENABLE_LONG_TERM_MEMORY=true
|
||||
LONG_TERM_MEMORY_PROVIDER=sqlite
|
||||
LONG_TERM_MEMORY_SQLITE_PATH=./data/agent_framework.db
|
||||
LONG_TERM_MEMORY_TABLE=agentfw_long_term_memory
|
||||
# For Autonomous/Oracle, defaults to ${ADB_TABLE_PREFIX}_LONG_TERM_MEMORY
|
||||
# LONG_TERM_MEMORY_ORACLE_TABLE=AGENTFW_LONG_TERM_MEMORY
|
||||
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS=20
|
||||
LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70
|
||||
LONG_TERM_MEMORY_AUTO_EXTRACT=true
|
||||
LONG_TERM_MEMORY_INJECT_CONTEXT=true
|
||||
@@ -0,0 +1,195 @@
|
||||
###############################################################################
|
||||
# AI AGENT PLATFORM - CONFIGURAÇÃO ÚNICA
|
||||
# Este arquivo é lido por Pydantic Settings no framework e no backend template.
|
||||
###############################################################################
|
||||
|
||||
APP_NAME=ai-agent-template
|
||||
APP_ENV=local
|
||||
LOG_LEVEL=INFO
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
###############################################################################
|
||||
# LLM - OCI Generative AI como provider principal
|
||||
###############################################################################
|
||||
# Opções: mock, oci_openai, oci_sdk, openai_compatible
|
||||
LLM_PROVIDER=oci_openai
|
||||
LLM_TEMPERATURE=0.2
|
||||
LLM_MAX_TOKENS=2048
|
||||
LLM_TIMEOUT_SECONDS=120
|
||||
|
||||
# OCI OpenAI-compatible endpoint
|
||||
OCI_GENAI_BASE_URL=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1
|
||||
OCI_GENAI_MODEL=openai.gpt-4.1
|
||||
OCI_GENAI_API_KEY=sk-ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6
|
||||
OCI_GENAI_PROJECT_OCID=
|
||||
|
||||
# OCI SDK / signer / profiles
|
||||
OCI_CONFIG_FILE=~/.oci/config
|
||||
OCI_PROFILE=DEFAULT
|
||||
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
OCI_REGION=us-chicago-1
|
||||
|
||||
###############################################################################
|
||||
# Persistência
|
||||
###############################################################################
|
||||
# Opções: memory, autonomous, mongodb
|
||||
SESSION_REPOSITORY_PROVIDER=sqlite
|
||||
MEMORY_REPOSITORY_PROVIDER=sqlite
|
||||
CHECKPOINT_REPOSITORY_PROVIDER=sqlite
|
||||
SQLITE_DB_PATH=./data/agent_framework.db
|
||||
|
||||
# Autonomous Database
|
||||
ADB_USER=admin
|
||||
ADB_PASSWORD=fjhsdf04954hf
|
||||
ADB_DSN=oradb23aidev_high
|
||||
ADB_WALLET_LOCATION=/ORACLE/DEFAULT/Wallet_ORADB23aiDev
|
||||
ADB_WALLET_PASSWORD=fjhsdf04954hf
|
||||
ADB_TABLE_PREFIX=AGENTFW
|
||||
|
||||
# MongoDB - também pode representar Autonomous usando API compatível com Mongo, se habilitada no ambiente
|
||||
MONGODB_URI=mongodb://mongo:mongopassword@localhost:27017
|
||||
MONGODB_DATABASE=agent_platform
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
ENABLE_REDIS_CACHE=false
|
||||
|
||||
###############################################################################
|
||||
# RAG / Vector / Graph
|
||||
###############################################################################
|
||||
VECTOR_STORE_PROVIDER=sqlite
|
||||
GRAPH_STORE_PROVIDER=sqlite
|
||||
RAG_TOP_K=5
|
||||
EMBEDDING_PROVIDER=mock
|
||||
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
|
||||
RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json
|
||||
|
||||
###############################################################################
|
||||
# Observabilidade
|
||||
###############################################################################
|
||||
ENABLE_LANGFUSE=true
|
||||
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
|
||||
LANGFUSE_PUBLIC_KEY=pk-lf-2f9da109-5b0f-4c78-b61d-9598ed787eba
|
||||
LANGFUSE_SECRET_KEY=sk-lf-a4cb0cdd-f2ea-4468-9911-cebeb91ba944
|
||||
LANGFUSE_HOST=http://localhost:3005
|
||||
ENABLE_OTEL=false
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
OTEL_SERVICE_NAME=ai-agent-template
|
||||
ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true
|
||||
|
||||
###############################################################################
|
||||
# Analytics / Observer corporativo
|
||||
###############################################################################
|
||||
# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo.
|
||||
ENABLE_ANALYTICS=false
|
||||
# Providers aceitos: oci_streaming,pubsub,noop
|
||||
ANALYTICS_PROVIDERS=pubsub
|
||||
# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente.
|
||||
AGENT_PUBSUB_TOPIC=
|
||||
GCP_PUBSUB_TOPIC_PATH=
|
||||
GCP_PROJECT_ID=
|
||||
GCP_PUBSUB_TOPIC=
|
||||
GCP_PUBSUB_TIMEOUT_SECONDS=30
|
||||
# Credencial GCP segue padrão Google:
|
||||
# GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
|
||||
|
||||
###############################################################################
|
||||
# OCI Streaming
|
||||
###############################################################################
|
||||
ENABLE_OCI_STREAMING=false
|
||||
OCI_STREAM_ENDPOINT=
|
||||
OCI_STREAM_OCID=
|
||||
OCI_STREAM_PARTITION_KEY=agent-events
|
||||
|
||||
###############################################################################
|
||||
# Guardrails, Judges, Supervisor
|
||||
###############################################################################
|
||||
ENABLE_INPUT_GUARDRAILS=true
|
||||
ENABLE_OUTPUT_GUARDRAILS=true
|
||||
ENABLE_JUDGES=true
|
||||
ENABLE_SUPERVISOR=true
|
||||
ENABLE_OUTPUT_SUPERVISOR=true
|
||||
ENABLE_PARALLEL_GUARDRAILS=true
|
||||
GUARDRAILS_FAIL_FAST=true
|
||||
OUTPUT_SUPERVISOR_MAX_RETRIES=3
|
||||
GUARDRAILS_CONFIG_PATH=./config/guardrails.yaml
|
||||
JUDGES_CONFIG_PATH=./config/judges.yaml
|
||||
PROMPT_POLICY_PATH=./config/prompt_policy.yaml
|
||||
|
||||
###############################################################################
|
||||
# Gateway de canais
|
||||
###############################################################################
|
||||
DEFAULT_CHANNEL=web
|
||||
# embedded = backend may parse simple/native channel payloads.
|
||||
# external = backend only accepts GatewayRequest normalized by an external Channel Gateway.
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE=embedded
|
||||
ENABLE_VOICE_ADAPTER=true
|
||||
ENABLE_WHATSAPP_ADAPTER=true
|
||||
ENABLE_TEXT_ADAPTER=true
|
||||
|
||||
#################################################
|
||||
# ENTERPRISE ROUTING
|
||||
#################################################
|
||||
# Arquivo YAML com intents, keywords, políticas de estado e fallback.
|
||||
ROUTING_CONFIG_PATH=./config/routing.yaml
|
||||
# true = usa LLM para classificar quando keywords/estado não resolverem.
|
||||
# Em produção, costuma ser útil; em desenvolvimento, false evita custo e latência.
|
||||
ENABLE_LLM_ROUTER=true
|
||||
|
||||
###############################################################################
|
||||
# MCP / Tools
|
||||
###############################################################################
|
||||
ENABLE_MCP_TOOLS=true
|
||||
MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.yaml
|
||||
TOOLS_CONFIG_PATH=./config/tools.yaml
|
||||
TOOL_POLICIES_PATH=./config/tool_policies.yaml
|
||||
MCP_TOOL_TIMEOUT_SECONDS=30
|
||||
|
||||
# router = EnterpriseRouter seleciona um agente; supervisor = pode acionar múltiplos agentes
|
||||
ROUTING_MODE=router
|
||||
|
||||
# Usage/cost accounting
|
||||
USAGE_REPOSITORY_PROVIDER=sqlite
|
||||
IDENTITY_CONFIG_PATH=./config/identity.yaml
|
||||
MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# ConversationSummaryMemory / compressão de contexto conversacional
|
||||
# -----------------------------------------------------------------------------
|
||||
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
|
||||
MEMORY_CONTEXT_STRATEGY=summary
|
||||
MEMORY_HISTORY_LIMIT=80
|
||||
MEMORY_RECENT_MESSAGES_LIMIT=8
|
||||
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
|
||||
MEMORY_MAX_SUMMARY_CHARS=6000
|
||||
MEMORY_SUMMARY_USE_LLM=true
|
||||
MEMORY_INJECT_RECENT_MESSAGES=true
|
||||
MEMORY_INJECT_SUMMARY=true
|
||||
|
||||
###############################################################################
|
||||
# MCP Gateway
|
||||
###############################################################################
|
||||
# true = framework routes tool calls to the dedicated MCP Gateway.
|
||||
# false = framework calls MCP servers directly from mcp_servers.yaml.
|
||||
MCP_GATEWAY_ENABLED=true
|
||||
MCP_GATEWAY_URL=http://localhost:8300
|
||||
MCP_GATEWAY_TIMEOUT_SECONDS=60
|
||||
# MCP_GATEWAY_TOKEN=
|
||||
MCP_GATEWAY_AGENT_ID=telecom_contas
|
||||
MCP_GATEWAY_TENANT_ID=default
|
||||
|
||||
###############################################################################
|
||||
# LONG-TERM MEMORY
|
||||
###############################################################################
|
||||
ENABLE_LONG_TERM_MEMORY=true
|
||||
LONG_TERM_MEMORY_PROVIDER=sqlite
|
||||
LONG_TERM_MEMORY_SQLITE_PATH=./data/agent_framework.db
|
||||
LONG_TERM_MEMORY_TABLE=agentfw_long_term_memory
|
||||
# For Autonomous/Oracle, defaults to ${ADB_TABLE_PREFIX}_LONG_TERM_MEMORY
|
||||
# LONG_TERM_MEMORY_ORACLE_TABLE=AGENTFW_LONG_TERM_MEMORY
|
||||
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS=20
|
||||
LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70
|
||||
LONG_TERM_MEMORY_AUTO_EXTRACT=true
|
||||
LONG_TERM_MEMORY_INJECT_CONTEXT=true
|
||||
@@ -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,11 @@
|
||||
from __future__ import annotations
|
||||
from agent_framework.guardrails.base import Guardrail, RailDecision
|
||||
|
||||
class ExternalBusinessPolicyRail(Guardrail):
|
||||
code = "EXTERNAL_BUSINESS_POLICY"
|
||||
stage = "output"
|
||||
|
||||
def evaluate(self, text, context):
|
||||
# Synchronous on purpose: framework executes this method in a worker thread.
|
||||
blocked = bool((context or {}).get("example_block"))
|
||||
return RailDecision(code=self.code, allowed=not blocked, reason="example business policy" if blocked else "", metadata={"external": True})
|
||||
@@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
from agent_framework.judges.judge import JudgeResult
|
||||
|
||||
class ExternalBusinessJudge:
|
||||
name = "external_business_quality"
|
||||
def __init__(self, threshold=0.5, **kwargs): self.threshold=float(threshold or 0.5)
|
||||
def evaluate(self, question, answer, context):
|
||||
score = 1.0 if answer and len(answer.strip()) >= 10 else 0.0
|
||||
return JudgeResult(name=self.name, score=score, passed=score >= self.threshold, reason="example external judge", metadata={"external": True})
|
||||
@@ -0,0 +1,532 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework.channels.base import ChannelResponse
|
||||
from agent_framework.channels.gateway import ChannelGateway
|
||||
from agent_framework.config.agent_registry import AgentProfileRegistry
|
||||
from agent_framework.config.settings import settings
|
||||
from agent_framework.analytics.factory import create_analytics_publisher
|
||||
from agent_framework.observer import configure as configure_global_observer
|
||||
from agent_framework.llm.providers import create_llm
|
||||
from agent_framework.memory.message_history import create_memory
|
||||
from agent_framework.memory.summary_memory import create_conversation_summary_memory
|
||||
from agent_framework.mcp.tool_router import create_mcp_tool_router
|
||||
from agent_framework.models.identity import AgentIdentity
|
||||
from agent_framework.identity import IdentityResolver, BusinessContext
|
||||
from agent_framework.models.session import ChatMessage, SessionContext
|
||||
from agent_framework.observability.telemetry import Telemetry
|
||||
from agent_framework.observability.context import set_observability_context, clear_observability_context
|
||||
from agent_framework.repositories.session_repository import create_session_repository
|
||||
from agent_framework.checkpoints.checkpoint_repository import create_checkpoint_repository
|
||||
from agent_framework.cache.cache import create_cache
|
||||
from agent_framework.billing.usage_repository import create_usage_repository
|
||||
from agent_framework.sse.events import SSEHub
|
||||
from app.workflows.agent_graph import AgentWorkflow
|
||||
from app.observability.telemetry_observer import TelemetryBackedAgentObserver
|
||||
|
||||
logging.basicConfig(level=settings.LOG_LEVEL)
|
||||
logger = logging.getLogger("agent_template_backend")
|
||||
|
||||
app = FastAPI(title="Agent Template Backend FIRST-ready")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",")],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
telemetry = Telemetry(settings)
|
||||
usage_repository = create_usage_repository(settings)
|
||||
llm = create_llm(settings, telemetry=telemetry, usage_repository=usage_repository)
|
||||
memory = create_memory(settings)
|
||||
summary_memory = create_conversation_summary_memory(settings, message_history=memory, llm=llm, telemetry=telemetry)
|
||||
sessions = create_session_repository(settings)
|
||||
checkpoints = create_checkpoint_repository(settings)
|
||||
cache = create_cache(settings, telemetry=telemetry)
|
||||
gateway = ChannelGateway(input_mode=settings.FRAMEWORK_CHANNEL_INPUT_MODE)
|
||||
analytics = create_analytics_publisher(settings)
|
||||
observer = TelemetryBackedAgentObserver(telemetry=telemetry)
|
||||
configure_global_observer({
|
||||
"enabled": getattr(settings, "ENABLE_ANALYTICS", False),
|
||||
"providers": getattr(settings, "ANALYTICS_PROVIDERS", "oci_streaming"),
|
||||
"topic_path": getattr(settings, "GCP_PUBSUB_TOPIC_PATH", None) or getattr(settings, "AGENT_PUBSUB_TOPIC", None),
|
||||
})
|
||||
tool_router = create_mcp_tool_router(settings, telemetry=telemetry)
|
||||
identity_resolver = IdentityResolver.from_yaml(settings.IDENTITY_CONFIG_PATH)
|
||||
agent_profiles = AgentProfileRegistry(settings)
|
||||
sse_hub = SSEHub(settings, telemetry=telemetry)
|
||||
workflow = AgentWorkflow(llm, memory, telemetry, analytics, settings, observer=observer, tool_router=tool_router, summary_memory=summary_memory)
|
||||
|
||||
logger.info("LLM provider carregado: %s", llm.__class__.__name__)
|
||||
logger.info("Langfuse habilitado: %s host=%s", telemetry.is_enabled(), settings.LANGFUSE_HOST)
|
||||
logger.info("Analytics habilitado: %s providers=%s", getattr(settings, "ENABLE_ANALYTICS", False), getattr(settings, "ANALYTICS_PROVIDERS", ""))
|
||||
logger.info("Agentes disponíveis: %s", [p.agent_id for p in agent_profiles.list_profiles()])
|
||||
logger.info("Framework channel input mode: %s", gateway.input_mode)
|
||||
|
||||
@app.middleware("http")
|
||||
async def observability_context_middleware(request: Request, call_next):
|
||||
clear_observability_context()
|
||||
request_id = request.headers.get("x-request-id") or str(uuid4())
|
||||
set_observability_context(
|
||||
request_id=request_id,
|
||||
channel=request.headers.get("x-channel") or "http",
|
||||
ura_call_id=request.headers.get("x-ura-call-id"),
|
||||
)
|
||||
started = time.time()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
response.headers["x-request-id"] = request_id
|
||||
await telemetry.event("http.request.completed", {
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": int((time.time() - started) * 1000),
|
||||
}, kind="http")
|
||||
return response
|
||||
except Exception as exc:
|
||||
await telemetry.event("http.request.failed", {
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"error": str(exc),
|
||||
"duration_ms": int((time.time() - started) * 1000),
|
||||
}, kind="http")
|
||||
raise
|
||||
finally:
|
||||
clear_observability_context()
|
||||
|
||||
|
||||
class GatewayRequest(BaseModel):
|
||||
channel: str = "web"
|
||||
payload: dict
|
||||
agent_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
|
||||
|
||||
def _metadata_value(payload: dict, key: str):
|
||||
metadata = payload.get("metadata")
|
||||
if isinstance(metadata, dict):
|
||||
return metadata.get(key)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_workflow_id(payload: dict) -> str | None:
|
||||
return (
|
||||
payload.get("workflow_id")
|
||||
or payload.get("workflowId")
|
||||
or _metadata_value(payload, "workflow_id")
|
||||
or _metadata_value(payload, "workflowId")
|
||||
)
|
||||
|
||||
|
||||
def _format_root_span_name(template: str | None, values: dict) -> str:
|
||||
template = template or "agent.gateway_message"
|
||||
try:
|
||||
return template.format(**{k: v or "unknown" for k, v in values.items()})
|
||||
except Exception:
|
||||
logger.warning("LANGFUSE_ROOT_SPAN_NAME inválido: %s", template)
|
||||
return "agent.gateway_message"
|
||||
|
||||
|
||||
def _resolve_identity(req: GatewayRequest, msg) -> tuple[AgentIdentity, dict, BusinessContext, list[str]]:
|
||||
payload = req.payload or {}
|
||||
context = dict(msg.context or {})
|
||||
tenant_id = req.tenant_id or payload.get("tenant_id") or context.get("tenant_id") or "default"
|
||||
agent_id = req.agent_id or payload.get("agent_id") or context.get("agent_id") or agent_profiles.default_agent_id
|
||||
profile = agent_profiles.get(agent_id)
|
||||
|
||||
# 1) Identidade técnica do framework: isola tenant/agente/sessão.
|
||||
context.update({"tenant_id": tenant_id, "agent_id": profile.agent_id, "agent_profile": profile.__dict__})
|
||||
identity = AgentIdentity.from_context(context, session_id=msg.session_id)
|
||||
|
||||
# 2) Identidade de negócio: chaves canônicas vindas do front/canal.
|
||||
# Estas chaves são estáveis na sessão e seguem até agentes e MCP Router.
|
||||
previous_business_context = context.get("business_context") or context.get("identity") or {}
|
||||
business_context = identity_resolver.resolve(
|
||||
{**payload, **context},
|
||||
session_id=identity.conversation_key(),
|
||||
previous=previous_business_context,
|
||||
)
|
||||
missing_identity_keys = identity_resolver.validate(business_context)
|
||||
context.update({
|
||||
"business_context": business_context.model_dump(),
|
||||
"business_keys": business_context.to_context_dict(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
"conversation_key": identity.conversation_key(),
|
||||
"original_session_id": msg.session_id,
|
||||
})
|
||||
return identity, context, business_context, missing_identity_keys
|
||||
|
||||
|
||||
async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False) -> dict:
|
||||
try:
|
||||
msg = await gateway.normalize(req.channel, req.payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
payload = req.payload or {}
|
||||
identity, normalized_context, business_context, missing_identity_keys = _resolve_identity(req, msg)
|
||||
agent_session_id = identity.conversation_key()
|
||||
message_id = payload.get("message_id") or str(uuid4())
|
||||
workflow_id = _extract_workflow_id(payload)
|
||||
set_observability_context(
|
||||
session_id=agent_session_id,
|
||||
user_id=msg.user_id,
|
||||
tenant_id=identity.tenant_id,
|
||||
agent_id=identity.agent_id,
|
||||
channel=msg.channel,
|
||||
message_id=message_id,
|
||||
workflow_id=workflow_id,
|
||||
ura_call_id=payload.get("ura_call_id") or normalized_context.get("ura_call_id") or business_context.interaction_key,
|
||||
)
|
||||
|
||||
stream = sse_hub.stream_for(agent_session_id)
|
||||
async with stream.lock:
|
||||
await sse_hub.emit(agent_session_id, "flow.start", {"session_id": agent_session_id, "message_id": message_id, "agent_id": identity.agent_id}) if emit_sse else None
|
||||
|
||||
session = await sessions.get(agent_session_id)
|
||||
if not session:
|
||||
context_fields = {
|
||||
k: v
|
||||
for k, v in normalized_context.items()
|
||||
if k in SessionContext.model_fields
|
||||
and k not in {"tenant_id", "agent_id", "session_id", "user_id", "channel", "channel_id"}
|
||||
}
|
||||
session = SessionContext(
|
||||
tenant_id=identity.tenant_id,
|
||||
agent_id=identity.agent_id,
|
||||
session_id=agent_session_id,
|
||||
user_id=msg.user_id,
|
||||
channel=msg.channel,
|
||||
channel_id=msg.channel_id,
|
||||
**context_fields,
|
||||
)
|
||||
|
||||
session.tenant_id = identity.tenant_id
|
||||
session.agent_id = identity.agent_id
|
||||
session.channel = msg.channel
|
||||
session.channel_id = msg.channel_id or session.channel_id
|
||||
await sessions.upsert(session)
|
||||
session.metadata = {
|
||||
**(session.metadata or {}),
|
||||
"business_context": business_context.model_dump(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
"original_context": normalized_context,
|
||||
}
|
||||
await sse_hub.emit(agent_session_id, "session.upserted", {"session_id": agent_session_id, "business_context": business_context.model_dump()}) if emit_sse else None
|
||||
|
||||
await memory.append(
|
||||
agent_session_id,
|
||||
ChatMessage(
|
||||
role="user",
|
||||
content=msg.text,
|
||||
metadata={
|
||||
**normalized_context,
|
||||
"agent_id": identity.agent_id,
|
||||
"tenant_id": identity.tenant_id,
|
||||
"message_id": message_id,
|
||||
"business_context": business_context.model_dump(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
},
|
||||
),
|
||||
)
|
||||
await sse_hub.emit(agent_session_id, "message.received", {"session_id": agent_session_id, "role": "user"}) if emit_sse else None
|
||||
history = [m.model_dump(mode="json") for m in await memory.list(agent_session_id)]
|
||||
|
||||
cms_input = {
|
||||
"channel": req.channel,
|
||||
"tenant_id": req.tenant_id,
|
||||
"agent_id": req.agent_id,
|
||||
"payload": payload,
|
||||
}
|
||||
trace_context = {
|
||||
"text": msg.text,
|
||||
"channel": msg.channel,
|
||||
"channel_id": msg.channel_id,
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"conversation_key": agent_session_id,
|
||||
"workflow_id": workflow_id,
|
||||
"message_id": message_id,
|
||||
"business_context": business_context.model_dump(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
}
|
||||
root_span_name = _format_root_span_name(
|
||||
getattr(settings, "LANGFUSE_ROOT_SPAN_NAME", "agent.gateway_message"),
|
||||
{
|
||||
"workflow_id": workflow_id,
|
||||
"channel": msg.channel,
|
||||
"agent_id": identity.agent_id,
|
||||
"tenant_id": identity.tenant_id,
|
||||
},
|
||||
)
|
||||
root_tags = ["agent-template", msg.channel, f"agent:{identity.agent_id}", f"tenant:{identity.tenant_id}"]
|
||||
if workflow_id:
|
||||
root_tags.append(f"workflow:{workflow_id}")
|
||||
|
||||
async with telemetry.span(
|
||||
root_span_name,
|
||||
session_id=agent_session_id,
|
||||
user_id=session.user_id,
|
||||
channel=msg.channel,
|
||||
workflow_id=workflow_id,
|
||||
input=cms_input,
|
||||
tags=root_tags,
|
||||
_root_span=True,
|
||||
) as root_span:
|
||||
await telemetry.event("gateway.message.received", trace_context)
|
||||
await sse_hub.emit(agent_session_id, "workflow.started", trace_context) if emit_sse else None
|
||||
result = await workflow.ainvoke(
|
||||
{
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"session_id": agent_session_id,
|
||||
"conversation_key": agent_session_id,
|
||||
"workflow_id": workflow_id,
|
||||
"agent_profile": normalized_context["agent_profile"],
|
||||
"user_text": msg.text,
|
||||
"history": history,
|
||||
"context": {
|
||||
**normalized_context,
|
||||
"session": session.model_dump(mode="json"),
|
||||
"original_session_id": msg.session_id,
|
||||
"session_id": agent_session_id,
|
||||
"conversation_key": agent_session_id,
|
||||
"workflow_id": workflow_id,
|
||||
"user_id": session.user_id,
|
||||
"channel": msg.channel,
|
||||
"message_id": message_id,
|
||||
"business_context": business_context.model_dump(),
|
||||
"business_keys": business_context.to_context_dict(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await checkpoints.put(agent_session_id, {"state": result, "message_id": message_id})
|
||||
await sse_hub.emit(agent_session_id, "workflow.completed", {"session_id": agent_session_id, "route": result.get("route"), "intent": result.get("intent")}) if emit_sse else None
|
||||
|
||||
answer = result.get("final_answer") or result.get("answer") or ""
|
||||
await memory.append(
|
||||
agent_session_id,
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
content=answer,
|
||||
metadata={
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"message_id": f"assistant-{message_id}",
|
||||
"route": result.get("route"),
|
||||
"intent": result.get("intent"),
|
||||
"route_decision": result.get("route_decision"),
|
||||
"judges": result.get("judge_results"),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
await telemetry.event(
|
||||
"gateway.message.responded",
|
||||
{
|
||||
"session_id": agent_session_id,
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"route": result.get("route"),
|
||||
"intent": result.get("intent"),
|
||||
"answer_chars": len(answer),
|
||||
},
|
||||
)
|
||||
|
||||
response = ChannelResponse(
|
||||
channel=msg.channel,
|
||||
session_id=agent_session_id,
|
||||
text=answer,
|
||||
metadata={
|
||||
"channel_id": msg.channel_id,
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"original_session_id": msg.session_id,
|
||||
"conversation_key": agent_session_id,
|
||||
"workflow_id": workflow_id,
|
||||
"message_id": message_id,
|
||||
"route": result.get("route"),
|
||||
"intent": result.get("intent"),
|
||||
"route_decision": result.get("route_decision"),
|
||||
"domain": result.get("domain"),
|
||||
"mcp_tools": result.get("mcp_tools"),
|
||||
"mcp_results": result.get("mcp_results"),
|
||||
"business_context": business_context.model_dump(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
"judges": result.get("judge_results"),
|
||||
"guardrails": result.get("guardrail_decisions"),
|
||||
},
|
||||
)
|
||||
rendered = await gateway.render(response)
|
||||
root_span.set_output(rendered)
|
||||
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
|
||||
await sse_hub.emit(agent_session_id, "flow.end", {"session_id": agent_session_id, "message_id": message_id}) if emit_sse else None
|
||||
return rendered
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"llm_provider": settings.LLM_PROVIDER,
|
||||
"llm_class": llm.__class__.__name__,
|
||||
"langfuse_enabled": telemetry.is_enabled(),
|
||||
"agents": [p.agent_id for p in agent_profiles.list_profiles()],
|
||||
"default_agent_id": agent_profiles.default_agent_id,
|
||||
"routing_mode": settings.ROUTING_MODE,
|
||||
"sse_enabled": settings.ENABLE_SSE,
|
||||
"session_repository": settings.SESSION_REPOSITORY_PROVIDER,
|
||||
"memory_repository": settings.MEMORY_REPOSITORY_PROVIDER,
|
||||
"checkpoint_repository": settings.CHECKPOINT_REPOSITORY_PROVIDER,
|
||||
"usage_repository": settings.USAGE_REPOSITORY_PROVIDER,
|
||||
"identity_config_path": settings.IDENTITY_CONFIG_PATH,
|
||||
"mcp_parameter_mapping_path": settings.MCP_PARAMETER_MAPPING_PATH,
|
||||
"framework_channel_input_mode": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
|
||||
"legacy_channel_gateway_mode": settings.CHANNEL_GATEWAY_MODE,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/agents")
|
||||
async def list_agents():
|
||||
return {"default_agent_id": agent_profiles.default_agent_id, "agents": [p.__dict__ for p in agent_profiles.list_profiles()]}
|
||||
|
||||
|
||||
@app.get("/debug/env")
|
||||
async def debug_env():
|
||||
return {
|
||||
"APP_ENV": settings.APP_ENV,
|
||||
"LLM_PROVIDER": settings.LLM_PROVIDER,
|
||||
"ENABLE_LANGFUSE": settings.ENABLE_LANGFUSE,
|
||||
"LANGFUSE_HOST": settings.LANGFUSE_HOST,
|
||||
"TELEMETRY_ENABLED": telemetry.is_enabled(),
|
||||
"SQLITE_DB_PATH": settings.SQLITE_DB_PATH,
|
||||
"SESSION_REPOSITORY_PROVIDER": settings.SESSION_REPOSITORY_PROVIDER,
|
||||
"MEMORY_REPOSITORY_PROVIDER": settings.MEMORY_REPOSITORY_PROVIDER,
|
||||
"CHECKPOINT_REPOSITORY_PROVIDER": settings.CHECKPOINT_REPOSITORY_PROVIDER,
|
||||
"AGENTS_CONFIG_PATH": settings.AGENTS_CONFIG_PATH,
|
||||
"ROUTING_CONFIG_PATH": settings.ROUTING_CONFIG_PATH,
|
||||
"ROUTING_MODE": settings.ROUTING_MODE,
|
||||
"FRAMEWORK_CHANNEL_INPUT_MODE": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
|
||||
"CHANNEL_GATEWAY_MODE": settings.CHANNEL_GATEWAY_MODE,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/test-llm")
|
||||
async def test_llm():
|
||||
async with telemetry.span("debug.test_llm", input={"message": "Diga apenas OK"}):
|
||||
answer = await llm.ainvoke([
|
||||
{"role": "system", "content": "Responda de forma curta."},
|
||||
{"role": "user", "content": "Diga apenas OK"},
|
||||
])
|
||||
telemetry.flush()
|
||||
return {"provider": llm.__class__.__name__, "answer": answer}
|
||||
|
||||
|
||||
@app.post("/debug/route")
|
||||
async def debug_route(req: GatewayRequest):
|
||||
msg = await gateway.normalize(req.channel, req.payload)
|
||||
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
|
||||
state = {
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"session_id": msg.session_id or "debug-session",
|
||||
"conversation_key": identity.conversation_key(),
|
||||
"agent_profile": context["agent_profile"],
|
||||
"user_text": msg.text,
|
||||
"sanitized_input": msg.text,
|
||||
"history": [],
|
||||
"context": {**context, "session": context.get("session", {}), "channel": msg.channel, "business_context": business_context.model_dump()},
|
||||
}
|
||||
if settings.ROUTING_MODE == "supervisor":
|
||||
plan = await workflow.supervisor.route_plan(state)
|
||||
return {"mode": "supervisor", "route": "supervisor_agent", "agents": plan.agents, "intent": plan.intent, "confidence": plan.confidence, "reason": plan.reason, "metadata": plan.metadata}
|
||||
decision = await workflow.router.route(state)
|
||||
data = decision.model_dump(mode="json")
|
||||
data["mode"] = "router"
|
||||
return data
|
||||
|
||||
|
||||
|
||||
|
||||
@app.post("/debug/identity")
|
||||
async def debug_identity(req: GatewayRequest):
|
||||
msg = await gateway.normalize(req.channel, req.payload)
|
||||
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
|
||||
return {
|
||||
"technical_identity": {
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"conversation_key": identity.conversation_key(),
|
||||
"original_session_id": msg.session_id,
|
||||
},
|
||||
"business_context": business_context.model_dump(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
"context_keys": sorted(context.keys()),
|
||||
}
|
||||
|
||||
@app.get("/debug/usage")
|
||||
async def debug_usage(tenant_id: str | None = None, session_id: str | None = None):
|
||||
return await usage_repository.summarize(tenant_id=tenant_id, session_id=session_id)
|
||||
|
||||
|
||||
@app.get("/debug/mcp/tools")
|
||||
async def debug_mcp_tools():
|
||||
return {"enabled": tool_router.enabled, "tools": tool_router.describe_tools()}
|
||||
|
||||
|
||||
@app.post("/debug/mcp/call/{tool_name}")
|
||||
async def debug_mcp_call(tool_name: str, arguments: dict | None = None):
|
||||
arguments = arguments or {}
|
||||
ctx = arguments.get("business_context") or arguments.get("identity") or {}
|
||||
result = await tool_router.call(
|
||||
tool_name,
|
||||
arguments,
|
||||
business_context=ctx,
|
||||
original_context=arguments,
|
||||
)
|
||||
return result.model_dump(mode="json")
|
||||
|
||||
|
||||
@app.post("/gateway/message")
|
||||
async def gateway_message(req: GatewayRequest):
|
||||
return await _process_gateway_message(req, emit_sse=False)
|
||||
|
||||
|
||||
@app.post("/gateway/message/sse")
|
||||
async def gateway_message_sse(req: GatewayRequest):
|
||||
return await _process_gateway_message(req, emit_sse=True)
|
||||
|
||||
|
||||
@app.get("/gateway/events/{session_id}")
|
||||
async def gateway_events(session_id: str, request: Request):
|
||||
last = request.headers.get("last-event-id") or request.query_params.get("last_event_id") or "0"
|
||||
return StreamingResponse(
|
||||
sse_hub.subscribe(session_id, int(last)),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/sessions/{session_id}/messages")
|
||||
async def get_session_messages(session_id: str, limit: int = 50):
|
||||
return {"session_id": session_id, "messages": [m.model_dump(mode="json") for m in await memory.list(session_id, limit)]}
|
||||
|
||||
|
||||
@app.get("/sessions/{session_id}/checkpoint")
|
||||
async def get_session_checkpoint(session_id: str):
|
||||
return {"session_id": session_id, "checkpoint": await checkpoints.get_latest(session_id)}
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
telemetry.shutdown()
|
||||
@@ -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,53 @@
|
||||
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]
|
||||
active_transaction: dict[str, Any]
|
||||
last_transaction: dict[str, Any]
|
||||
transaction_status: str
|
||||
confirmation_required: bool
|
||||
confirmation_received: bool
|
||||
tool_policy_result: dict[str, Any]
|
||||
missing_parameters: list[str]
|
||||
supervisor_plan: dict[str, Any]
|
||||
supervisor_results: list[dict[str, Any]]
|
||||
active_agent: str
|
||||
route_bypassed: bool
|
||||
continuity_signal: dict[str, Any]
|
||||
session_control: str
|
||||
session_ended: bool
|
||||
human_handoff_requested: bool
|
||||
blocked: bool
|
||||
supervisor_action: str
|
||||
supervisor_guidance: str
|
||||
supervisor_attempt: int
|
||||
supervisor_handover_reason: str
|
||||
output_supervisor_results: list[dict[str, Any]]
|
||||
output_guardrails_already_applied: bool
|
||||
long_term_memories: list[dict[str, Any]]
|
||||
long_term_memory_context: str
|
||||
long_term_memory_write_result: dict[str, Any]
|
||||
@@ -0,0 +1,816 @@
|
||||
from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
|
||||
from agent_framework.guardrails.pipeline import GuardrailPipeline
|
||||
from agent_framework.guardrails.output_supervisor import OutputSupervisor
|
||||
from agent_framework.guardrails.rail_action import RailAction
|
||||
from agent_framework.guardrails.rail_result import RailResult
|
||||
from agent_framework.judges.judge import JudgePipeline
|
||||
from agent_framework.routing.enterprise_router import EnterpriseRouter
|
||||
from agent_framework.supervisor.supervisor import Supervisor
|
||||
from agent_framework.observability.workflow_events import WorkflowTelemetry
|
||||
from agent_framework.observability.guardrail_events import GuardrailTelemetry
|
||||
from agent_framework.observability.judge_events import JudgeTelemetry
|
||||
from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry
|
||||
from agent_framework.observability.observer import AgentObserver
|
||||
from app.agents.billing_agent import BillingAgent
|
||||
from app.agents.product_agent import ProductAgent
|
||||
from app.agents.orders_agent import OrdersAgent
|
||||
from app.agents.support_agent import SupportAgent
|
||||
from app.state import AgentState
|
||||
from agent_framework.rag.rag_service import RagService
|
||||
from agent_framework.rag.embedding_provider import create_embedding_provider
|
||||
from agent_framework.cache.cache import create_cache
|
||||
from agent_framework.memory.long_term_memory import create_long_term_memory_manager
|
||||
|
||||
|
||||
class LegacyOutputGuardrailRail:
|
||||
"""Adapter: reutiliza GuardrailPipeline.run_output dentro do OutputSupervisor novo.
|
||||
|
||||
O framework antigo retornava decisões allowed=True/False. O OutputSupervisor
|
||||
corporativo trabalha com RailAction (allow/sanitize/retry/block/handover).
|
||||
Este adapter evita reescrever todos os rails agora e mantém compatibilidade.
|
||||
"""
|
||||
|
||||
code = "LEGACY_OUTPUT_GUARDRAILS"
|
||||
|
||||
def __init__(self, pipeline: GuardrailPipeline):
|
||||
self.pipeline = pipeline
|
||||
|
||||
async def evaluate(self, candidate: str, context: dict):
|
||||
final, decisions = await self.pipeline.run_output(candidate, context)
|
||||
serialized = [d.model_dump() for d in decisions]
|
||||
|
||||
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
|
||||
if blocked:
|
||||
first = blocked[0]
|
||||
code = (getattr(first, "code", "") or "").upper()
|
||||
action = RailAction.RETRY if code in {"REVPREC", "CMP", "SCO", "GND"} else RailAction.BLOCK
|
||||
return RailResult(
|
||||
code=code or self.code,
|
||||
action=action,
|
||||
reason=getattr(first, "reason", "Resposta bloqueada por guardrail de saída"),
|
||||
guidance=getattr(first, "reason", "Regerar resposta seguindo as políticas de saída."),
|
||||
sanitized_text=final,
|
||||
metadata={"legacy_decisions": serialized},
|
||||
)
|
||||
|
||||
if final != candidate:
|
||||
return RailResult(
|
||||
code=self.code,
|
||||
action=RailAction.SANITIZE,
|
||||
reason="Resposta sanitizada por guardrail de saída legado.",
|
||||
sanitized_text=final,
|
||||
metadata={"legacy_decisions": serialized},
|
||||
)
|
||||
|
||||
return RailResult(
|
||||
code=self.code,
|
||||
action=RailAction.ALLOW,
|
||||
reason="Resposta aprovada pelos guardrails de saída legados.",
|
||||
sanitized_text=final,
|
||||
metadata={"legacy_decisions": serialized},
|
||||
)
|
||||
|
||||
|
||||
class AgentWorkflow:
|
||||
"""Workflow principal com dois modos de roteamento.
|
||||
|
||||
Modos suportados por configuração:
|
||||
ROUTING_MODE=router
|
||||
input_guardrails -> routing_decision/EnterpriseRouter -> 1 agente -> output_guardrails
|
||||
|
||||
ROUTING_MODE=supervisor
|
||||
input_guardrails -> routing_decision/Supervisor -> supervisor_agent -> N agentes -> consolidação
|
||||
|
||||
Em ambos os modos, memória/checkpoint/session usam tenant_id:agent_id:session_id.
|
||||
"""
|
||||
|
||||
def __init__(self, llm, memory, telemetry, analytics, settings, observer: AgentObserver | None = None, tool_router=None, summary_memory=None):
|
||||
self.llm = llm
|
||||
self.memory = memory
|
||||
self.telemetry = telemetry
|
||||
self.analytics = analytics
|
||||
self.observer = observer or AgentObserver(analytics=analytics)
|
||||
self.settings = settings
|
||||
self.tool_router = tool_router
|
||||
self.summary_memory = summary_memory
|
||||
self.long_term_memory_manager = create_long_term_memory_manager(settings, telemetry=telemetry)
|
||||
self.guardrails = GuardrailPipeline(
|
||||
observer=self.observer,
|
||||
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
|
||||
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
|
||||
)
|
||||
self.output_supervisor_engine = OutputSupervisor(
|
||||
rails=[LegacyOutputGuardrailRail(self.guardrails)],
|
||||
observer=self.observer,
|
||||
max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)),
|
||||
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
|
||||
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
|
||||
)
|
||||
self.judges = JudgePipeline()
|
||||
self.supervisor = Supervisor()
|
||||
self.workflow_telemetry = WorkflowTelemetry(telemetry)
|
||||
self.guardrail_telemetry = GuardrailTelemetry(telemetry)
|
||||
self.judge_telemetry = JudgeTelemetry(telemetry)
|
||||
self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry)
|
||||
self.cache = create_cache(settings)
|
||||
self.embedding_provider = create_embedding_provider(settings)
|
||||
self.rag_service = RagService(settings, embedding_provider=self.embedding_provider, telemetry=telemetry)
|
||||
self.router = EnterpriseRouter(settings, llm=llm, telemetry=telemetry)
|
||||
agent_kwargs = {"telemetry": telemetry, "tool_router": getattr(self, "tool_router", None), "rag_service": self.rag_service, "cache": self.cache, "settings": settings, "observer": self.observer, "memory": memory, "summary_memory": summary_memory}
|
||||
self.billing = BillingAgent(llm, **agent_kwargs)
|
||||
self.product = ProductAgent(llm, **agent_kwargs)
|
||||
self.orders = OrdersAgent(llm, **agent_kwargs)
|
||||
self.support = SupportAgent(llm, **agent_kwargs)
|
||||
|
||||
# The existing agent constructors intentionally keep their stable API.
|
||||
# Long-term memory is injected as a runtime capability after creation.
|
||||
for agent in (self.billing, self.product, self.orders, self.support):
|
||||
agent.long_term_memory_manager = self.long_term_memory_manager
|
||||
self.graph = self._build_graph()
|
||||
|
||||
def _node(self, name, fn):
|
||||
async def _wrapped(state):
|
||||
async with self.langgraph_telemetry.node(name, state):
|
||||
return await fn(state)
|
||||
return _wrapped
|
||||
|
||||
def _build_graph(self):
|
||||
builder = StateGraph(AgentState)
|
||||
builder.add_node("input_guardrails", self._node("input_guardrails", self.input_guardrails))
|
||||
builder.add_node("routing_decision", self._node("routing_decision", self.routing_decision))
|
||||
builder.add_node("billing_agent", self._node("billing_agent", self.billing_agent))
|
||||
builder.add_node("product_agent", self._node("product_agent", self.product_agent))
|
||||
builder.add_node("orders_agent", self._node("orders_agent", self.orders_agent))
|
||||
builder.add_node("support_agent", self._node("support_agent", self.support_agent))
|
||||
builder.add_node("handoff", self._node("handoff", self.handoff))
|
||||
builder.add_node("human_handoff", self._node("human_handoff", self.human_handoff))
|
||||
builder.add_node("end_session", self._node("end_session", self.end_session))
|
||||
builder.add_node("supervisor_agent", self._node("supervisor_agent", self.supervisor_agent))
|
||||
builder.add_node("output_supervisor", self._node("output_supervisor", self.output_supervisor))
|
||||
builder.add_node("output_guardrails", self._node("output_guardrails", self.output_guardrails))
|
||||
builder.add_node("judge", self._node("judge", self.judge))
|
||||
builder.add_node("supervisor_review", self._node("supervisor_review", self.supervisor_review))
|
||||
builder.add_node("persist_long_term_memory", self._node("persist_long_term_memory", self.persist_long_term_memory))
|
||||
builder.add_node("persist", self._node("persist", self.persist))
|
||||
|
||||
builder.add_edge(START, "input_guardrails")
|
||||
builder.add_conditional_edges(
|
||||
"input_guardrails",
|
||||
self._after_input_guardrails,
|
||||
{"blocked": "persist", "continue": "routing_decision"},
|
||||
)
|
||||
builder.add_conditional_edges(
|
||||
"routing_decision",
|
||||
lambda s: s.get("route", "billing_agent"),
|
||||
{
|
||||
"billing_agent": "billing_agent",
|
||||
"product_agent": "product_agent",
|
||||
"orders_agent": "orders_agent",
|
||||
"support_agent": "support_agent",
|
||||
"handoff": "handoff",
|
||||
"human_handoff": "human_handoff",
|
||||
"end_session": "end_session",
|
||||
"supervisor_agent": "supervisor_agent",
|
||||
},
|
||||
)
|
||||
builder.add_edge("billing_agent", "output_supervisor")
|
||||
builder.add_edge("product_agent", "output_supervisor")
|
||||
builder.add_edge("orders_agent", "output_supervisor")
|
||||
builder.add_edge("support_agent", "output_supervisor")
|
||||
builder.add_edge("handoff", "output_supervisor")
|
||||
builder.add_edge("human_handoff", "output_supervisor")
|
||||
builder.add_edge("end_session", "output_supervisor")
|
||||
builder.add_edge("supervisor_agent", "output_supervisor")
|
||||
builder.add_edge("output_supervisor", "output_guardrails")
|
||||
builder.add_edge("output_guardrails", "judge")
|
||||
builder.add_edge("judge", "supervisor_review")
|
||||
builder.add_edge("supervisor_review", "persist_long_term_memory")
|
||||
builder.add_edge("persist_long_term_memory", "persist")
|
||||
builder.add_edge("persist", END)
|
||||
|
||||
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
|
||||
|
||||
def _after_input_guardrails(self, state):
|
||||
return "blocked" if state.get("blocked") else "continue"
|
||||
|
||||
async def input_guardrails(self, state):
|
||||
if state.get("session_ended") is True:
|
||||
answer = str(getattr(
|
||||
self.settings,
|
||||
"SESSION_ALREADY_ENDED_MESSAGE",
|
||||
"Este atendimento já foi encerrado. Inicie uma nova sessão para continuar.",
|
||||
))
|
||||
await self.telemetry.event(
|
||||
"session.message.rejected_after_end",
|
||||
{"session_id": state.get("conversation_key") or state.get("session_id")},
|
||||
)
|
||||
return {
|
||||
"answer": answer,
|
||||
"final_answer": answer,
|
||||
"blocked": True,
|
||||
"session_control": "END_SESSION",
|
||||
"session_ended": True,
|
||||
"next_state": "SESSION_ENDED",
|
||||
}
|
||||
async with self.telemetry.span(
|
||||
"workflow.input_guardrails",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input=state.get("user_text"),
|
||||
):
|
||||
history_texts = [m.get("content", "") for m in state.get("history", [])]
|
||||
await self.observer.emit_grl(
|
||||
"001",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"phase": "input",
|
||||
},
|
||||
component="workflow.input_guardrails.start",
|
||||
)
|
||||
sanitized, decisions = await self.guardrails.run_input(
|
||||
state["user_text"],
|
||||
{
|
||||
**(state.get("context") or {}),
|
||||
"history_texts": history_texts,
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"agent_profile": state.get("agent_profile") or {},
|
||||
},
|
||||
)
|
||||
for _decision in decisions:
|
||||
await self.guardrail_telemetry.evaluated("input", _decision)
|
||||
await self.observer.emit_grl(
|
||||
"002" if _decision.allowed else "004",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"phase": "input",
|
||||
"rail_code": getattr(_decision, "code", None),
|
||||
"allowed": bool(_decision.allowed),
|
||||
"reason": getattr(_decision, "reason", None),
|
||||
},
|
||||
component="workflow.input_guardrails.decision",
|
||||
)
|
||||
if not _decision.allowed:
|
||||
await self.guardrail_telemetry.blocked("input", _decision)
|
||||
await self.telemetry.event(
|
||||
"guardrails.input.completed",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"decisions": [d.model_dump() for d in decisions],
|
||||
},
|
||||
)
|
||||
await self.observer.emit_grl(
|
||||
"009",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"phase": "input",
|
||||
"blocked": any(not d.allowed for d in decisions),
|
||||
"decision_count": len(decisions),
|
||||
},
|
||||
component="workflow.input_guardrails.final",
|
||||
)
|
||||
if any(not d.allowed for d in decisions):
|
||||
return {
|
||||
"sanitized_input": sanitized,
|
||||
"answer": "Não consegui seguir com essa mensagem por regra de segurança.",
|
||||
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.",
|
||||
"guardrail_decisions": [d.model_dump() for d in decisions],
|
||||
"route": "blocked",
|
||||
"blocked": True,
|
||||
}
|
||||
return {
|
||||
"sanitized_input": sanitized,
|
||||
"guardrail_decisions": [d.model_dump() for d in decisions],
|
||||
"blocked": False,
|
||||
}
|
||||
|
||||
async def routing_decision(self, state):
|
||||
mode = getattr(self.settings, "ROUTING_MODE", "router")
|
||||
async with self.telemetry.span(
|
||||
"workflow.routing_decision",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={
|
||||
"mode": mode,
|
||||
"text": state.get("sanitized_input") or state.get("user_text"),
|
||||
"previous_state": state.get("next_state"),
|
||||
},
|
||||
):
|
||||
if mode == "supervisor":
|
||||
plan = await self.supervisor.route_plan(state)
|
||||
await self.langgraph_telemetry.edge("routing_decision", "supervisor_agent", state, {"method": "supervisor", "intent": plan.intent, "confidence": plan.confidence})
|
||||
return {
|
||||
"route": "supervisor_agent",
|
||||
"intent": plan.intent,
|
||||
"supervisor_plan": {
|
||||
"agents": plan.agents,
|
||||
"intent": plan.intent,
|
||||
"confidence": plan.confidence,
|
||||
"reason": plan.reason,
|
||||
"metadata": plan.metadata,
|
||||
},
|
||||
"route_decision": {
|
||||
"route": "supervisor_agent",
|
||||
"agent": "supervisor",
|
||||
"intent": plan.intent,
|
||||
"confidence": plan.confidence,
|
||||
"reason": plan.reason,
|
||||
"method": "supervisor",
|
||||
"metadata": plan.metadata,
|
||||
},
|
||||
}
|
||||
|
||||
decision = await self.router.route(state)
|
||||
await self.langgraph_telemetry.edge("routing_decision", decision.route, state, {"method": getattr(decision, "method", None), "intent": decision.intent, "confidence": decision.confidence})
|
||||
await self.observer.emit_ic(
|
||||
"ROUTE_SELECTED",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"route": decision.route,
|
||||
"intent": decision.intent,
|
||||
"confidence": decision.confidence,
|
||||
"method": getattr(decision, "method", None),
|
||||
},
|
||||
component="workflow.routing_decision",
|
||||
)
|
||||
return {
|
||||
"route": decision.route,
|
||||
"intent": decision.intent,
|
||||
"route_decision": decision.model_dump(mode="json"),
|
||||
"domain": decision.domain,
|
||||
"mcp_tools": decision.mcp_tools,
|
||||
"next_state": decision.next_state,
|
||||
"active_agent": decision.agent,
|
||||
"route_bypassed": decision.method == "continuity",
|
||||
"session_control": (decision.metadata or {}).get("session_control", ""),
|
||||
"human_handoff_requested": (decision.metadata or {}).get("session_control") == "HUMAN_HANDOFF",
|
||||
"session_ended": (decision.metadata or {}).get("session_control") == "END_SESSION",
|
||||
"continuity_signal": {
|
||||
"decision": (decision.metadata or {}).get("continuity_decision"),
|
||||
"confidence": decision.confidence if decision.method == "continuity" else None,
|
||||
"reason": decision.reason if decision.method == "continuity" else None,
|
||||
"profile": (decision.metadata or {}).get("continuity_profile"),
|
||||
} if decision.method == "continuity" else {},
|
||||
}
|
||||
|
||||
async def billing_agent(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.agent.billing",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"intent": state.get("intent")},
|
||||
):
|
||||
return await self.billing.run(state)
|
||||
|
||||
async def product_agent(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.agent.product",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"intent": state.get("intent")},
|
||||
):
|
||||
return await self.product.run(state)
|
||||
|
||||
async def orders_agent(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.agent.orders",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"intent": state.get("intent")},
|
||||
):
|
||||
return await self.orders.run(state)
|
||||
|
||||
async def support_agent(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.agent.support",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"intent": state.get("intent")},
|
||||
):
|
||||
return await self.support.run(state)
|
||||
|
||||
async def supervisor_agent(self, state):
|
||||
"""Executa um ou mais agentes no modo supervisor e consolida a resposta.
|
||||
|
||||
Este nó mantém o desenho de supervisor sem obrigar o restante do workflow
|
||||
a conhecer quantos agentes foram acionados. Cada execução especializada
|
||||
recebe o mesmo estado, mas com route/active_agent atualizados.
|
||||
"""
|
||||
plan = state.get("supervisor_plan") or {}
|
||||
agents = plan.get("agents") or ["billing_agent"]
|
||||
handlers = {
|
||||
"billing_agent": self.billing.run,
|
||||
"product_agent": self.product.run,
|
||||
"orders_agent": self.orders.run,
|
||||
"support_agent": self.support.run,
|
||||
}
|
||||
partials = []
|
||||
mcp_results = []
|
||||
async with self.telemetry.span(
|
||||
"workflow.supervisor_agent",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"agents": agents, "intent": state.get("intent")},
|
||||
):
|
||||
for agent_name in agents:
|
||||
handler = handlers.get(agent_name)
|
||||
if handler is None:
|
||||
continue
|
||||
child_state = {**state, "route": agent_name, "active_agent": agent_name}
|
||||
result = await handler(child_state)
|
||||
partials.append({"agent": agent_name, "answer": result.get("answer", "")})
|
||||
mcp_results.extend(result.get("mcp_results") or [])
|
||||
|
||||
if len(partials) == 1:
|
||||
answer = partials[0]["answer"]
|
||||
else:
|
||||
joined = "\n\n".join(f"{p['agent']}: {p['answer']}" for p in partials)
|
||||
answer = (
|
||||
"[Supervisor] Consolidação de múltiplos agentes acionados.\n"
|
||||
f"{joined}"
|
||||
)
|
||||
return {
|
||||
"answer": answer,
|
||||
"supervisor_results": partials,
|
||||
"mcp_results": mcp_results,
|
||||
"next_state": "SUPERVISOR_ACTIVE",
|
||||
}
|
||||
|
||||
async def handoff(self, state):
|
||||
async with self.telemetry.span("workflow.handoff", session_id=state.get("session_id")):
|
||||
target = (state.get("route_decision") or {}).get("metadata", {}).get("target_agent")
|
||||
answer = (
|
||||
"Vou redirecionar sua solicitação para o especialista correto. "
|
||||
f"Destino sugerido: {target or 'agente especializado'}."
|
||||
)
|
||||
return {"answer": answer}
|
||||
|
||||
async def human_handoff(self, state):
|
||||
session_id = state.get("conversation_key") or state.get("session_id")
|
||||
async with self.telemetry.span("workflow.human_handoff", session_id=session_id):
|
||||
answer = str(getattr(self.settings, "HUMAN_HANDOFF_MESSAGE", "Vou encaminhar seu atendimento para uma pessoa."))
|
||||
await self.telemetry.event(
|
||||
"session.human_handoff.requested",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"reason": (state.get("route_decision") or {}).get("reason"),
|
||||
},
|
||||
)
|
||||
return {
|
||||
"answer": answer,
|
||||
"session_control": "HUMAN_HANDOFF",
|
||||
"human_handoff_requested": True,
|
||||
"session_ended": False,
|
||||
"next_state": "HUMAN_HANDOFF_REQUESTED",
|
||||
}
|
||||
|
||||
async def end_session(self, state):
|
||||
session_id = state.get("conversation_key") or state.get("session_id")
|
||||
async with self.telemetry.span("workflow.end_session", session_id=session_id):
|
||||
answer = str(getattr(self.settings, "END_SESSION_MESSAGE", "Atendimento encerrado. Obrigado pelo contato."))
|
||||
await self.telemetry.event(
|
||||
"session.end.requested",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"reason": (state.get("route_decision") or {}).get("reason"),
|
||||
},
|
||||
)
|
||||
return {
|
||||
"answer": answer,
|
||||
"session_control": "END_SESSION",
|
||||
"session_ended": True,
|
||||
"human_handoff_requested": False,
|
||||
"next_state": "SESSION_ENDED",
|
||||
}
|
||||
|
||||
async def output_supervisor(self, state):
|
||||
"""Valida a resposta candidata com o OutputSupervisor corporativo.
|
||||
|
||||
Este nó não substitui o roteador/supervisor multiagente. Ele roda após o
|
||||
agente gerar `answer` e antes dos judges/persistência, produzindo campos
|
||||
supervisor_* no state e eventos GRL.001..GRL.009 via AgentObserver.
|
||||
"""
|
||||
if not bool(getattr(self.settings, "ENABLE_OUTPUT_SUPERVISOR", True)):
|
||||
return {
|
||||
"output_guardrails_already_applied": False,
|
||||
"supervisor_action": "disabled",
|
||||
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
|
||||
}
|
||||
|
||||
candidate = state.get("answer") or ""
|
||||
context = {
|
||||
**(state.get("context") or {}),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"route": state.get("route"),
|
||||
"intent": state.get("intent"),
|
||||
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
|
||||
}
|
||||
async with self.telemetry.span(
|
||||
"workflow.output_supervisor",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input=candidate,
|
||||
):
|
||||
decision = await self.output_supervisor_engine.evaluate(candidate, context)
|
||||
action = decision.action.value
|
||||
await self.telemetry.event(
|
||||
"output_supervisor.completed",
|
||||
{
|
||||
"session_id": context["session_id"],
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"action": action,
|
||||
"approved": decision.approved,
|
||||
"guidance": decision.guidance,
|
||||
},
|
||||
)
|
||||
|
||||
await self.observer.emit_ic(
|
||||
"IC.OUTPUT_SUPERVISOR_COMPLETED",
|
||||
{
|
||||
"session_id": context["session_id"],
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"route": state.get("route"),
|
||||
"intent": state.get("intent"),
|
||||
"action": action,
|
||||
"approved": decision.approved,
|
||||
"result_count": len(decision.results),
|
||||
},
|
||||
component="workflow.output_supervisor",
|
||||
)
|
||||
|
||||
if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
|
||||
final_answer = decision.candidate
|
||||
elif decision.action == RailAction.HANDOVER:
|
||||
final_answer = "Vou encaminhar seu atendimento para continuidade com um especialista."
|
||||
else:
|
||||
final_answer = decision.fallback_message
|
||||
|
||||
return {
|
||||
"answer": final_answer,
|
||||
"final_answer": final_answer,
|
||||
"supervisor_action": action,
|
||||
"supervisor_guidance": decision.guidance,
|
||||
"supervisor_attempt": int(state.get("supervisor_attempt", 0)) + (1 if decision.action == RailAction.RETRY else 0),
|
||||
"supervisor_handover_reason": decision.handover_reason,
|
||||
"output_supervisor_results": [
|
||||
{
|
||||
"code": r.code,
|
||||
"action": r.action.value,
|
||||
"reason": r.reason,
|
||||
"guidance": r.guidance,
|
||||
"metadata": r.metadata,
|
||||
}
|
||||
for r in decision.results
|
||||
],
|
||||
"output_guardrails_already_applied": True,
|
||||
"guardrail_decisions": state.get("guardrail_decisions", [])
|
||||
+ [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])],
|
||||
}
|
||||
|
||||
async def output_guardrails(self, state):
|
||||
if state.get("output_guardrails_already_applied"):
|
||||
return {"final_answer": state.get("final_answer") or state.get("answer") or ""}
|
||||
|
||||
async with self.telemetry.span(
|
||||
"workflow.output_guardrails",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input=state.get("answer"),
|
||||
):
|
||||
await self.observer.emit_grl(
|
||||
"001",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"phase": "output",
|
||||
"route": state.get("route"),
|
||||
"intent": state.get("intent"),
|
||||
},
|
||||
component="workflow.output_guardrails.start",
|
||||
)
|
||||
final, decisions = await self.guardrails.run_output(
|
||||
state["answer"], state.get("context", {})
|
||||
)
|
||||
for _decision in decisions:
|
||||
await self.guardrail_telemetry.evaluated("output", _decision)
|
||||
await self.observer.emit_grl(
|
||||
"002" if _decision.allowed else "004",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"phase": "output",
|
||||
"rail_code": getattr(_decision, "code", None),
|
||||
"allowed": bool(_decision.allowed),
|
||||
"reason": getattr(_decision, "reason", None),
|
||||
},
|
||||
component="workflow.output_guardrails.decision",
|
||||
)
|
||||
if not _decision.allowed:
|
||||
await self.guardrail_telemetry.blocked("output", _decision)
|
||||
await self.telemetry.event(
|
||||
"guardrails.output.completed",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"decisions": [d.model_dump() for d in decisions],
|
||||
},
|
||||
)
|
||||
await self.observer.emit_grl(
|
||||
"009",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"phase": "output",
|
||||
"blocked": any(not d.allowed for d in decisions),
|
||||
"decision_count": len(decisions),
|
||||
},
|
||||
component="workflow.output_guardrails.final",
|
||||
)
|
||||
return {
|
||||
"final_answer": final,
|
||||
"guardrail_decisions": state.get("guardrail_decisions", [])
|
||||
+ [d.model_dump() for d in decisions],
|
||||
}
|
||||
|
||||
async def judge(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.judge",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"question": state.get("user_text"), "answer": state.get("final_answer")},
|
||||
):
|
||||
judge_context = dict(state.get("context", {}) or {})
|
||||
judge_context["mcp_results"] = state.get("mcp_results", [])
|
||||
judge_context["evidence"] = state.get("mcp_results", []) or judge_context.get("evidence")
|
||||
judge_context["route"] = state.get("route")
|
||||
judge_context["intent"] = state.get("intent")
|
||||
# Judge sampling must see the finalized transaction state. These
|
||||
# fields are populated by the agent/tool runtime before this node.
|
||||
for key in (
|
||||
"transaction_status",
|
||||
"confirmation_required",
|
||||
"confirmation_received",
|
||||
"tool_policy_result",
|
||||
"selected_tool_call",
|
||||
"pending_tool_call",
|
||||
):
|
||||
judge_context[key] = state.get(key)
|
||||
judge_context["transactional_tools"] = [
|
||||
result.get("tool_name")
|
||||
for result in state.get("mcp_results", [])
|
||||
if isinstance(result, dict)
|
||||
and (
|
||||
(result.get("metadata") or {}).get("operation_type") == "transactional"
|
||||
or result.get("awaiting_confirmation")
|
||||
or result.get("transaction_status")
|
||||
)
|
||||
]
|
||||
results = await self.judges.evaluate_all(
|
||||
state["user_text"], state["final_answer"], judge_context
|
||||
)
|
||||
for _result in results:
|
||||
await self.judge_telemetry.evaluated(_result)
|
||||
await self.telemetry.event(
|
||||
"judges.completed",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"results": [r.model_dump() for r in results],
|
||||
},
|
||||
)
|
||||
return {"judge_results": [r.model_dump() for r in results]}
|
||||
|
||||
async def supervisor_review(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.supervisor_review",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input=state.get("final_answer"),
|
||||
):
|
||||
ok, answer = await self.supervisor.review(
|
||||
state["final_answer"], state.get("context", {})
|
||||
)
|
||||
await self.telemetry.event(
|
||||
"supervisor.review.completed",
|
||||
{"session_id": state.get("session_id"), "approved": ok},
|
||||
)
|
||||
return {"final_answer": answer if ok else answer}
|
||||
|
||||
async def persist_long_term_memory(self, state):
|
||||
result = await self.long_term_memory_manager.persist_turn(state)
|
||||
return {"long_term_memory_write_result": result}
|
||||
|
||||
async def persist(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.persist",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
input={"route": state.get("route"), "intent": state.get("intent")},
|
||||
):
|
||||
await self.observer.emit_ic(
|
||||
"AGENT_COMPLETED",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state["session_id"],
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"route": state.get("route"),
|
||||
"intent": state.get("intent"),
|
||||
"route_decision": state.get("route_decision"),
|
||||
"judges": state.get("judge_results", []),
|
||||
"mcp_tools": state.get("mcp_tools", []),
|
||||
"mcp_results": state.get("mcp_results", []),
|
||||
},
|
||||
)
|
||||
|
||||
await self.observer.emit_noc(
|
||||
"006",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state["session_id"],
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"route": state.get("route"),
|
||||
"intent": state.get("intent"),
|
||||
"answer_chars": len(state.get("final_answer") or ""),
|
||||
},
|
||||
component="workflow.persist",
|
||||
)
|
||||
|
||||
await self.telemetry.event(
|
||||
"agent.completed",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state["session_id"],
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"route": state.get("route"),
|
||||
"intent": state.get("intent"),
|
||||
"answer_chars": len(state.get("final_answer") or ""),
|
||||
},
|
||||
)
|
||||
return state
|
||||
|
||||
async def ainvoke(self, state):
|
||||
thread_id = state.get("conversation_key") or state["session_id"]
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
async with self.telemetry.span(
|
||||
"workflow.langgraph.ainvoke",
|
||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||
user_id=state.get("context", {}).get("user_id"),
|
||||
input={"user_text": state.get("user_text")},
|
||||
tags=["langgraph", "agent-workflow", f"routing-mode:{getattr(self.settings, 'ROUTING_MODE', 'router')}",],
|
||||
):
|
||||
await self.workflow_telemetry.started("agent_workflow", state)
|
||||
await self.observer.emit_noc(
|
||||
"001",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"channel_id": (state.get("context") or {}).get("channel"),
|
||||
"message_id": (state.get("context") or {}).get("message_id"),
|
||||
"ura_call_id": (state.get("context") or {}).get("ura_call_id"),
|
||||
},
|
||||
component="workflow.ainvoke",
|
||||
)
|
||||
await self.observer.emit_ic(
|
||||
"AGENT_STARTED",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"channel_id": (state.get("context") or {}).get("channel"),
|
||||
"message_id": (state.get("context") or {}).get("message_id"),
|
||||
"user_text_chars": len(state.get("user_text") or ""),
|
||||
},
|
||||
component="workflow.ainvoke",
|
||||
)
|
||||
try:
|
||||
result = await self.graph.ainvoke(state, config=config)
|
||||
await self.workflow_telemetry.completed("agent_workflow", result)
|
||||
return result
|
||||
except Exception as exc:
|
||||
await self.workflow_telemetry.failed("agent_workflow", exc)
|
||||
await self.observer.emit_noc(
|
||||
"005",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"error": str(exc),
|
||||
"exception_type": exc.__class__.__name__,
|
||||
},
|
||||
component="workflow.ainvoke",
|
||||
)
|
||||
raise
|
||||
@@ -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,9 @@
|
||||
enabled: true
|
||||
input:
|
||||
- {code: INPUT_SIZE, enabled: true}
|
||||
- {code: PINJ, enabled: true}
|
||||
output:
|
||||
- {code: DLEX_OUT, enabled: true}
|
||||
- {code: EXTERNAL_BUSINESS_POLICY, type: external, class: app.extensions.example_guardrails:ExternalBusinessPolicyRail, enabled: true}
|
||||
retrieval: []
|
||||
tool: []
|
||||
@@ -0,0 +1,55 @@
|
||||
identity:
|
||||
version: "2"
|
||||
required:
|
||||
- session_key
|
||||
keys:
|
||||
customer_key:
|
||||
description: Cliente/assinante/consumidor canônico.
|
||||
sources:
|
||||
- business_context.customer_key
|
||||
- customer_key
|
||||
- msisdn
|
||||
- customer_id
|
||||
- user_id
|
||||
- ani
|
||||
- from
|
||||
contract_key:
|
||||
description: Contrato, conta, fatura, pedido ou asset principal.
|
||||
sources:
|
||||
- business_context.contract_key
|
||||
- contract_key
|
||||
- invoice_id
|
||||
- current_invoice_number
|
||||
- order_id
|
||||
- pedido_id
|
||||
- asset_id
|
||||
interaction_key:
|
||||
description: Chave externa da interação/call/chat vinda do canal.
|
||||
sources:
|
||||
- business_context.interaction_key
|
||||
- interaction_key
|
||||
- ura_call_id
|
||||
- call_id
|
||||
- message_id
|
||||
account_key:
|
||||
description: Conta de cobrança/conta comercial.
|
||||
sources:
|
||||
- business_context.account_key
|
||||
- account_key
|
||||
- account_id
|
||||
- billing_account_id
|
||||
resource_key:
|
||||
description: Recurso/linha/produto/asset específico.
|
||||
sources:
|
||||
- business_context.resource_key
|
||||
- resource_key
|
||||
- asset_id
|
||||
- product_id
|
||||
- sku
|
||||
session_key:
|
||||
description: Sessão técnica estável já escopada por tenant e agente.
|
||||
sources:
|
||||
- business_context.session_key
|
||||
- session_key
|
||||
- conversation_key
|
||||
- session_id
|
||||
@@ -0,0 +1,6 @@
|
||||
enabled: true
|
||||
fail_closed: true
|
||||
sample_rate: 1.0
|
||||
judges:
|
||||
- {name: response_quality, enabled: true, threshold: 0.70}
|
||||
- {name: external_business_quality, type: external, class: app.extensions.example_judges:ExternalBusinessJudge, enabled: true, threshold: 0.50}
|
||||
@@ -0,0 +1,104 @@
|
||||
mcp_parameter_mapping:
|
||||
defaults:
|
||||
use_mock: true
|
||||
tools:
|
||||
consultar_fatura:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
contract_key: invoice_id
|
||||
interaction_key: ura_call_id
|
||||
session_key: session_id
|
||||
extract:
|
||||
mes_referencia:
|
||||
from: message
|
||||
type: int
|
||||
strategy: month_name_pt
|
||||
description: 'Extrair mês citado na mensagem. janeiro=1, fevereiro=2, março=3,
|
||||
abril=4, maio=5, junho=6, julho=7, agosto=8, setembro=9, outubro=10, novembro=11,
|
||||
dezembro=12.
|
||||
|
||||
'
|
||||
consultar_pagamentos:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
interaction_key: ura_call_id
|
||||
session_key: session_id
|
||||
consultar_plano:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
resource_key: asset_id
|
||||
contract_key: asset_id
|
||||
session_key: session_id
|
||||
listar_servicos:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
session_key: session_id
|
||||
consultar_pedido:
|
||||
map:
|
||||
customer_key: customer_id
|
||||
session_key: session_id
|
||||
extract:
|
||||
order_id:
|
||||
from: message
|
||||
type: string
|
||||
strategy: hybrid
|
||||
description: Extraia somente o identificador do pedido informado explicitamente
|
||||
pelo usuário. Retorne null quando não houver identificador de pedido na
|
||||
mensagem.
|
||||
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
||||
group: 1
|
||||
consultar_entrega:
|
||||
map:
|
||||
session_key: session_id
|
||||
extract:
|
||||
order_id:
|
||||
from: message
|
||||
type: string
|
||||
strategy: hybrid
|
||||
description: Extraia somente o identificador do pedido informado explicitamente
|
||||
pelo usuário. Retorne null quando não houver identificador de pedido na
|
||||
mensagem.
|
||||
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
||||
group: 1
|
||||
cancelar_pedido:
|
||||
map:
|
||||
session_key: session_id
|
||||
extract:
|
||||
order_id:
|
||||
from: message
|
||||
type: string
|
||||
strategy: hybrid
|
||||
description: Extraia somente o identificador do pedido informado explicitamente pelo usuário. Retorne null quando não houver identificador de pedido na mensagem.
|
||||
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
||||
group: 1
|
||||
|
||||
solicitar_troca:
|
||||
map:
|
||||
session_key: session_id
|
||||
defaults:
|
||||
reason: Solicitação aberta pelo atendimento conversacional.
|
||||
extract:
|
||||
order_id:
|
||||
from: message
|
||||
type: string
|
||||
strategy: hybrid
|
||||
description: Extraia somente o identificador do pedido informado explicitamente
|
||||
pelo usuário. Retorne null quando não houver identificador de pedido na
|
||||
mensagem.
|
||||
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
||||
group: 1
|
||||
solicitar_devolucao:
|
||||
map:
|
||||
session_key: session_id
|
||||
defaults:
|
||||
reason: Solicitação aberta pelo atendimento conversacional.
|
||||
extract:
|
||||
order_id:
|
||||
from: message
|
||||
type: string
|
||||
strategy: hybrid
|
||||
description: Extraia somente o identificador do pedido informado explicitamente
|
||||
pelo usuário. Retorne null quando não houver identificador de pedido na
|
||||
mensagem.
|
||||
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
||||
group: 1
|
||||
@@ -0,0 +1,12 @@
|
||||
servers:
|
||||
telecom:
|
||||
transport: http
|
||||
endpoint: http://telecom-mcp:8100/mcp
|
||||
enabled: true
|
||||
description: MCP Server Telecom via docker-compose.
|
||||
|
||||
retail:
|
||||
transport: http
|
||||
endpoint: http://retail-mcp:8200/mcp
|
||||
enabled: true
|
||||
description: MCP Server Retail via docker-compose.
|
||||
@@ -0,0 +1,30 @@
|
||||
# MCP servers registry.
|
||||
# transport=http keeps the legacy framework mock contract:
|
||||
# GET <endpoint>/tools/list
|
||||
# POST <endpoint>/tools/call
|
||||
# transport=fastmcp uses official MCP Streamable HTTP, typically endpoint http://host:port/mcp
|
||||
# transport=sse uses official MCP SSE, typically endpoint http://host:port/sse
|
||||
servers:
|
||||
# telecom:
|
||||
# enabled: true
|
||||
# transport: fastmcp
|
||||
# endpoint: http://localhost:8001/mcp
|
||||
# description: Telecom FastMCP server using official MCP protocol
|
||||
#
|
||||
# retail:
|
||||
# enabled: true
|
||||
# transport: fastmcp
|
||||
# endpoint: http://localhost:8002/mcp
|
||||
# description: Retail FastMCP server using official MCP protocol
|
||||
|
||||
telecom:
|
||||
enabled: true
|
||||
transport: http
|
||||
endpoint: http://localhost:8100/mcp
|
||||
description: Telecom legacy HTTP mock MCP server
|
||||
|
||||
retail:
|
||||
enabled: true
|
||||
transport: http
|
||||
endpoint: http://localhost:8200/mcp
|
||||
description: Retail legacy HTTP mock MCP server
|
||||
@@ -0,0 +1,19 @@
|
||||
tone:
|
||||
style: "claro, objetivo, empático"
|
||||
forbidden_phrases:
|
||||
- "procure atendimento humano"
|
||||
vocabulary:
|
||||
preferred:
|
||||
fatura: "fatura"
|
||||
contestacao: "contestação"
|
||||
intents:
|
||||
billing_agent:
|
||||
- fatura
|
||||
- boleto
|
||||
- cobrança
|
||||
- segunda via
|
||||
product_agent:
|
||||
- plano
|
||||
- produto
|
||||
- oferta
|
||||
- serviço
|
||||
@@ -0,0 +1,147 @@
|
||||
# Roteamento enterprise configurável com MCP-aware intents.
|
||||
router:
|
||||
# mode também pode ser definido por variável de ambiente ROUTING_MODE.
|
||||
# Valores: router | supervisor
|
||||
mode: router
|
||||
fallback_agent: billing_agent
|
||||
confidence_threshold: 0.65
|
||||
allow_handoff: true
|
||||
|
||||
state_policies:
|
||||
- state: WAITING_BILLING_CONFIRMATION
|
||||
agent: billing_agent
|
||||
description: Mantém mensagens curtas como "sim" ou "não" no fluxo de fatura.
|
||||
- state: WAITING_PRODUCT_CONFIRMATION
|
||||
agent: product_agent
|
||||
description: Mantém confirmações no fluxo de produtos/serviços.
|
||||
- state: WAITING_ORDER_CONFIRMATION
|
||||
agent: orders_agent
|
||||
description: Mantém confirmações no fluxo de pedidos.
|
||||
- state: WAITING_SUPPORT_CONFIRMATION
|
||||
agent: support_agent
|
||||
description: Mantém confirmações no fluxo de suporte retail.
|
||||
- state: COLLECTING_BILLING_PARAMETERS
|
||||
agent: billing_agent
|
||||
description: Mantém a coleta de parâmetros no fluxo de faturamento.
|
||||
- state: COLLECTING_PRODUCT_PARAMETERS
|
||||
agent: product_agent
|
||||
description: Mantém a coleta de parâmetros no fluxo de produtos e serviços.
|
||||
- state: COLLECTING_ORDER_PARAMETERS
|
||||
agent: orders_agent
|
||||
description: Mantém a coleta de parâmetros no fluxo de pedidos.
|
||||
- state: COLLECTING_SUPPORT_PARAMETERS
|
||||
agent: support_agent
|
||||
description: Mantém a coleta de parâmetros no fluxo transacional de suporte retail.
|
||||
|
||||
intents:
|
||||
- name: billing_invoice_explanation
|
||||
domain: telecom
|
||||
agent: billing_agent
|
||||
description: Dúvidas sobre fatura, cobrança, vencimento, segunda via, contestação e valores.
|
||||
priority: 10
|
||||
mcp_tools:
|
||||
- consultar_fatura
|
||||
- consultar_pagamentos
|
||||
keywords:
|
||||
- fatura
|
||||
- conta
|
||||
- cobrança
|
||||
- boleto
|
||||
- vencimento
|
||||
- segunda via
|
||||
- contestar
|
||||
- valor alto
|
||||
- invoice
|
||||
examples:
|
||||
- Minha fatura veio alta.
|
||||
- Quero entender uma cobrança.
|
||||
- Preciso da segunda via da conta.
|
||||
|
||||
- name: product_services_information
|
||||
domain: telecom
|
||||
agent: product_agent
|
||||
description: Dúvidas sobre plano, pacote, produto, serviço, VAS, internet, roaming e benefícios.
|
||||
priority: 20
|
||||
mcp_tools:
|
||||
- consultar_plano
|
||||
- listar_servicos
|
||||
keywords:
|
||||
- plano
|
||||
- serviço
|
||||
- pacote
|
||||
- internet
|
||||
- roaming
|
||||
- vas
|
||||
- benefício
|
||||
- assinatura
|
||||
examples:
|
||||
- Quais serviços estão ativos no meu plano?
|
||||
- Quero saber sobre meu pacote de internet.
|
||||
- Tenho roaming internacional?
|
||||
|
||||
|
||||
- name: retail_order_cancel
|
||||
domain: retail
|
||||
agent: orders_agent
|
||||
description: Cancelamento explícito de pedido ou compra.
|
||||
priority: 20
|
||||
mcp_tools:
|
||||
- consultar_pedido
|
||||
- cancelar_pedido
|
||||
keywords:
|
||||
- cancelar pedido
|
||||
- cancelamento do pedido
|
||||
- cancelar a compra
|
||||
- cancelar compra
|
||||
examples:
|
||||
- Quero cancelar meu pedido.
|
||||
- Cancele o pedido.
|
||||
- Quero cancelar a compra.
|
||||
|
||||
- name: retail_order_tracking
|
||||
domain: retail
|
||||
agent: orders_agent
|
||||
description: Consulta de pedido, entrega, rastreamento, atraso e status de compra.
|
||||
priority: 30
|
||||
mcp_tools:
|
||||
- consultar_pedido
|
||||
- consultar_entrega
|
||||
keywords:
|
||||
- pedido
|
||||
- entrega
|
||||
- rastreio
|
||||
- rastreamento
|
||||
- encomenda
|
||||
- compra
|
||||
- atraso
|
||||
- correios
|
||||
examples:
|
||||
- Meu pedido não chegou.
|
||||
- Quero rastrear minha entrega.
|
||||
- Qual é o status da minha compra?
|
||||
|
||||
- name: retail_support_exchange_return
|
||||
domain: retail
|
||||
agent: support_agent
|
||||
description: Suporte, troca, devolução, garantia e problema com produto.
|
||||
priority: 25
|
||||
mcp_tools:
|
||||
- consultar_pedido
|
||||
- solicitar_troca
|
||||
- solicitar_devolucao
|
||||
keywords:
|
||||
- solicitar devolução
|
||||
- devolver pedido
|
||||
- solicitar troca
|
||||
- troca
|
||||
- devolução
|
||||
- devolver
|
||||
- garantia
|
||||
- defeito
|
||||
- produto quebrado
|
||||
- suporte
|
||||
- arrependimento
|
||||
examples:
|
||||
- Quero trocar um produto.
|
||||
- Meu produto veio com defeito.
|
||||
- Como faço uma devolução?
|
||||
@@ -0,0 +1,27 @@
|
||||
version: 1
|
||||
|
||||
# Arquivo opcional da aplicação. A ausência mantém o comportamento dos
|
||||
# templates anteriores e as políticas legadas declaradas em tools.yaml.
|
||||
defaults:
|
||||
operation_type: read_only
|
||||
require_confirmation: false
|
||||
|
||||
tool_policies:
|
||||
cancelar_pedido:
|
||||
operation_type: transactional
|
||||
require_confirmation: true
|
||||
requires: [order_id]
|
||||
|
||||
|
||||
solicitar_troca:
|
||||
operation_type: transactional
|
||||
require_confirmation: true
|
||||
|
||||
solicitar_devolucao:
|
||||
operation_type: transactional
|
||||
require_confirmation: true
|
||||
|
||||
# Exemplo para uma operação real que só pode executar após confirmação:
|
||||
# cancelar_servico:
|
||||
# operation_type: transactional
|
||||
# require_confirmation: true
|
||||
@@ -0,0 +1,130 @@
|
||||
tools:
|
||||
consultar_fatura:
|
||||
description: Consulta dados resumidos de fatura por msisdn/invoice_id.
|
||||
mcp_server: telecom
|
||||
enabled: true
|
||||
args_schema:
|
||||
msisdn: string
|
||||
invoice_id: string
|
||||
selection_keywords:
|
||||
- fatura
|
||||
- conta
|
||||
- boleto
|
||||
response:
|
||||
mode: renderer
|
||||
renderer: telecom.invoice
|
||||
consultar_pagamentos:
|
||||
description: Consulta histórico de pagamentos do cliente.
|
||||
mcp_server: telecom
|
||||
enabled: true
|
||||
args_schema:
|
||||
msisdn: string
|
||||
selection_keywords:
|
||||
- pagamento
|
||||
- pagamentos
|
||||
consultar_plano:
|
||||
description: Consulta plano ativo e atributos comerciais.
|
||||
mcp_server: telecom
|
||||
enabled: true
|
||||
args_schema:
|
||||
msisdn: string
|
||||
asset_id: string
|
||||
selection_keywords:
|
||||
- plano
|
||||
response:
|
||||
mode: renderer
|
||||
renderer: telecom.plan
|
||||
listar_servicos:
|
||||
description: Lista serviços ativos e adicionais VAS.
|
||||
mcp_server: telecom
|
||||
enabled: true
|
||||
args_schema:
|
||||
msisdn: string
|
||||
selection_keywords:
|
||||
- serviços
|
||||
- servicos
|
||||
- vas
|
||||
consultar_pedido:
|
||||
description: Consulta pedido de varejo por order_id/customer_id.
|
||||
mcp_server: retail
|
||||
enabled: true
|
||||
args_schema:
|
||||
order_id: string
|
||||
customer_id: string
|
||||
selection_keywords:
|
||||
- consultar pedido
|
||||
- status do pedido
|
||||
- pedido
|
||||
response:
|
||||
mode: renderer
|
||||
renderer: retail.order
|
||||
consultar_entrega:
|
||||
description: Consulta entrega e rastreamento do pedido.
|
||||
mcp_server: retail
|
||||
enabled: true
|
||||
args_schema:
|
||||
order_id: string
|
||||
selection_keywords:
|
||||
- entrega
|
||||
- rastreio
|
||||
- rastreamento
|
||||
- transportadora
|
||||
- previsão
|
||||
response:
|
||||
mode: renderer
|
||||
renderer: retail.delivery
|
||||
cancelar_pedido:
|
||||
description: Simula o cancelamento de um pedido de varejo.
|
||||
mcp_server: retail
|
||||
enabled: true
|
||||
tool_type: action
|
||||
requires:
|
||||
- order_id
|
||||
confirmation_required: true
|
||||
args_schema:
|
||||
order_id: string
|
||||
selection_keywords:
|
||||
- cancelar pedido
|
||||
- cancelamento do pedido
|
||||
- cancelar compra
|
||||
- cancelar a compra
|
||||
|
||||
|
||||
solicitar_troca:
|
||||
description: Simula abertura de solicitação de troca.
|
||||
mcp_server: retail
|
||||
enabled: true
|
||||
tool_type: action
|
||||
requires:
|
||||
- order_id
|
||||
- reason
|
||||
confirmation_required: true
|
||||
args_schema:
|
||||
order_id: string
|
||||
reason: string
|
||||
selection_keywords:
|
||||
- solicitar troca
|
||||
- trocar
|
||||
- troca
|
||||
- defeito
|
||||
- quebrado
|
||||
solicitar_devolucao:
|
||||
description: Simula abertura de solicitação de devolução.
|
||||
mcp_server: retail
|
||||
enabled: true
|
||||
tool_type: action
|
||||
requires:
|
||||
- order_id
|
||||
- reason
|
||||
confirmation_required: true
|
||||
args_schema:
|
||||
order_id: string
|
||||
reason: string
|
||||
selection_keywords:
|
||||
- solicitar devolução
|
||||
- solicitar devolucao
|
||||
- devolver pedido
|
||||
- devolver
|
||||
- devolução
|
||||
- devolucao
|
||||
- arrependimento
|
||||
@@ -0,0 +1,95 @@
|
||||
# Atualização do Template Backend — Analytics, Observer, NOC/GRL e OutputSupervisor
|
||||
|
||||
Esta versão do `agent_template_backend` foi atualizada para consumir as novidades transportadas para o `agent_framework`.
|
||||
|
||||
## 1. Analytics e Pub/Sub
|
||||
|
||||
O backend não chama mais diretamente apenas o publisher antigo de eventos. Agora ele cria um `AnalyticsPublisher`:
|
||||
|
||||
```python
|
||||
from agent_framework.analytics.factory import create_analytics_publisher
|
||||
from agent_framework.observability.observer import AgentObserver
|
||||
|
||||
analytics = create_analytics_publisher(settings)
|
||||
observer = AgentObserver(analytics=analytics)
|
||||
```
|
||||
|
||||
Com isso, o mesmo backend pode publicar em:
|
||||
|
||||
- OCI Streaming
|
||||
- GCP Pub/Sub
|
||||
- CompositePublisher, quando `ANALYTICS_PROVIDERS=oci_streaming,pubsub`
|
||||
- Noop, quando analytics estiver desligado
|
||||
|
||||
## 2. Configuração mínima
|
||||
|
||||
```env
|
||||
ENABLE_ANALYTICS=true
|
||||
ANALYTICS_PROVIDERS=pubsub
|
||||
GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-name>
|
||||
GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
|
||||
```
|
||||
|
||||
Para publicar simultaneamente em OCI Streaming e GCP Pub/Sub:
|
||||
|
||||
```env
|
||||
ENABLE_ANALYTICS=true
|
||||
ANALYTICS_PROVIDERS=oci_streaming,pubsub
|
||||
ENABLE_OCI_STREAMING=true
|
||||
OCI_STREAM_ENDPOINT=<endpoint>
|
||||
OCI_STREAM_OCID=<stream-ocid>
|
||||
GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-name>
|
||||
```
|
||||
|
||||
## 3. Observer corporativo
|
||||
|
||||
O workflow recebeu emissão automática dos principais eventos corporativos:
|
||||
|
||||
- `NOC.001`: início do workflow
|
||||
- `NOC.005`: exceção fatal no workflow
|
||||
- `NOC.006`: fim do workflow antes da resposta final
|
||||
- `IC.AGENT_COMPLETED`: evento informacional de conclusão
|
||||
- `GRL.001` a `GRL.009`: emitidos pelo `OutputSupervisor`
|
||||
|
||||
## 4. OutputSupervisor
|
||||
|
||||
Foi inserido um novo nó LangGraph:
|
||||
|
||||
```text
|
||||
agent -> output_supervisor -> output_guardrails -> judge -> supervisor_review -> persist
|
||||
```
|
||||
|
||||
O `OutputSupervisor` não substitui o supervisor de roteamento. Ele valida a saída candidata do agente usando o contrato corporativo:
|
||||
|
||||
- `allow`
|
||||
- `sanitize`
|
||||
- `retry`
|
||||
- `block`
|
||||
- `handover`
|
||||
- `observe`
|
||||
|
||||
Para compatibilidade com os guardrails já existentes, o template inclui o adapter `LegacyOutputGuardrailRail`, que converte decisões antigas `allowed=True/False` para `RailAction`.
|
||||
|
||||
## 5. Campos adicionados ao AgentState
|
||||
|
||||
```python
|
||||
supervisor_action: str
|
||||
supervisor_guidance: str
|
||||
supervisor_attempt: int
|
||||
supervisor_handover_reason: str
|
||||
output_supervisor_results: list[dict]
|
||||
output_guardrails_already_applied: bool
|
||||
```
|
||||
|
||||
## 6. Arquivos alterados
|
||||
|
||||
- `agent_template_backend/app/main.py`
|
||||
- `agent_template_backend/app/workflows/agent_graph.py`
|
||||
- `agent_template_backend/app/state.py`
|
||||
- `agent_template_backend/.env`
|
||||
- `agent_template_backend/requirements.txt`
|
||||
- `agent_framework/src/agent_framework/config/settings.py`
|
||||
|
||||
## 7. Observação importante
|
||||
|
||||
O `OutputSupervisor` roda os guardrails de saída por meio do adapter legado e marca `output_guardrails_already_applied=True`. Assim o nó `output_guardrails` permanece no grafo para compatibilidade, mas evita reexecutar a mesma validação quando o supervisor já aplicou os rails.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Como usar IC, NOC e GRL no Template Backend
|
||||
|
||||
## IC — Item de Controle
|
||||
|
||||
Use IC para registrar eventos de negócio relevantes.
|
||||
|
||||
```python
|
||||
await observer.emit_ic(
|
||||
"IC.FATURA_CONSULTADA",
|
||||
{"session_id": session_id, "invoice_id": invoice_id},
|
||||
component="billing_agent",
|
||||
)
|
||||
```
|
||||
|
||||
## NOC — Evento operacional
|
||||
|
||||
Use NOC para saúde técnica, latência, erros e checkpoints operacionais.
|
||||
|
||||
```python
|
||||
await observer.emit_noc(
|
||||
"003",
|
||||
{"session_id": session_id, "resourceName": "ADB", "latencyMs": 120},
|
||||
component="repository",
|
||||
)
|
||||
```
|
||||
|
||||
## GRL — Evento de guardrail
|
||||
|
||||
Normalmente o framework emite GRL automaticamente. Use manualmente apenas para
|
||||
rails customizados dentro do agente.
|
||||
|
||||
```python
|
||||
await observer.emit_grl(
|
||||
"OBSERVE",
|
||||
{"session_id": session_id, "rail_code": "CUSTOM_POLICY"},
|
||||
component="custom_rail",
|
||||
)
|
||||
```
|
||||
|
||||
## Onde já existe no template
|
||||
|
||||
- `app/workflows/agent_graph.py` emite IC/NOC no ciclo do workflow.
|
||||
- `app/agents/runtime.py` emite IC para MCP/tools.
|
||||
- `app/agents/*_agent.py` contém exemplos dentro do método `run()`.
|
||||
- `app/examples/` contém exemplos isolados.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Backends atualizados para ConversationSummaryMemory
|
||||
|
||||
Esta versão dos backends foi compatibilizada com a versão do framework que adiciona `ConversationSummaryMemory`.
|
||||
|
||||
## O que mudou
|
||||
|
||||
- `app/main.py` agora inicializa `create_conversation_summary_memory(...)` junto com `create_memory(...)`.
|
||||
- `AgentWorkflow` recebe `summary_memory` e repassa para os agentes.
|
||||
- Os agentes não montam mais prompts manuais para o LLM; agora usam `build_messages()` do framework.
|
||||
- Antes da chamada ao LLM, os agentes executam `await self.prepare_memory_context(state)`.
|
||||
- Quando habilitado por `.env`, o prompt passa a receber:
|
||||
- resumo acumulado da conversa;
|
||||
- últimas mensagens completas;
|
||||
- mensagem atual;
|
||||
- BusinessContext;
|
||||
- MCP results;
|
||||
- RAG context e metadata.
|
||||
|
||||
## Configuração
|
||||
|
||||
```env
|
||||
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
|
||||
MEMORY_CONTEXT_STRATEGY=summary
|
||||
MEMORY_HISTORY_LIMIT=80
|
||||
MEMORY_RECENT_MESSAGES_LIMIT=8
|
||||
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
|
||||
MEMORY_MAX_SUMMARY_CHARS=6000
|
||||
MEMORY_SUMMARY_USE_LLM=true
|
||||
MEMORY_INJECT_RECENT_MESSAGES=true
|
||||
MEMORY_INJECT_SUMMARY=true
|
||||
```
|
||||
|
||||
## Backends alterados
|
||||
|
||||
- `backoffice_convertido_framework`
|
||||
- `agent_template_backend`
|
||||
- `agent_template_backend_day_zero`
|
||||
|
||||
## Observação importante
|
||||
|
||||
Estes backends esperam que o pacote `agent_framework` instalado/conectado seja a versão com os módulos:
|
||||
|
||||
- `agent_framework.memory.summary_memory`
|
||||
- `agent_framework.memory.summary_store`
|
||||
- `AgentRuntimeMixin.prepare_memory_context()`
|
||||
- `AgentRuntimeMixin.build_messages()` com injeção de memória
|
||||
|
||||
Use junto com o ZIP `agent_framework_conversation_summary_memory.zip`.
|
||||
@@ -0,0 +1,84 @@
|
||||
# FRAMEWORK_CHANNEL_INPUT_MODE
|
||||
|
||||
This backend setting controls what kind of channel input the Agent Framework backend accepts.
|
||||
|
||||
It replaces the ambiguous use of `CHANNEL_GATEWAY_MODE` inside the backend.
|
||||
|
||||
## Values
|
||||
|
||||
```env
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE=embedded
|
||||
```
|
||||
|
||||
The backend may use internal channel adapters to interpret simple/native channel payloads. This is useful for demos, labs, local frontend, curl tests, and simple environments.
|
||||
|
||||
```env
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE=external
|
||||
```
|
||||
|
||||
The backend accepts only a normalized `GatewayRequest` produced by an external Channel Gateway. It does not parse native WhatsApp, Voice, Teams, or other channel payloads.
|
||||
|
||||
## Recommended enterprise setup
|
||||
|
||||
In the external channel gateway service:
|
||||
|
||||
```env
|
||||
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
|
||||
```
|
||||
|
||||
In this backend:
|
||||
|
||||
```env
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE=external
|
||||
```
|
||||
|
||||
Flow:
|
||||
|
||||
```text
|
||||
External channel / browser / customer adapter
|
||||
↓
|
||||
channel_gateway:7000
|
||||
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
|
||||
↓ GatewayRequest
|
||||
agent_template_backend:8000
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE=external
|
||||
↓
|
||||
LangGraph / Agents / MCP / Guardrails
|
||||
```
|
||||
|
||||
## Valid direct request to backend in external mode
|
||||
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:8000/gateway/message" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"channel": "web",
|
||||
"tenant_id": "default",
|
||||
"agent_id": "telecom_contas",
|
||||
"payload": {
|
||||
"message": "Quero consultar minha fatura",
|
||||
"session_id": "backend-external-ok-001"
|
||||
}
|
||||
}' | jq
|
||||
```
|
||||
|
||||
## Invalid direct request to backend in external mode
|
||||
|
||||
```bash
|
||||
curl -i -s -X POST "http://localhost:8000/gateway/message" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "Quero consultar minha fatura",
|
||||
"session_id": "raw-payload-error-001"
|
||||
}'
|
||||
```
|
||||
|
||||
Expected result: HTTP 422.
|
||||
|
||||
## Legacy compatibility
|
||||
|
||||
`CHANNEL_GATEWAY_MODE` is still present as a legacy alias for older environments, but new deployments should use:
|
||||
|
||||
```env
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE=embedded|external
|
||||
```
|
||||
@@ -0,0 +1,127 @@
|
||||
# Guardrails paralelos fail-fast e Observer IC
|
||||
|
||||
## O que foi implementado
|
||||
|
||||
### 1. ParallelRailExecutor
|
||||
|
||||
Arquivo principal:
|
||||
|
||||
```text
|
||||
agent_framework/src/agent_framework/guardrails/parallel_executor.py
|
||||
```
|
||||
|
||||
Também foi criado um alias de compatibilidade:
|
||||
|
||||
```text
|
||||
agent_framework/src/agent_framework/guardrails/executor.py
|
||||
```
|
||||
|
||||
Esse alias evita erro quando algum código antigo importar:
|
||||
|
||||
```python
|
||||
from agent_framework.guardrails.executor import ParallelRailExecutor
|
||||
```
|
||||
|
||||
### 2. Execução paralela no GuardrailPipeline
|
||||
|
||||
Arquivo alterado:
|
||||
|
||||
```text
|
||||
agent_framework/src/agent_framework/guardrails/pipeline.py
|
||||
```
|
||||
|
||||
O pipeline continua retornando o contrato antigo:
|
||||
|
||||
```python
|
||||
(texto_final, list[RailDecision])
|
||||
```
|
||||
|
||||
mas internamente pode executar rails em paralelo com fail-fast.
|
||||
|
||||
### 3. Execução paralela no OutputSupervisor
|
||||
|
||||
Arquivo alterado:
|
||||
|
||||
```text
|
||||
agent_framework/src/agent_framework/guardrails/output_supervisor.py
|
||||
```
|
||||
|
||||
O `OutputSupervisor` agora usa `ParallelRailExecutor` quando habilitado.
|
||||
|
||||
### 4. Configuração
|
||||
|
||||
Novas configurações:
|
||||
|
||||
```env
|
||||
ENABLE_PARALLEL_GUARDRAILS=true
|
||||
GUARDRAILS_FAIL_FAST=true
|
||||
```
|
||||
|
||||
Também foram adicionadas em:
|
||||
|
||||
```text
|
||||
agent_framework/src/agent_framework/config/settings.py
|
||||
.env
|
||||
.env.example
|
||||
agent_template_backend/.env
|
||||
agent_template_backend_day_zero/.env
|
||||
```
|
||||
|
||||
### 5. Observer IC
|
||||
|
||||
O `AgentObserver` já tinha `emit_ic()`.
|
||||
|
||||
Foi complementada a API global compatível com FIRST/TIM:
|
||||
|
||||
```python
|
||||
from agent_framework.observer import ic, aic, noc, anoc, grl, agrl
|
||||
```
|
||||
|
||||
Exemplos:
|
||||
|
||||
```python
|
||||
ic("AGENT_COMPLETED", data={"session_id": "..."})
|
||||
await aic("MCP_TOOL_CALLED", data={"tool_name": "consultar_fatura"})
|
||||
```
|
||||
|
||||
### 6. ICs automáticos no template backend
|
||||
|
||||
O backend emite agora:
|
||||
|
||||
```text
|
||||
IC.AGENT_STARTED
|
||||
IC.ROUTE_SELECTED
|
||||
IC.MCP_TOOL_CALLED
|
||||
IC.TOOL_CALLED
|
||||
IC.AGENT_COMPLETED
|
||||
```
|
||||
|
||||
Além dos eventos já existentes:
|
||||
|
||||
```text
|
||||
NOC.001
|
||||
NOC.005
|
||||
NOC.006
|
||||
GRL.001 ... GRL.009
|
||||
```
|
||||
|
||||
## Validações executadas
|
||||
|
||||
Foram executadas validações locais com `PYTHONPATH=agent_framework/src`:
|
||||
|
||||
```bash
|
||||
python3 -m compileall -q agent_framework/src/agent_framework agent_template_backend/app agent_template_backend_day_zero/app
|
||||
```
|
||||
|
||||
Smoke tests executados:
|
||||
|
||||
```text
|
||||
1. Import de ParallelRailExecutor via agent_framework.guardrails
|
||||
2. Import de ParallelRailExecutor via agent_framework.guardrails.executor
|
||||
3. Execução fail-fast: FastBlock cancela SlowAllow
|
||||
4. GuardrailPipeline paralelo retorna RailDecision legado
|
||||
5. OutputSupervisor paralelo retorna RailAction.BLOCK
|
||||
6. API global observer.ic/noc/grl/aic/anoc/agrl
|
||||
```
|
||||
|
||||
Observação: o import completo do `agent_template_backend.app.workflows.agent_graph` depende de `langgraph`, que não está instalado no sandbox de validação. O arquivo foi validado por `compileall`, e a dependência já consta em `agent_template_backend/requirements.txt`.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Implementação IC/NOC/GRL preservando lógica existente
|
||||
|
||||
Esta versão mantém a lógica original dos agentes do `agent_template_backend` e adiciona observabilidade corporativa.
|
||||
|
||||
## IC adicionados nos agentes
|
||||
|
||||
Cada agente agora emite eventos de negócio sem alterar a resposta final:
|
||||
|
||||
- `IC.BILLING_AGENT_STARTED` / `IC.BILLING_AGENT_COMPLETED`
|
||||
- `IC.ORDERS_AGENT_STARTED` / `IC.ORDERS_AGENT_COMPLETED`
|
||||
- `IC.PRODUCT_AGENT_STARTED` / `IC.PRODUCT_AGENT_COMPLETED`
|
||||
- `IC.SUPPORT_AGENT_STARTED` / `IC.SUPPORT_AGENT_COMPLETED`
|
||||
- `IC.<AGENT>_MCP_CONTEXT_COLLECTED` quando houver dados MCP
|
||||
- `IC.<AGENT>_RAG_CONTEXT_RETRIEVED` quando RAG estiver habilitado
|
||||
|
||||
O mixin `AgentRuntimeMixin` também emite:
|
||||
|
||||
- `IC.MCP_TOOL_CALLED` antes da chamada MCP
|
||||
- `IC.TOOL_CALLED` após a chamada MCP
|
||||
|
||||
## NOC
|
||||
|
||||
O workflow já emite eventos operacionais principais:
|
||||
|
||||
- `NOC.001` no início da execução
|
||||
- `NOC.005` em exceção fatal
|
||||
- `NOC.006` na persistência/finalização
|
||||
|
||||
## GRL
|
||||
|
||||
O backend agora também exemplifica emissão GRL no workflow:
|
||||
|
||||
- `GRL.001` início do pipeline de guardrails
|
||||
- `GRL.002` decisão allow
|
||||
- `GRL.004` decisão block
|
||||
- `GRL.009` decisão final agregada
|
||||
|
||||
Quando `OutputSupervisor` está habilitado, ele continua sendo o principal mecanismo corporativo de supervisão de saída.
|
||||
|
||||
## Garantia
|
||||
|
||||
A lógica original dos agentes não foi substituída por stubs. As chamadas LLM, MCP, RAG, cache e os retornos originais foram preservados.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Langfuse single trace observer fix
|
||||
|
||||
This backend now uses `TelemetryBackedAgentObserver` instead of publishing IC/NOC/GRL through `AgentObserver(analytics=...)`.
|
||||
|
||||
Why: when analytics includes the Langfuse provider, observer events such as `IC.AGENT_COMPLETED` and `NOC.006` may create a second root trace with little detail. Emitting those events through `Telemetry.event(...)` keeps them inside the active request/workflow trace.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Validação da versão com IC/NOC/GRL
|
||||
|
||||
Validações executadas nesta geração:
|
||||
|
||||
1. `python -m compileall -q agent_template_backend/app`
|
||||
- Resultado: OK.
|
||||
|
||||
2. Smoke test dos agentes com LLM fake e Observer fake:
|
||||
- `BillingAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
|
||||
- `OrdersAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
|
||||
- `ProductAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
|
||||
- `SupportAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
|
||||
|
||||
3. Verificação de regressão:
|
||||
- Nenhum agente retorna `Template Enterprise ativo`.
|
||||
- A lógica LLM/MCP/RAG/cache existente foi preservada.
|
||||
|
||||
## Eventos adicionados
|
||||
|
||||
### IC
|
||||
|
||||
Nos agentes:
|
||||
|
||||
- `IC.BILLING_AGENT_STARTED`
|
||||
- `IC.BILLING_MCP_CONTEXT_COLLECTED`
|
||||
- `IC.BILLING_RAG_CONTEXT_RETRIEVED`
|
||||
- `IC.BILLING_AGENT_COMPLETED`
|
||||
- `IC.ORDERS_AGENT_STARTED`
|
||||
- `IC.ORDERS_MCP_CONTEXT_COLLECTED`
|
||||
- `IC.ORDERS_RAG_CONTEXT_RETRIEVED`
|
||||
- `IC.ORDERS_AGENT_COMPLETED`
|
||||
- `IC.PRODUCT_AGENT_STARTED`
|
||||
- `IC.PRODUCT_MCP_CONTEXT_COLLECTED`
|
||||
- `IC.PRODUCT_RAG_CONTEXT_RETRIEVED`
|
||||
- `IC.PRODUCT_AGENT_COMPLETED`
|
||||
- `IC.SUPPORT_AGENT_STARTED`
|
||||
- `IC.SUPPORT_MCP_CONTEXT_COLLECTED`
|
||||
- `IC.SUPPORT_RAG_CONTEXT_RETRIEVED`
|
||||
- `IC.SUPPORT_AGENT_COMPLETED`
|
||||
|
||||
No runtime MCP:
|
||||
|
||||
- `IC.MCP_TOOL_CALLED`
|
||||
- `IC.TOOL_CALLED`
|
||||
|
||||
### NOC
|
||||
|
||||
Já integrados no workflow:
|
||||
|
||||
- `NOC.001` início da execução
|
||||
- `NOC.005` erro fatal
|
||||
- `NOC.006` finalização/persistência
|
||||
|
||||
### GRL
|
||||
|
||||
No workflow de guardrails:
|
||||
|
||||
- `GRL.001` início da avaliação
|
||||
- `GRL.002` allow
|
||||
- `GRL.004` block
|
||||
- `GRL.009` decisão final
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
compileall app: OK
|
||||
Arquivos de exemplos IC/NOC/GRL adicionados.
|
||||
Agentes preservam implementação original comentada.
|
||||
@@ -0,0 +1,81 @@
|
||||
profiles:
|
||||
default:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0.2
|
||||
max_tokens: 2048
|
||||
supervisor:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 700
|
||||
router:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 500
|
||||
guardrail:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 600
|
||||
grl:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 700
|
||||
judge:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 800
|
||||
rag_rewriter:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 300
|
||||
rag_compressor:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 1200
|
||||
rag_generation:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0.1
|
||||
max_tokens: 1800
|
||||
summary_memory:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0.1
|
||||
max_tokens: 1200
|
||||
noc:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 700
|
||||
billing_agent:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0.2
|
||||
product_agent:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0.2
|
||||
backoffice_agent:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0.2
|
||||
mcp_parameter_extraction:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1-mini
|
||||
temperature: 0
|
||||
max_tokens: 80
|
||||
timeout_seconds: 5
|
||||
|
||||
transaction_parameter_extraction:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1-mini
|
||||
temperature: 0
|
||||
max_tokens: 500
|
||||
timeout_seconds: 8
|
||||
@@ -0,0 +1,23 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
pydantic>=2.8.0
|
||||
pydantic-settings>=2.4.0
|
||||
python-dotenv>=1.0.1
|
||||
langgraph>=0.2.60
|
||||
langchain-core>=0.3.0
|
||||
openai>=1.60.0
|
||||
oci>=2.130.0
|
||||
oracledb>=2.4.0
|
||||
pymongo>=4.8.0
|
||||
redis>=5.0.0
|
||||
PyYAML>=6.0.2
|
||||
|
||||
langfuse>=3.0.0
|
||||
httpx>=0.27.0
|
||||
opentelemetry-api>=1.27.0
|
||||
opentelemetry-sdk>=1.27.0
|
||||
opentelemetry-exporter-otlp-proto-http>=1.27.0
|
||||
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
google-cloud-pubsub>=2.28.0
|
||||
@@ -0,0 +1,29 @@
|
||||
import asyncio
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from agent_framework.memory.long_term_memory import create_long_term_memory_manager
|
||||
|
||||
async def main():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
settings = SimpleNamespace(
|
||||
ENABLE_LONG_TERM_MEMORY=True,
|
||||
LONG_TERM_MEMORY_PROVIDER='sqlite',
|
||||
LONG_TERM_MEMORY_SQLITE_PATH=f'{d}/memory.db',
|
||||
LONG_TERM_MEMORY_TABLE='agentfw_long_term_memory',
|
||||
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS=20,
|
||||
LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70,
|
||||
LONG_TERM_MEMORY_AUTO_EXTRACT=True,
|
||||
)
|
||||
manager = create_long_term_memory_manager(settings)
|
||||
first = {'tenant_id':'default','agent_id':'memory_test','session_id':'a','user_text':'Me chame de Cris. Minha linguagem preferida é Python. Meu projeto atual se chama Atlas.','context':{'business_context':{'customer_key':'MEM-001'}}}
|
||||
assert (await manager.persist_turn(first))['saved'] >= 3
|
||||
second = {'tenant_id':'default','agent_id':'memory_test','session_id':'b','context':{'business_context':{'customer_key':'MEM-001'}}}
|
||||
values = {item.key:item.value for item in await manager.load(second)}
|
||||
assert values['preferred_name'].lower() == 'cris'
|
||||
assert values['preferred_language'].lower() == 'python'
|
||||
assert values['current_project'].lower() == 'atlas'
|
||||
isolated = {'tenant_id':'default','agent_id':'memory_test','session_id':'c','context':{'business_context':{'customer_key':'MEM-002'}}}
|
||||
assert await manager.load(isolated) == []
|
||||
print('OK: persistência, recuperação entre sessões e isolamento validados')
|
||||
|
||||
asyncio.run(main())
|
||||
27
docs/EXTERNAL_GUARDRAILS_JUDGES.md
Normal file
27
docs/EXTERNAL_GUARDRAILS_JUDGES.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# External Guardrails and Judges SPI
|
||||
|
||||
`agent_framework_oci` supports agent-owned guardrails and judges without importing domain code into the core.
|
||||
|
||||
```yaml
|
||||
output:
|
||||
- code: ACME_POLICY
|
||||
type: external
|
||||
class: app.extensions.guardrails:AcmePolicyRail
|
||||
```
|
||||
|
||||
```yaml
|
||||
judges:
|
||||
- name: acme_quality
|
||||
type: external
|
||||
class: app.extensions.judges:AcmeQualityJudge
|
||||
threshold: 0.7
|
||||
```
|
||||
|
||||
Native entries remain unchanged. External synchronous `evaluate()` methods execute in worker threads via `asyncio.to_thread`; asynchronous methods execute concurrently on the framework event loop. Judges run concurrently with `asyncio.gather`, preserving YAML result order. Agent plugins should reuse the LLM supplied by the framework rather than instantiate a separate provider.
|
||||
|
||||
The core must not reference a concrete agent package, company, product, telecom identifier or domain-specific policy. Domain-specific variants belong to the agent and should receive distinct public codes/names.
|
||||
|
||||
## Compatibility rule
|
||||
Domain policies must not be replaced by cosmetically generic text inside the core while losing the original policy. The generic core implementation and the agent-specific implementation may coexist; the embedding agent explicitly selects its own code/name in YAML.
|
||||
|
||||
Legacy business validators should migrate to the agent domain. A temporary compatibility shim is acceptable for old imports, but new application code must import the agent-owned implementation.
|
||||
@@ -0,0 +1,4 @@
|
||||
__all__ = ['settings']
|
||||
from .config.settings import settings
|
||||
|
||||
from .idempotency import IdempotencyStore, InMemoryIdempotencyStore, create_idempotency_store
|
||||
@@ -0,0 +1,12 @@
|
||||
from .publisher import AnalyticsPublisher, NoopAnalyticsPublisher
|
||||
from .composite_publisher import CompositeAnalyticsPublisher
|
||||
from .event_builder import build_analytics_event
|
||||
from .factory import create_analytics_publisher
|
||||
|
||||
__all__ = [
|
||||
"AnalyticsPublisher",
|
||||
"NoopAnalyticsPublisher",
|
||||
"CompositeAnalyticsPublisher",
|
||||
"build_analytics_event",
|
||||
"create_analytics_publisher",
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .publisher import AnalyticsPublisher
|
||||
|
||||
logger = logging.getLogger("agent_framework.analytics.composite")
|
||||
|
||||
|
||||
class CompositeAnalyticsPublisher(AnalyticsPublisher):
|
||||
"""Publica o mesmo evento em múltiplos destinos.
|
||||
|
||||
Use para rodar OCI Streaming e Pub/Sub em paralelo durante transição,
|
||||
homologação ou estratégia multi-cloud.
|
||||
"""
|
||||
|
||||
def __init__(self, publishers: Iterable[AnalyticsPublisher], *, fail_silent: bool = True):
|
||||
self.publishers = list(publishers)
|
||||
self.fail_silent = fail_silent
|
||||
|
||||
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||
if not self.publishers:
|
||||
return
|
||||
|
||||
async def _safe_publish(publisher: AnalyticsPublisher) -> None:
|
||||
try:
|
||||
await publisher.publish(event_type, payload)
|
||||
except Exception:
|
||||
logger.exception("analytics.publisher_failed provider=%s event_type=%s", publisher.__class__.__name__, event_type)
|
||||
if not self.fail_silent:
|
||||
raise
|
||||
|
||||
await asyncio.gather(*[_safe_publish(p) for p in self.publishers])
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_analytics_event(
|
||||
event_type: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
*,
|
||||
source: str = "agent_framework",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Monta envelope uniforme para IC/NOC/GRL.
|
||||
|
||||
O campo metadata.noc=true é preservado para que o Observer consiga rotear
|
||||
eventos também para NOC/OTEL/Elastic quando aplicável.
|
||||
"""
|
||||
body = dict(payload or {})
|
||||
meta = dict(metadata or {})
|
||||
return {
|
||||
"eventType": event_type,
|
||||
"source": source,
|
||||
"eventDate": datetime.now(timezone.utc).isoformat(),
|
||||
"payload": body,
|
||||
"metadata": meta,
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .composite_publisher import CompositeAnalyticsPublisher
|
||||
from .publisher import AnalyticsPublisher, NoopAnalyticsPublisher
|
||||
|
||||
logger = logging.getLogger("agent_framework.analytics.factory")
|
||||
|
||||
|
||||
def _split_csv(value: str | None) -> list[str]:
|
||||
return [item.strip().lower() for item in (value or "").split(",") if item.strip()]
|
||||
|
||||
|
||||
def create_analytics_publisher(settings: Any | None = None) -> AnalyticsPublisher:
|
||||
"""Cria publisher conforme env/config.
|
||||
|
||||
Variáveis novas compatíveis:
|
||||
- ENABLE_ANALYTICS=true|false
|
||||
- ANALYTICS_PROVIDERS=oci_streaming,pubsub
|
||||
- GCP_PUBSUB_TOPIC_PATH=projects/.../topics/...
|
||||
- AGENT_PUBSUB_TOPIC=projects/.../topics/... # compatibilidade FIRST/TIM
|
||||
- GCP_PROJECT_ID=... + GCP_PUBSUB_TOPIC=...
|
||||
"""
|
||||
if settings is None:
|
||||
from agent_framework.config.settings import settings as default_settings
|
||||
settings = default_settings
|
||||
|
||||
analytics_enabled = bool(getattr(settings, "ENABLE_ANALYTICS", False))
|
||||
langfuse_enabled = bool(getattr(settings, "ENABLE_LANGFUSE", False))
|
||||
|
||||
# Historicamente o observer era usado para enviar IC/NOC/GRL ao Langfuse
|
||||
# mesmo quando o pipeline de analytics/streaming não estava habilitado.
|
||||
# Portanto, ENABLE_LANGFUSE=true também ativa o publisher Langfuse do observer.
|
||||
if not analytics_enabled and not langfuse_enabled:
|
||||
return NoopAnalyticsPublisher()
|
||||
|
||||
providers = _split_csv(getattr(settings, "ANALYTICS_PROVIDERS", "")) or ["oci_streaming"]
|
||||
if langfuse_enabled and "langfuse" not in providers:
|
||||
providers.insert(0, "langfuse")
|
||||
|
||||
# Se analytics geral estiver desligado, publica somente no Langfuse para
|
||||
# evitar inicializar OCI Streaming/PubSub por engano em ambientes locais.
|
||||
if not analytics_enabled:
|
||||
providers = [p for p in providers if p in {"langfuse", "noop", "none"}] or ["langfuse"]
|
||||
publishers: list[AnalyticsPublisher] = []
|
||||
|
||||
for provider in providers:
|
||||
try:
|
||||
if provider == "langfuse":
|
||||
from .providers.langfuse import LangfuseAnalyticsPublisher
|
||||
publishers.append(LangfuseAnalyticsPublisher(settings=settings))
|
||||
elif provider == "oci_streaming":
|
||||
from .providers.oci_streaming import OCIStreamingAnalyticsPublisher
|
||||
publishers.append(OCIStreamingAnalyticsPublisher(settings=settings))
|
||||
elif provider in {"pubsub", "gcp_pubsub", "gcp"}:
|
||||
from .providers.pubsub import PubSubAnalyticsPublisher
|
||||
topic = (
|
||||
getattr(settings, "GCP_PUBSUB_TOPIC_PATH", None)
|
||||
or getattr(settings, "AGENT_PUBSUB_TOPIC", None)
|
||||
)
|
||||
publishers.append(PubSubAnalyticsPublisher(topic_path=topic))
|
||||
elif provider in {"noop", "none"}:
|
||||
publishers.append(NoopAnalyticsPublisher())
|
||||
else:
|
||||
logger.warning("analytics.provider_ignored provider=%s", provider)
|
||||
except Exception:
|
||||
logger.exception("analytics.provider_init_failed provider=%s", provider)
|
||||
|
||||
if not publishers:
|
||||
# Sem este log, "analytics ligado mas todos os providers falharam" fica
|
||||
# indistinguivel de "analytics desligado": o publisher no-op descarta
|
||||
# IC/NOC/GRL em silencio ate o processo ser reiniciado.
|
||||
logger.error(
|
||||
"analytics.no_publisher_available providers=%s enable_analytics=%s "
|
||||
"enable_langfuse=%s; telemetria sera descartada ate o proximo restart",
|
||||
",".join(providers),
|
||||
analytics_enabled,
|
||||
langfuse_enabled,
|
||||
)
|
||||
return NoopAnalyticsPublisher()
|
||||
if len(publishers) == 1:
|
||||
return publishers[0]
|
||||
return CompositeAnalyticsPublisher(publishers)
|
||||
@@ -0,0 +1,11 @@
|
||||
from .oci_streaming import OCIStreamingAnalyticsPublisher
|
||||
from .pubsub import PubSubAnalyticsPublisher
|
||||
from .kafka import KafkaAnalyticsPublisher
|
||||
from .langfuse import LangfuseAnalyticsPublisher
|
||||
|
||||
__all__ = [
|
||||
"OCIStreamingAnalyticsPublisher",
|
||||
"PubSubAnalyticsPublisher",
|
||||
"KafkaAnalyticsPublisher",
|
||||
"LangfuseAnalyticsPublisher",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.analytics.publisher import AnalyticsPublisher
|
||||
|
||||
|
||||
class KafkaAnalyticsPublisher(AnalyticsPublisher):
|
||||
"""Publisher Kafka opcional.
|
||||
|
||||
Recebe um producer já criado para não acoplar o framework a uma lib específica
|
||||
(confluent-kafka, aiokafka, kafka-python etc.). O producer precisa expor send
|
||||
assíncrono ou síncrono.
|
||||
"""
|
||||
|
||||
def __init__(self, producer: Any, topic: str):
|
||||
self.producer = producer
|
||||
self.topic = topic
|
||||
|
||||
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||
message = json.dumps({"type": event_type, "payload": payload}, default=str).encode("utf-8")
|
||||
result = self.producer.send(self.topic, key=event_type.encode("utf-8"), value=message)
|
||||
if hasattr(result, "__await__"):
|
||||
await result
|
||||
@@ -0,0 +1,446 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.analytics.publisher import AnalyticsPublisher
|
||||
from agent_framework.observability.code_mapper import create_observability_code_mapper
|
||||
|
||||
try: # Avoid making analytics import fragile in old deployments.
|
||||
from agent_framework.observability.context import get_current_observation_id, get_observability_context
|
||||
except Exception: # pragma: no cover
|
||||
get_observability_context = None # type: ignore
|
||||
get_current_observation_id = None # type: ignore
|
||||
|
||||
logger = logging.getLogger("agent_framework.analytics.langfuse")
|
||||
|
||||
|
||||
def _truthy(value: Any, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on", "y"}
|
||||
|
||||
|
||||
def _safe_metadata(value: Any) -> Any:
|
||||
"""Remove/mascara segredos antes de enviar metadata para Langfuse."""
|
||||
if isinstance(value, dict):
|
||||
out: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
lk = str(key).lower()
|
||||
if any(token in lk for token in ("password", "secret", "token", "api_key", "authorization")):
|
||||
out[key] = "***"
|
||||
else:
|
||||
out[key] = _safe_metadata(item)
|
||||
return out
|
||||
if isinstance(value, list):
|
||||
return [_safe_metadata(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
_LANGFUSE_TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||
_INTERNAL_PREFIXES = ("IC.", "AGA.", "NOC.", "GRL.")
|
||||
_TECHNICAL_PREFIXES = (
|
||||
"langgraph.",
|
||||
"mcp.",
|
||||
"guardrail.",
|
||||
"judge.",
|
||||
"workflow.",
|
||||
"rag.",
|
||||
"cache.",
|
||||
"checkpoint.",
|
||||
)
|
||||
|
||||
|
||||
def _clean_str(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _first(*values: Any) -> str | None:
|
||||
for value in values:
|
||||
text = _clean_str(value)
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _current_context() -> dict[str, Any]:
|
||||
if get_observability_context is None:
|
||||
return {}
|
||||
try:
|
||||
return get_observability_context().clean()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _current_parent_observation_id() -> str | None:
|
||||
if get_current_observation_id is None:
|
||||
return None
|
||||
try:
|
||||
value = get_current_observation_id()
|
||||
return str(value) if value else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_internal_name(name: Any) -> bool:
|
||||
text = _clean_str(name) or ""
|
||||
return text.startswith(_INTERNAL_PREFIXES)
|
||||
|
||||
|
||||
def _is_technical_name(name: Any) -> bool:
|
||||
text = _clean_str(name) or ""
|
||||
return text.startswith(_TECHNICAL_PREFIXES)
|
||||
|
||||
|
||||
def _is_control_or_technical(name: Any) -> bool:
|
||||
return _is_internal_name(name) or _is_technical_name(name)
|
||||
|
||||
|
||||
def _extract_envelope_event_type(envelope: dict[str, Any]) -> str | None:
|
||||
return _first(
|
||||
envelope.get("eventType"),
|
||||
envelope.get("event_type"),
|
||||
envelope.get("name"),
|
||||
envelope.get("type"),
|
||||
)
|
||||
|
||||
|
||||
def _is_wrapped_internal_event(event_type: str, envelope: dict[str, Any]) -> bool:
|
||||
"""Detecta caso que gerava trace raiz errado.
|
||||
|
||||
Exemplo observado no Langfuse:
|
||||
name=http.request.completed
|
||||
input={"eventType": "NOC.006", ...}
|
||||
output={"published": true}
|
||||
|
||||
Isso não é o trace real da request; é apenas o publisher de analytics
|
||||
emitindo um envelope IC/NOC/GRL através de um evento técnico. Esse registro
|
||||
deve ser suprimido para não poluir a tela Tracing -> Traces.
|
||||
"""
|
||||
envelope_event_type = _extract_envelope_event_type(envelope)
|
||||
return bool(
|
||||
envelope_event_type
|
||||
and _is_internal_name(envelope_event_type)
|
||||
and str(event_type) != envelope_event_type
|
||||
and str(event_type).startswith(("http.request.", "gateway.", "telemetry."))
|
||||
)
|
||||
|
||||
|
||||
def _raw_correlation_id(metadata: dict[str, Any]) -> str | None:
|
||||
# IMPORTANT: prefer request/trace ids over transaction/session ids. Using
|
||||
# transaction/session as first choice created duplicate root traces for
|
||||
# IC/NOC/GRL events while the HTTP trace used request_id.
|
||||
value = (
|
||||
metadata.get("traceId")
|
||||
or metadata.get("trace_id")
|
||||
or metadata.get("requestId")
|
||||
or metadata.get("request_id")
|
||||
or metadata.get("transactionId")
|
||||
or metadata.get("transaction_id")
|
||||
or metadata.get("sessionId")
|
||||
or metadata.get("session_id")
|
||||
)
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _langfuse_trace_id(value: Any) -> str | None:
|
||||
"""Normaliza ids do framework/business para o formato aceito pelo Langfuse.
|
||||
|
||||
Langfuse SDK v3 exige 32 caracteres hex minúsculos. UUIDs com hífens são
|
||||
compactados; ids de negócio/sessão viram hash md5 determinístico.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
raw = str(value).strip().lower()
|
||||
if not raw:
|
||||
return None
|
||||
compact = raw.replace("-", "")
|
||||
if _LANGFUSE_TRACE_ID_RE.match(compact):
|
||||
return compact
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _correlation_trace_id(metadata: dict[str, Any]) -> str | None:
|
||||
return _langfuse_trace_id(_raw_correlation_id(metadata))
|
||||
|
||||
|
||||
def _with_trace_context(kwargs: dict[str, Any], metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_id = _raw_correlation_id(metadata)
|
||||
trace_id = _langfuse_trace_id(raw_id)
|
||||
parent_id = (
|
||||
metadata.get("parent_observation_id")
|
||||
or metadata.get("parent_span_id")
|
||||
or kwargs.get("parent_observation_id")
|
||||
or kwargs.get("parent_span_id")
|
||||
or _current_parent_observation_id()
|
||||
)
|
||||
if trace_id:
|
||||
trace_context = dict(kwargs.get("trace_context") or {})
|
||||
trace_context.setdefault("trace_id", trace_id)
|
||||
if parent_id:
|
||||
trace_context.setdefault("parent_span_id", str(parent_id))
|
||||
kwargs["trace_context"] = trace_context
|
||||
meta = kwargs.setdefault("metadata", {})
|
||||
if isinstance(meta, dict):
|
||||
meta.setdefault("framework_trace_id", raw_id)
|
||||
meta.setdefault("langfuse_trace_id", trace_id)
|
||||
if parent_id:
|
||||
meta.setdefault("parent_observation_id", str(parent_id))
|
||||
return kwargs
|
||||
|
||||
|
||||
def _allow_standalone_internal_events() -> bool:
|
||||
# Default false: IC/NOC/GRL sem contexto de request não devem criar linhas
|
||||
# soltas na tela principal de Traces. Habilite só para debug isolado.
|
||||
return _truthy(os.getenv("LANGFUSE_ALLOW_STANDALONE_INTERNAL_EVENTS"), False)
|
||||
|
||||
|
||||
class LangfuseAnalyticsPublisher(AnalyticsPublisher):
|
||||
"""Publica eventos IC/NOC/GRL no Langfuse sem criar traces raiz duplicados.
|
||||
|
||||
Regra principal:
|
||||
- 1 request/workflow = 1 trace raiz;
|
||||
- IC/NOC/GRL e eventos técnicos entram como observations/spans dentro do
|
||||
trace corrente;
|
||||
- envelopes internos embrulhados em eventos HTTP/gateway não criam trace
|
||||
próprio com output {"published": true}.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Any | None = None, langfuse: Any | None = None):
|
||||
if settings is None:
|
||||
from agent_framework.config.settings import settings as default_settings
|
||||
settings = default_settings
|
||||
|
||||
self.settings = settings
|
||||
self.code_mapper = create_observability_code_mapper(settings)
|
||||
self.langfuse = langfuse
|
||||
self.enabled = True
|
||||
|
||||
if self.langfuse is not None:
|
||||
return
|
||||
|
||||
public_key = getattr(settings, "LANGFUSE_PUBLIC_KEY", None) or os.getenv("LANGFUSE_PUBLIC_KEY")
|
||||
secret_key = getattr(settings, "LANGFUSE_SECRET_KEY", None) or os.getenv("LANGFUSE_SECRET_KEY")
|
||||
host = getattr(settings, "LANGFUSE_HOST", None) or os.getenv("LANGFUSE_HOST") or "https://cloud.langfuse.com"
|
||||
|
||||
if not public_key or not secret_key:
|
||||
self.enabled = False
|
||||
logger.warning("LangfuseAnalyticsPublisher desabilitado: LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY ausentes")
|
||||
return
|
||||
|
||||
try:
|
||||
from langfuse import Langfuse # type: ignore
|
||||
self.langfuse = Langfuse(public_key=public_key, secret_key=secret_key, host=host)
|
||||
logger.info("LangfuseAnalyticsPublisher habilitado host=%s", host)
|
||||
except Exception:
|
||||
self.enabled = False
|
||||
self.langfuse = None
|
||||
logger.exception("Falha ao inicializar LangfuseAnalyticsPublisher")
|
||||
|
||||
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||
if not self.enabled or self.langfuse is None:
|
||||
return
|
||||
|
||||
event_type = str(event_type)
|
||||
envelope = dict(payload or {})
|
||||
|
||||
# Prevent the exact pollution seen in Langfuse: http.request.completed
|
||||
# traces whose input is a NOC/IC envelope and output is {published:true}.
|
||||
if _is_wrapped_internal_event(event_type, envelope):
|
||||
logger.debug(
|
||||
"langfuse.analytics.skip_wrapped_internal event_type=%s envelope_event_type=%s",
|
||||
event_type,
|
||||
_extract_envelope_event_type(envelope),
|
||||
)
|
||||
return
|
||||
|
||||
body = envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {}
|
||||
metadata = envelope.get("metadata") if isinstance(envelope.get("metadata"), dict) else {}
|
||||
ctx = _current_context()
|
||||
|
||||
source = envelope.get("source") or "agent_framework"
|
||||
event_date = envelope.get("eventDate")
|
||||
envelope_event_type = _extract_envelope_event_type(envelope)
|
||||
effective_event_type = envelope_event_type if _is_internal_name(envelope_event_type) else event_type
|
||||
|
||||
# LangfuseAnalyticsPublisher talks directly to the Langfuse SDK and does
|
||||
# not pass through Telemetry._start_observation(). Apply the same contract
|
||||
# mapper here so analytics observations cannot leak internal names.
|
||||
original_effective_event_type = str(effective_event_type)
|
||||
effective_event_type, mapping_meta = self.code_mapper.normalize_name(
|
||||
original_effective_event_type,
|
||||
metadata,
|
||||
)
|
||||
if mapping_meta != metadata:
|
||||
metadata = mapping_meta
|
||||
if isinstance(envelope.get("metadata"), dict):
|
||||
envelope["metadata"] = dict(mapping_meta)
|
||||
|
||||
# Correlation priority: current ObservabilityContext > payload metadata >
|
||||
# transaction/session fallback. This keeps IC/NOC/GRL in the same HTTP trace.
|
||||
correlation_request_id = _first(
|
||||
ctx.get("request_id"),
|
||||
ctx.get("trace_id"),
|
||||
body.get("request_id"), metadata.get("request_id"),
|
||||
body.get("requestId"), metadata.get("requestId"),
|
||||
envelope.get("request_id"), envelope.get("requestId"),
|
||||
)
|
||||
correlation_trace_id = _first(
|
||||
ctx.get("trace_id"),
|
||||
ctx.get("request_id"),
|
||||
body.get("trace_id"), metadata.get("trace_id"),
|
||||
body.get("traceId"), metadata.get("traceId"),
|
||||
correlation_request_id,
|
||||
)
|
||||
correlation_session_id = _first(
|
||||
ctx.get("session_id"),
|
||||
body.get("session_id"), metadata.get("session_id"),
|
||||
body.get("sessionId"), metadata.get("sessionId"),
|
||||
body.get("transaction_id"), metadata.get("transaction_id"),
|
||||
body.get("transactionId"), metadata.get("transactionId"),
|
||||
)
|
||||
|
||||
is_internal = _is_internal_name(effective_event_type)
|
||||
is_technical = _is_technical_name(effective_event_type)
|
||||
|
||||
# IC/NOC/GRL without current/request correlation are usually emitted by
|
||||
# background/legacy publishers. Do not create standalone trace rows unless
|
||||
# explicitly requested for debugging.
|
||||
if (is_internal or is_technical) and not correlation_trace_id and not _allow_standalone_internal_events():
|
||||
logger.debug("langfuse.analytics.skip_unrelated_internal event_type=%s", effective_event_type)
|
||||
return
|
||||
|
||||
langfuse_metadata = _safe_metadata({
|
||||
"eventType": effective_event_type,
|
||||
"observability_name_internal": mapping_meta.get("observability_name_internal"),
|
||||
"observability_name_mapped": mapping_meta.get("observability_name_mapped"),
|
||||
"observability_code_mapped": mapping_meta.get("observability_code_mapped"),
|
||||
"original_event_type": original_effective_event_type if original_effective_event_type != effective_event_type else (event_type if event_type != effective_event_type else None),
|
||||
"source": source,
|
||||
"eventDate": event_date,
|
||||
"payload": body,
|
||||
"metadata": metadata,
|
||||
"ic": _is_ic(str(effective_event_type), metadata),
|
||||
"noc": _is_noc(str(effective_event_type), metadata),
|
||||
"grl": _is_grl(str(effective_event_type), metadata),
|
||||
"tag": body.get("tag") or metadata.get("tag") or effective_event_type,
|
||||
"request_id": correlation_request_id,
|
||||
"trace_id": correlation_trace_id,
|
||||
"transaction_id": body.get("transaction_id") or metadata.get("transaction_id") or body.get("transactionId") or metadata.get("transactionId"),
|
||||
"sessionId": correlation_session_id,
|
||||
"session_id": correlation_session_id,
|
||||
"messageId": body.get("messageId") or metadata.get("messageId") or body.get("message_id") or metadata.get("message_id") or ctx.get("message_id"),
|
||||
"agentId": body.get("agentId") or metadata.get("agentId") or body.get("agent_id") or metadata.get("agent_id") or ctx.get("agent_id"),
|
||||
"channelId": body.get("channelId") or metadata.get("channelId") or body.get("channel") or metadata.get("channel") or ctx.get("channel"),
|
||||
"workflow_id": body.get("workflow_id") or metadata.get("workflow_id") or ctx.get("workflow_id"),
|
||||
"tenant_id": body.get("tenant_id") or metadata.get("tenant_id") or ctx.get("tenant_id"),
|
||||
"parent_observation_id": body.get("parent_observation_id") or metadata.get("parent_observation_id") or _current_parent_observation_id(),
|
||||
})
|
||||
|
||||
# Keep correlation metadata on the trace, but do not turn every control
|
||||
# event code into a trace tag. IC/NOC/GRL are represented by the child
|
||||
# observation below; tags are not a substitute for the event span and
|
||||
# high-cardinality event-code tags make the trace harder to inspect.
|
||||
self._update_current_trace(langfuse_metadata)
|
||||
|
||||
# Prefer current/correlated observation API. For internal/technical events,
|
||||
# do not fall back to standalone span/trace APIs if this fails.
|
||||
try:
|
||||
if hasattr(self.langfuse, "start_as_current_observation"):
|
||||
kwargs = {
|
||||
"name": str(effective_event_type),
|
||||
"as_type": "span",
|
||||
"input": envelope,
|
||||
"metadata": langfuse_metadata,
|
||||
}
|
||||
# trace_context rebuilds the parent as a remote span (SDK cross-process
|
||||
# propagation); skip it when a real span is already active locally.
|
||||
if not _current_parent_observation_id():
|
||||
kwargs = _with_trace_context(kwargs, langfuse_metadata)
|
||||
try:
|
||||
cm = self.langfuse.start_as_current_observation(**kwargs)
|
||||
except (TypeError, ValueError):
|
||||
kwargs.pop("trace_context", None)
|
||||
cm = self.langfuse.start_as_current_observation(**kwargs)
|
||||
with cm as observation:
|
||||
_update_observation(observation, output={"published": True})
|
||||
return
|
||||
except Exception:
|
||||
log = logger.warning if is_internal else logger.debug
|
||||
log("Falha ao publicar Langfuse observation para %s", effective_event_type, exc_info=True)
|
||||
if is_internal or is_technical:
|
||||
return
|
||||
|
||||
if is_internal or is_technical:
|
||||
return
|
||||
|
||||
# Legacy fallbacks only for non-internal, high-level events.
|
||||
try:
|
||||
trace_id = _correlation_trace_id(langfuse_metadata)
|
||||
if trace_id and hasattr(self.langfuse, "trace"):
|
||||
trace = self.langfuse.trace(
|
||||
id=str(trace_id),
|
||||
name=str(langfuse_metadata.get("request_id") or langfuse_metadata.get("sessionId") or "agent_framework.request"),
|
||||
session_id=langfuse_metadata.get("sessionId"),
|
||||
user_id=langfuse_metadata.get("user_id") or langfuse_metadata.get("userId"),
|
||||
metadata={k: v for k, v in langfuse_metadata.items() if v is not None},
|
||||
)
|
||||
if hasattr(trace, "span"):
|
||||
span = trace.span(name=str(effective_event_type), input=envelope, metadata=langfuse_metadata)
|
||||
if hasattr(span, "end"):
|
||||
span.end(output={"published": True})
|
||||
return
|
||||
except Exception:
|
||||
logger.debug("Falha ao publicar Langfuse span correlacionado para %s", effective_event_type, exc_info=True)
|
||||
|
||||
try:
|
||||
if hasattr(self.langfuse, "span"):
|
||||
span = self.langfuse.span(name=str(effective_event_type), input=envelope, metadata=langfuse_metadata)
|
||||
if hasattr(span, "end"):
|
||||
span.end(output={"published": True})
|
||||
return
|
||||
except Exception:
|
||||
logger.debug("Falha ao publicar Langfuse span legado para %s", effective_event_type, exc_info=True)
|
||||
|
||||
def _update_current_trace(self, metadata: dict[str, Any]) -> None:
|
||||
try:
|
||||
kwargs: dict[str, Any] = {
|
||||
"metadata": {k: v for k, v in metadata.items() if v is not None},
|
||||
}
|
||||
session_id = metadata.get("sessionId") or metadata.get("session_id")
|
||||
if session_id:
|
||||
kwargs["session_id"] = str(session_id)
|
||||
if hasattr(self.langfuse, "update_current_trace"):
|
||||
self.langfuse.update_current_trace(**kwargs)
|
||||
except Exception:
|
||||
logger.debug("Langfuse update_current_trace ignorado", exc_info=True)
|
||||
|
||||
|
||||
def _update_observation(observation: Any, **kwargs: Any) -> None:
|
||||
if observation is None:
|
||||
return
|
||||
try:
|
||||
if hasattr(observation, "update"):
|
||||
observation.update(**{k: v for k, v in kwargs.items() if v is not None})
|
||||
except Exception:
|
||||
logger.debug("Langfuse observation update ignorado", exc_info=True)
|
||||
|
||||
|
||||
def _is_noc(event_type: str, metadata: dict[str, Any]) -> bool:
|
||||
return event_type.startswith("NOC.") or _truthy(metadata.get("noc"))
|
||||
|
||||
|
||||
def _is_grl(event_type: str, metadata: dict[str, Any]) -> bool:
|
||||
return event_type.startswith("GRL.") or _truthy(metadata.get("grl"))
|
||||
|
||||
|
||||
def _is_ic(event_type: str, metadata: dict[str, Any]) -> bool:
|
||||
return event_type.startswith(("IC.", "AGA.")) or _truthy(metadata.get("ic"))
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.analytics.publisher import AnalyticsPublisher
|
||||
from agent_framework.analytics.tim_sequence import ensure_sequence_envelope
|
||||
|
||||
|
||||
class OCIStreamingAnalyticsPublisher(AnalyticsPublisher):
|
||||
"""Adapter para reutilizar o publisher OCI Streaming existente do framework."""
|
||||
|
||||
def __init__(self, settings: Any | None = None, event_publisher: Any | None = None):
|
||||
if event_publisher is not None:
|
||||
self.event_publisher = event_publisher
|
||||
else:
|
||||
from agent_framework.config.settings import settings as default_settings
|
||||
from agent_framework.events.oci_streaming import create_event_publisher
|
||||
self.event_publisher = create_event_publisher(settings or default_settings)
|
||||
|
||||
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||
# Carimba o contador de sequence no envelope antes do publish, espelhando o
|
||||
# PubSubAnalyticsPublisher. Sem isto o path OCI Streaming sai sem sequence
|
||||
# (a geração estava amarrada apenas ao Pub/Sub na migração do framework).
|
||||
# ensure_sequence_envelope não quebra observabilidade: se faltar sessionId
|
||||
# ou o backend do contador falhar, o evento segue sem o campo.
|
||||
if isinstance(payload, dict):
|
||||
payload = await ensure_sequence_envelope(payload)
|
||||
await self.event_publisher.publish(event_type, payload)
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.analytics.tim_payload_mapper import map_analytics_event_to_tim_flat_payload
|
||||
from agent_framework.analytics.tim_sequence import ensure_sequence
|
||||
|
||||
from agent_framework.analytics.publisher import AnalyticsPublisher
|
||||
|
||||
logger = logging.getLogger("agent_framework.analytics.pubsub")
|
||||
|
||||
|
||||
class PubSubAnalyticsPublisher(AnalyticsPublisher):
|
||||
"""Publisher GCP Pub/Sub real, compatível com FIRST/TIM.
|
||||
|
||||
Formas aceitas de configuração:
|
||||
|
||||
1. GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-id>
|
||||
2. AGENT_PUBSUB_TOPIC=projects/<project-id>/topics/<topic-id>
|
||||
3. GCP_PROJECT_ID=<project-id> + GCP_PUBSUB_TOPIC=<topic-id>
|
||||
|
||||
Credenciais seguem o padrão Google:
|
||||
GOOGLE_APPLICATION_CREDENTIALS=/secrets/service-account.json
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
topic_path: str | None = None,
|
||||
*,
|
||||
project_id: str | None = None,
|
||||
topic_id: str | None = None,
|
||||
ordering_key: str | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
):
|
||||
self.topic_path = self._resolve_topic_path(topic_path, project_id=project_id, topic_id=topic_id)
|
||||
self.ordering_key = ordering_key or os.getenv("GCP_PUBSUB_ORDERING_KEY") or ""
|
||||
self.timeout_seconds = float(timeout_seconds or os.getenv("GCP_PUBSUB_TIMEOUT_SECONDS") or 30)
|
||||
self.payload_mode = (os.getenv("PUBSUB_PAYLOAD_MODE") or os.getenv("ANALYTICS_PUBSUB_PAYLOAD_MODE") or "flat").strip().lower()
|
||||
self.exclude_noc = (os.getenv("PUBSUB_EXCLUDE_NOC") or "true").strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||
self.excluded_event_types = {
|
||||
item.strip().upper()
|
||||
for item in os.getenv("PUBSUB_EXCLUDED_EVENT_TYPES", "").split(",")
|
||||
if item.strip()
|
||||
}
|
||||
|
||||
from google.cloud import pubsub_v1 # type: ignore
|
||||
|
||||
self.client = pubsub_v1.PublisherClient()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_topic_path(topic_path: str | None, *, project_id: str | None, topic_id: str | None) -> str:
|
||||
explicit = (
|
||||
topic_path
|
||||
or os.getenv("GCP_PUBSUB_TOPIC_PATH")
|
||||
or os.getenv("AGENT_PUBSUB_TOPIC")
|
||||
or os.getenv("PUBSUB_TOPIC_PATH")
|
||||
)
|
||||
if explicit:
|
||||
explicit = explicit.strip()
|
||||
if explicit.startswith("projects/"):
|
||||
return explicit
|
||||
# Permite passar só o nome do tópico quando project_id estiver disponível.
|
||||
project = project_id or os.getenv("GCP_PROJECT_ID") or os.getenv("GOOGLE_CLOUD_PROJECT")
|
||||
if project:
|
||||
return f"projects/{project}/topics/{explicit}"
|
||||
raise ValueError("topic_path deve estar no formato projects/<project-id>/topics/<topic-id> quando GCP_PROJECT_ID não está definido")
|
||||
|
||||
project = project_id or os.getenv("GCP_PROJECT_ID") or os.getenv("GOOGLE_CLOUD_PROJECT")
|
||||
topic = topic_id or os.getenv("GCP_PUBSUB_TOPIC") or os.getenv("PUBSUB_TOPIC")
|
||||
if project and topic:
|
||||
return f"projects/{project}/topics/{topic}"
|
||||
|
||||
raise ValueError("Configure GCP_PUBSUB_TOPIC_PATH, AGENT_PUBSUB_TOPIC ou GCP_PROJECT_ID + GCP_PUBSUB_TOPIC")
|
||||
|
||||
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||
event_key = str(event_type).upper()
|
||||
if event_key in self.excluded_event_types:
|
||||
logger.debug("analytics.pubsub.skipped_event event_type=%s", event_type)
|
||||
return
|
||||
|
||||
metadata = payload.get("metadata") if isinstance(payload, dict) else None
|
||||
is_noc = str(event_type).startswith("NOC.") or (isinstance(metadata, dict) and metadata.get("noc") is True)
|
||||
if is_noc and self.exclude_noc:
|
||||
logger.debug("analytics.pubsub.skipped_noc event_type=%s", event_type)
|
||||
return
|
||||
|
||||
if self.payload_mode in {"legacy", "envelope", "wrapped"}:
|
||||
message = {"type": event_type, "payload": payload}
|
||||
else:
|
||||
message = map_analytics_event_to_tim_flat_payload(event_type, payload, keep_none=False)
|
||||
message = await ensure_sequence(message)
|
||||
|
||||
data = json.dumps(message, default=str, ensure_ascii=False).encode("utf-8")
|
||||
attributes = {
|
||||
"event_type": str(event_type),
|
||||
"source": str(payload.get("source") or "agent_framework"),
|
||||
}
|
||||
if is_noc:
|
||||
attributes["noc"] = "true"
|
||||
|
||||
kwargs: dict[str, Any] = dict(attributes)
|
||||
if self.ordering_key:
|
||||
kwargs["ordering_key"] = self.ordering_key
|
||||
|
||||
future = self.client.publish(self.topic_path, data=data, **kwargs)
|
||||
await asyncio.to_thread(future.result, timeout=self.timeout_seconds)
|
||||
logger.debug("analytics.pubsub.published event_type=%s topic=%s", event_type, self.topic_path)
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("agent_framework.analytics")
|
||||
|
||||
|
||||
class AnalyticsPublisher(ABC):
|
||||
"""Contrato único para eventos analíticos corporativos.
|
||||
|
||||
A intenção é desacoplar o agente de OCI Streaming, GCP Pub/Sub, Kafka,
|
||||
BigQuery ou qualquer outro destino. Os agentes publicam eventos de negócio
|
||||
ou operação usando apenas este contrato.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class NoopAnalyticsPublisher(AnalyticsPublisher):
|
||||
"""Publisher seguro para ambientes locais/testes."""
|
||||
|
||||
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||
logger.info("analytics.noop event_type=%s payload_keys=%s", event_type, sorted(payload.keys()))
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _first(mapping: dict[str, Any], *keys: str) -> Any:
|
||||
for key in keys:
|
||||
if key in mapping and mapping.get(key) is not None:
|
||||
return mapping.get(key)
|
||||
return None
|
||||
|
||||
|
||||
def _as_list(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, (tuple, set)):
|
||||
return list(value)
|
||||
return [value]
|
||||
|
||||
|
||||
def _collect_agent_specific_data(metadata: dict[str, Any], body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
prefixed: dict[str, Any] = {}
|
||||
for source in (metadata, body):
|
||||
for key, value in source.items():
|
||||
if key.startswith("agentSpecificData."):
|
||||
prefixed[key.removeprefix("agentSpecificData.")] = value
|
||||
if prefixed:
|
||||
return prefixed
|
||||
|
||||
direct = _first(metadata, "agentSpecificData")
|
||||
if isinstance(direct, dict):
|
||||
return dict(direct)
|
||||
if isinstance(direct, str) and direct.strip():
|
||||
try:
|
||||
parsed = json.loads(direct)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
direct = _first(body, "agentSpecificData")
|
||||
if isinstance(direct, dict):
|
||||
return dict(direct)
|
||||
if isinstance(direct, str) and direct.strip():
|
||||
try:
|
||||
parsed = json.loads(direct)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def map_analytics_event_to_tim_flat_payload(
|
||||
event_type: str,
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
keep_none: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Map the framework analytics envelope to TIM's flat Pub/Sub/NOC schema.
|
||||
|
||||
The canonical fields are published at the JSON root. The only intentional
|
||||
nested object is ``agentSpecificData``.
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
event = {}
|
||||
|
||||
body = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
||||
metadata = event.get("metadata") if isinstance(event.get("metadata"), dict) else {}
|
||||
data: dict[str, Any] = {**body, **metadata}
|
||||
|
||||
token_usage = event.get("token_usage") if isinstance(event.get("token_usage"), dict) else {}
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
# Tracking
|
||||
"eventType": event.get("eventType") or event_type,
|
||||
"traceId": _first(data, "traceId", "trace_id"),
|
||||
"transactionId": _first(data, "transactionId", "transaction_id", "transactionID"),
|
||||
"spanId": _first(data, "spanId", "span_id"),
|
||||
"parentSpanId": _first(data, "parentSpanId", "parent_span_id"),
|
||||
"eventName": _first(data, "eventName", "name"),
|
||||
"version": _first(data, "version") or "1.0",
|
||||
"eventDate": _first(data, "eventDate") or event.get("eventDate") or datetime.now(timezone.utc).isoformat(),
|
||||
# Session/channel
|
||||
"sessionId": _first(data, "sessionId", "session_id"),
|
||||
"channelId": _first(data, "channelId", "channel", "channel_id"),
|
||||
"agentId": _first(data, "agentId", "agent_id"),
|
||||
"customerCode": _first(data, "customerCode", "customer_code"),
|
||||
"touchpoint": _first(data, "touchpoint"),
|
||||
"protocol": _first(data, "protocol"),
|
||||
"tag": _first(data, "tag") or event.get("eventType") or event_type,
|
||||
"noc": True if _first(data, "noc") is True else None,
|
||||
# Protocol/session
|
||||
"agentProtocolId": _first(data, "agentProtocolId", "agent_protocol_id"),
|
||||
"adjustedProtocol": _first(data, "adjustedProtocol", "adjusted_protocol"),
|
||||
"sessionCreatedAt": _first(data, "sessionCreatedAt", "session_created_at"),
|
||||
"sessionEndAt": _first(data, "sessionEndAt", "session_end_at"),
|
||||
# URA/voice
|
||||
"uraCallId": _first(data, "uraCallId", "ura_call_id"),
|
||||
"transcriptionId": _first(data, "transcriptionId", "transcription_id"),
|
||||
"gsm": _first(data, "gsm"),
|
||||
"ani": _first(data, "ani"),
|
||||
"uraProtocolId": _first(data, "uraProtocolId", "ura_protocol_id"),
|
||||
"uraLatency": _first(data, "uraLatency", "ura_latency"),
|
||||
"uraResolution": _first(data, "uraResolution", "urResolution", "ura_resolution"),
|
||||
"customerMessage": _first(data, "customerMessage", "customer_message"),
|
||||
# Message/guardrails/analysis
|
||||
"messageId": _first(data, "messageId", "message_id"),
|
||||
"blockingGuardrailsOutput": _first(data, "blockingGuardrailsOutput", "blocking_guardrails_output"),
|
||||
"blockingGuardrailsInput": _first(data, "blockingGuardrailsInput", "blocking_guardrails_input"),
|
||||
"llmResponse": _first(data, "llmResponse", "llm_response"),
|
||||
"alucinationScore": _first(data, "alucinationScore", "hallucinationScore", "alucination_score"),
|
||||
"noMatchRag": _first(data, "noMatchRag", "no_match_rag"),
|
||||
"promptLength": _first(data, "promptLength", "prompt_length"),
|
||||
"intention": _first(data, "intention", "intent"),
|
||||
"loop": _first(data, "loop"),
|
||||
"inferredCsiScore": _first(data, "inferredCsiScore", "inferred_csi_score"),
|
||||
"supervisorBlockReasons": _first(data, "supervisorBlockReasons", "supervisor_block_reasons"),
|
||||
"resolution": _first(data, "resolution"),
|
||||
"ConversationPrecision": _first(data, "ConversationPrecision", "conversationPrecision", "conversation_precision"),
|
||||
# LLM metrics
|
||||
"model": _first(data, "model") or event.get("model"),
|
||||
"tokenInput": _first(token_usage, "input_tokens") or _first(data, "tokenInput", "input_tokens"),
|
||||
"tokenOutput": _first(token_usage, "output_tokens") or _first(data, "tokenOutput", "output_tokens"),
|
||||
"latencyMs": _first(data, "latencyMs", "duration_ms"),
|
||||
"toxicityScore": _first(data, "toxicityScore", "toxicity_score"),
|
||||
"nps": _first(data, "nps"),
|
||||
"judgeScore": _first(data, "judgeScore", "judge_score"),
|
||||
"accuracyScore": _first(data, "accuracyScore", "accuracy_score"),
|
||||
"guardrails": _first(data, "guardrails"),
|
||||
# RAG
|
||||
"ragRetrievedDocuments": _as_list(_first(data, "documentsRetrieved", "ragRetrievedDocuments")),
|
||||
"ragSelectedDocuments": _as_list(_first(data, "documentsSelected", "ragSelectedDocuments")),
|
||||
# API
|
||||
"apiUrl": _first(data, "apiUrl", "api_url"),
|
||||
"apiStatusCode": _first(data, "httpStatusCode", "apiStatusCode", "http_status_code"),
|
||||
"apiResponsePayload": _first(data, "apiResponsePayload", "api_response_payload"),
|
||||
# I/O
|
||||
"inputData": _first(data, "inputData", "input_data"),
|
||||
"outputData": _first(data, "outputData", "output_data"),
|
||||
# Business/status/sequence
|
||||
"agentSpecificData": _collect_agent_specific_data(metadata, body),
|
||||
"status": _first(data, "status"),
|
||||
"sequence": _first(data, "sequence"),
|
||||
}
|
||||
|
||||
if keep_none:
|
||||
return {k: ("" if v is None else v) for k, v in payload.items()}
|
||||
return {k: v for k, v in payload.items() if v is not None}
|
||||
@@ -0,0 +1,396 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
logger = logging.getLogger("agent_framework.analytics.tim_sequence")
|
||||
|
||||
# In-process fallback. This is not cross-process/global, but keeps telemetry alive
|
||||
# when the configured shared sequence backend is unavailable, matching the
|
||||
# framework principle that observability must not break business execution.
|
||||
_memory_lock = threading.Lock()
|
||||
_memory_counters: dict[str, int] = defaultdict(int)
|
||||
|
||||
SequenceProvider = Literal["auto", "redis", "mongodb", "mongo", "memory", "none"]
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||
|
||||
|
||||
def sequence_enabled() -> bool:
|
||||
return _env_bool("PUBSUB_SEQUENCE_ENABLED", True)
|
||||
|
||||
|
||||
def _sequence_provider() -> SequenceProvider:
|
||||
raw = (os.getenv("PUBSUB_SEQUENCE_PROVIDER") or "auto").strip().lower()
|
||||
if raw in {"mongo"}:
|
||||
return "mongodb"
|
||||
if raw in {"auto", "redis", "mongodb", "memory", "none"}:
|
||||
return raw # type: ignore[return-value]
|
||||
logger.warning("tim_sequence.invalid_provider provider=%s; using auto", raw)
|
||||
return "auto"
|
||||
|
||||
|
||||
def _redis_url() -> str | None:
|
||||
return os.getenv("PUBSUB_SEQUENCE_REDIS_URL") or os.getenv("REDIS_URL")
|
||||
|
||||
|
||||
def _mongo_uri() -> str | None:
|
||||
return (
|
||||
os.getenv("PUBSUB_SEQUENCE_MONGODB_URI")
|
||||
or os.getenv("MONGODB_URI")
|
||||
or os.getenv("MONGO_URI")
|
||||
)
|
||||
|
||||
|
||||
def _mongo_database() -> str:
|
||||
return (
|
||||
os.getenv("PUBSUB_SEQUENCE_MONGODB_DATABASE")
|
||||
or os.getenv("MONGODB_DATABASE")
|
||||
or os.getenv("MONGO_DATABASE")
|
||||
or "agent_platform"
|
||||
)
|
||||
|
||||
|
||||
def _legacy_agent_name() -> str:
|
||||
return _safe_part(os.getenv("AGENT_NAME") or "agent", "agent")
|
||||
|
||||
|
||||
def _mongo_collection() -> str:
|
||||
"""Return the shared MongoDB collection used by every event producer.
|
||||
|
||||
The collection must not vary by agent. A transaction can emit GRL, AGA,
|
||||
NOC and other events from different components, and all of them must
|
||||
increment the same counter document. Deployments may override the name,
|
||||
but the configured value must be identical in every producer/pod.
|
||||
"""
|
||||
return (
|
||||
os.getenv("PUBSUB_SEQUENCE_MONGODB_COLLECTION")
|
||||
or os.getenv("MONGODB_EVENT_COUNTERS_COLLECTION")
|
||||
or os.getenv("EVENT_COUNTERS_COLLECTION")
|
||||
or "observer_event_counters"
|
||||
)
|
||||
|
||||
|
||||
def _ttl_seconds() -> int:
|
||||
raw = os.getenv("PUBSUB_SEQUENCE_TTL_SECONDS") or os.getenv("SESSION_TTL_SECONDS") or "86400"
|
||||
try:
|
||||
return max(0, int(raw))
|
||||
except Exception:
|
||||
return 86400
|
||||
|
||||
|
||||
def _fallback_enabled() -> bool:
|
||||
# An in-memory fallback creates duplicate sequences when multiple pods or
|
||||
# event producers handle the same transaction. Keep it opt-in only for
|
||||
# local/single-process development.
|
||||
return _env_bool("PUBSUB_SEQUENCE_MEMORY_FALLBACK", False)
|
||||
|
||||
|
||||
def _key_prefix() -> str:
|
||||
return os.getenv("PUBSUB_SEQUENCE_KEY_PREFIX") or "observer:sequence"
|
||||
|
||||
|
||||
def _safe_part(value: Any, fallback: str) -> str:
|
||||
text = str(value or fallback).strip()
|
||||
return text.replace(" ", "_").replace("/", "_").replace("\\", "_")
|
||||
|
||||
|
||||
def build_sequence_key(
|
||||
agent_id: str | None,
|
||||
session_id: str | None,
|
||||
transaction_id: str | None = None,
|
||||
) -> str:
|
||||
"""Build one counter key for the whole transaction.
|
||||
|
||||
``agent_id`` is intentionally ignored for transaction-scoped counters.
|
||||
A single transaction may emit events from different agents/components
|
||||
(for example GRL and AGA), and those events must share one monotonic
|
||||
sequence. ``session_id`` is retained only as a compatibility fallback when
|
||||
no transaction identifier is present.
|
||||
"""
|
||||
if transaction_id:
|
||||
transaction = _safe_part(transaction_id, "unknown_transaction")
|
||||
return f"{_key_prefix()}:transaction:{transaction}"
|
||||
|
||||
# Legacy fallback. Including the agent here avoids changing old session-only
|
||||
# behavior, but new integrations should always provide transactionId.
|
||||
agent = _safe_part(agent_id or os.getenv("AGENT_NAME"), "agent")
|
||||
session = _safe_part(session_id, "unknown_session")
|
||||
return f"{_key_prefix()}:{agent}:session:{session}"
|
||||
|
||||
|
||||
async def _next_sequence_redis(key: str, ttl_seconds: int) -> int | None:
|
||||
url = _redis_url()
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
import redis.asyncio as redis_async # type: ignore
|
||||
|
||||
client = redis_async.Redis.from_url(url, decode_responses=True)
|
||||
try:
|
||||
value = await client.incr(key)
|
||||
if ttl_seconds > 0 and value == 1:
|
||||
await client.expire(key, ttl_seconds)
|
||||
return int(value)
|
||||
finally:
|
||||
try:
|
||||
await client.aclose()
|
||||
except AttributeError: # redis-py older compatibility
|
||||
await client.close()
|
||||
except Exception:
|
||||
logger.exception("tim_sequence.redis_failed key=%s", key)
|
||||
return None
|
||||
|
||||
|
||||
_mongo_index_checked = False
|
||||
_mongo_index_lock = threading.Lock()
|
||||
|
||||
|
||||
def _next_sequence_mongodb_sync(
|
||||
key: str,
|
||||
agent_id: str | None,
|
||||
session_id: str | None,
|
||||
transaction_id: str | None,
|
||||
ttl_seconds: int,
|
||||
) -> int | None:
|
||||
uri = _mongo_uri()
|
||||
if not uri:
|
||||
return None
|
||||
|
||||
from pymongo import MongoClient, ReturnDocument # type: ignore
|
||||
|
||||
client = MongoClient(uri)
|
||||
try:
|
||||
collection = client[_mongo_database()][_mongo_collection()]
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(seconds=ttl_seconds) if ttl_seconds > 0 else None
|
||||
|
||||
# update: dict[str, Any] = {
|
||||
# "$inc": {"sequence": 1},
|
||||
# "$set": {
|
||||
# "agentId": agent_id or os.getenv("AGENT_NAME") or "agent",
|
||||
# "sessionId": session_id,
|
||||
# "transactionId": transaction_id,
|
||||
# "sequenceScope": "transaction" if transaction_id else "session",
|
||||
# "updatedAt": now,
|
||||
# },
|
||||
# "$setOnInsert": {
|
||||
# "_id": key,
|
||||
# "createdAt": now,
|
||||
# },
|
||||
# }
|
||||
update: dict[str, Any] = {
|
||||
"$inc": {"sequence": 1},
|
||||
"$set": {
|
||||
"agentId": agent_id or os.getenv("AGENT_NAME") or "agent",
|
||||
"sessionId": session_id,
|
||||
"transactionId": transaction_id,
|
||||
"sequenceScope": "transaction" if transaction_id else "session",
|
||||
"updatedAt": now,
|
||||
},
|
||||
"$setOnInsert": {
|
||||
"createdAt": now,
|
||||
},
|
||||
}
|
||||
if expires_at is not None:
|
||||
update["$set"]["expiresAt"] = expires_at
|
||||
|
||||
doc = collection.find_one_and_update(
|
||||
{"_id": key},
|
||||
update,
|
||||
upsert=True,
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
if not doc:
|
||||
return None
|
||||
return int(doc.get("sequence", 0))
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _ensure_mongo_ttl_index_once_sync(ttl_seconds: int) -> None:
|
||||
"""Best-effort TTL index initialization, safe across threads/event loops.
|
||||
|
||||
``asyncio.Lock`` must not be shared by independent event loops. Observer
|
||||
compatibility calls may originate in worker threads, so this one-time
|
||||
process-local guard deliberately uses ``threading.Lock``. The blocking
|
||||
Mongo operation is executed by the async wrapper in a worker thread.
|
||||
"""
|
||||
global _mongo_index_checked
|
||||
if _mongo_index_checked or ttl_seconds <= 0 or not _mongo_uri():
|
||||
return
|
||||
|
||||
with _mongo_index_lock:
|
||||
if _mongo_index_checked:
|
||||
return
|
||||
try:
|
||||
from pymongo import MongoClient # type: ignore
|
||||
|
||||
client = MongoClient(_mongo_uri())
|
||||
try:
|
||||
collection = client[_mongo_database()][_mongo_collection()]
|
||||
collection.create_index("expiresAt", expireAfterSeconds=0, background=True)
|
||||
finally:
|
||||
client.close()
|
||||
except Exception:
|
||||
logger.warning("tim_sequence.mongodb_ttl_index_failed", exc_info=True)
|
||||
finally:
|
||||
# The index is an observability housekeeping concern, not a
|
||||
# prerequisite for sequence generation. Do not retry on every
|
||||
# event if the application user lacks index privileges.
|
||||
_mongo_index_checked = True
|
||||
|
||||
|
||||
async def _ensure_mongo_ttl_index_once(ttl_seconds: int) -> None:
|
||||
await asyncio.to_thread(_ensure_mongo_ttl_index_once_sync, ttl_seconds)
|
||||
|
||||
|
||||
async def _next_sequence_mongodb(
|
||||
key: str,
|
||||
agent_id: str | None,
|
||||
session_id: str | None,
|
||||
transaction_id: str | None,
|
||||
ttl_seconds: int,
|
||||
) -> int | None:
|
||||
if not _mongo_uri():
|
||||
return None
|
||||
try:
|
||||
await _ensure_mongo_ttl_index_once(ttl_seconds)
|
||||
return await asyncio.to_thread(
|
||||
_next_sequence_mongodb_sync,
|
||||
key,
|
||||
agent_id,
|
||||
session_id,
|
||||
transaction_id,
|
||||
ttl_seconds,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("tim_sequence.mongodb_failed key=%s", key)
|
||||
return None
|
||||
|
||||
|
||||
async def _next_sequence_memory(key: str) -> int:
|
||||
# Tiny in-process critical section; a thread lock is intentional because
|
||||
# this fallback can be reached from more than one asyncio event loop.
|
||||
with _memory_lock:
|
||||
_memory_counters[key] += 1
|
||||
return _memory_counters[key]
|
||||
|
||||
|
||||
async def next_sequence(
|
||||
agent_id: str | None,
|
||||
session_id: str | None,
|
||||
transaction_id: str | None = None,
|
||||
) -> int | None:
|
||||
"""Return the next observer sequence isolated by transaction.
|
||||
|
||||
The preferred scope is only ``transaction_id``. Agent/event family must
|
||||
never participate in the key because one transaction can emit events from
|
||||
several components. ``session_id`` is used only as a backward-compatible
|
||||
fallback. Redis and MongoDB increments remain atomic across replicas.
|
||||
"""
|
||||
if not sequence_enabled() or (not transaction_id and not session_id):
|
||||
return None
|
||||
|
||||
provider = _sequence_provider()
|
||||
if provider == "none":
|
||||
return None
|
||||
|
||||
key = build_sequence_key(agent_id, session_id, transaction_id)
|
||||
ttl_seconds = _ttl_seconds()
|
||||
value: int | None = None
|
||||
|
||||
if provider == "memory":
|
||||
return await _next_sequence_memory(key)
|
||||
|
||||
if provider == "redis":
|
||||
value = await _next_sequence_redis(key, ttl_seconds)
|
||||
elif provider == "mongodb":
|
||||
value = await _next_sequence_mongodb(
|
||||
key, agent_id, session_id, transaction_id, ttl_seconds
|
||||
)
|
||||
else: # auto
|
||||
if _redis_url():
|
||||
value = await _next_sequence_redis(key, ttl_seconds)
|
||||
if value is None and _mongo_uri():
|
||||
value = await _next_sequence_mongodb(
|
||||
key, agent_id, session_id, transaction_id, ttl_seconds
|
||||
)
|
||||
|
||||
if value is not None:
|
||||
return value
|
||||
if _fallback_enabled():
|
||||
return await _next_sequence_memory(key)
|
||||
return None
|
||||
|
||||
|
||||
async def ensure_sequence(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Inject sequence if missing, preserving explicit values from metadata/body.
|
||||
|
||||
Used by the flat Pub/Sub schema, where sessionId/agentId sit at the root.
|
||||
For the nested analytics envelope (OCI Streaming) use
|
||||
:func:`ensure_sequence_envelope`.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return payload
|
||||
if payload.get("sequence") is not None:
|
||||
return payload
|
||||
session_id = payload.get("sessionId") or payload.get("session_id")
|
||||
transaction_id = (
|
||||
payload.get("transactionId")
|
||||
or payload.get("transaction_id")
|
||||
or payload.get("transactionID")
|
||||
)
|
||||
agent_id = payload.get("agentId") or payload.get("agent_id") or os.getenv("AGENT_NAME")
|
||||
seq = await next_sequence(agent_id, session_id, transaction_id)
|
||||
if seq is not None:
|
||||
payload["sequence"] = seq
|
||||
return payload
|
||||
|
||||
|
||||
async def ensure_sequence_envelope(event: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Inject sequence into a ``build_analytics_event`` envelope.
|
||||
|
||||
The envelope shape is ``{eventType, source, eventDate, payload, metadata}``.
|
||||
Unlike the flat Pub/Sub payload, sessionId/agentId are not at the root: they
|
||||
live inside ``payload`` and/or ``metadata``. We read them from the merged
|
||||
``{**payload, **metadata}`` view, mirroring the flat mapper
|
||||
(tim_payload_mapper.map_analytics_event_to_tim_flat_payload) and the legacy
|
||||
observer (observer/api.py: metadata.sessionId -> sessionId).
|
||||
|
||||
The counter is written at the envelope root, as a sibling of ``eventType`` —
|
||||
the faithful analog of the legacy flat payload where ``sequence`` sat next to
|
||||
``eventType``/``traceId``. The outer transport contract ``{type, payload}`` is
|
||||
left untouched; only this inner field is added.
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
if event.get("sequence") is not None:
|
||||
return event
|
||||
body = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
||||
metadata = event.get("metadata") if isinstance(event.get("metadata"), dict) else {}
|
||||
data = {**body, **metadata}
|
||||
session_id = data.get("sessionId") or data.get("session_id")
|
||||
# Os adapters do BO emitem snake_case; o contrato TIM usa transactionId e
|
||||
# payloads antigos trazem transactionID. Sem as tres grafias o contador cai
|
||||
# em escopo de sessao e perde o isolamento por transacao.
|
||||
transaction_id = (
|
||||
data.get("transactionId")
|
||||
or data.get("transaction_id")
|
||||
or data.get("transactionID")
|
||||
)
|
||||
agent_id = data.get("agentId") or data.get("agent_id") or os.getenv("AGENT_NAME")
|
||||
seq = await next_sequence(agent_id, session_id, transaction_id)
|
||||
if seq is not None:
|
||||
event["sequence"] = seq
|
||||
return event
|
||||
@@ -0,0 +1 @@
|
||||
from .usage_repository import UsageRecord, UsageRepository, SQLiteUsageRepository, OracleUsageRepository, create_usage_repository
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.observability.context import get_observability_context
|
||||
|
||||
@dataclass
|
||||
class UsageRecord:
|
||||
provider: str
|
||||
model: str
|
||||
operation: str
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
cached_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
cost_usd: float = 0.0
|
||||
cost_brl: float = 0.0
|
||||
metadata: dict[str, Any] | None = None
|
||||
request_id: str | None = None
|
||||
session_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
user_id: str | None = None
|
||||
message_id: str | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_usage(cls, provider: str, model: str, operation: str, usage: dict[str, Any], metadata: dict[str, Any] | None = None) -> "UsageRecord":
|
||||
ctx = get_observability_context()
|
||||
return cls(
|
||||
provider=provider, model=model, operation=operation,
|
||||
prompt_tokens=int(usage.get("prompt_tokens") or 0),
|
||||
completion_tokens=int(usage.get("completion_tokens") or 0),
|
||||
cached_tokens=int(usage.get("cached_tokens") or 0),
|
||||
total_tokens=int(usage.get("total_tokens") or 0),
|
||||
cost_usd=float(usage.get("cost_usd") or 0),
|
||||
cost_brl=float(usage.get("cost_brl") or 0),
|
||||
metadata=metadata or {}, request_id=ctx.request_id, session_id=ctx.session_id,
|
||||
tenant_id=ctx.tenant_id, agent_id=ctx.agent_id, user_id=ctx.user_id,
|
||||
message_id=ctx.message_id, created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
def model_dump(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
class UsageRepository:
|
||||
async def record(self, usage: UsageRecord) -> None: ...
|
||||
async def summarize(self, *, tenant_id: str | None = None, session_id: str | None = None) -> dict[str, Any]: ...
|
||||
|
||||
class SQLiteUsageRepository(UsageRepository):
|
||||
def __init__(self, settings):
|
||||
from agent_framework.persistence.sqlite_store import SQLiteStore
|
||||
self.store = SQLiteStore(settings.SQLITE_DB_PATH)
|
||||
self._init_schema()
|
||||
|
||||
def _init_schema(self):
|
||||
ddl = """
|
||||
create table if not exists llm_usage_records (
|
||||
id integer primary key autoincrement,
|
||||
request_id text, session_id text, tenant_id text, agent_id text, user_id text, message_id text,
|
||||
provider text not null, model text not null, operation text not null,
|
||||
prompt_tokens integer not null default 0,
|
||||
completion_tokens integer not null default 0,
|
||||
cached_tokens integer not null default 0,
|
||||
total_tokens integer not null default 0,
|
||||
cost_usd real not null default 0,
|
||||
cost_brl real not null default 0,
|
||||
metadata_json text,
|
||||
created_at text not null
|
||||
);
|
||||
create index if not exists idx_usage_tenant_created on llm_usage_records(tenant_id, created_at);
|
||||
create index if not exists idx_usage_session_created on llm_usage_records(session_id, created_at);
|
||||
"""
|
||||
with self.store._lock, self.store.connect() as con:
|
||||
con.executescript(ddl)
|
||||
|
||||
async def record(self, usage: UsageRecord) -> None:
|
||||
with self.store._lock, self.store.connect() as con:
|
||||
con.execute("""
|
||||
insert into llm_usage_records(
|
||||
request_id,session_id,tenant_id,agent_id,user_id,message_id,
|
||||
provider,model,operation,prompt_tokens,completion_tokens,cached_tokens,total_tokens,
|
||||
cost_usd,cost_brl,metadata_json,created_at
|
||||
) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
usage.request_id, usage.session_id, usage.tenant_id, usage.agent_id, usage.user_id, usage.message_id,
|
||||
usage.provider, usage.model, usage.operation, usage.prompt_tokens, usage.completion_tokens,
|
||||
usage.cached_tokens, usage.total_tokens, usage.cost_usd, usage.cost_brl,
|
||||
json.dumps(usage.metadata or {}, ensure_ascii=False, default=str), usage.created_at,
|
||||
))
|
||||
|
||||
async def summarize(self, *, tenant_id: str | None = None, session_id: str | None = None) -> dict[str, Any]:
|
||||
where=[]; params=[]
|
||||
if tenant_id: where.append('tenant_id=?'); params.append(tenant_id)
|
||||
if session_id: where.append('session_id=?'); params.append(session_id)
|
||||
sql="""select count(*) calls, coalesce(sum(prompt_tokens),0) prompt_tokens,
|
||||
coalesce(sum(completion_tokens),0) completion_tokens,
|
||||
coalesce(sum(total_tokens),0) total_tokens,
|
||||
coalesce(sum(cost_usd),0) cost_usd,
|
||||
coalesce(sum(cost_brl),0) cost_brl
|
||||
from llm_usage_records"""
|
||||
if where: sql += ' where ' + ' and '.join(where)
|
||||
with self.store._lock, self.store.connect() as con:
|
||||
row=con.execute(sql, params).fetchone()
|
||||
return dict(row) if row else {"calls":0,"prompt_tokens":0,"completion_tokens":0,"total_tokens":0,"cost_usd":0,"cost_brl":0}
|
||||
|
||||
class OracleUsageRepository(UsageRepository):
|
||||
def __init__(self, settings):
|
||||
from agent_framework.persistence.oracle_store import OracleStore
|
||||
self.store = OracleStore(settings)
|
||||
self._init_schema()
|
||||
|
||||
def _init_schema(self):
|
||||
with self.store.connect() as conn:
|
||||
cur=conn.cursor()
|
||||
self.store._exec_ddl_ignore_exists(cur, f"""
|
||||
create table {self.store.t('LLM_USAGE_RECORD')} (
|
||||
ID number generated always as identity primary key,
|
||||
REQUEST_ID varchar2(128), SESSION_ID varchar2(256), TENANT_ID varchar2(128),
|
||||
AGENT_ID varchar2(128), USER_ID varchar2(256), MESSAGE_ID varchar2(256),
|
||||
PROVIDER varchar2(128) not null, MODEL varchar2(256) not null, OPERATION varchar2(128) not null,
|
||||
PROMPT_TOKENS number default 0, COMPLETION_TOKENS number default 0, CACHED_TOKENS number default 0,
|
||||
TOTAL_TOKENS number default 0, COST_USD number default 0, COST_BRL number default 0,
|
||||
METADATA_JSON clob check (METADATA_JSON is json), CREATED_AT timestamp with time zone not null
|
||||
)
|
||||
""")
|
||||
self.store._exec_ddl_ignore_exists(cur, f"create index {self.store.t('IX_USAGE_TENANT')} on {self.store.t('LLM_USAGE_RECORD')}(TENANT_ID, CREATED_AT)")
|
||||
self.store._exec_ddl_ignore_exists(cur, f"create index {self.store.t('IX_USAGE_SESSION')} on {self.store.t('LLM_USAGE_RECORD')}(SESSION_ID, CREATED_AT)")
|
||||
|
||||
async def record(self, usage: UsageRecord) -> None:
|
||||
await asyncio.to_thread(self._record_sync, usage)
|
||||
|
||||
def _record_sync(self, usage: UsageRecord):
|
||||
with self.store.connect() as conn:
|
||||
conn.cursor().execute(f"""
|
||||
insert into {self.store.t('LLM_USAGE_RECORD')}(
|
||||
REQUEST_ID,SESSION_ID,TENANT_ID,AGENT_ID,USER_ID,MESSAGE_ID,PROVIDER,MODEL,OPERATION,
|
||||
PROMPT_TOKENS,COMPLETION_TOKENS,CACHED_TOKENS,TOTAL_TOKENS,COST_USD,COST_BRL,METADATA_JSON,CREATED_AT
|
||||
) values(:1,:2,:3,:4,:5,:6,:7,:8,:9,:10,:11,:12,:13,:14,:15,:16,:17)
|
||||
""", [
|
||||
usage.request_id, usage.session_id, usage.tenant_id, usage.agent_id, usage.user_id, usage.message_id,
|
||||
usage.provider, usage.model, usage.operation, usage.prompt_tokens, usage.completion_tokens, usage.cached_tokens,
|
||||
usage.total_tokens, usage.cost_usd, usage.cost_brl, json.dumps(usage.metadata or {}, ensure_ascii=False, default=str), usage.created_at,
|
||||
])
|
||||
|
||||
async def summarize(self, *, tenant_id: str | None = None, session_id: str | None = None) -> dict[str, Any]:
|
||||
return await asyncio.to_thread(self._summarize_sync, tenant_id, session_id)
|
||||
|
||||
def _summarize_sync(self, tenant_id, session_id):
|
||||
where=[]; params={}
|
||||
if tenant_id: where.append('TENANT_ID=:tenant_id'); params['tenant_id']=tenant_id
|
||||
if session_id: where.append('SESSION_ID=:session_id'); params['session_id']=session_id
|
||||
sql=f"""select count(*) CALLS, coalesce(sum(PROMPT_TOKENS),0) PROMPT_TOKENS,
|
||||
coalesce(sum(COMPLETION_TOKENS),0) COMPLETION_TOKENS,
|
||||
coalesce(sum(TOTAL_TOKENS),0) TOTAL_TOKENS,
|
||||
coalesce(sum(COST_USD),0) COST_USD,
|
||||
coalesce(sum(COST_BRL),0) COST_BRL
|
||||
from {self.store.t('LLM_USAGE_RECORD')}"""
|
||||
if where: sql += ' where ' + ' and '.join(where)
|
||||
with self.store.connect() as conn:
|
||||
cur=conn.cursor(); cur.execute(sql, params); row=cur.fetchone()
|
||||
cols=[d[0].lower() for d in cur.description]
|
||||
return dict(zip(cols,row)) if row else {}
|
||||
|
||||
def create_usage_repository(settings) -> UsageRepository:
|
||||
provider = getattr(settings, 'USAGE_REPOSITORY_PROVIDER', None) or getattr(settings, 'MEMORY_REPOSITORY_PROVIDER', 'memory')
|
||||
if provider in {'autonomous','oracle'}:
|
||||
return OracleUsageRepository(settings)
|
||||
return SQLiteUsageRepository(settings)
|
||||
0
libs/agent_framework/build/lib/agent_framework/cache/__init__.py
vendored
Normal file
0
libs/agent_framework/build/lib/agent_framework/cache/__init__.py
vendored
Normal file
184
libs/agent_framework/build/lib/agent_framework/cache/cache.py
vendored
Normal file
184
libs/agent_framework/build/lib/agent_framework/cache/cache.py
vendored
Normal file
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("agent_framework.cache")
|
||||
|
||||
|
||||
class Cache:
|
||||
async def get(self, key: str) -> Any | None: ...
|
||||
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None: ...
|
||||
async def delete(self, key: str) -> None: ...
|
||||
|
||||
|
||||
class InMemoryCache(Cache):
|
||||
def __init__(self):
|
||||
self._data: dict[str, tuple[Any, float | None]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self, key):
|
||||
async with self._lock:
|
||||
item = self._data.get(key)
|
||||
if not item:
|
||||
return None
|
||||
value, expires = item
|
||||
if expires and expires < time.time():
|
||||
self._data.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
async def set(self, key, value, ttl_seconds=None):
|
||||
async with self._lock:
|
||||
self._data[key] = (value, time.time() + ttl_seconds if ttl_seconds else None)
|
||||
|
||||
async def delete(self, key):
|
||||
async with self._lock:
|
||||
self._data.pop(key, None)
|
||||
|
||||
|
||||
class RedisCache(Cache):
|
||||
"""Redis L2 cache with redis-py sync/async compatibility and safe fallback."""
|
||||
def __init__(self, settings):
|
||||
self.url = settings.REDIS_URL
|
||||
self.prefix = getattr(settings, "CACHE_KEY_PREFIX", "agentfw")
|
||||
self._async = False
|
||||
try:
|
||||
import redis.asyncio as redis_async
|
||||
self.client = redis_async.Redis.from_url(self.url, decode_responses=True)
|
||||
self._async = True
|
||||
except Exception:
|
||||
import redis
|
||||
self.client = redis.Redis.from_url(self.url, decode_responses=True)
|
||||
|
||||
def _key(self, key: str) -> str:
|
||||
return f"{self.prefix}:{key}"
|
||||
|
||||
async def get(self, key):
|
||||
try:
|
||||
raw = await self.client.get(self._key(key)) if self._async else await asyncio.to_thread(self.client.get, self._key(key))
|
||||
return json.loads(raw) if raw else None
|
||||
except Exception:
|
||||
logger.exception("Redis GET falhou key=%s", key)
|
||||
return None
|
||||
|
||||
async def set(self, key, value, ttl_seconds=None):
|
||||
raw = json.dumps(value, ensure_ascii=False, default=str)
|
||||
try:
|
||||
if self._async:
|
||||
await self.client.set(self._key(key), raw, ex=ttl_seconds)
|
||||
else:
|
||||
await asyncio.to_thread(self.client.set, self._key(key), raw, ex=ttl_seconds)
|
||||
except Exception:
|
||||
logger.exception("Redis SET falhou key=%s", key)
|
||||
|
||||
async def delete(self, key):
|
||||
try:
|
||||
if self._async:
|
||||
await self.client.delete(self._key(key))
|
||||
else:
|
||||
await asyncio.to_thread(self.client.delete, self._key(key))
|
||||
except Exception:
|
||||
logger.exception("Redis DELETE falhou key=%s", key)
|
||||
|
||||
|
||||
class SQLiteCache(Cache):
|
||||
def __init__(self, settings):
|
||||
from agent_framework.persistence.sqlite_store import SQLiteStore
|
||||
self.store = SQLiteStore(settings.SQLITE_DB_PATH)
|
||||
|
||||
async def get(self, key):
|
||||
return await asyncio.to_thread(self._get_sync, key)
|
||||
|
||||
def _get_sync(self, key):
|
||||
with self.store._lock, self.store.connect() as con:
|
||||
row = con.execute("select value_json, expires_at from cache_entries where key=?", (key,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
if row["expires_at"] and row["expires_at"] < time.time():
|
||||
con.execute("delete from cache_entries where key=?", (key,))
|
||||
return None
|
||||
return json.loads(row["value_json"])
|
||||
|
||||
async def set(self, key, value, ttl_seconds=None):
|
||||
await asyncio.to_thread(self._set_sync, key, value, ttl_seconds)
|
||||
|
||||
def _set_sync(self, key, value, ttl_seconds=None):
|
||||
expires = time.time() + ttl_seconds if ttl_seconds else None
|
||||
with self.store._lock, self.store.connect() as con:
|
||||
con.execute(
|
||||
"insert or replace into cache_entries(key,value_json,expires_at,created_at) values(?,?,?,?)",
|
||||
(key, json.dumps(value, ensure_ascii=False, default=str), expires, self.store.now()),
|
||||
)
|
||||
|
||||
async def delete(self, key):
|
||||
await asyncio.to_thread(self._delete_sync, key)
|
||||
|
||||
def _delete_sync(self, key):
|
||||
with self.store._lock, self.store.connect() as con:
|
||||
con.execute("delete from cache_entries where key=?", (key,))
|
||||
|
||||
|
||||
class OracleCache(Cache):
|
||||
def __init__(self, settings):
|
||||
from agent_framework.persistence.oracle_store import OracleStore
|
||||
self.store = OracleStore(settings)
|
||||
|
||||
async def get(self, key): return await self.store.cache_get(key)
|
||||
async def set(self, key, value, ttl_seconds=None):
|
||||
expires = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds) if ttl_seconds else None
|
||||
await self.store.cache_set(key, value, expires_at=expires)
|
||||
async def delete(self, key): await self.store.cache_delete(key)
|
||||
|
||||
|
||||
class DistributedCache(Cache):
|
||||
"""L1 memory + optional L2 Redis/SQLite/Oracle with telemetry hooks."""
|
||||
def __init__(self, l1: Cache, l2: Cache | None = None, telemetry=None, default_ttl: int | None = None):
|
||||
self.l1, self.l2, self.telemetry, self.default_ttl = l1, l2, telemetry, default_ttl
|
||||
|
||||
async def get(self, key):
|
||||
v = await self.l1.get(key)
|
||||
if v is not None:
|
||||
if self.telemetry: await self.telemetry.cache_event("hit.l1", key, True)
|
||||
return v
|
||||
if not self.l2:
|
||||
if self.telemetry: await self.telemetry.cache_event("miss", key, False)
|
||||
return None
|
||||
v = await self.l2.get(key)
|
||||
if v is not None:
|
||||
await self.l1.set(key, v, self.default_ttl)
|
||||
if self.telemetry: await self.telemetry.cache_event("hit.l2", key, True)
|
||||
return v
|
||||
if self.telemetry: await self.telemetry.cache_event("miss", key, False)
|
||||
return None
|
||||
|
||||
async def set(self, key, value, ttl_seconds=None):
|
||||
ttl = ttl_seconds if ttl_seconds is not None else self.default_ttl
|
||||
await self.l1.set(key, value, ttl)
|
||||
if self.l2: await self.l2.set(key, value, ttl)
|
||||
if self.telemetry: await self.telemetry.cache_event("set", key, None, {"ttl_seconds": ttl})
|
||||
|
||||
async def delete(self, key):
|
||||
await self.l1.delete(key)
|
||||
if self.l2: await self.l2.delete(key)
|
||||
if self.telemetry: await self.telemetry.cache_event("delete", key, None)
|
||||
|
||||
|
||||
def create_cache(settings, telemetry=None):
|
||||
l1 = InMemoryCache()
|
||||
l2 = None
|
||||
if getattr(settings, "ENABLE_REDIS_CACHE", False):
|
||||
try:
|
||||
l2 = RedisCache(settings)
|
||||
except Exception:
|
||||
logger.exception("Redis indisponível; cache seguirá apenas com L1 memória")
|
||||
l2 = None
|
||||
if l2 is None:
|
||||
provider = getattr(settings, "CACHE_BACKEND_PROVIDER", "memory")
|
||||
if provider == "sqlite": l2 = SQLiteCache(settings)
|
||||
elif provider in {"autonomous", "oracle"}: l2 = OracleCache(settings)
|
||||
return DistributedCache(l1, l2, telemetry=telemetry, default_ttl=getattr(settings, "CACHE_TTL_SECONDS", None))
|
||||
@@ -0,0 +1,69 @@
|
||||
from .base import ChannelAdapter, ChannelMessage, ChannelResponse
|
||||
|
||||
|
||||
def _merge_context(payload: dict) -> dict:
|
||||
"""Preserva todo payload como contexto.
|
||||
|
||||
Antes o WebAdapter só copiava payload["context"]. Com isso, campos como
|
||||
business_context, msisdn, invoice_id e ura_call_id eram perdidos antes de
|
||||
chegar ao workflow/MCP.
|
||||
"""
|
||||
payload = dict(payload or {})
|
||||
ctx = dict(payload.get("context") or {})
|
||||
for k, v in payload.items():
|
||||
if k != "context" and k not in ctx:
|
||||
ctx[k] = v
|
||||
return ctx
|
||||
|
||||
|
||||
class WebAdapter(ChannelAdapter):
|
||||
name = "web"
|
||||
|
||||
async def normalize(self, payload):
|
||||
payload = payload or {}
|
||||
text = payload.get("message") or payload.get("text") or payload.get("content") or ""
|
||||
return ChannelMessage(
|
||||
channel="web",
|
||||
text=text,
|
||||
session_id=payload.get("session_id"),
|
||||
user_id=payload.get("user_id"),
|
||||
channel_id=payload.get("channel_id") or payload.get("channelId"),
|
||||
context=_merge_context(payload),
|
||||
)
|
||||
|
||||
async def render(self, response):
|
||||
return response.model_dump()
|
||||
|
||||
|
||||
class WhatsAppAdapter(ChannelAdapter):
|
||||
name = "whatsapp"
|
||||
|
||||
async def normalize(self, payload):
|
||||
payload = payload or {}
|
||||
return ChannelMessage(
|
||||
channel="whatsapp",
|
||||
channel_id=payload.get("from"),
|
||||
text=payload.get("text") or payload.get("message") or "",
|
||||
session_id=payload.get("session_id"),
|
||||
context=_merge_context(payload),
|
||||
)
|
||||
|
||||
async def render(self, response):
|
||||
return {"to": response.metadata.get("channel_id"), "text": response.text, "session_id": response.session_id}
|
||||
|
||||
|
||||
class VoiceAdapter(ChannelAdapter):
|
||||
name = "voice"
|
||||
|
||||
async def normalize(self, payload):
|
||||
payload = payload or {}
|
||||
return ChannelMessage(
|
||||
channel="voice",
|
||||
channel_id=payload.get("ani"),
|
||||
text=payload.get("transcript") or payload.get("text") or payload.get("message") or "",
|
||||
session_id=payload.get("session_id"),
|
||||
context=_merge_context(payload),
|
||||
)
|
||||
|
||||
async def render(self, response):
|
||||
return {"speak": response.text, "session_id": response.session_id}
|
||||
@@ -0,0 +1,21 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any
|
||||
|
||||
class ChannelMessage(BaseModel):
|
||||
channel: str
|
||||
channel_id: str | None = None
|
||||
session_id: str | None = None
|
||||
user_id: str | None = None
|
||||
text: str
|
||||
context: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
class ChannelResponse(BaseModel):
|
||||
channel: str
|
||||
session_id: str
|
||||
text: str
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
class ChannelAdapter:
|
||||
name = 'base'
|
||||
async def normalize(self, payload: dict) -> ChannelMessage: ...
|
||||
async def render(self, response: ChannelResponse) -> dict: ...
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .adapters import WebAdapter, WhatsAppAdapter, VoiceAdapter, _merge_context
|
||||
from .base import ChannelMessage, ChannelResponse
|
||||
|
||||
try:
|
||||
from agent_framework.config.settings import settings
|
||||
except Exception: # pragma: no cover
|
||||
settings = None
|
||||
|
||||
|
||||
class ChannelGateway:
|
||||
"""Normalize and render messages at the Agent Framework boundary.
|
||||
|
||||
This class is used by the Agent Framework backend, not by the external
|
||||
Channel Gateway service.
|
||||
|
||||
input_mode semantics:
|
||||
- embedded: the backend may use internal channel adapters to interpret
|
||||
simple/native channel payloads. This is useful for demos, labs and local
|
||||
testing.
|
||||
- external: the backend expects a GatewayRequest payload that was already
|
||||
normalized by an external Channel Gateway. In this mode the backend does
|
||||
not parse native WhatsApp, Voice, Teams, or other channel payloads.
|
||||
|
||||
Backward compatibility:
|
||||
- The legacy constructor argument ``mode`` and setting
|
||||
``CHANNEL_GATEWAY_MODE`` are still accepted, but the preferred setting is
|
||||
``FRAMEWORK_CHANNEL_INPUT_MODE``.
|
||||
"""
|
||||
|
||||
def __init__(self, input_mode: str | None = None, mode: str | None = None):
|
||||
configured = (
|
||||
input_mode
|
||||
or mode
|
||||
or getattr(settings, "FRAMEWORK_CHANNEL_INPUT_MODE", None)
|
||||
or getattr(settings, "CHANNEL_GATEWAY_MODE", None)
|
||||
or "embedded"
|
||||
)
|
||||
self.input_mode = str(configured).strip().lower()
|
||||
if self.input_mode not in {"embedded", "external"}:
|
||||
raise ValueError(
|
||||
"INVALID_FRAMEWORK_CHANNEL_INPUT_MODE: expected 'embedded' or 'external'"
|
||||
)
|
||||
# Compatibility with previous code that accessed gateway.mode.
|
||||
self.mode = self.input_mode
|
||||
self.adapters = {a.name: a for a in [WebAdapter(), WhatsAppAdapter(), VoiceAdapter()]}
|
||||
|
||||
def get(self, channel: str):
|
||||
return self.adapters.get(channel, self.adapters["web"])
|
||||
|
||||
def _validate_external_payload(self, channel: str, payload: dict):
|
||||
"""Validate the payload portion of a GatewayRequest.
|
||||
|
||||
In external input mode, the backend is not accepting native channel
|
||||
payloads. It expects req.channel plus req.payload.message at minimum.
|
||||
Business keys remain optional because some journeys start without all
|
||||
identifiers and are completed by IdentityResolver or the agent.
|
||||
"""
|
||||
if not isinstance(channel, str) or not channel.strip():
|
||||
raise ValueError("INVALID_GATEWAY_REQUEST: channel is required")
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("INVALID_GATEWAY_REQUEST: payload must be an object")
|
||||
message = payload.get("message")
|
||||
if not isinstance(message, str) or not message.strip():
|
||||
raise ValueError(
|
||||
"INVALID_GATEWAY_REQUEST: payload.message is required and must be a non-empty string"
|
||||
)
|
||||
|
||||
async def _normalize_external(self, channel: str, payload: dict) -> ChannelMessage:
|
||||
self._validate_external_payload(channel, payload)
|
||||
return ChannelMessage(
|
||||
channel=channel,
|
||||
text=payload.get("message"),
|
||||
session_id=payload.get("session_id") or payload.get("session_key"),
|
||||
user_id=payload.get("user_id"),
|
||||
channel_id=payload.get("channel_id") or payload.get("channelId"),
|
||||
context=_merge_context(payload),
|
||||
)
|
||||
|
||||
async def normalize(self, channel: str, payload: dict) -> ChannelMessage:
|
||||
if self.input_mode == "external":
|
||||
return await self._normalize_external(channel, payload)
|
||||
return await self.get(channel).normalize(payload)
|
||||
|
||||
async def render(self, response: ChannelResponse) -> dict:
|
||||
if self.input_mode == "external":
|
||||
# The external Channel Gateway owns the final translation back to
|
||||
# WhatsApp, Voice, Teams, etc. The backend returns its canonical
|
||||
# response shape.
|
||||
return response.model_dump()
|
||||
return await self.get(response.channel).render(response)
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InterruptionDecision:
|
||||
action: str # process | replay | classify
|
||||
text: str
|
||||
replay_text: str = ""
|
||||
reason: str = ""
|
||||
is_interruptible: bool = True
|
||||
terminal_status: str = ""
|
||||
heard_text: str = ""
|
||||
|
||||
|
||||
def _idle_nudges(payload: dict[str, Any]) -> list[str]:
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for event in payload.get("events") or []:
|
||||
if not isinstance(event, dict) or event.get("type") != "idle_nudge":
|
||||
continue
|
||||
text = str(event.get("text") or "").strip()
|
||||
if text and text not in seen:
|
||||
seen.add(text)
|
||||
out.append(text)
|
||||
return out
|
||||
|
||||
|
||||
async def classify_processing_interruption(
|
||||
llm: Any,
|
||||
*,
|
||||
original_agent: str,
|
||||
original_client: str = "",
|
||||
supplement_client: str = "",
|
||||
profile_name: str = "processing_interruption_classifier",
|
||||
) -> bool:
|
||||
"""Decide se um barge-in interrompível exige regeneração da resposta.
|
||||
|
||||
Fail-safe: qualquer erro, resposta vazia ou formato inesperado retorna False,
|
||||
fazendo replay da fala anterior. O domínio não conhece este classificador;
|
||||
ele usa exclusivamente o LLMProvider do framework.
|
||||
"""
|
||||
if llm is None:
|
||||
return False
|
||||
prompt = (
|
||||
"Você classifica interrupções de voz durante uma resposta de atendimento. "
|
||||
"Responda somente 1 ou 0.\n"
|
||||
"1 = a fala/complemento do cliente adiciona ou altera informação relevante e "
|
||||
"a resposta do agente deve ser regenerada.\n"
|
||||
"0 = a interrupção não exige nova resposta; a fala anterior deve ser repetida.\n\n"
|
||||
f"Última fala do agente: {original_agent}\n"
|
||||
f"Última fala do cliente antes da resposta: {original_client}\n"
|
||||
f"Complemento/interrupção atual: {supplement_client}\n"
|
||||
)
|
||||
try:
|
||||
response = await llm.ainvoke(
|
||||
[{"role": "system", "content": prompt}],
|
||||
temperature=0,
|
||||
max_tokens=8,
|
||||
profile_name=profile_name,
|
||||
component_name=profile_name,
|
||||
generation_name=f"llm.{profile_name}",
|
||||
)
|
||||
raw = getattr(response, "content", response)
|
||||
text = str(raw or "").strip()
|
||||
return text.startswith("1")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def evaluate_interruption(
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
message_text: str,
|
||||
session_metadata: dict[str, Any] | None,
|
||||
terminal_fallback_text: str = "",
|
||||
terminal_fallback_status: str = "erro_falha_sistema",
|
||||
) -> InterruptionDecision:
|
||||
"""Framework-level replay/interruption policy.
|
||||
|
||||
- sessão terminal: replay da última fala/fallback, sem reabrir o workflow;
|
||||
- idle_nudge: replay da última fala real;
|
||||
- fala não interrompível: replay;
|
||||
- fala interrompível com fala anterior: classificar antes de regenerar;
|
||||
- sem contexto anterior suficiente: processar normalmente.
|
||||
"""
|
||||
metadata = session_metadata or {}
|
||||
last_text = str(metadata.get("last_assistant_text") or "").strip()
|
||||
last_interruptible = bool(metadata.get("last_assistant_is_interruptible", True))
|
||||
|
||||
if bool(metadata.get("conversation_closed")):
|
||||
replay_text = (
|
||||
last_text
|
||||
or str(metadata.get("terminal_replay_text") or "").strip()
|
||||
or str(terminal_fallback_text or "").strip()
|
||||
)
|
||||
terminal_status = str(metadata.get("terminal_status") or "").strip() or terminal_fallback_status
|
||||
if replay_text:
|
||||
return InterruptionDecision(
|
||||
action="replay",
|
||||
text=message_text,
|
||||
replay_text=replay_text,
|
||||
reason="post_finalize",
|
||||
is_interruptible=False,
|
||||
terminal_status=terminal_status,
|
||||
)
|
||||
|
||||
if _idle_nudges(payload) and last_text:
|
||||
return InterruptionDecision(
|
||||
action="replay",
|
||||
text=message_text,
|
||||
replay_text=last_text,
|
||||
reason="idle_nudge",
|
||||
is_interruptible=last_interruptible,
|
||||
)
|
||||
|
||||
interruption = payload.get("processing_interruption")
|
||||
if isinstance(interruption, dict):
|
||||
heard = str(interruption.get("heard_text") or "").strip()
|
||||
current_text = str(message_text or heard).strip()
|
||||
if not last_interruptible and last_text:
|
||||
return InterruptionDecision(
|
||||
action="replay",
|
||||
text=current_text,
|
||||
replay_text=last_text,
|
||||
reason="non_interruptible_speech",
|
||||
is_interruptible=False,
|
||||
heard_text=heard,
|
||||
)
|
||||
if last_text:
|
||||
return InterruptionDecision(
|
||||
action="classify",
|
||||
text=current_text,
|
||||
replay_text=last_text,
|
||||
reason="interruptible_speech",
|
||||
is_interruptible=True,
|
||||
heard_text=heard,
|
||||
)
|
||||
return InterruptionDecision(
|
||||
action="process",
|
||||
text=current_text,
|
||||
reason="interruptible_speech_no_history",
|
||||
is_interruptible=True,
|
||||
heard_text=heard,
|
||||
)
|
||||
|
||||
return InterruptionDecision(action="process", text=message_text)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"InterruptionDecision",
|
||||
"classify_processing_interruption",
|
||||
"evaluate_interruption",
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Correções determinísticas e conservadoras para transcrição de canal de voz."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Mapping
|
||||
|
||||
# Só falas inteiras entram nesta tabela. Nunca substitua tokens dentro de frases.
|
||||
DEFAULT_WHOLE_UTTERANCE_FIXES: dict[str, str] = {
|
||||
"fim": "Sim",
|
||||
"mim": "Sim",
|
||||
}
|
||||
|
||||
_TRAILING_PUNCT = re.compile(r"[.!?]+$")
|
||||
|
||||
|
||||
def fix_whole_utterance_transcription(
|
||||
text: str,
|
||||
*,
|
||||
fixes: Mapping[str, str] | None = None,
|
||||
) -> str:
|
||||
raw = str(text or "")
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
return raw
|
||||
candidate = _TRAILING_PUNCT.sub("", stripped).strip().casefold()
|
||||
table = fixes or DEFAULT_WHOLE_UTTERANCE_FIXES
|
||||
replacement = table.get(candidate)
|
||||
return str(replacement) if replacement is not None else raw
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_WHOLE_UTTERANCE_FIXES", "fix_whole_utterance_transcription"]
|
||||
@@ -0,0 +1,32 @@
|
||||
from .checkpoint_repository import (
|
||||
AutonomousCheckpointRepository,
|
||||
CheckpointIntegrityError,
|
||||
CheckpointIntegrityService,
|
||||
CheckpointRecoveryError,
|
||||
InMemoryCheckpointRepository,
|
||||
LangGraphCheckpointRepository,
|
||||
OracleCheckpointRepository,
|
||||
ResilientCheckpointRepository,
|
||||
RetryPolicy,
|
||||
SQLiteCheckpointRepository,
|
||||
create_checkpoint_repository,
|
||||
create_raw_checkpoint_repository,
|
||||
)
|
||||
from .langgraph_saver import RepositoryCheckpointSaver, create_langgraph_checkpointer
|
||||
|
||||
__all__ = [
|
||||
"AutonomousCheckpointRepository",
|
||||
"CheckpointIntegrityError",
|
||||
"CheckpointIntegrityService",
|
||||
"CheckpointRecoveryError",
|
||||
"InMemoryCheckpointRepository",
|
||||
"LangGraphCheckpointRepository",
|
||||
"OracleCheckpointRepository",
|
||||
"RepositoryCheckpointSaver",
|
||||
"ResilientCheckpointRepository",
|
||||
"RetryPolicy",
|
||||
"SQLiteCheckpointRepository",
|
||||
"create_checkpoint_repository",
|
||||
"create_langgraph_checkpointer",
|
||||
"create_raw_checkpoint_repository",
|
||||
]
|
||||
@@ -0,0 +1,425 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable
|
||||
|
||||
from agent_framework.persistence.sqlite_store import SQLiteStore
|
||||
|
||||
logger = logging.getLogger("agent_framework.checkpoints")
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _json_dumps(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
def _json_loads(value: str | bytes | None, default: Any):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf-8")
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _sha256(value: Any) -> str:
|
||||
return hashlib.sha256(_json_dumps(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class CheckpointIntegrityError(RuntimeError):
|
||||
"""Raised when a persisted checkpoint envelope fails checksum validation."""
|
||||
|
||||
|
||||
class CheckpointRecoveryError(RuntimeError):
|
||||
"""Raised when recovery cannot find a valid checkpoint."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetryPolicy:
|
||||
max_attempts: int = 3
|
||||
base_delay_seconds: float = 0.05
|
||||
max_delay_seconds: float = 1.0
|
||||
jitter_seconds: float = 0.05
|
||||
|
||||
|
||||
class CheckpointIntegrityService:
|
||||
"""Creates and validates immutable checkpoint envelopes.
|
||||
|
||||
The repository stores an envelope instead of only the raw LangGraph payload:
|
||||
- schema_version: enables future migrations;
|
||||
- payload_hash: SHA-256 over the payload;
|
||||
- envelope_id: idempotency/correlation id;
|
||||
- compacted: marks synthetic compacted snapshots.
|
||||
"""
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
ENVELOPE_MARKER = "agent_framework_checkpoint_envelope"
|
||||
|
||||
def wrap(self, thread_id: str, checkpoint: dict[str, Any], *, compacted: bool = False) -> dict[str, Any]:
|
||||
payload = checkpoint or {}
|
||||
return {
|
||||
"_type": self.ENVELOPE_MARKER,
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"envelope_id": str(uuid.uuid4()),
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_id": str(payload.get("checkpoint_id") or (payload.get("checkpoint") or {}).get("id") or uuid.uuid4()),
|
||||
"payload_hash": _sha256(payload),
|
||||
"payload": payload,
|
||||
"compacted": bool(compacted),
|
||||
"created_at": _utc_now(),
|
||||
}
|
||||
|
||||
def is_envelope(self, value: dict[str, Any] | None) -> bool:
|
||||
return isinstance(value, dict) and value.get("_type") == self.ENVELOPE_MARKER
|
||||
|
||||
def unwrap(self, value: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not self.is_envelope(value):
|
||||
# Backwards compatibility with old checkpoints from previous project versions.
|
||||
return value
|
||||
expected = value.get("payload_hash")
|
||||
payload = value.get("payload") or {}
|
||||
actual = _sha256(payload)
|
||||
if expected != actual:
|
||||
raise CheckpointIntegrityError(
|
||||
f"Checkpoint corrompido para thread_id={value.get('thread_id')}: hash esperado={expected}, hash atual={actual}"
|
||||
)
|
||||
if int(value.get("schema_version") or 0) > self.SCHEMA_VERSION:
|
||||
raise CheckpointIntegrityError(
|
||||
f"Checkpoint usa schema_version={value.get('schema_version')} maior que o suportado={self.SCHEMA_VERSION}"
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
class LangGraphCheckpointRepository(ABC):
|
||||
@abstractmethod
|
||||
async def put(self, thread_id: str, checkpoint: dict[str, Any]) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get_latest(self, thread_id: str) -> dict[str, Any] | None: ...
|
||||
|
||||
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
latest = await self.get_latest(thread_id)
|
||||
return [latest] if latest else []
|
||||
|
||||
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def is_valid_checkpoint(checkpoint):
|
||||
if not isinstance(checkpoint, dict):
|
||||
return False
|
||||
if "v" in checkpoint:
|
||||
return True
|
||||
if (
|
||||
"checkpoint" in checkpoint
|
||||
and isinstance(checkpoint["checkpoint"], dict)
|
||||
and "v" in checkpoint["checkpoint"]
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
class InMemoryCheckpointRepository(LangGraphCheckpointRepository):
|
||||
def __init__(self):
|
||||
self._data: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
async def put(self, thread_id: str, checkpoint: dict[str, Any]):
|
||||
self._data.setdefault(thread_id, []).append(checkpoint)
|
||||
|
||||
async def get_latest(self, thread_id: str):
|
||||
items = self._data.get(thread_id, [])
|
||||
return items[-1] if items else None
|
||||
|
||||
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
return list(reversed(self._data.get(thread_id, [])[-limit:]))
|
||||
|
||||
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
|
||||
items = self._data.get(thread_id, [])
|
||||
if len(items) <= keep_last:
|
||||
return 0
|
||||
removed = len(items) - keep_last
|
||||
self._data[thread_id] = items[-keep_last:]
|
||||
return removed
|
||||
|
||||
|
||||
class SQLiteCheckpointRepository(LangGraphCheckpointRepository):
|
||||
def __init__(self, settings):
|
||||
self.store = SQLiteStore(settings.SQLITE_DB_PATH)
|
||||
|
||||
async def put(self, thread_id: str, checkpoint: dict[str, Any]):
|
||||
await asyncio.to_thread(self.store.put_checkpoint, thread_id, checkpoint)
|
||||
|
||||
async def get_latest(self, thread_id: str):
|
||||
return await asyncio.to_thread(self.store.get_latest_checkpoint, thread_id)
|
||||
|
||||
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
def _list():
|
||||
with self.store.connect() as con:
|
||||
rows = con.execute(
|
||||
"select checkpoint_json from workflow_checkpoints where thread_id=? order by id desc limit ?",
|
||||
(thread_id, int(limit)),
|
||||
).fetchall()
|
||||
return [_json_loads(r["checkpoint_json"], None) for r in rows if r]
|
||||
|
||||
return await asyncio.to_thread(_list)
|
||||
|
||||
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
|
||||
def _compact():
|
||||
with self.store.connect() as con:
|
||||
rows = con.execute(
|
||||
"select id from workflow_checkpoints where thread_id=? order by id desc",
|
||||
(thread_id,),
|
||||
).fetchall()
|
||||
ids = [int(r["id"]) for r in rows]
|
||||
delete_ids = ids[int(keep_last):]
|
||||
if not delete_ids:
|
||||
return 0
|
||||
con.executemany("delete from workflow_checkpoints where id=?", [(i,) for i in delete_ids])
|
||||
return len(delete_ids)
|
||||
|
||||
return await asyncio.to_thread(_compact)
|
||||
|
||||
|
||||
class OracleCheckpointRepository(LangGraphCheckpointRepository):
|
||||
"""Checkpoint repository real para Oracle/Autonomous Database.
|
||||
|
||||
O OracleStore já cria as tabelas FIRST-compatible. A compactação é best-effort:
|
||||
remove checkpoints antigos quando o store expõe conexão e prefixo de tabelas.
|
||||
"""
|
||||
|
||||
def __init__(self, settings):
|
||||
from agent_framework.persistence.oracle_store import OracleStore
|
||||
|
||||
self.store = OracleStore(settings)
|
||||
|
||||
async def put(self, thread_id: str, checkpoint: dict[str, Any]):
|
||||
await self.store.put_checkpoint(thread_id, checkpoint)
|
||||
|
||||
async def get_latest(self, thread_id: str):
|
||||
return await self.store.get_latest_checkpoint(thread_id)
|
||||
|
||||
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
if not hasattr(self.store, "connect") or not hasattr(self.store, "t"):
|
||||
return await super().list_latest(thread_id, limit)
|
||||
|
||||
def _list():
|
||||
sql = f"""
|
||||
select CHECKPOINT_JSON
|
||||
from {self.store.t('WORKFLOW_CHECKPOINT')}
|
||||
where THREAD_ID = :thread_id
|
||||
order by ID desc
|
||||
fetch first :limit rows only
|
||||
"""
|
||||
with self.store.connect() as conn:
|
||||
rows = conn.cursor().execute(sql, dict(thread_id=thread_id, limit=int(limit))).fetchall()
|
||||
return [_json_loads(r[0], None) for r in rows if r]
|
||||
|
||||
return await asyncio.to_thread(_list)
|
||||
|
||||
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
|
||||
if not hasattr(self.store, "connect") or not hasattr(self.store, "t"):
|
||||
return 0
|
||||
|
||||
def _compact():
|
||||
table = self.store.t("WORKFLOW_CHECKPOINT")
|
||||
sql_count = f"select count(*) from {table} where THREAD_ID = :thread_id"
|
||||
sql_delete = f"""
|
||||
delete from {table}
|
||||
where THREAD_ID = :thread_id
|
||||
and ID not in (
|
||||
select ID from {table}
|
||||
where THREAD_ID = :thread_id
|
||||
order by ID desc
|
||||
fetch first :keep_last rows only
|
||||
)
|
||||
"""
|
||||
with self.store.connect() as conn:
|
||||
cur = conn.cursor()
|
||||
before = int(cur.execute(sql_count, dict(thread_id=thread_id)).fetchone()[0])
|
||||
cur.execute(sql_delete, dict(thread_id=thread_id, keep_last=int(keep_last)))
|
||||
after = int(cur.execute(sql_count, dict(thread_id=thread_id)).fetchone()[0])
|
||||
return max(0, before - after)
|
||||
|
||||
return await asyncio.to_thread(_compact)
|
||||
|
||||
|
||||
AutonomousCheckpointRepository = OracleCheckpointRepository
|
||||
|
||||
|
||||
class ResilientCheckpointRepository(LangGraphCheckpointRepository):
|
||||
"""Adds integrity, retry, compaction and recovery to any repository.
|
||||
|
||||
This wrapper is intentionally repository-neutral. It can protect memory,
|
||||
SQLite and Oracle repositories without changing LangGraph code.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inner: LangGraphCheckpointRepository,
|
||||
*,
|
||||
integrity: CheckpointIntegrityService | None = None,
|
||||
retry_policy: RetryPolicy | None = None,
|
||||
enable_integrity: bool = True,
|
||||
enable_compaction: bool = True,
|
||||
compact_every: int = 50,
|
||||
keep_last: int = 20,
|
||||
recovery_scan_limit: int = 25,
|
||||
):
|
||||
self.inner = inner
|
||||
self.integrity = integrity or CheckpointIntegrityService()
|
||||
self.retry_policy = retry_policy or RetryPolicy()
|
||||
self.enable_integrity = enable_integrity
|
||||
self.enable_compaction = enable_compaction
|
||||
self.compact_every = max(1, int(compact_every))
|
||||
self.keep_last = max(1, int(keep_last))
|
||||
self.recovery_scan_limit = max(1, int(recovery_scan_limit))
|
||||
self._put_count_by_thread: dict[str, int] = {}
|
||||
|
||||
async def _with_retry(self, operation_name: str, coro_factory):
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, self.retry_policy.max_attempts + 1):
|
||||
try:
|
||||
return await coro_factory()
|
||||
except Exception as exc: # noqa: BLE001 - repository failures vary by backend
|
||||
last_exc = exc
|
||||
if attempt >= self.retry_policy.max_attempts:
|
||||
break
|
||||
delay = min(
|
||||
self.retry_policy.max_delay_seconds,
|
||||
self.retry_policy.base_delay_seconds * (2 ** (attempt - 1)),
|
||||
) + random.uniform(0, self.retry_policy.jitter_seconds)
|
||||
logger.warning("checkpoint.%s.retry attempt=%s delay=%.3fs error=%s", operation_name, attempt, delay, exc)
|
||||
await asyncio.sleep(delay)
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
async def put(self, thread_id: str, checkpoint: dict[str, Any]) -> None:
|
||||
payload = self.integrity.wrap(thread_id, checkpoint) if self.enable_integrity else checkpoint
|
||||
await self._with_retry("put", lambda: self.inner.put(thread_id, payload))
|
||||
self._put_count_by_thread[thread_id] = self._put_count_by_thread.get(thread_id, 0) + 1
|
||||
if self.enable_compaction and self._put_count_by_thread[thread_id] % self.compact_every == 0:
|
||||
try:
|
||||
removed = await self.inner.compact(thread_id, keep_last=self.keep_last)
|
||||
if removed:
|
||||
logger.info("checkpoint.compaction thread_id=%s removed=%s keep_last=%s", thread_id, removed, self.keep_last)
|
||||
except Exception as exc: # compaction must never break the user flow
|
||||
logger.warning("checkpoint.compaction.failed thread_id=%s error=%s", thread_id, exc)
|
||||
|
||||
async def get_latest(self, thread_id: str) -> dict[str, Any] | None:
|
||||
return await self.recover_latest(thread_id)
|
||||
|
||||
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
raw_items = await self.inner.list_latest(thread_id, limit)
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in raw_items:
|
||||
try:
|
||||
payload = self.integrity.unwrap(item) if self.enable_integrity else item
|
||||
if payload is not None:
|
||||
out.append(payload)
|
||||
except CheckpointIntegrityError:
|
||||
continue
|
||||
return out
|
||||
|
||||
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
|
||||
return await self.inner.compact(thread_id, keep_last=keep_last)
|
||||
|
||||
async def recover_latest(self, thread_id: str) -> dict[str, Any] | None:
|
||||
"""Return the newest valid LangGraph checkpoint, skipping corrupt or legacy records."""
|
||||
raw_items = await self._with_retry(
|
||||
"list_latest",
|
||||
lambda: self.inner.list_latest(thread_id, self.recovery_scan_limit),
|
||||
)
|
||||
|
||||
first_integrity_error: Exception | None = None
|
||||
invalid_count = 0
|
||||
|
||||
for raw in raw_items:
|
||||
try:
|
||||
payload = self.integrity.unwrap(raw)
|
||||
|
||||
candidate = payload
|
||||
|
||||
if (
|
||||
isinstance(payload, dict)
|
||||
and "checkpoint" in payload
|
||||
):
|
||||
candidate = payload["checkpoint"]
|
||||
|
||||
if not self.is_valid_checkpoint(candidate):
|
||||
continue
|
||||
|
||||
return payload
|
||||
|
||||
except CheckpointIntegrityError as exc:
|
||||
first_integrity_error = first_integrity_error or exc
|
||||
logger.error(
|
||||
"checkpoint.recovery.skip_corrupt thread_id=%s error=%s",
|
||||
thread_id,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
if first_integrity_error:
|
||||
# No valid checkpoint: return None so the run starts clean instead of crashing ainvoke.
|
||||
logger.error(
|
||||
"checkpoint.recovery.no_valid_checkpoint thread_id=%s starting_fresh error=%s",
|
||||
thread_id,
|
||||
first_integrity_error,
|
||||
)
|
||||
return None
|
||||
|
||||
if invalid_count:
|
||||
logger.warning(
|
||||
"checkpoint.recovery.no_valid_langgraph_checkpoint "
|
||||
"thread_id=%s invalid_count=%s",
|
||||
thread_id,
|
||||
invalid_count,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _retry_policy_from_settings(settings) -> RetryPolicy:
|
||||
return RetryPolicy(
|
||||
max_attempts=int(getattr(settings, "CHECKPOINT_RETRY_MAX_ATTEMPTS", 3) or 3),
|
||||
base_delay_seconds=float(getattr(settings, "CHECKPOINT_RETRY_BASE_DELAY_SECONDS", 0.05) or 0.05),
|
||||
max_delay_seconds=float(getattr(settings, "CHECKPOINT_RETRY_MAX_DELAY_SECONDS", 1.0) or 1.0),
|
||||
jitter_seconds=float(getattr(settings, "CHECKPOINT_RETRY_JITTER_SECONDS", 0.05) or 0.05),
|
||||
)
|
||||
|
||||
|
||||
def create_raw_checkpoint_repository(settings):
|
||||
provider = getattr(settings, "CHECKPOINT_REPOSITORY_PROVIDER", "memory")
|
||||
if provider == "sqlite":
|
||||
return SQLiteCheckpointRepository(settings)
|
||||
if provider in {"autonomous", "oracle"}:
|
||||
return OracleCheckpointRepository(settings)
|
||||
return InMemoryCheckpointRepository()
|
||||
|
||||
|
||||
def create_checkpoint_repository(settings):
|
||||
raw = create_raw_checkpoint_repository(settings)
|
||||
if not bool(getattr(settings, "ENABLE_RESILIENT_CHECKPOINTER", True)):
|
||||
return raw
|
||||
return ResilientCheckpointRepository(
|
||||
raw,
|
||||
retry_policy=_retry_policy_from_settings(settings),
|
||||
enable_integrity=bool(getattr(settings, "ENABLE_CHECKPOINT_INTEGRITY", True)),
|
||||
enable_compaction=bool(getattr(settings, "ENABLE_CHECKPOINT_COMPACTION", True)),
|
||||
compact_every=int(getattr(settings, "CHECKPOINT_COMPACT_EVERY", 50) or 50),
|
||||
keep_last=int(getattr(settings, "CHECKPOINT_KEEP_LAST", 20) or 20),
|
||||
recovery_scan_limit=int(getattr(settings, "CHECKPOINT_RECOVERY_SCAN_LIMIT", 25) or 25),
|
||||
)
|
||||
@@ -0,0 +1,454 @@
|
||||
from __future__ import annotations
|
||||
try:
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
except Exception: # pragma: no cover - fallback for lightweight unit tests without langgraph installed
|
||||
class BaseCheckpointSaver: # type: ignore[no-redef]
|
||||
pass
|
||||
|
||||
"""LangGraph checkpoint saver backed by the framework checkpoint repository.
|
||||
|
||||
This module intentionally keeps a small adapter surface so the framework can run
|
||||
with multiple LangGraph versions. It implements the common synchronous and
|
||||
asynchronous methods used by BaseCheckpointSaver/MemorySaver: get_tuple,
|
||||
aget_tuple, put, aput, put_writes, aput_writes, list and alist.
|
||||
|
||||
The persisted payload stores LangGraph's raw checkpoint/config/metadata values in
|
||||
repository-neutral JSON. When LangGraph is installed, checkpoint tuples are
|
||||
returned using CheckpointTuple; otherwise a simple dict is returned for tests.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, AsyncIterator, Iterator
|
||||
|
||||
from .checkpoint_repository import create_checkpoint_repository
|
||||
|
||||
|
||||
def _parse_legacy_json_container(value: Any, expected: type) -> Any:
|
||||
"""Recover containers that older JSON backends persisted as JSON strings.
|
||||
|
||||
This is intentionally field-scoped: ordinary business strings must stay
|
||||
strings, even if their text happens to look like JSON.
|
||||
"""
|
||||
current = value
|
||||
for _ in range(3):
|
||||
if isinstance(current, expected):
|
||||
return current
|
||||
if not isinstance(current, str):
|
||||
break
|
||||
text = current.strip()
|
||||
if not text:
|
||||
break
|
||||
if expected is dict and not text.startswith("{"):
|
||||
break
|
||||
if expected is list and not text.startswith("["):
|
||||
break
|
||||
try:
|
||||
current = json.loads(text)
|
||||
except Exception:
|
||||
break
|
||||
return current if isinstance(current, expected) else expected()
|
||||
|
||||
|
||||
def _strict_json_value(value: Any, *, path: str = "$") -> Any:
|
||||
"""Convert to repository-safe JSON without ever falling back to ``str``.
|
||||
|
||||
``default=str`` is unsafe for LangGraph checkpoints: runtime/task objects can
|
||||
become ordinary strings and later be consumed as typed values by Pregel.
|
||||
Keep native JSON containers recursively and fail loudly for an unsupported
|
||||
object instead of corrupting it silently.
|
||||
"""
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): _strict_json_value(item, path=f"{path}.{key}")
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [
|
||||
_strict_json_value(item, path=f"{path}[{idx}]")
|
||||
for idx, item in enumerate(value)
|
||||
]
|
||||
# Common durable scalar types that JSON does not know natively.
|
||||
if isinstance(value, uuid.UUID):
|
||||
return str(value)
|
||||
try:
|
||||
from datetime import date, datetime
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value.isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from enum import Enum
|
||||
if isinstance(value, Enum):
|
||||
return _strict_json_value(value.value, path=path)
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(value, "model_dump") and callable(value.model_dump):
|
||||
return _strict_json_value(value.model_dump(), path=path)
|
||||
raise TypeError(
|
||||
f"Checkpoint contém valor não serializável em {path}: "
|
||||
f"{type(value).__module__}.{type(value).__qualname__}"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_checkpoint(checkpoint: Any) -> dict[str, Any]:
|
||||
checkpoint = _parse_legacy_json_container(checkpoint, dict)
|
||||
if not isinstance(checkpoint, dict):
|
||||
return {}
|
||||
out = dict(checkpoint)
|
||||
out["channel_values"] = _parse_legacy_json_container(out.get("channel_values"), dict)
|
||||
out["channel_versions"] = _parse_legacy_json_container(out.get("channel_versions"), dict)
|
||||
raw_seen = _parse_legacy_json_container(out.get("versions_seen"), dict)
|
||||
out["versions_seen"] = {
|
||||
str(node): _parse_legacy_json_container(versions, dict)
|
||||
for node, versions in raw_seen.items()
|
||||
}
|
||||
if "pending_sends" in out:
|
||||
out["pending_sends"] = _parse_legacy_json_container(out.get("pending_sends"), list)
|
||||
if "updated_channels" in out and isinstance(out.get("updated_channels"), str):
|
||||
out["updated_channels"] = _parse_legacy_json_container(out.get("updated_channels"), list)
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_metadata(metadata: Any) -> dict[str, Any]:
|
||||
value = _parse_legacy_json_container(metadata, dict)
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _normalize_config(config: Any) -> dict[str, Any]:
|
||||
value = _parse_legacy_json_container(config, dict)
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
out = dict(value)
|
||||
out["configurable"] = _parse_legacy_json_container(out.get("configurable"), dict)
|
||||
return out
|
||||
|
||||
|
||||
_EPHEMERAL_RUNTIME_KEYS = {"__pregel_runtime", "__pregel_store"}
|
||||
|
||||
|
||||
def _strip_runtime_refs(value: Any) -> Any:
|
||||
"""Recursively remove process-local runtime/store references only.
|
||||
|
||||
Checkpoints may legitimately contain LangGraph internal channels whose names
|
||||
also start with ``__pregel_`` (for example task channels). Those are durable
|
||||
graph state and must be preserved. The corruption that triggers
|
||||
``str.override`` is specifically a runtime/store object captured inside a
|
||||
nested RunnableConfig and later stringified by the JSON repository.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _strip_runtime_refs(item)
|
||||
for key, item in value.items()
|
||||
if str(key) not in _EPHEMERAL_RUNTIME_KEYS
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_strip_runtime_refs(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_strip_runtime_refs(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def _durable_config(config: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return a checkpoint-safe copy of a LangGraph RunnableConfig.
|
||||
|
||||
LangGraph injects ephemeral private values such as ``__pregel_runtime`` and
|
||||
``__pregel_store`` under ``configurable`` while a graph is running. They are
|
||||
process-local and must never cross the durable checkpoint boundary.
|
||||
|
||||
The scrub is recursive because task/pending-write config fragments may be
|
||||
nested below regular config fields in newer LangGraph versions.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return {}
|
||||
cleaned = _strip_runtime_refs(config)
|
||||
if not isinstance(cleaned, dict):
|
||||
return {}
|
||||
configurable = cleaned.get("configurable")
|
||||
if isinstance(configurable, dict):
|
||||
cleaned = dict(cleaned)
|
||||
cleaned["configurable"] = {
|
||||
key: value
|
||||
for key, value in configurable.items()
|
||||
if not str(key).startswith("__pregel_")
|
||||
}
|
||||
return cleaned
|
||||
|
||||
|
||||
def _canonical_checkpoint_config(
|
||||
payload: dict[str, Any],
|
||||
request_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Rebuild the RunnableConfig returned to LangGraph from durable IDs only.
|
||||
|
||||
Official LangGraph savers do not re-bind the full config that happened to be
|
||||
present when a checkpoint was written. They reconstruct a fresh config from
|
||||
``thread_id``, ``checkpoint_ns`` and ``checkpoint_id``. Doing the same here
|
||||
prevents a historical/factory-time runtime value from being rebound into a
|
||||
new execution while remaining backward compatible with existing rows.
|
||||
"""
|
||||
requested = _durable_config(request_config)
|
||||
stored = _durable_config(_normalize_config(payload.get("config")) if isinstance(payload, dict) else None)
|
||||
req_cfg = requested.get("configurable") if isinstance(requested.get("configurable"), dict) else {}
|
||||
stored_cfg = stored.get("configurable") if isinstance(stored.get("configurable"), dict) else {}
|
||||
checkpoint = payload.get("checkpoint") if isinstance(payload, dict) else {}
|
||||
checkpoint = checkpoint if isinstance(checkpoint, dict) else {}
|
||||
|
||||
thread_id = (
|
||||
req_cfg.get("thread_id")
|
||||
or stored_cfg.get("thread_id")
|
||||
or payload.get("thread_id")
|
||||
or "default"
|
||||
)
|
||||
checkpoint_ns = req_cfg.get("checkpoint_ns")
|
||||
if checkpoint_ns is None:
|
||||
checkpoint_ns = stored_cfg.get("checkpoint_ns", "")
|
||||
|
||||
requested_checkpoint_id = req_cfg.get("checkpoint_id")
|
||||
checkpoint_id = (
|
||||
requested_checkpoint_id
|
||||
or payload.get("checkpoint_id")
|
||||
or checkpoint.get("id")
|
||||
or stored_cfg.get("checkpoint_id")
|
||||
)
|
||||
|
||||
configurable: dict[str, Any] = {
|
||||
"thread_id": str(thread_id),
|
||||
"checkpoint_ns": str(checkpoint_ns or ""),
|
||||
}
|
||||
if checkpoint_id not in (None, ""):
|
||||
configurable["checkpoint_id"] = str(checkpoint_id)
|
||||
return {"configurable": configurable}
|
||||
|
||||
|
||||
def _thread_id(config: dict[str, Any] | None) -> str:
|
||||
configurable = (config or {}).get("configurable") or {}
|
||||
return str(configurable.get("thread_id") or configurable.get("checkpoint_ns") or "default")
|
||||
|
||||
|
||||
def _checkpoint_id(checkpoint: dict[str, Any] | None) -> str:
|
||||
if isinstance(checkpoint, dict):
|
||||
return str(checkpoint.get("id") or checkpoint.get("checkpoint_id") or uuid.uuid4())
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _normalize_pending_writes(pending_writes: Any) -> list[tuple[Any, Any, Any]]:
|
||||
"""Normalize persisted pending_writes to LangGraph's expected runtime format.
|
||||
|
||||
LangGraph 1.1.x expects CheckpointTuple.pending_writes to be an iterable of
|
||||
3-item tuples: (task_id, channel, value).
|
||||
|
||||
Older framework versions persisted writes as dictionaries containing
|
||||
task_id, task_path, channel and value. Some stores/tests may also contain
|
||||
4-item tuples: (task_id, task_path, channel, value). This adapter accepts
|
||||
those legacy forms while preserving already-correct 3-item tuples.
|
||||
"""
|
||||
normalized: list[tuple[Any, Any, Any]] = []
|
||||
for item in pending_writes or []:
|
||||
if isinstance(item, dict):
|
||||
normalized.append((
|
||||
item.get("task_id"),
|
||||
item.get("channel"),
|
||||
item.get("value"),
|
||||
))
|
||||
continue
|
||||
|
||||
if isinstance(item, (list, tuple)):
|
||||
if len(item) == 3:
|
||||
task_id, channel, value = item
|
||||
normalized.append((task_id, channel, value))
|
||||
continue
|
||||
if len(item) == 4:
|
||||
task_id, _task_path, channel, value = item
|
||||
normalized.append((task_id, channel, value))
|
||||
continue
|
||||
|
||||
# Defensive fallback: keep malformed legacy entries from crashing resume.
|
||||
# Use a synthetic channel so the data remains inspectable in telemetry/logs.
|
||||
normalized.append((None, "__malformed_pending_write__", item))
|
||||
return normalized
|
||||
|
||||
|
||||
class RepositoryCheckpointSaver(BaseCheckpointSaver):
|
||||
"""Checkpoint saver nativo para LangGraph usando os repositories do framework."""
|
||||
|
||||
def __init__(self, settings, repository=None):
|
||||
super().__init__()
|
||||
self.settings = settings
|
||||
self.repository = repository or create_checkpoint_repository(settings)
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def _run(self, coro):
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
# LangGraph may call sync methods from a worker thread; when already in
|
||||
# an event loop prefer a short-lived thread to avoid nested-loop errors.
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
|
||||
return ex.submit(lambda: asyncio.run(coro)).result()
|
||||
|
||||
def _make_tuple(
|
||||
self,
|
||||
payload: dict[str, Any] | None,
|
||||
request_config: dict[str, Any] | None = None,
|
||||
):
|
||||
if not payload:
|
||||
return None
|
||||
# Second-stage protection: never re-bind the full persisted RunnableConfig.
|
||||
# Rebuild only the durable identifiers, as official LangGraph savers do.
|
||||
config = _canonical_checkpoint_config(payload, request_config)
|
||||
checkpoint = _strip_runtime_refs(_normalize_checkpoint(payload.get("checkpoint") or {}))
|
||||
metadata = _strip_runtime_refs(_normalize_metadata(payload.get("metadata") or {}))
|
||||
raw_parent_config = payload.get("parent_config")
|
||||
if isinstance(raw_parent_config, dict):
|
||||
parent_payload = {
|
||||
"thread_id": payload.get("thread_id"),
|
||||
"config": raw_parent_config,
|
||||
"checkpoint_id": (raw_parent_config.get("configurable") or {}).get("checkpoint_id")
|
||||
if isinstance(raw_parent_config.get("configurable"), dict)
|
||||
else None,
|
||||
"checkpoint": {},
|
||||
}
|
||||
parent_config = _canonical_checkpoint_config(parent_payload)
|
||||
else:
|
||||
parent_config = None
|
||||
pending_writes = _normalize_pending_writes(
|
||||
_strip_runtime_refs(payload.get("pending_writes") or [])
|
||||
)
|
||||
try:
|
||||
from langgraph.checkpoint.base import CheckpointTuple
|
||||
return CheckpointTuple(config=config, checkpoint=checkpoint, metadata=metadata, parent_config=parent_config, pending_writes=pending_writes)
|
||||
except Exception:
|
||||
return {
|
||||
"config": _durable_config(config),
|
||||
"checkpoint": checkpoint,
|
||||
"metadata": metadata,
|
||||
"parent_config": parent_config,
|
||||
"pending_writes": pending_writes,
|
||||
}
|
||||
|
||||
async def aget_tuple(self, config: dict[str, Any]):
|
||||
return self._make_tuple(
|
||||
await self.repository.get_latest(_thread_id(config)),
|
||||
request_config=config,
|
||||
)
|
||||
|
||||
def get_tuple(self, config: dict[str, Any]):
|
||||
return self._run(self.aget_tuple(config))
|
||||
|
||||
async def aput(self, config: dict[str, Any], checkpoint: dict[str, Any], metadata: dict[str, Any] | None = None, new_versions: dict[str, Any] | None = None):
|
||||
thread_id = _thread_id(config)
|
||||
checkpoint_id = _checkpoint_id(checkpoint)
|
||||
clean_config = _durable_config(config)
|
||||
clean_cfg = clean_config.get("configurable") if isinstance(clean_config.get("configurable"), dict) else {}
|
||||
checkpoint_ns = str(clean_cfg.get("checkpoint_ns") or "")
|
||||
# Return a fresh canonical config. Never feed process-local/factory-time
|
||||
# configurable values back into the next LangGraph super-step.
|
||||
next_config = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"checkpoint_id": checkpoint_id,
|
||||
}
|
||||
}
|
||||
await self.repository.put(thread_id, {
|
||||
"thread_id": thread_id,
|
||||
"config": _strict_json_value(next_config, path="$.config"),
|
||||
"checkpoint": _strict_json_value(_strip_runtime_refs(_normalize_checkpoint(checkpoint)), path="$.checkpoint"),
|
||||
"metadata": _strict_json_value(_strip_runtime_refs(_normalize_metadata(metadata or {})), path="$.metadata"),
|
||||
"new_versions": _strict_json_value(_strip_runtime_refs(new_versions or {}), path="$.new_versions"),
|
||||
"checkpoint_id": checkpoint_id,
|
||||
})
|
||||
return next_config
|
||||
|
||||
def put(self, config: dict[str, Any], checkpoint: dict[str, Any], metadata: dict[str, Any] | None = None, new_versions: dict[str, Any] | None = None):
|
||||
return self._run(self.aput(config, checkpoint, metadata, new_versions))
|
||||
|
||||
async def aput_writes(self, config: dict[str, Any], writes: list[tuple[str, Any]], task_id: str, task_path: str = ""):
|
||||
thread_id = _thread_id(config)
|
||||
try:
|
||||
latest = await self.repository.get_latest(thread_id) or {"thread_id": thread_id, "config": _durable_config(config), "checkpoint": {}, "metadata": {}}
|
||||
except:
|
||||
latest = {
|
||||
"thread_id": thread_id,
|
||||
"config": _durable_config(config),
|
||||
"checkpoint": {},
|
||||
"metadata": {},
|
||||
"pending_writes": [],
|
||||
}
|
||||
|
||||
if isinstance(latest, dict):
|
||||
# Do not keep extending a persisted RunnableConfig across super-steps.
|
||||
# Rebuild the same canonical config that aget_tuple() will expose.
|
||||
latest["config"] = _canonical_checkpoint_config(latest, config)
|
||||
if isinstance(latest.get("checkpoint"), dict):
|
||||
latest["checkpoint"] = _strip_runtime_refs(latest.get("checkpoint"))
|
||||
if isinstance(latest.get("metadata"), dict):
|
||||
latest["metadata"] = _strip_runtime_refs(latest.get("metadata"))
|
||||
if isinstance(latest.get("parent_config"), dict):
|
||||
parent_payload = {
|
||||
"thread_id": latest.get("thread_id") or thread_id,
|
||||
"config": latest.get("parent_config"),
|
||||
"checkpoint_id": (latest.get("parent_config", {}).get("configurable") or {}).get("checkpoint_id")
|
||||
if isinstance(latest.get("parent_config", {}).get("configurable"), dict)
|
||||
else None,
|
||||
"checkpoint": {},
|
||||
}
|
||||
latest["parent_config"] = _canonical_checkpoint_config(parent_payload)
|
||||
|
||||
pending = list(latest.get("pending_writes") or [])
|
||||
for channel, value in writes or []:
|
||||
# Writes may contain nested task/RunnableConfig fragments. Scrub the
|
||||
# private runtime before the repository's JSON ``default=str`` layer.
|
||||
durable_value = _strip_runtime_refs(value)
|
||||
pending.append({
|
||||
"task_id": task_id,
|
||||
"task_path": task_path,
|
||||
"channel": channel,
|
||||
"value": _strict_json_value(durable_value, path=f"$.pending_writes[{task_id}].{channel}"),
|
||||
})
|
||||
latest["pending_writes"] = pending
|
||||
await self.repository.put(thread_id, latest)
|
||||
|
||||
def put_writes(self, config: dict[str, Any], writes: list[tuple[str, Any]], task_id: str, task_path: str = ""):
|
||||
return self._run(self.aput_writes(config, writes, task_id, task_path))
|
||||
|
||||
async def alist(self, config: dict[str, Any] | None = None, *, filter: dict[str, Any] | None = None, before: dict[str, Any] | None = None, limit: int | None = None) -> AsyncIterator[Any]:
|
||||
# Repository interface currently exposes only latest; this is enough for
|
||||
# resume/recovery. Oracle/SQLite repositories can later implement full list.
|
||||
if config is None:
|
||||
return
|
||||
item = await self.aget_tuple(config)
|
||||
if item:
|
||||
yield item
|
||||
|
||||
def list(self, config: dict[str, Any] | None = None, *, filter: dict[str, Any] | None = None, before: dict[str, Any] | None = None, limit: int | None = None) -> Iterator[Any]:
|
||||
item = self.get_tuple(config or {}) if config else None
|
||||
if item:
|
||||
yield item
|
||||
|
||||
|
||||
def create_langgraph_checkpointer(settings):
|
||||
"""Factory used by applications when compiling LangGraph.
|
||||
|
||||
By default the framework now returns RepositoryCheckpointSaver even for
|
||||
CHECKPOINT_REPOSITORY_PROVIDER=memory, because the repository wrapper adds
|
||||
integrity checks, retry, recovery and compaction.
|
||||
|
||||
Set ENABLE_RESILIENT_CHECKPOINTER=false to fall back to LangGraph MemorySaver
|
||||
for very small local experiments.
|
||||
"""
|
||||
provider = getattr(settings, "CHECKPOINT_REPOSITORY_PROVIDER", "memory")
|
||||
resilient = bool(getattr(settings, "ENABLE_RESILIENT_CHECKPOINTER", True))
|
||||
if provider == "memory" and not resilient:
|
||||
try:
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
return MemorySaver()
|
||||
except Exception:
|
||||
return RepositoryCheckpointSaver(settings)
|
||||
return RepositoryCheckpointSaver(settings)
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except Exception: # pragma: no cover
|
||||
yaml = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentProfile:
|
||||
agent_id: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
prompt_policy_path: str | None = None
|
||||
routing_config_path: str | None = None
|
||||
guardrails_config_path: str | None = None
|
||||
judges_config_path: str | None = None
|
||||
mcp_servers_config_path: str | None = None
|
||||
tools_config_path: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentProfileRegistry:
|
||||
"""Carrega perfis de agentes/templates a partir de YAML.
|
||||
|
||||
O objetivo é permitir múltiplos agent_template no mesmo backend sem misturar
|
||||
memória, checkpoints, prompts, guardrails ou judges.
|
||||
"""
|
||||
|
||||
def __init__(self, settings):
|
||||
self.settings = settings
|
||||
self.base_dir = Path.cwd()
|
||||
self.profiles: dict[str, AgentProfile] = {}
|
||||
self.default_agent_id = "default_agent"
|
||||
self._load()
|
||||
|
||||
def _resolve(self, value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
path = Path(value)
|
||||
return str(path if path.is_absolute() else (self.base_dir / path).resolve())
|
||||
|
||||
def _load(self) -> None:
|
||||
config_path = Path(getattr(self.settings, "AGENTS_CONFIG_PATH", "./config/agents.yaml"))
|
||||
if not config_path.is_absolute():
|
||||
config_path = self.base_dir / config_path
|
||||
if not config_path.exists() or yaml is None:
|
||||
self.profiles[self.default_agent_id] = AgentProfile(
|
||||
agent_id=self.default_agent_id,
|
||||
name="Default Agent",
|
||||
prompt_policy_path=self._resolve(getattr(self.settings, "PROMPT_POLICY_PATH", None)),
|
||||
routing_config_path=self._resolve(getattr(self.settings, "ROUTING_CONFIG_PATH", None)),
|
||||
guardrails_config_path=self._resolve(getattr(self.settings, "GUARDRAILS_CONFIG_PATH", None)),
|
||||
judges_config_path=self._resolve(getattr(self.settings, "JUDGES_CONFIG_PATH", None)),
|
||||
mcp_servers_config_path=self._resolve(getattr(self.settings, "MCP_SERVERS_CONFIG_PATH", None)),
|
||||
tools_config_path=self._resolve(getattr(self.settings, "TOOLS_CONFIG_PATH", None)),
|
||||
)
|
||||
return
|
||||
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
self.default_agent_id = raw.get("default_agent_id") or self.default_agent_id
|
||||
for item in raw.get("agents", []):
|
||||
agent_id = str(item.get("agent_id") or item.get("id") or "").strip()
|
||||
if not agent_id:
|
||||
continue
|
||||
self.profiles[agent_id] = AgentProfile(
|
||||
agent_id=agent_id,
|
||||
name=item.get("name", agent_id),
|
||||
description=item.get("description", ""),
|
||||
prompt_policy_path=self._resolve(item.get("prompt_policy_path") or getattr(self.settings, "PROMPT_POLICY_PATH", None)),
|
||||
routing_config_path=self._resolve(item.get("routing_config_path") or getattr(self.settings, "ROUTING_CONFIG_PATH", None)),
|
||||
guardrails_config_path=self._resolve(item.get("guardrails_config_path") or getattr(self.settings, "GUARDRAILS_CONFIG_PATH", None)),
|
||||
judges_config_path=self._resolve(item.get("judges_config_path") or getattr(self.settings, "JUDGES_CONFIG_PATH", None)),
|
||||
mcp_servers_config_path=self._resolve(item.get("mcp_servers_config_path") or getattr(self.settings, "MCP_SERVERS_CONFIG_PATH", None)),
|
||||
tools_config_path=self._resolve(item.get("tools_config_path") or getattr(self.settings, "TOOLS_CONFIG_PATH", None)),
|
||||
metadata=item.get("metadata") or {},
|
||||
)
|
||||
if self.default_agent_id not in self.profiles and self.profiles:
|
||||
self.default_agent_id = next(iter(self.profiles))
|
||||
|
||||
def get(self, agent_id: str | None = None) -> AgentProfile:
|
||||
key = agent_id or self.default_agent_id
|
||||
return self.profiles.get(key) or self.profiles[self.default_agent_id]
|
||||
|
||||
def list_profiles(self) -> list[AgentProfile]:
|
||||
return list(self.profiles.values())
|
||||
@@ -0,0 +1,82 @@
|
||||
version: "2"
|
||||
|
||||
# Default compatibility registry shipped with agent_framework_oci.
|
||||
#
|
||||
# This file reproduces the historical behavior that used to be hardcoded in
|
||||
# OutputSupervisor / ParallelRailExecutor. It is ALWAYS loaded by the framework.
|
||||
# An agent/deployment observability_mapping.yaml is then applied as an overlay.
|
||||
#
|
||||
# Therefore an older agent can replace only the framework and keep the same
|
||||
# GRL contract and legacy guardrail actions without adding new configuration.
|
||||
mappings:
|
||||
# Historical OutputSupervisor taxonomy.
|
||||
guardrail.output_supervisor.started:
|
||||
label: GRL.001
|
||||
guardrail.result.allow:
|
||||
label: GRL.002
|
||||
guardrail.result.sanitize:
|
||||
label: GRL.003
|
||||
guardrail.result.block:
|
||||
label: GRL.004
|
||||
guardrail.result.retry:
|
||||
label: GRL.005
|
||||
guardrail.result.handover:
|
||||
label: GRL.006
|
||||
guardrail.result.observe:
|
||||
label: GRL.007
|
||||
guardrail.fail_closed:
|
||||
label: GRL.008
|
||||
guardrail.output_supervisor.completed:
|
||||
label: GRL.009
|
||||
|
||||
# Named guardrail events historically emitted as GRL.<RAIL_CODE>.
|
||||
guardrail.input_size: {label: GRL.INPUT_SIZE, aliases: [INPUT_SIZE, SIZE]}
|
||||
guardrail.msk: {label: GRL.MSK, aliases: [MSK, PII]}
|
||||
guardrail.tox: {label: GRL.TOX, aliases: [TOX]}
|
||||
guardrail.pinj: {label: GRL.PINJ, aliases: [PINJ]}
|
||||
guardrail.jailbreak: {label: GRL.JAILBREAK, aliases: [JAILBREAK]}
|
||||
guardrail.vloop: {label: GRL.VLOOP, aliases: [VLOOP, LOOP]}
|
||||
guardrail.dlex_in: {label: GRL.DLEX_IN, aliases: [DLEX_IN]}
|
||||
guardrail.oos: {label: GRL.OOS, aliases: [OOS]}
|
||||
guardrail.coer: {label: GRL.COER, aliases: [COER]}
|
||||
guardrail.msk_out: {label: GRL.MSK_OUT, aliases: [MSK_OUT, OUTPUT_MSK]}
|
||||
guardrail.toxout: {label: GRL.TOXOUT, aliases: [TOXOUT, TOX_OUT]}
|
||||
guardrail.aoferta: {label: GRL.AOFERTA, aliases: [AOFERTA, PROACTIVE_OFFER]}
|
||||
guardrail.dlex_out: {label: GRL.DLEX_OUT, aliases: [DLEX_OUT]}
|
||||
guardrail.aluc_risk: {label: GRL.ALUC_RISK, aliases: [ALUC_RISK, HALLUCINATION_RISK]}
|
||||
guardrail.ret_rel: {label: GRL.RET_REL, aliases: [RET_REL, RETRIEVAL_RELEVANCE]}
|
||||
guardrail.ragsec: {label: GRL.RAGSEC, aliases: [RAGSEC]}
|
||||
guardrail.tool_val: {label: GRL.TOOL_VAL, aliases: [TOOL_VAL, TOOL_VALIDATION]}
|
||||
|
||||
# Historical action-by-name behavior, now declarative.
|
||||
guardrail.revprec:
|
||||
label: GRL.REVPREC
|
||||
action: retry
|
||||
aliases: [REVPREC, PREMATURE_ACTION]
|
||||
guardrail.cmp:
|
||||
label: GRL.CMP
|
||||
action: retry
|
||||
aliases: [CMP, COMPLIANCE]
|
||||
guardrail.sco:
|
||||
label: GRL.SCO
|
||||
action: retry
|
||||
aliases: [SCO]
|
||||
guardrail.gnd:
|
||||
label: GRL.GND
|
||||
action: retry
|
||||
aliases: [GND, GROUNDEDNESS]
|
||||
guardrail.handover:
|
||||
action: handover
|
||||
aliases: [HANDOVER, ATH, HUMAN]
|
||||
|
||||
# Historical FRASEOLOGIA special-case rewrite, now capability-driven.
|
||||
guardrail.fraseologia:
|
||||
label: GRL.FRASEOLOGIA
|
||||
aliases: [FRASEOLOGIA]
|
||||
remediation:
|
||||
type: rewrite
|
||||
max_attempts: 1
|
||||
prompt_id: FALLBACK
|
||||
profile_name: grl
|
||||
component_name: guardrail.fraseologia.rewrite
|
||||
generation_name: guardrail.fraseologia.rewrite
|
||||
@@ -0,0 +1,254 @@
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Load .env into os.environ as well.
|
||||
# Pydantic Settings reads .env for Settings fields, but parts of the calibrated
|
||||
# guardrails intentionally use os.getenv for compatibility with the original
|
||||
# guardrails package. Loading here keeps both paths consistent.
|
||||
load_dotenv(override=False)
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', extra='ignore')
|
||||
|
||||
APP_NAME: str = 'ai-agent-template'
|
||||
APP_ENV: str = 'local'
|
||||
LOG_LEVEL: str = 'INFO'
|
||||
API_HOST: str = '0.0.0.0'
|
||||
API_PORT: int = 8000
|
||||
CORS_ORIGINS: str = 'http://localhost:5173'
|
||||
|
||||
LLM_PROVIDER: Literal['mock','oci_openai','oci_sdk','openai_compatible'] = 'mock'
|
||||
LLM_TEMPERATURE: float = 0.2
|
||||
LLM_MAX_TOKENS: int = 2048
|
||||
LLM_TIMEOUT_SECONDS: int = 120
|
||||
LLM_PROFILES_PATH: str = './llm_profiles.yaml'
|
||||
# Reasoning controls. When absent from .env, auto is the default.
|
||||
# auto = enable only when the provider/model capability resolver says it is supported.
|
||||
# true = force-enable (the provider still performs SDK/request safety checks).
|
||||
# false = never send reasoning_effort.
|
||||
LLM_REASONING_ENABLED: Literal['auto','true','false'] = 'auto'
|
||||
LLM_REASONING_EFFORT: str | None = None
|
||||
|
||||
OCI_GENAI_BASE_URL: str = ''
|
||||
OCI_GENAI_MODEL: str = 'openai.gpt-4.1'
|
||||
OCI_GENAI_API_KEY: str | None = None
|
||||
OCI_GENAI_PROJECT_OCID: str | None = None
|
||||
# OCI SDK authentication mode.
|
||||
# config_file = ~/.oci/config profile (default/local development)
|
||||
# instance_principal = OCI Instance Principal signer (Compute/OKE without API key)
|
||||
# resource_principal = OCI Resource Principal signer (Functions/resource principal contexts)
|
||||
OCI_AUTH_MODE: Literal['config_file','instance_principal','resource_principal', 'oke_workload_identity'] = 'config_file'
|
||||
OCI_CONFIG_FILE: str = '~/.oci/config'
|
||||
OCI_PROFILE: str = 'DEFAULT'
|
||||
OCI_COMPARTMENT_ID: str | None = None
|
||||
OCI_REGION: str = ''
|
||||
OCI_GENAI_ENDPOINT: str | None = None
|
||||
OCI_EMBEDDING_ENDPOINT: str | None = None
|
||||
|
||||
SESSION_REPOSITORY_PROVIDER: Literal['memory','sqlite','autonomous','oracle','mongodb'] = 'memory'
|
||||
MEMORY_REPOSITORY_PROVIDER: Literal['memory','sqlite','autonomous','oracle','mongodb'] = 'memory'
|
||||
CHECKPOINT_REPOSITORY_PROVIDER: Literal['memory','sqlite','autonomous','oracle','mongodb'] = 'memory'
|
||||
|
||||
# ConversationSummaryMemory: compressão de contexto conversacional.
|
||||
# none = não injeta histórico no prompt
|
||||
# window = injeta somente últimas mensagens
|
||||
# summary = resumo acumulado + últimas mensagens completas
|
||||
ENABLE_CONVERSATION_SUMMARY_MEMORY: bool = False
|
||||
MEMORY_CONTEXT_STRATEGY: Literal['none','window','summary'] = 'window'
|
||||
MEMORY_HISTORY_LIMIT: int = 80
|
||||
MEMORY_RECENT_MESSAGES_LIMIT: int = 8
|
||||
MEMORY_SUMMARY_TRIGGER_MESSAGES: int = 20
|
||||
MEMORY_MAX_SUMMARY_CHARS: int = 6000
|
||||
MEMORY_SUMMARY_USE_LLM: bool = True
|
||||
MEMORY_INJECT_RECENT_MESSAGES: bool = True
|
||||
MEMORY_INJECT_SUMMARY: bool = True
|
||||
|
||||
ENABLE_LONG_TERM_MEMORY: bool = False
|
||||
LONG_TERM_MEMORY_PROVIDER: Literal['memory','sqlite','autonomous','oracle'] = 'sqlite'
|
||||
LONG_TERM_MEMORY_SQLITE_PATH: str | None = None
|
||||
LONG_TERM_MEMORY_TABLE: str = 'agentfw_long_term_memory'
|
||||
LONG_TERM_MEMORY_ORACLE_TABLE: str | None = None
|
||||
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS: int = 20
|
||||
LONG_TERM_MEMORY_MIN_CONFIDENCE: float = 0.70
|
||||
LONG_TERM_MEMORY_AUTO_EXTRACT: bool = True
|
||||
LONG_TERM_MEMORY_INJECT_CONTEXT: bool = True
|
||||
|
||||
# LangGraph enterprise checkpointing
|
||||
ENABLE_RESILIENT_CHECKPOINTER: bool = True
|
||||
ENABLE_CHECKPOINT_INTEGRITY: bool = True
|
||||
ENABLE_CHECKPOINT_COMPACTION: bool = True
|
||||
CHECKPOINT_COMPACT_EVERY: int = 50
|
||||
CHECKPOINT_KEEP_LAST: int = 20
|
||||
CHECKPOINT_RECOVERY_SCAN_LIMIT: int = 25
|
||||
CHECKPOINT_RETRY_MAX_ATTEMPTS: int = 3
|
||||
CHECKPOINT_RETRY_BASE_DELAY_SECONDS: float = 0.05
|
||||
CHECKPOINT_RETRY_MAX_DELAY_SECONDS: float = 1.0
|
||||
CHECKPOINT_RETRY_JITTER_SECONDS: float = 0.05
|
||||
USAGE_REPOSITORY_PROVIDER: Literal['sqlite','autonomous','oracle'] = 'sqlite'
|
||||
|
||||
ADB_USER: str | None = None
|
||||
ADB_PASSWORD: str | None = None
|
||||
ADB_DSN: str | None = None
|
||||
ADB_WALLET_LOCATION: str | None = None
|
||||
ADB_WALLET_PASSWORD: str | None = None
|
||||
ADB_TABLE_PREFIX: str = 'AGENTFW'
|
||||
|
||||
MONGODB_URI: str = 'mongodb://localhost:27017'
|
||||
MONGODB_DATABASE: str = 'agent_platform'
|
||||
REDIS_URL: str = 'redis://localhost:6379/0'
|
||||
ENABLE_REDIS_CACHE: bool = False
|
||||
CACHE_KEY_PREFIX: str = 'agentfw'
|
||||
|
||||
VECTOR_STORE_PROVIDER: Literal['memory','sqlite','autonomous','oracle','mongodb'] = 'memory'
|
||||
GRAPH_STORE_PROVIDER: Literal['memory','autonomous','oracle'] = 'memory'
|
||||
ORACLE_GRAPH_NAME: str = 'AGENTFW_GRAPH'
|
||||
ORACLE_GRAPH_AUTO_CREATE: bool = False
|
||||
RAG_TOP_K: int = 5
|
||||
SKIP_RAG_WHEN_MCP_SUFFICIENT: bool = True
|
||||
ENABLE_RAG_QUERY_REWRITE: bool = False
|
||||
ENABLE_RAG_CONTEXT_COMPRESSION: bool = False
|
||||
ENABLE_RAG_GENERATION: bool = False
|
||||
EMBEDDING_PROVIDER: Literal['mock','oci'] = 'mock'
|
||||
OCI_EMBEDDING_MODEL: str = 'cohere.embed-multilingual-v3.0'
|
||||
|
||||
ENABLE_LANGFUSE: bool = False
|
||||
LANGFUSE_TRACE_MODE: Literal['verbose','compact'] = 'verbose'
|
||||
LANGFUSE_ROOT_SPAN_NAME: str = 'agent.gateway_message'
|
||||
LANGFUSE_LEGACY_IO_FALLBACK: bool = True
|
||||
LANGFUSE_PUBLIC_KEY: str | None = None
|
||||
LANGFUSE_SECRET_KEY: str | None = None
|
||||
LANGFUSE_HOST: str = 'https://cloud.langfuse.com'
|
||||
MODEL_PRICES_JSON: str | None = None
|
||||
USD_BRL_RATE: str | None = None
|
||||
ENABLE_OTEL: bool = False
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: str | None = None
|
||||
OTEL_SERVICE_NAME: str = 'ai-agent-template'
|
||||
# Dedicated NOC OpenTelemetry Logs channel. This is separate from trace/span OTel.
|
||||
ENABLE_NOC_OTEL_LOGS: bool = False
|
||||
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: str | None = None
|
||||
OTEL_EXPORTER_OTLP_HOST_HEADER: str | None = None
|
||||
|
||||
ENABLE_ANALYTICS: bool = False
|
||||
ANALYTICS_PROVIDERS: str = 'oci_streaming'
|
||||
# Framework compatibility registry is loaded by default so legacy agents can
|
||||
# adopt a newer framework without changing their observability/guardrail behavior.
|
||||
OBSERVABILITY_DEFAULT_MAPPING_ENABLED: bool = True
|
||||
OBSERVABILITY_DEFAULT_MAPPING_PATH: str | None = None
|
||||
# Optional agent/deployment overlay applied on top of the framework defaults.
|
||||
OBSERVABILITY_CODE_MAPPING_ENABLED: bool = False
|
||||
OBSERVABILITY_CODE_MAPPING_PATH: str | None = None
|
||||
GCP_PUBSUB_TOPIC_PATH: str | None = None
|
||||
AGENT_PUBSUB_TOPIC: str | None = None
|
||||
GCP_PROJECT_ID: str | None = None
|
||||
GCP_PUBSUB_TOPIC: str | None = None
|
||||
GCP_PUBSUB_TIMEOUT_SECONDS: float = 30.0
|
||||
# Payload shape is a transport concern. Domain-specific adapters must be selected by the embedding application.
|
||||
PUBSUB_PAYLOAD_MODE: Literal['flat','legacy','envelope','wrapped'] = 'flat'
|
||||
# Match the old Observer behavior: NOC.* goes to OTel Logs, not Pub/Sub.
|
||||
PUBSUB_EXCLUDE_NOC: bool = True
|
||||
|
||||
# Automatic Pub/Sub sequence generation.
|
||||
# auto: Redis if configured; otherwise MongoDB if configured; otherwise memory fallback.
|
||||
# mongodb: atomic find_one_and_update/$inc.
|
||||
PUBSUB_SEQUENCE_ENABLED: bool = True
|
||||
PUBSUB_SEQUENCE_PROVIDER: Literal['auto','redis','mongodb','mongo','memory','none'] = 'auto'
|
||||
PUBSUB_SEQUENCE_REDIS_URL: str | None = None
|
||||
PUBSUB_SEQUENCE_MONGODB_URI: str | None = None
|
||||
PUBSUB_SEQUENCE_MONGODB_DATABASE: str | None = None
|
||||
PUBSUB_SEQUENCE_MONGODB_COLLECTION: str = 'observer_sequences'
|
||||
PUBSUB_SEQUENCE_TTL_SECONDS: int = 86400
|
||||
PUBSUB_SEQUENCE_MEMORY_FALLBACK: bool = True
|
||||
PUBSUB_SEQUENCE_KEY_PREFIX: str = 'observer:sequence'
|
||||
|
||||
ANALYTICS_FAIL_SILENT: bool = True
|
||||
|
||||
ENABLE_OCI_STREAMING: bool = False
|
||||
OCI_STREAM_ENDPOINT: str | None = None
|
||||
OCI_STREAM_OCID: str | None = None
|
||||
OCI_STREAM_PARTITION_KEY: str = 'agent-events'
|
||||
|
||||
ENABLE_INPUT_GUARDRAILS: bool = True
|
||||
ENABLE_OUTPUT_GUARDRAILS: bool = True
|
||||
ENABLE_PARALLEL_GUARDRAILS: bool = True
|
||||
GUARDRAILS_FAIL_FAST: bool = True
|
||||
# Optional LLM inference points. Defaults keep the current deterministic behavior.
|
||||
ENABLE_JUDGES: bool = True
|
||||
ENABLE_SUPERVISOR: bool = True
|
||||
ENABLE_OUTPUT_SUPERVISOR: bool = True
|
||||
OUTPUT_SUPERVISOR_MAX_RETRIES: int = 3
|
||||
GUARDRAILS_CONFIG_PATH: str = './config/guardrails.yaml'
|
||||
JUDGES_CONFIG_PATH: str = './config/judges.yaml'
|
||||
PROMPT_POLICY_PATH: str = './config/prompt_policy.yaml'
|
||||
AGENTS_CONFIG_PATH: str = './config/agents.yaml'
|
||||
ROUTING_CONFIG_PATH: str = './config/routing.yaml'
|
||||
ENABLE_LLM_ROUTER: bool = False
|
||||
ROUTING_MODE: Literal['router','supervisor'] = 'router'
|
||||
# Semantic route stickiness. Uses an LLM profile; no regex or language rules.
|
||||
ENABLE_ROUTE_STICKINESS: bool = False
|
||||
ROUTE_STICKINESS_LLM_PROFILE: str = 'route_continuity'
|
||||
ROUTE_STICKINESS_CONFIDENCE_THRESHOLD: float = 0.90
|
||||
ROUTE_STICKINESS_HISTORY_TURNS: int = 2
|
||||
ROUTE_STICKINESS_MAX_TOKENS: int = 80
|
||||
HUMAN_HANDOFF_MESSAGE: str = 'Vou encaminhar seu atendimento para uma pessoa.'
|
||||
END_SESSION_MESSAGE: str = 'Atendimento encerrado. Obrigado pelo contato.'
|
||||
POST_FINALIZE_REPLAY_MESSAGE: str = (
|
||||
'Por aqui finalizamos o tratamento da sua solicitação. '
|
||||
'Aguarde um instante na linha.'
|
||||
)
|
||||
SESSION_ALREADY_ENDED_MESSAGE: str = 'Este atendimento já foi encerrado. Inicie uma nova sessão para continuar.'
|
||||
|
||||
# MCP / Tooling
|
||||
ENABLE_MCP_TOOLS: bool = True
|
||||
ENABLE_MCP_CACHE: bool = True
|
||||
MCP_CACHE_TTL_SECONDS: int = 300
|
||||
MCP_SERVERS_CONFIG_PATH: str = './config/mcp_servers.yaml'
|
||||
TOOLS_CONFIG_PATH: str = './config/tools.yaml'
|
||||
# Opcional. Se ausente, permanecem válidas as políticas legadas de tools.yaml.
|
||||
TOOL_POLICIES_PATH: str | None = './config/tool_policies.yaml'
|
||||
ENABLE_TRANSACTIONAL_WORKFLOWS: bool = False
|
||||
WORKFLOWS_PATH: str = './workflows'
|
||||
IDENTITY_CONFIG_PATH: str = './config/identity.yaml'
|
||||
MCP_PARAMETER_MAPPING_PATH: str = './config/mcp_parameter_mapping.yaml'
|
||||
MCP_TOOL_TIMEOUT_SECONDS: int = 30
|
||||
# When enabled, the framework routes tool calls to the dedicated MCP Gateway
|
||||
# instead of calling individual MCP servers directly. The gateway then owns
|
||||
# server selection, retry, cache and policy enforcement.
|
||||
MCP_GATEWAY_ENABLED: bool = False
|
||||
MCP_GATEWAY_URL: str = 'http://localhost:8300'
|
||||
MCP_GATEWAY_TIMEOUT_SECONDS: int = 60
|
||||
MCP_GATEWAY_TOKEN: str | None = None
|
||||
MCP_GATEWAY_AGENT_ID: str = 'telecom_contas'
|
||||
MCP_GATEWAY_TENANT_ID: str = 'default'
|
||||
|
||||
DEFAULT_CHANNEL: str = 'web'
|
||||
# Agent Framework channel input mode.
|
||||
# embedded = backend may use internal adapters to interpret simple/native payloads.
|
||||
# external = backend accepts only GatewayRequest payloads already normalized by an external Channel Gateway.
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE: Literal['embedded','external'] = 'embedded'
|
||||
# Legacy alias kept for compatibility with older .env files. Prefer FRAMEWORK_CHANNEL_INPUT_MODE.
|
||||
CHANNEL_GATEWAY_MODE: str | None = None
|
||||
ENABLE_VOICE_ADAPTER: bool = True
|
||||
ENABLE_WHATSAPP_ADAPTER: bool = True
|
||||
ENABLE_TEXT_ADAPTER: bool = True
|
||||
|
||||
|
||||
# FIRST-ready runtime options
|
||||
SQLITE_DB_PATH: str = './data/agent_framework.db'
|
||||
ENABLE_SSE: bool = True
|
||||
SSE_KEEPALIVE_SECONDS: float = 15.0
|
||||
SSE_EVENT_REPLAY_LIMIT: int = 100
|
||||
ENABLE_MESSAGE_IDEMPOTENCY: bool = True
|
||||
ENABLE_LOCAL_CACHE: bool = True
|
||||
CACHE_TTL_SECONDS: int = 300
|
||||
CACHE_BACKEND_PROVIDER: Literal['memory','sqlite','autonomous','oracle'] = 'memory'
|
||||
SSE_STORE_PROVIDER: Literal['sqlite','autonomous','oracle'] | None = None
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,28 @@
|
||||
import json, base64, logging
|
||||
logger=logging.getLogger('agent_framework.streaming')
|
||||
|
||||
class EventPublisher:
|
||||
async def publish(self, event_type: str, payload: dict): ...
|
||||
|
||||
class NoopEventPublisher(EventPublisher):
|
||||
async def publish(self, event_type, payload):
|
||||
logger.info('event.noop %s %s', event_type, payload)
|
||||
|
||||
class OCIStreamingPublisher(EventPublisher):
|
||||
def __init__(self, settings):
|
||||
import oci
|
||||
config = oci.config.from_file(settings.OCI_CONFIG_FILE, settings.OCI_PROFILE)
|
||||
self.client = oci.streaming.StreamClient(config, service_endpoint=settings.OCI_STREAM_ENDPOINT)
|
||||
self.stream_id = settings.OCI_STREAM_OCID
|
||||
self.partition_key = settings.OCI_STREAM_PARTITION_KEY
|
||||
async def publish(self, event_type, payload):
|
||||
import oci
|
||||
body = json.dumps({'type': event_type, 'payload': payload}, default=str).encode()
|
||||
entry = oci.streaming.models.PutMessagesDetailsEntry(key=self.partition_key.encode(), value=body)
|
||||
details = oci.streaming.models.PutMessagesDetails(messages=[entry])
|
||||
self.client.put_messages(self.stream_id, details)
|
||||
|
||||
def create_event_publisher(settings):
|
||||
if settings.ENABLE_OCI_STREAMING and settings.OCI_STREAM_ENDPOINT and settings.OCI_STREAM_OCID:
|
||||
return OCIStreamingPublisher(settings)
|
||||
return NoopEventPublisher()
|
||||
47
libs/agent_framework/build/lib/agent_framework/extensions.py
Normal file
47
libs/agent_framework/build/lib/agent_framework/extensions.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Extension SPI for agent-owned guardrails and judges.
|
||||
|
||||
The framework owns execution, telemetry and lifecycle. Agents may contribute
|
||||
classes through YAML using ``type: external`` and ``class: module:Class``.
|
||||
No agent/domain package is imported unless explicitly declared in configuration.
|
||||
"""
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_external_class(path: str) -> type[Any]:
|
||||
value = str(path or "").strip()
|
||||
if not value:
|
||||
raise ValueError("External component requires 'class: module:ClassName'")
|
||||
if ':' in value:
|
||||
module_name, class_name = value.rsplit(':', 1)
|
||||
elif '.' in value:
|
||||
module_name, class_name = value.rsplit('.', 1)
|
||||
else:
|
||||
raise ValueError(f"Invalid external class path: {value}")
|
||||
module = import_module(module_name)
|
||||
cls = getattr(module, class_name, None)
|
||||
if cls is None or not isinstance(cls, type):
|
||||
raise ValueError(f"External class not found: {value}")
|
||||
return cls
|
||||
|
||||
|
||||
def instantiate_external(path: str, *, kwargs: dict[str, Any] | None = None, injected: dict[str, Any] | None = None) -> Any:
|
||||
cls = load_external_class(path)
|
||||
params = dict(kwargs or {})
|
||||
for key, value in (injected or {}).items():
|
||||
params.setdefault(key, value)
|
||||
try:
|
||||
return cls(**params)
|
||||
except TypeError:
|
||||
# Backward-friendly path for simple plugins with no constructor args.
|
||||
if params:
|
||||
obj = cls()
|
||||
for key, value in params.items():
|
||||
if not hasattr(obj, key):
|
||||
continue
|
||||
setattr(obj, key, value)
|
||||
return obj
|
||||
raise
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def get_gateway_model_policy(state: dict[str, Any]) -> dict[str, Any] | None:
|
||||
metadata = state.get("metadata") or {}
|
||||
policy = metadata.get("model_policy")
|
||||
return policy if isinstance(policy, dict) else None
|
||||
|
||||
|
||||
def apply_gateway_model_policy_to_llm_kwargs(
|
||||
state: dict[str, Any],
|
||||
fallback_profile: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
policy = get_gateway_model_policy(state)
|
||||
if not policy:
|
||||
return fallback_profile or {}
|
||||
|
||||
params = dict(policy.get("parameters") or {})
|
||||
if policy.get("model"):
|
||||
params["model"] = policy["model"]
|
||||
if policy.get("provider"):
|
||||
params["provider"] = policy["provider"]
|
||||
if policy.get("profile"):
|
||||
params["profile"] = policy["profile"]
|
||||
return params
|
||||
@@ -0,0 +1,3 @@
|
||||
from .mcp_gateway_client import MCPGatewayClient
|
||||
|
||||
__all__ = ["MCPGatewayClient"]
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class MCPGatewayClient:
|
||||
def __init__(self, base_url: str, token: str | None = None, timeout_seconds: int = 60):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,25 @@
|
||||
from .client import BackendClient
|
||||
from .config import BackendRegistry
|
||||
from .models import (
|
||||
BackendCallResult,
|
||||
BackendDefinition,
|
||||
BackendRegistryConfig,
|
||||
GlobalRouteDecision,
|
||||
GlobalRouteRequest,
|
||||
GlobalSessionState,
|
||||
)
|
||||
from .router import GlobalSupervisorRouter
|
||||
from .session_store import InMemoryGlobalSessionStore
|
||||
|
||||
__all__ = [
|
||||
"BackendClient",
|
||||
"BackendRegistry",
|
||||
"BackendCallResult",
|
||||
"BackendDefinition",
|
||||
"BackendRegistryConfig",
|
||||
"GlobalRouteDecision",
|
||||
"GlobalRouteRequest",
|
||||
"GlobalSessionState",
|
||||
"GlobalSupervisorRouter",
|
||||
"InMemoryGlobalSessionStore",
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import BackendCallResult, BackendDefinition, GlobalRouteDecision
|
||||
|
||||
|
||||
class BackendClient:
|
||||
def __init__(self, timeout_seconds: float = 120.0):
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
async def call_message(
|
||||
self,
|
||||
backend: BackendDefinition,
|
||||
request_payload: dict[str, Any],
|
||||
route_decision: GlobalRouteDecision,
|
||||
use_sse: bool = False,
|
||||
) -> BackendCallResult:
|
||||
path = backend.sse_message_path if use_sse else backend.message_path
|
||||
url = f"{backend.base_url}{path}"
|
||||
payload = dict(request_payload)
|
||||
# Mantém compatibilidade com agent_template_backend.
|
||||
payload.setdefault("agent_id", backend.default_agent_id)
|
||||
payload.setdefault("tenant_id", request_payload.get("tenant_id"))
|
||||
inner = payload.setdefault("payload", {}) if isinstance(payload.get("payload"), dict) else None
|
||||
if inner is not None:
|
||||
inner.setdefault("selected_backend", backend.backend_id)
|
||||
inner.setdefault("global_route_decision", route_decision.model_dump(mode="json"))
|
||||
started = time.time()
|
||||
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
|
||||
resp = await client.post(url, json=payload)
|
||||
elapsed_ms = int((time.time() - started) * 1000)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return BackendCallResult(
|
||||
backend_id=backend.backend_id,
|
||||
backend_url=backend.base_url,
|
||||
status_code=resp.status_code,
|
||||
response=data,
|
||||
route_decision=route_decision,
|
||||
elapsed_ms=elapsed_ms,
|
||||
)
|
||||
|
||||
async def health(self, backend: BackendDefinition) -> dict[str, Any]:
|
||||
url = f"{backend.base_url}{backend.health_path}"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
resp = await client.get(url)
|
||||
return {"backend_id": backend.backend_id, "status_code": resp.status_code, "ok": resp.is_success, "body": self._safe_json(resp)}
|
||||
except Exception as exc:
|
||||
return {"backend_id": backend.backend_id, "ok": False, "error": str(exc)}
|
||||
|
||||
def _safe_json(self, resp: httpx.Response) -> Any:
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return resp.text[:500]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user