mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-07-09 14:04:19 +00:00
Compare commits
14 Commits
eced6e690f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d2b8b5be0 | |||
| 9607c723a0 | |||
| d603a01039 | |||
| 7893c4c8ab | |||
| ffcdbe5d85 | |||
| 5b0bf3f826 | |||
| dc618bfe47 | |||
| 853c286313 | |||
| fe194a9f2a | |||
| 8659eb552a | |||
| d3667e805d | |||
| 804244b39d | |||
| 4df0f39834 | |||
| 67ca651561 |
164
.env
Normal file
164
.env
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
###############################################################################
|
||||||
|
# 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-ph3FgX6iP3fxAQCXb9IpPIDTadkeeYAWntUWhzcWysIM6zsS
|
||||||
|
OCI_GENAI_PROJECT_OCID=
|
||||||
|
|
||||||
|
# OCI SDK / signer / profiles
|
||||||
|
OCI_CONFIG_FILE=~/.oci/config
|
||||||
|
OCI_PROFILE=LATINOAMERICA-Chicago
|
||||||
|
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaexpiw4a7dio64mkfv2t273s2hgdl6mgfvvyv7tycalnjlvpvfl3q
|
||||||
|
OCI_REGION=us-chicago-1
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Persistência
|
||||||
|
###############################################################################
|
||||||
|
# Opções: memory, autonomous, mongodb
|
||||||
|
SESSION_REPOSITORY_PROVIDER=sqlite
|
||||||
|
MEMORY_REPOSITORY_PROVIDER=sqlite
|
||||||
|
CHECKPOINT_REPOSITORY_PROVIDER=sqlite
|
||||||
|
SQLITE_DB_PATH=./data/agent_framework.db
|
||||||
|
|
||||||
|
# Autonomous Database
|
||||||
|
ADB_USER=admin
|
||||||
|
ADB_PASSWORD=Moniquinha1972
|
||||||
|
ADB_DSN=oradb23ai_high
|
||||||
|
ADB_WALLET_LOCATION=/mnt/d/Dropbox/ORACLE/LatinoAmerica/Wallet_ORADB23ai
|
||||||
|
ADB_WALLET_PASSWORD=Moniquinha1972
|
||||||
|
ADB_TABLE_PREFIX=AGENTFW
|
||||||
|
|
||||||
|
# MongoDB - também pode representar Autonomous usando API compatível com Mongo, se habilitada no ambiente
|
||||||
|
MONGODB_URI=mongodb://mongo:mongopassword@localhost:27017
|
||||||
|
MONGODB_DATABASE=agent_platform
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
ENABLE_REDIS_CACHE=false
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# RAG / Vector / Graph
|
||||||
|
###############################################################################
|
||||||
|
VECTOR_STORE_PROVIDER=memory
|
||||||
|
GRAPH_STORE_PROVIDER=memory
|
||||||
|
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
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Analytics / Observer corporativo
|
||||||
|
###############################################################################
|
||||||
|
# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo.
|
||||||
|
ENABLE_ANALYTICS=false
|
||||||
|
# Providers aceitos: oci_streaming,pubsub,noop
|
||||||
|
ANALYTICS_PROVIDERS=pubsub
|
||||||
|
# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente.
|
||||||
|
AGENT_PUBSUB_TOPIC=
|
||||||
|
GCP_PUBSUB_TOPIC_PATH=
|
||||||
|
GCP_PROJECT_ID=
|
||||||
|
GCP_PUBSUB_TOPIC=
|
||||||
|
GCP_PUBSUB_TIMEOUT_SECONDS=30
|
||||||
|
# Credencial GCP segue padrão Google:
|
||||||
|
# GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# OCI Streaming
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_OCI_STREAMING=false
|
||||||
|
OCI_STREAM_ENDPOINT=
|
||||||
|
OCI_STREAM_OCID=
|
||||||
|
OCI_STREAM_PARTITION_KEY=agent-events
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Guardrails, Judges, Supervisor
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_INPUT_GUARDRAILS=true
|
||||||
|
ENABLE_OUTPUT_GUARDRAILS=true
|
||||||
|
ENABLE_JUDGES=true
|
||||||
|
ENABLE_SUPERVISOR=true
|
||||||
|
ENABLE_OUTPUT_SUPERVISOR=true
|
||||||
|
ENABLE_PARALLEL_GUARDRAILS=true
|
||||||
|
GUARDRAILS_FAIL_FAST=true
|
||||||
|
OUTPUT_SUPERVISOR_MAX_RETRIES=3
|
||||||
|
GUARDRAILS_CONFIG_PATH=./config/guardrails.yaml
|
||||||
|
JUDGES_CONFIG_PATH=./config/judges.yaml
|
||||||
|
PROMPT_POLICY_PATH=./config/prompt_policy.yaml
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Gateway de canais
|
||||||
|
###############################################################################
|
||||||
|
DEFAULT_CHANNEL=web
|
||||||
|
ENABLE_VOICE_ADAPTER=true
|
||||||
|
ENABLE_WHATSAPP_ADAPTER=true
|
||||||
|
ENABLE_TEXT_ADAPTER=true
|
||||||
|
|
||||||
|
#################################################
|
||||||
|
# ENTERPRISE ROUTING
|
||||||
|
#################################################
|
||||||
|
# Arquivo YAML com intents, keywords, políticas de estado e fallback.
|
||||||
|
ROUTING_CONFIG_PATH=./config/routing.yaml
|
||||||
|
# true = usa LLM para classificar quando keywords/estado não resolverem.
|
||||||
|
# Em produção, costuma ser útil; em desenvolvimento, false evita custo e latência.
|
||||||
|
ENABLE_LLM_ROUTER=true
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# MCP / Tools
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_MCP_TOOLS=true
|
||||||
|
MCP_SERVERS_CONFIG_PATH=./agent_template_backend/config/mcp_servers.yaml
|
||||||
|
TOOLS_CONFIG_PATH=./agent_template_backend/config/tools.yaml
|
||||||
|
MCP_TOOL_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
|
# router = EnterpriseRouter seleciona um agente; supervisor = pode acionar múltiplos agentes
|
||||||
|
ROUTING_MODE=router
|
||||||
|
|
||||||
|
# Usage/cost accounting
|
||||||
|
USAGE_REPOSITORY_PROVIDER=sqlite
|
||||||
|
IDENTITY_CONFIG_PATH=./agent_template_backend/config/identity.yaml
|
||||||
|
MCP_PARAMETER_MAPPING_PATH=./agent_template_backend/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
|
||||||
18
.env.example
18
.env.example
@@ -71,12 +71,16 @@ RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json
|
|||||||
# Observabilidade
|
# Observabilidade
|
||||||
###############################################################################
|
###############################################################################
|
||||||
ENABLE_LANGFUSE=true
|
ENABLE_LANGFUSE=true
|
||||||
|
LANGFUSE_TRACE_MODE=verbose # Opcional: verbose, compact
|
||||||
|
LANGFUSE_ROOT_SPAN_NAME=agent.gateway_message
|
||||||
|
LANGFUSE_LEGACY_IO_FALLBACK=true
|
||||||
LANGFUSE_PUBLIC_KEY=pk-lf-bd9b0c7e-2b8b-4e5b-a382-284a9b4413b3
|
LANGFUSE_PUBLIC_KEY=pk-lf-bd9b0c7e-2b8b-4e5b-a382-284a9b4413b3
|
||||||
LANGFUSE_SECRET_KEY=sk-lf-5f5cc18d-0bb5-424e-b5d0-cb3664d58c20
|
LANGFUSE_SECRET_KEY=sk-lf-5f5cc18d-0bb5-424e-b5d0-cb3664d58c20
|
||||||
LANGFUSE_HOST=http://localhost:3005
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
ENABLE_OTEL=false
|
ENABLE_OTEL=false
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
OTEL_SERVICE_NAME=ai-agent-template
|
OTEL_SERVICE_NAME=ai-agent-template
|
||||||
|
ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# Analytics / Observer corporativo
|
# Analytics / Observer corporativo
|
||||||
@@ -84,7 +88,7 @@ OTEL_SERVICE_NAME=ai-agent-template
|
|||||||
# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo.
|
# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo.
|
||||||
ENABLE_ANALYTICS=false
|
ENABLE_ANALYTICS=false
|
||||||
# Providers aceitos: oci_streaming,pubsub,noop
|
# Providers aceitos: oci_streaming,pubsub,noop
|
||||||
ANALYTICS_PROVIDERS=oci_streaming
|
ANALYTICS_PROVIDERS=pubsub
|
||||||
# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente.
|
# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente.
|
||||||
AGENT_PUBSUB_TOPIC=
|
AGENT_PUBSUB_TOPIC=
|
||||||
GCP_PUBSUB_TOPIC_PATH=
|
GCP_PUBSUB_TOPIC_PATH=
|
||||||
@@ -165,3 +169,15 @@ MEMORY_MAX_SUMMARY_CHARS=6000
|
|||||||
MEMORY_SUMMARY_USE_LLM=true
|
MEMORY_SUMMARY_USE_LLM=true
|
||||||
MEMORY_INJECT_RECENT_MESSAGES=true
|
MEMORY_INJECT_RECENT_MESSAGES=true
|
||||||
MEMORY_INJECT_SUMMARY=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
|
||||||
10
.idea/.gitignore
generated
vendored
10
.idea/.gitignore
generated
vendored
@@ -1,10 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
# Environment-dependent path to Maven home directory
|
|
||||||
/mavenHomeManager.xml
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
16
.idea/agent_platform_oci.iml
generated
Normal file
16
.idea/agent_platform_oci.iml
generated
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
<component name="TemplatesService">
|
||||||
|
<option name="TEMPLATE_FOLDERS">
|
||||||
|
<list>
|
||||||
|
<option value="$MODULE_DIR$/templates" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/agent_platform_oci.iml" filepath="$PROJECT_DIR$/.idea/agent_platform_oci.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
229
.idea/workspace.xml
generated
Normal file
229
.idea/workspace.xml
generated
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AutoImportSettings">
|
||||||
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
|
</component>
|
||||||
|
<component name="ChangeListManager">
|
||||||
|
<list default="true" id="30a0e1d8-9d7d-469b-b241-f300911cee8a" name="Changes" comment="bugfix Alex 2026-06-30">
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
|
</list>
|
||||||
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
|
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||||
|
</component>
|
||||||
|
<component name="ComposerSettings">
|
||||||
|
<execution />
|
||||||
|
</component>
|
||||||
|
<component name="Git.Settings">
|
||||||
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
|
</component>
|
||||||
|
<component name="GitHubPullRequestSearchHistory">{
|
||||||
|
"lastFilter": {
|
||||||
|
"state": "OPEN",
|
||||||
|
"assignee": "hoshikawa2"
|
||||||
|
}
|
||||||
|
}</component>
|
||||||
|
<component name="GithubPullRequestsUISettings">{
|
||||||
|
"selectedUrlAndAccountId": {
|
||||||
|
"url": "https://github.com/hoshikawa2/agent_platform_oci.git",
|
||||||
|
"accountId": "bd799ac0-7624-42b2-999a-b96e6e11f98a"
|
||||||
|
}
|
||||||
|
}</component>
|
||||||
|
<component name="KubernetesApiPersistence">{}</component>
|
||||||
|
<component name="KubernetesApiProvider">{
|
||||||
|
"isMigrated": true
|
||||||
|
}</component>
|
||||||
|
<component name="PhpWorkspaceProjectConfiguration" interpreter_name="PHP 8.1" />
|
||||||
|
<component name="ProjectCodeStyleSettingsMigration">
|
||||||
|
<option name="version" value="2" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectColorInfo">{
|
||||||
|
"customColor": "",
|
||||||
|
"associatedIndex": 2
|
||||||
|
}</component>
|
||||||
|
<component name="ProjectId" id="3FQ8m21arbKaZKgqpTYDVjVGTBf" />
|
||||||
|
<component name="ProjectViewState">
|
||||||
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
|
<option name="showLibraryContents" value="true" />
|
||||||
|
</component>
|
||||||
|
<component name="PropertiesComponent">{
|
||||||
|
"keyToString": {
|
||||||
|
"ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
|
||||||
|
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||||
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
|
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
|
||||||
|
"git-widget-placeholder": "main",
|
||||||
|
"node.js.detected.package.eslint": "true",
|
||||||
|
"node.js.detected.package.tslint": "true",
|
||||||
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
|
"nodejs_package_manager_path": "npm",
|
||||||
|
"settings.editor.selected.configurable": "configurable.group.appearance",
|
||||||
|
"vue.rearranger.settings.migration": "true"
|
||||||
|
}
|
||||||
|
}</component>
|
||||||
|
<component name="SharedIndexes">
|
||||||
|
<attachedChunks>
|
||||||
|
<set>
|
||||||
|
<option value="bundled-jdk-9823dce3aa75-fbdcb00ec9e3-intellij.indexing.shared.core-IU-251.23774.435" />
|
||||||
|
<option value="bundled-js-predefined-d6986cc7102b-f27c65a3e318-JavaScript-IU-251.23774.435" />
|
||||||
|
</set>
|
||||||
|
</attachedChunks>
|
||||||
|
</component>
|
||||||
|
<component name="TaskManager">
|
||||||
|
<task active="true" id="Default" summary="Default task">
|
||||||
|
<changelist id="30a0e1d8-9d7d-469b-b241-f300911cee8a" name="Changes" comment="" />
|
||||||
|
<created>1781996088468</created>
|
||||||
|
<option name="number" value="Default" />
|
||||||
|
<option name="presentableId" value="Default" />
|
||||||
|
<updated>1781996088468</updated>
|
||||||
|
<workItem from="1781996091031" duration="1242000" />
|
||||||
|
<workItem from="1782038604623" duration="6486000" />
|
||||||
|
<workItem from="1782047166074" duration="27000" />
|
||||||
|
<workItem from="1782047194363" duration="961000" />
|
||||||
|
<workItem from="1782048494672" duration="2790000" />
|
||||||
|
<workItem from="1782900499027" duration="911000" />
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00001" summary="Ajustes na documentação e remanejamento dos folders">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782045250014</created>
|
||||||
|
<option name="number" value="00001" />
|
||||||
|
<option name="presentableId" value="LOCAL-00001" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782045250014</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00002" summary="Ajustes na documentação e remanejamento dos folders">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782045265085</created>
|
||||||
|
<option name="number" value="00002" />
|
||||||
|
<option name="presentableId" value="LOCAL-00002" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782045265085</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00003" summary="Ajustes na documentação e remanejamento dos folders">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782048304579</created>
|
||||||
|
<option name="number" value="00003" />
|
||||||
|
<option name="presentableId" value="LOCAL-00003" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782048304579</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00004" summary="Ajustes na documentação e remanejamento dos folders">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782048747421</created>
|
||||||
|
<option name="number" value="00004" />
|
||||||
|
<option name="presentableId" value="LOCAL-00004" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782048747421</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00005" summary="bugfix Alex 2026-06-24">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782321346355</created>
|
||||||
|
<option name="number" value="00005" />
|
||||||
|
<option name="presentableId" value="LOCAL-00005" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782321346355</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00006" summary="bugfix Alex 2026-06-30">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782900906490</created>
|
||||||
|
<option name="number" value="00006" />
|
||||||
|
<option name="presentableId" value="LOCAL-00006" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782900906490</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00007" summary="bugfix Alex 2026-06-30">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782900910793</created>
|
||||||
|
<option name="number" value="00007" />
|
||||||
|
<option name="presentableId" value="LOCAL-00007" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782900910793</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00008" summary="bugfix Alex 2026-06-30">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782900913579</created>
|
||||||
|
<option name="number" value="00008" />
|
||||||
|
<option name="presentableId" value="LOCAL-00008" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782900913579</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00009" summary="bugfix Alex 2026-06-30">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782900914906</created>
|
||||||
|
<option name="number" value="00009" />
|
||||||
|
<option name="presentableId" value="LOCAL-00009" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782900914906</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00010" summary="bugfix Alex 2026-06-30">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782900923834</created>
|
||||||
|
<option name="number" value="00010" />
|
||||||
|
<option name="presentableId" value="LOCAL-00010" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782900923834</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00011" summary="bugfix Alex 2026-06-30">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782900925229</created>
|
||||||
|
<option name="number" value="00011" />
|
||||||
|
<option name="presentableId" value="LOCAL-00011" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782900925229</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00012" summary="bugfix Alex 2026-06-30">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782900926577</created>
|
||||||
|
<option name="number" value="00012" />
|
||||||
|
<option name="presentableId" value="LOCAL-00012" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782900926577</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00013" summary="bugfix Alex 2026-06-30">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782900927457</created>
|
||||||
|
<option name="number" value="00013" />
|
||||||
|
<option name="presentableId" value="LOCAL-00013" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782900927457</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00014" summary="bugfix Alex 2026-06-30">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782900928320</created>
|
||||||
|
<option name="number" value="00014" />
|
||||||
|
<option name="presentableId" value="LOCAL-00014" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782900928320</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00015" summary="bugfix Alex 2026-06-30">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782900928490</created>
|
||||||
|
<option name="number" value="00015" />
|
||||||
|
<option name="presentableId" value="LOCAL-00015" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782900928490</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00016" summary="bugfix Alex 2026-06-30">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782900939364</created>
|
||||||
|
<option name="number" value="00016" />
|
||||||
|
<option name="presentableId" value="LOCAL-00016" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782900939364</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="17" />
|
||||||
|
<servers />
|
||||||
|
</component>
|
||||||
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
<option name="version" value="3" />
|
||||||
|
</component>
|
||||||
|
<component name="VcsManagerConfiguration">
|
||||||
|
<MESSAGE value="Ajustes na documentação e remanejamento dos folders" />
|
||||||
|
<MESSAGE value="bugfix Alex 2026-06-24" />
|
||||||
|
<MESSAGE value="bugfix Alex 2026-06-30" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="bugfix Alex 2026-06-30" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
133
Documentacao/INVENTARIO_AGENT_GATEWAY_MCP_GATEWAY.md
Normal file
133
Documentacao/INVENTARIO_AGENT_GATEWAY_MCP_GATEWAY.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Inventário — Agent Gateway + MCP Gateway Overlay
|
||||||
|
|
||||||
|
Este inventário lista os arquivos incluídos no overlay `agent_platform_agent_gateway_mcp_gateway_overlay.zip`, indicando a área, o tipo de alteração e a finalidade de cada arquivo.
|
||||||
|
|
||||||
|
## Resumo
|
||||||
|
|
||||||
|
| Área | Quantidade |
|
||||||
|
|---|---:|
|
||||||
|
| Documentação | 1 |
|
||||||
|
| Agent Gateway | 10 |
|
||||||
|
| MCP Gateway | 5 |
|
||||||
|
| Agent Framework | 4 |
|
||||||
|
| Template Backend | 2 |
|
||||||
|
| MCP Server Mock | 2 |
|
||||||
|
| Deploy | 2 |
|
||||||
|
|
||||||
|
## Arquivos por área
|
||||||
|
|
||||||
|
### Documentação
|
||||||
|
|
||||||
|
| Arquivo | Tipo | Finalidade |
|
||||||
|
|---|---|---|
|
||||||
|
| `README_AGENT_GATEWAY_AND_MCP_GATEWAY_EVOLUTION.md` | Novo / overlay | Documento principal do overlay. Explica a nova arquitetura sem AI Gateway separado, com Agent Gateway governando políticas/modelos e MCP Gateway separado para tools. |
|
||||||
|
|
||||||
|
### Agent Gateway
|
||||||
|
|
||||||
|
| Arquivo | Tipo | Finalidade |
|
||||||
|
|---|---|---|
|
||||||
|
| `apps/agent_gateway/app/config/governance_loader.py` | Novo / overlay | Carrega o arquivo YAML de governança do Agent Gateway a partir de AGENT_GATEWAY_GOVERNANCE_CONFIG. |
|
||||||
|
| `apps/agent_gateway/app/governance/__init__.py` | Novo / overlay | Inicializa o pacote Python de governança do Agent Gateway. |
|
||||||
|
| `apps/agent_gateway/app/governance/audit.py` | Novo / overlay | Centraliza logging/auditoria das decisões de governança do Agent Gateway, com proteção simples para não logar mensagem completa. |
|
||||||
|
| `apps/agent_gateway/app/governance/evaluation_hooks.py` | Novo / overlay | Hooks antes e depois da chamada ao backend/runtime. Serve para amostragem, evaluator, scoring ou integração futura com Langfuse. |
|
||||||
|
| `apps/agent_gateway/app/governance/model_policies.py` | Novo / overlay | Resolve políticas de modelo/profile no Agent Gateway. Define qual provider/model/profile deve ser usado por operação, tenant e agente. |
|
||||||
|
| `apps/agent_gateway/app/governance/rate_limit.py` | Novo / overlay | Implementa rate limit em memória por tenant, agente e canal antes de encaminhar a requisição ao backend/runtime. |
|
||||||
|
| `apps/agent_gateway/app/governance/usage.py` | Novo / overlay | Hook para registrar uso de gateway, políticas aplicadas e respostas do backend. Pronto para plugar métricas, banco, Langfuse ou OTEL. |
|
||||||
|
| `apps/agent_gateway/app/governance_middleware.py` | Novo / overlay | Componente principal de governança do Agent Gateway. Aplica rate limit, resolve model_policy, gera headers/metadados e executa hooks antes/depois do backend. |
|
||||||
|
| `apps/agent_gateway/app/routes/governed_proxy_example.py` | Novo / overlay | Exemplo de rota governada para demonstrar como aplicar governança antes de encaminhar para o Agent Backend/Runtime. |
|
||||||
|
| `apps/agent_gateway/config/gateway_governance.yaml` | Novo / overlay | Configuração de governança do Agent Gateway: profiles, operation_profiles, providers permitidos, rate limits, headers propagados e evaluation hooks. |
|
||||||
|
|
||||||
|
### MCP Gateway
|
||||||
|
|
||||||
|
| Arquivo | Tipo | Finalidade |
|
||||||
|
|---|---|---|
|
||||||
|
| `apps/mcp_gateway/Dockerfile` | Novo / overlay | Imagem Docker do MCP Gateway. |
|
||||||
|
| `apps/mcp_gateway/app/__init__.py` | Novo / overlay | Inicializa o pacote Python da aplicação MCP Gateway. |
|
||||||
|
| `apps/mcp_gateway/app/main.py` | Novo / overlay | Aplicação FastAPI do MCP Gateway. Expõe health, ready, catálogo de tools e endpoint de invoke com auth, autorização, mapping, cache, timeout e retry. |
|
||||||
|
| `apps/mcp_gateway/config/mcp_gateway.yaml` | Novo / overlay | Configuração central do MCP Gateway: MCP servers, tools, versões, cache, timeout, retry, autorização por agente/canal e mapping BusinessContext → parâmetros. |
|
||||||
|
| `apps/mcp_gateway/requirements.txt` | Novo / overlay | Dependências Python do MCP Gateway. |
|
||||||
|
|
||||||
|
### Agent Framework
|
||||||
|
|
||||||
|
| Arquivo | Tipo | Finalidade |
|
||||||
|
|---|---|---|
|
||||||
|
| `libs/agent_framework/src/agent_framework/gateway_policy_context.py` | Novo / overlay | Helper no framework para o Runtime ler a política de modelo enviada pelo Agent Gateway em state['metadata']['model_policy']. |
|
||||||
|
| `libs/agent_framework/src/agent_framework/gateways/__init__.py` | Novo / overlay | Inicializa o pacote de clients de gateways no framework, exportando MCPGatewayClient. |
|
||||||
|
| `libs/agent_framework/src/agent_framework/gateways/mcp_gateway_client.py` | Novo / overlay | Client assíncrono do framework para chamar o MCP Gateway: listar tools e executar tools. |
|
||||||
|
| `libs/agent_framework/src/agent_framework/runtime_mcp_gateway_adapter.py` | Novo / overlay | Mixin opcional para agentes/runtime chamarem tools via MCP Gateway e anexarem resultados em state['mcp_results']. |
|
||||||
|
|
||||||
|
### Template Backend
|
||||||
|
|
||||||
|
| Arquivo | Tipo | Finalidade |
|
||||||
|
|---|---|---|
|
||||||
|
| `templates/agent_template_backend/app/mcp_gateway_client_factory.py` | Novo / overlay | Factory no template backend para construir MCPGatewayClient a partir de variáveis de ambiente. |
|
||||||
|
|
||||||
|
### MCP Server Mock
|
||||||
|
|
||||||
|
| Arquivo | Tipo | Finalidade |
|
||||||
|
|---|---|---|
|
||||||
|
| `mcp/servers/mock_telecom_mcp/app.py` | Novo / overlay | Mock MCP Server com tools consultar_fatura e consultar_pagamentos para validar o MCP Gateway localmente. |
|
||||||
|
| `mcp/servers/mock_telecom_mcp/requirements.txt` | Novo / overlay | Dependências do mock MCP Server de telecom usado para testes locais. |
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
|
||||||
|
| Arquivo | Tipo | Finalidade |
|
||||||
|
|---|---|---|
|
||||||
|
| `deploy/docker/docker-compose.mcp-gateway.yml` | Novo / overlay | Docker Compose para subir MCP Gateway e mock_telecom_mcp localmente. |
|
||||||
|
| `deploy/k8s/mcp-gateway.yaml` | Novo / overlay | Manifest Kubernetes de Deployment e Service do MCP Gateway. |
|
||||||
|
|
||||||
|
## Observações de integração
|
||||||
|
|
||||||
|
### Agent Gateway
|
||||||
|
|
||||||
|
Os arquivos em `apps/agent_gateway` não criam um novo serviço. Eles evoluem o Agent Gateway existente para atuar como gateway dedicado da plataforma, centralizando:
|
||||||
|
|
||||||
|
- políticas de modelo/profile;
|
||||||
|
- rate limit;
|
||||||
|
- auditoria;
|
||||||
|
- hooks de avaliação;
|
||||||
|
- propagação de metadados de governança para o Runtime.
|
||||||
|
|
||||||
|
A rota `governed_proxy_example.py` é um exemplo de integração. O handler real do `POST /gateway/message` deve aplicar:
|
||||||
|
|
||||||
|
```python
|
||||||
|
governed_body, headers = governance.prepare_backend_request(body)
|
||||||
|
```
|
||||||
|
|
||||||
|
antes de chamar o backend/runtime, e:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return governance.process_backend_response(data)
|
||||||
|
```
|
||||||
|
|
||||||
|
após receber a resposta.
|
||||||
|
|
||||||
|
### MCP Gateway
|
||||||
|
|
||||||
|
O MCP Gateway é um serviço separado. Ele centraliza:
|
||||||
|
|
||||||
|
- catálogo de tools;
|
||||||
|
- autorização por agente/canal;
|
||||||
|
- versionamento de tools;
|
||||||
|
- mapping de BusinessContext para parâmetros;
|
||||||
|
- cache;
|
||||||
|
- timeout;
|
||||||
|
- retry;
|
||||||
|
- auditoria simples.
|
||||||
|
|
||||||
|
### Runtime / Backend
|
||||||
|
|
||||||
|
O Runtime continua responsável por:
|
||||||
|
|
||||||
|
- LangGraph;
|
||||||
|
- estado;
|
||||||
|
- memória;
|
||||||
|
- checkpoints;
|
||||||
|
- fluxo;
|
||||||
|
- providers LLM existentes.
|
||||||
|
|
||||||
|
O Runtime passa a chamar tools via MCP Gateway usando `MCPGatewayClient` e/ou `MCPGatewayRuntimeMixin`.
|
||||||
|
|
||||||
|
### AI Gateway
|
||||||
|
|
||||||
|
Este overlay não cria `apps/ai_gateway`. A governança de modelo fica no Agent Gateway, e a execução LLM continua no Runtime/backend usando os providers já existentes.
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
# Manual de Execução Local
|
||||||
|
## Agent Gateway + MCP Gateway + Agent Template Backend + Frontend
|
||||||
|
|
||||||
|
## 1. Arquitetura de execução
|
||||||
|
|
||||||
|
A arquitetura local fica assim:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend
|
||||||
|
porta 5173
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Agent Gateway
|
||||||
|
porta 9000
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Agent Template Backend / Agent Runtime
|
||||||
|
porta 8000
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
MCP Gateway
|
||||||
|
porta 8300
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
MCP Server / Mock Telecom MCP
|
||||||
|
porta 8001
|
||||||
|
```
|
||||||
|
|
||||||
|
A governança de modelo, rate limit, auditoria e políticas ficam no **Agent Gateway**.
|
||||||
|
|
||||||
|
O **Agent Runtime / Agent Template Backend** continua responsável por:
|
||||||
|
|
||||||
|
- LangGraph;
|
||||||
|
- estado;
|
||||||
|
- memória;
|
||||||
|
- checkpoints;
|
||||||
|
- supervisor/router;
|
||||||
|
- guardrails;
|
||||||
|
- judges;
|
||||||
|
- chamada LLM via providers existentes;
|
||||||
|
- chamada de tools via MCP Gateway.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Portas
|
||||||
|
|
||||||
|
| Componente | Porta | URL |
|
||||||
|
|---|---:|---|
|
||||||
|
| Frontend | 5173 | `http://localhost:5173` |
|
||||||
|
| Agent Gateway | 9000 | `http://localhost:9000` |
|
||||||
|
| Agent Template Backend | 8000 | `http://localhost:8000` |
|
||||||
|
| MCP Gateway | 8300 | `http://localhost:8300` |
|
||||||
|
| MCP Server / Mock Telecom MCP | 8001 | `http://localhost:8001` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Ordem recomendada para subir
|
||||||
|
|
||||||
|
Subir nesta ordem:
|
||||||
|
|
||||||
|
1. MCP Server / Mock Telecom MCP
|
||||||
|
2. MCP Gateway
|
||||||
|
3. Agent Template Backend
|
||||||
|
4. Agent Gateway
|
||||||
|
5. Frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Terminal 1 — MCP Server / Mock Telecom MCP
|
||||||
|
|
||||||
|
Se estiver usando o mock incluído no overlay:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent_platform_oci/mcp/servers/mock_telecom_mcp
|
||||||
|
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
uvicorn app:app --host 0.0.0.0 --port 8001 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Validar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8001/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Resultado esperado:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "mock_telecom_mcp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Terminal 2 — MCP Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent_platform_oci/apps/mcp_gateway
|
||||||
|
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
export MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
|
||||||
|
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8300 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Validar health:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8300/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Validar readiness:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8300/ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Listar tools:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8300/v1/tools | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Executar tool:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8300/v1/tools/consultar_fatura/invoke \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"tenant_id": "default",
|
||||||
|
"agent_id": "telecom_contas",
|
||||||
|
"channel": "web",
|
||||||
|
"tool_name": "consultar_fatura",
|
||||||
|
"business_context": {
|
||||||
|
"customer_key": "11999999999",
|
||||||
|
"contract_key": "INV-001",
|
||||||
|
"session_key": "session-001"
|
||||||
|
}
|
||||||
|
}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Resultado esperado:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool_name": "consultar_fatura",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"invoice_id": "INV-001",
|
||||||
|
"msisdn": "11999999999",
|
||||||
|
"valor_total": 249.9,
|
||||||
|
"vencimento": "2026-06-10",
|
||||||
|
"status": "ABERTA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Terminal 3 — Agent Template Backend / Agent Runtime
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent_platform_oci/templates/agent_template_backend
|
||||||
|
```
|
||||||
|
|
||||||
|
ou, se o seu backend estiver em outra pasta:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent_platform_oci/templates/agent_template_backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Ativar ambiente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
Se ainda não existir `.venv`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Configurar variáveis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MCP_GATEWAY_ENABLED=true
|
||||||
|
export MCP_GATEWAY_URL=http://localhost:8300
|
||||||
|
export MCP_GATEWAY_TIMEOUT_SECONDS=60
|
||||||
|
|
||||||
|
export AGENT_GATEWAY_GOVERNANCE_CONFIG=config/gateway_governance.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Se estiver usando OCI/OpenAI-compatible, manter também as variáveis já existentes do backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LLM_PROVIDER=oci_openai
|
||||||
|
export OCI_GENAI_API_KEY=<sua-chave>
|
||||||
|
```
|
||||||
|
|
||||||
|
ou, para mock:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LLM_PROVIDER=mock
|
||||||
|
```
|
||||||
|
|
||||||
|
Subir backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Validar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Validar agentes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/agents | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Testar backend direto:
|
||||||
|
|
||||||
|
```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": "session-001",
|
||||||
|
"user_id": "user-001",
|
||||||
|
"message_id": "msg-001",
|
||||||
|
"business_context": {
|
||||||
|
"customer_key": "11999999999",
|
||||||
|
"contract_key": "INV-001",
|
||||||
|
"session_key": "session-001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Terminal 4 — Agent Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent_platform_oci/apps/agent_gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Ativar ambiente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Configurar variáveis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DEFAULT_AGENT_BACKEND_URL=http://localhost:8000
|
||||||
|
export AGENT_GATEWAY_GOVERNANCE_CONFIG=config/gateway_governance.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Subir Agent Gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 9000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Validar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:9000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Se a rota governada de exemplo estiver registrada no `app.main`, testar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:9000/gateway/message/governed \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"channel": "web",
|
||||||
|
"tenant_id": "default",
|
||||||
|
"agent_id": "telecom_contas",
|
||||||
|
"payload": {
|
||||||
|
"message": "Quero consultar minha fatura",
|
||||||
|
"session_id": "session-001",
|
||||||
|
"user_id": "user-001",
|
||||||
|
"message_id": "msg-001",
|
||||||
|
"metadata": {
|
||||||
|
"operation": "agent.final_answer"
|
||||||
|
},
|
||||||
|
"business_context": {
|
||||||
|
"customer_key": "11999999999",
|
||||||
|
"contract_key": "INV-001",
|
||||||
|
"session_key": "session-001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Se a rota real for `/gateway/message`, testar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:9000/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": "session-001",
|
||||||
|
"user_id": "user-001",
|
||||||
|
"message_id": "msg-001",
|
||||||
|
"metadata": {
|
||||||
|
"operation": "agent.final_answer"
|
||||||
|
},
|
||||||
|
"business_context": {
|
||||||
|
"customer_key": "11999999999",
|
||||||
|
"contract_key": "INV-001",
|
||||||
|
"session_key": "session-001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Terminal 5 — Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent_platform_oci/agent_frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
ou a pasta onde estiver o frontend.
|
||||||
|
|
||||||
|
Instalar dependências:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Subir:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev -- --host 0.0.0.0 --port 5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Abrir:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Configurar no frontend:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Backend URL: http://localhost:9000
|
||||||
|
Agent: telecom_contas
|
||||||
|
Session ID: session-001
|
||||||
|
Customer Key: 11999999999
|
||||||
|
Contract Key: INV-001
|
||||||
|
```
|
||||||
|
|
||||||
|
O frontend deve chamar o **Agent Gateway** na porta 9000, não o MCP Gateway.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. Fluxo final esperado
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend 5173
|
||||||
|
↓
|
||||||
|
Agent Gateway 9000
|
||||||
|
↓
|
||||||
|
Agent Template Backend 8000
|
||||||
|
↓
|
||||||
|
MCP Gateway 8300
|
||||||
|
↓
|
||||||
|
Mock Telecom MCP 8001
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Docker Compose para MCP Gateway + Mock MCP
|
||||||
|
|
||||||
|
Também é possível subir MCP Gateway + Mock MCP com Docker Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent_platform_oci
|
||||||
|
|
||||||
|
docker compose -f deploy/docker/docker-compose.mcp-gateway.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Isso sobe:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MCP Gateway http://localhost:8300
|
||||||
|
Mock Telecom MCP http://localhost:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
Depois subir manualmente:
|
||||||
|
|
||||||
|
- Agent Template Backend na porta 8000;
|
||||||
|
- Agent Gateway na porta 9000;
|
||||||
|
- Frontend na porta 5173.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Checklist de validação
|
||||||
|
|
||||||
|
## MCP Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8001/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8300/health
|
||||||
|
curl http://localhost:8300/v1/tools
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Runtime
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
curl http://localhost:8000/agents
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:9000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. Erros comuns
|
||||||
|
|
||||||
|
## 12.1. Frontend chamando porta errada
|
||||||
|
|
||||||
|
Errado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend → http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Correto:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend → http://localhost:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
Se você quiser testar sem Agent Gateway, pode apontar temporariamente para 8000. Mas no modelo final, o frontend deve usar o Agent Gateway.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12.2. MCP Gateway sem MCP Server
|
||||||
|
|
||||||
|
Sintoma:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MCP server unavailable
|
||||||
|
```
|
||||||
|
|
||||||
|
Correção:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8001/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Se falhar, subir o mock MCP server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12.3. Tool sem BusinessContext
|
||||||
|
|
||||||
|
Sintoma:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"missing_business_keys": ["customer_key", "contract_key"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correção:
|
||||||
|
|
||||||
|
enviar:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"business_context": {
|
||||||
|
"customer_key": "11999999999",
|
||||||
|
"contract_key": "INV-001",
|
||||||
|
"session_key": "session-001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12.4. Agent Gateway não encontra backend
|
||||||
|
|
||||||
|
Sintoma:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Connection refused http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Correção:
|
||||||
|
|
||||||
|
validar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
e configurar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DEFAULT_AGENT_BACKEND_URL=http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12.5. Rota governada não registrada
|
||||||
|
|
||||||
|
Se `/gateway/message/governed` retornar 404, significa que o arquivo de exemplo ainda não foi incluído no `app.main`.
|
||||||
|
|
||||||
|
Nesse caso, use a rota real `/gateway/message` ou registre no `main.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.routes.governed_proxy_example import router as governed_router
|
||||||
|
|
||||||
|
app.include_router(governed_router)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Variáveis consolidadas
|
||||||
|
|
||||||
|
## Agent Gateway
|
||||||
|
|
||||||
|
```env
|
||||||
|
DEFAULT_AGENT_BACKEND_URL=http://localhost:8000
|
||||||
|
AGENT_GATEWAY_GOVERNANCE_CONFIG=config/gateway_governance.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent Template Backend
|
||||||
|
|
||||||
|
```env
|
||||||
|
MCP_GATEWAY_ENABLED=true
|
||||||
|
MCP_GATEWAY_URL=http://localhost:8300
|
||||||
|
MCP_GATEWAY_TIMEOUT_SECONDS=60
|
||||||
|
LLM_PROVIDER=mock
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Gateway
|
||||||
|
|
||||||
|
```env
|
||||||
|
MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 14. Resumo rápido
|
||||||
|
|
||||||
|
Em cinco terminais:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1
|
||||||
|
cd mcp/servers/mock_telecom_mcp
|
||||||
|
source .venv/bin/activate
|
||||||
|
uvicorn app:app --host 0.0.0.0 --port 8001 --reload
|
||||||
|
|
||||||
|
# Terminal 2
|
||||||
|
cd apps/mcp_gateway
|
||||||
|
source .venv/bin/activate
|
||||||
|
export MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8300 --reload
|
||||||
|
|
||||||
|
# Terminal 3
|
||||||
|
cd templates/agent_template_backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
export MCP_GATEWAY_ENABLED=true
|
||||||
|
export MCP_GATEWAY_URL=http://localhost:8300
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
# Terminal 4
|
||||||
|
cd apps/agent_gateway
|
||||||
|
source .venv/bin/activate
|
||||||
|
export DEFAULT_AGENT_BACKEND_URL=http://localhost:8000
|
||||||
|
export AGENT_GATEWAY_GOVERNANCE_CONFIG=config/gateway_governance.yaml
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 9000 --reload
|
||||||
|
|
||||||
|
# Terminal 5
|
||||||
|
cd agent_frontend
|
||||||
|
npm install
|
||||||
|
npm run dev -- --host 0.0.0.0 --port 5173
|
||||||
|
```
|
||||||
93
Documentacao/MCP_GATEWAY_RUNBOOK.md
Normal file
93
Documentacao/MCP_GATEWAY_RUNBOOK.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# MCP Gateway Runbook
|
||||||
|
|
||||||
|
## Arquitetura corrigida
|
||||||
|
|
||||||
|
O backend/agente não deve chamar diretamente os MCP servers finais. O fluxo correto é:
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent_template_backend / agent_framework
|
||||||
|
-> MCP Gateway Client
|
||||||
|
-> apps/mcp_gateway
|
||||||
|
-> mcp/servers/telecom_mcp_server ou mcp/servers/retail_mcp_server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subir localmente
|
||||||
|
|
||||||
|
A partir da raiz do projeto:
|
||||||
|
|
||||||
|
### Terminal 1 - Telecom MCP Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mcp/servers/telecom_mcp_server
|
||||||
|
python -m uvicorn main:app --host 0.0.0.0 --port 8100 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terminal 2 - Retail MCP Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mcp/servers/retail_mcp_server
|
||||||
|
python -m uvicorn main:app --host 0.0.0.0 --port 8200 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terminal 3 - MCP Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/mcp_gateway
|
||||||
|
export MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8300 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terminal 4 - Backend/agente
|
||||||
|
|
||||||
|
No `.env` do backend/agente ou do runtime que usa o `agent_framework`, habilite:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ENABLE_MCP_TOOLS=true
|
||||||
|
MCP_GATEWAY_ENABLED=true
|
||||||
|
MCP_GATEWAY_URL=http://localhost:8300
|
||||||
|
MCP_GATEWAY_AGENT_ID=telecom_contas
|
||||||
|
MCP_GATEWAY_TENANT_ID=default
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testes rápidos
|
||||||
|
|
||||||
|
### Health do gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8300/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lista de tools expostas pelo gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8300/v1/tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chamada de tool via gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8300/v1/tools/consultar_fatura/invoke \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"tenant_id": "default",
|
||||||
|
"agent_id": "telecom_contas",
|
||||||
|
"channel": "web",
|
||||||
|
"tool_name": "consultar_fatura",
|
||||||
|
"arguments": {
|
||||||
|
"msisdn": "11999999999",
|
||||||
|
"invoice_id": "INV-123"
|
||||||
|
},
|
||||||
|
"business_context": {},
|
||||||
|
"metadata": {"session_id": "local-test"}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Resposta esperada: `ok: true`, `data.invoice_id`, `data.msisdn`, `metadata.server: telecom`.
|
||||||
|
|
||||||
|
## O que foi corrigido
|
||||||
|
|
||||||
|
- `apps/mcp_gateway/config/mcp_gateway.yaml` agora aponta para os MCP servers reais nas portas `8100` e `8200`.
|
||||||
|
- O MCP Gateway agora suporta o contrato legado dos MCP servers: `POST /mcp/tools/call` com `{tool_name, arguments}`.
|
||||||
|
- O `agent_framework` ganhou flags `MCP_GATEWAY_ENABLED`, `MCP_GATEWAY_URL`, `MCP_GATEWAY_TOKEN`, `MCP_GATEWAY_AGENT_ID` e `MCP_GATEWAY_TENANT_ID`.
|
||||||
|
- O `MCPToolRouter` passa a chamar o MCP Gateway quando `MCP_GATEWAY_ENABLED=true`.
|
||||||
|
- `libs/agent_framework/config/mcp_servers.yaml` foi mantido como registry lógico/fallback, não como caminho principal quando o gateway está ativo.
|
||||||
129
Documentacao/README_AGENT_GATEWAY_AND_MCP_GATEWAY_EVOLUTION.md
Normal file
129
Documentacao/README_AGENT_GATEWAY_AND_MCP_GATEWAY_EVOLUTION.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Agent Platform OCI — Agent Gateway + MCP Gateway Evolution
|
||||||
|
|
||||||
|
Este overlay remove o conceito de `AI Gateway` separado.
|
||||||
|
|
||||||
|
## Arquitetura
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend
|
||||||
|
↓
|
||||||
|
Agent Gateway
|
||||||
|
├── governance
|
||||||
|
├── model policies
|
||||||
|
├── rate limit
|
||||||
|
├── audit
|
||||||
|
└── evaluation hooks
|
||||||
|
↓
|
||||||
|
Agent Backend / Runtime
|
||||||
|
├── LangGraph
|
||||||
|
├── state
|
||||||
|
├── memory
|
||||||
|
├── checkpoints
|
||||||
|
└── LLM providers via profiles existentes
|
||||||
|
↓
|
||||||
|
MCP Gateway
|
||||||
|
↓
|
||||||
|
MCP Servers
|
||||||
|
```
|
||||||
|
|
||||||
|
## O que entra no Agent Gateway
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/agent_gateway/app/governance/
|
||||||
|
apps/agent_gateway/app/governance_middleware.py
|
||||||
|
apps/agent_gateway/app/routes/governed_proxy_example.py
|
||||||
|
apps/agent_gateway/config/gateway_governance.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## O que entra no MCP Gateway
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/mcp_gateway/
|
||||||
|
libs/agent_framework/src/agent_framework/gateways/mcp_gateway_client.py
|
||||||
|
libs/agent_framework/src/agent_framework/runtime_mcp_gateway_adapter.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aplicar overlay
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unzip agent_platform_agent_gateway_mcp_gateway_overlay.zip -d /tmp/overlay
|
||||||
|
rsync -av /tmp/overlay/ ./
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subir MCP Gateway local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/docker/docker-compose.mcp-gateway.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Serviços:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MCP Gateway http://localhost:8300
|
||||||
|
Mock Telecom MCP http://localhost:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testar MCP Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8300/health
|
||||||
|
curl http://localhost:8300/v1/tools
|
||||||
|
```
|
||||||
|
|
||||||
|
Executar tool:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8300/v1/tools/consultar_fatura/invoke \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"tenant_id": "default",
|
||||||
|
"agent_id": "telecom_contas",
|
||||||
|
"channel": "web",
|
||||||
|
"tool_name": "consultar_fatura",
|
||||||
|
"business_context": {
|
||||||
|
"customer_key": "11999999999",
|
||||||
|
"contract_key": "INV-001",
|
||||||
|
"session_key": "session-001"
|
||||||
|
}
|
||||||
|
}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
## Como plugar no Agent Gateway
|
||||||
|
|
||||||
|
No handler real do `POST /gateway/message`, antes de encaminhar ao backend/runtime:
|
||||||
|
|
||||||
|
```python
|
||||||
|
governed_body, headers = governance.prepare_backend_request(body)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ao receber resposta do backend:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return governance.process_backend_response(data)
|
||||||
|
```
|
||||||
|
|
||||||
|
O arquivo abaixo mostra um exemplo completo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/agent_gateway/app/routes/governed_proxy_example.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variáveis do Runtime
|
||||||
|
|
||||||
|
```env
|
||||||
|
MCP_GATEWAY_ENABLED=true
|
||||||
|
MCP_GATEWAY_URL=http://localhost:8300
|
||||||
|
MCP_GATEWAY_TIMEOUT_SECONDS=60
|
||||||
|
```
|
||||||
|
|
||||||
|
## Importante
|
||||||
|
|
||||||
|
Não existe `apps/ai_gateway`.
|
||||||
|
|
||||||
|
A governança de modelo fica no Agent Gateway como policy/metadados.
|
||||||
|
|
||||||
|
O Runtime continua usando os LLM providers existentes, podendo ler a política enviada pelo Gateway em:
|
||||||
|
|
||||||
|
```python
|
||||||
|
state["metadata"]["model_policy"]
|
||||||
|
```
|
||||||
272
MANUAL_AGENT_PLATFORM_GATEWAYS.md
Normal file
272
MANUAL_AGENT_PLATFORM_GATEWAYS.md
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
# Agent Platform OCI — Manual Oficial de Agent Gateway e MCP Gateway
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Este documento consolida:
|
||||||
|
- Arquitetura oficial
|
||||||
|
- Inventário dos componentes
|
||||||
|
- Procedimento completo de execução local
|
||||||
|
- MCP Gateway
|
||||||
|
- Agent Gateway
|
||||||
|
- Backend Runtime
|
||||||
|
- Frontend
|
||||||
|
- Testes E2E
|
||||||
|
- Troubleshooting
|
||||||
|
- Decisões arquiteturais
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Arquitetura Oficial
|
||||||
|
|
||||||
|
Frontend (5173)
|
||||||
|
↓
|
||||||
|
Agent Gateway (9000)
|
||||||
|
↓
|
||||||
|
Agent Template Backend / Runtime (8000)
|
||||||
|
↓
|
||||||
|
MCP Gateway (8300)
|
||||||
|
↓
|
||||||
|
Telecom MCP Server (8100)
|
||||||
|
Retail MCP Server (8200)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Portas Oficiais
|
||||||
|
|
||||||
|
| Componente | Porta |
|
||||||
|
|------------|--------|
|
||||||
|
| Frontend | 5173 |
|
||||||
|
| Agent Gateway | 9000 |
|
||||||
|
| Backend Runtime | 8000 |
|
||||||
|
| MCP Gateway | 8300 |
|
||||||
|
| Telecom MCP Server | 8100 |
|
||||||
|
| Retail MCP Server | 8200 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Variáveis Oficiais
|
||||||
|
|
||||||
|
## Agent Template Backend
|
||||||
|
|
||||||
|
ENABLE_MCP_TOOLS=true
|
||||||
|
|
||||||
|
MCP_GATEWAY_ENABLED=true
|
||||||
|
MCP_GATEWAY_URL=http://localhost:8300
|
||||||
|
MCP_GATEWAY_TIMEOUT_SECONDS=60
|
||||||
|
MCP_GATEWAY_AGENT_ID=telecom_contas
|
||||||
|
MCP_GATEWAY_TENANT_ID=default
|
||||||
|
|
||||||
|
## Agent Gateway
|
||||||
|
|
||||||
|
DEFAULT_AGENT_BACKEND_URL=http://localhost:8000
|
||||||
|
AGENT_GATEWAY_GOVERNANCE_CONFIG=config/gateway_governance.yaml
|
||||||
|
|
||||||
|
## MCP Gateway
|
||||||
|
|
||||||
|
MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ordem de Inicialização
|
||||||
|
|
||||||
|
1. Telecom MCP Server
|
||||||
|
2. Retail MCP Server
|
||||||
|
3. MCP Gateway
|
||||||
|
4. Agent Template Backend
|
||||||
|
5. Agent Gateway
|
||||||
|
6. Frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Terminal 1 — Telecom MCP Server
|
||||||
|
|
||||||
|
cd mcp/servers/telecom_mcp_server
|
||||||
|
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
python -m uvicorn main:app --host 0.0.0.0 --port 8100 --reload
|
||||||
|
|
||||||
|
Validação:
|
||||||
|
|
||||||
|
curl http://localhost:8100/health
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Terminal 2 — Retail MCP Server
|
||||||
|
|
||||||
|
cd mcp/servers/retail_mcp_server
|
||||||
|
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
python -m uvicorn main:app --host 0.0.0.0 --port 8200 --reload
|
||||||
|
|
||||||
|
Validação:
|
||||||
|
|
||||||
|
curl http://localhost:8200/health
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Terminal 3 — MCP Gateway
|
||||||
|
|
||||||
|
cd apps/mcp_gateway
|
||||||
|
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
export MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
|
||||||
|
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8300 --reload
|
||||||
|
|
||||||
|
Validações:
|
||||||
|
|
||||||
|
curl http://localhost:8300/health
|
||||||
|
curl http://localhost:8300/ready
|
||||||
|
curl http://localhost:8300/v1/tools
|
||||||
|
|
||||||
|
Teste:
|
||||||
|
|
||||||
|
curl -X POST http://localhost:8300/v1/tools/consultar_fatura/invoke
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Terminal 4 — Agent Template Backend
|
||||||
|
|
||||||
|
cd templates/agent_template_backend
|
||||||
|
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
Validações:
|
||||||
|
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
curl http://localhost:8000/agents
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Terminal 5 — Agent Gateway
|
||||||
|
|
||||||
|
cd apps/agent_gateway
|
||||||
|
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
export DEFAULT_AGENT_BACKEND_URL=http://localhost:8000
|
||||||
|
export AGENT_GATEWAY_GOVERNANCE_CONFIG=config/gateway_governance.yaml
|
||||||
|
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 9000 --reload
|
||||||
|
|
||||||
|
Validações:
|
||||||
|
|
||||||
|
curl http://localhost:9000/health
|
||||||
|
|
||||||
|
Teste:
|
||||||
|
|
||||||
|
curl -X POST http://localhost:9000/gateway/message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Terminal 6 — Frontend
|
||||||
|
|
||||||
|
cd agent_frontend
|
||||||
|
|
||||||
|
npm install
|
||||||
|
|
||||||
|
npm run dev -- --host 0.0.0.0 --port 5173
|
||||||
|
|
||||||
|
Abrir:
|
||||||
|
|
||||||
|
http://localhost:5173
|
||||||
|
|
||||||
|
Backend URL:
|
||||||
|
|
||||||
|
http://localhost:9000
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Fluxo de Tools
|
||||||
|
|
||||||
|
Agent
|
||||||
|
↓
|
||||||
|
MCPToolRouter
|
||||||
|
↓
|
||||||
|
MCPGatewayClient
|
||||||
|
↓
|
||||||
|
MCP Gateway
|
||||||
|
↓
|
||||||
|
MCP Server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Teste Integrado E2E
|
||||||
|
|
||||||
|
Frontend
|
||||||
|
↓
|
||||||
|
Agent Gateway
|
||||||
|
↓
|
||||||
|
Backend Runtime
|
||||||
|
↓
|
||||||
|
MCP Gateway
|
||||||
|
↓
|
||||||
|
Telecom MCP Server
|
||||||
|
|
||||||
|
Resultado esperado:
|
||||||
|
|
||||||
|
- Agent Gateway recebe requisição
|
||||||
|
- Runtime executa LangGraph
|
||||||
|
- MCP Gateway resolve tool
|
||||||
|
- MCP Server responde
|
||||||
|
- Usuário recebe resposta
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## Backend chamando MCP Server direto
|
||||||
|
|
||||||
|
Confirmar:
|
||||||
|
|
||||||
|
MCP_GATEWAY_ENABLED=true
|
||||||
|
|
||||||
|
MCP_GATEWAY_URL=http://localhost:8300
|
||||||
|
|
||||||
|
## Porta incorreta
|
||||||
|
|
||||||
|
A porta oficial do MCP Gateway é:
|
||||||
|
|
||||||
|
8300
|
||||||
|
|
||||||
|
## Agent Gateway não encontra Backend
|
||||||
|
|
||||||
|
Validar:
|
||||||
|
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
|
||||||
|
## MCP Gateway não encontra MCP Server
|
||||||
|
|
||||||
|
Validar:
|
||||||
|
|
||||||
|
curl http://localhost:8100/health
|
||||||
|
curl http://localhost:8200/health
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Decisões Arquiteturais Oficiais
|
||||||
|
|
||||||
|
- Agent Gateway centraliza governança
|
||||||
|
- Runtime executa LangGraph
|
||||||
|
- Runtime executa LLM
|
||||||
|
- MCP Gateway centraliza tools
|
||||||
|
- MCP Servers executam tools
|
||||||
|
- Backend usa MCP Gateway
|
||||||
|
- gateway_runtime.env.example foi removido
|
||||||
|
- MCP_GATEWAY_* fica no .env do backend
|
||||||
|
- Porta oficial MCP Gateway = 8300
|
||||||
168
README.md
168
README.md
@@ -25,83 +25,83 @@ A documentação da Agent Platform OCI está organizada em SPECs/SDDs numeradas,
|
|||||||
|
|
||||||
>**/agent_platform_oci/specs**
|
>**/agent_platform_oci/specs**
|
||||||
|
|
||||||
### SPEC-001 — Architecture
|
### [SPEC-001 — Architecture](specs/SPEC-001-Architecture.md)
|
||||||
|
|
||||||
Define a arquitetura geral da plataforma, seus componentes principais, estrutura de repositório, arquitetura lógica e física, contratos centrais, fluxo principal, requisitos não funcionais e critérios de aceite.
|
Define a arquitetura geral da plataforma, seus componentes principais, estrutura de repositório, arquitetura lógica e física, contratos centrais, fluxo principal, requisitos não funcionais e critérios de aceite.
|
||||||
|
|
||||||
### SPEC-002 — Agent Runtime
|
### [SPEC-002 — Agent Runtime](specs/SPEC-002-Agent-Runtime.md)
|
||||||
|
|
||||||
Descreve o runtime de execução conversacional dos agentes, incluindo LangGraph, estado, memória, checkpoints, roteamento, supervisor, BusinessContext, integração com MCP, RAG, eventos e tratamento de erros.
|
Descreve o runtime de execução conversacional dos agentes, incluindo LangGraph, estado, memória, checkpoints, roteamento, supervisor, BusinessContext, integração com MCP, RAG, eventos e tratamento de erros.
|
||||||
|
|
||||||
### SPEC-003 — AI Gateway
|
### [SPEC-003 — Agent Gateway](specs/SPEC-003-Agent-Gateway.md)
|
||||||
|
|
||||||
Especifica o gateway responsável por centralizar chamadas de LLM e embeddings, incluindo contratos de request/response, profiles, providers, autenticação OCI, fallback, rate limit, métricas, segurança e observabilidade.
|
Especifica o gateway responsável por centralizar chamadas de LLM e embeddings, incluindo contratos de request/response, profiles, providers, autenticação OCI, fallback, rate limit, métricas, segurança e observabilidade.
|
||||||
|
|
||||||
### SPEC-004 — MCP Gateway
|
### [SPEC-004 — MCP Gateway](specs/SPEC-004-MCP-Gateway.md)
|
||||||
|
|
||||||
Define o modelo de integração com MCP, incluindo catálogo de tools, roteamento, execução, autorização, cache, retry, timeout, mapeamento de parâmetros, eventos, métricas e resposta padronizada das ferramentas.
|
Define o modelo de integração com MCP, incluindo catálogo de tools, roteamento, execução, autorização, cache, retry, timeout, mapeamento de parâmetros, eventos, métricas e resposta padronizada das ferramentas.
|
||||||
|
|
||||||
### SPEC-005 — Guardrails
|
### [SPEC-005 — Guardrails](specs/SPEC-005-Guardrails.md)
|
||||||
|
|
||||||
Descreve o modelo de guardrails da plataforma, cobrindo políticas de entrada, saída, tools, RAG e resposta final. Também define fases, modos de execução, tipos de guardrails, profiles LLM, códigos base, eventos, testes e critérios de aceite.
|
Descreve o modelo de guardrails da plataforma, cobrindo políticas de entrada, saída, tools, RAG e resposta final. Também define fases, modos de execução, tipos de guardrails, profiles LLM, códigos base, eventos, testes e critérios de aceite.
|
||||||
|
|
||||||
### SPEC-006 — Evals
|
### [SPEC-006 — Evals](specs/SPEC-006-Evals.md)
|
||||||
|
|
||||||
Define a camada de avaliação da plataforma, incluindo avaliação online, avaliação offline, regressão, certificação, datasets, judges, métricas, CLI, API, persistência de resultados e publicação de evidências.
|
Define a camada de avaliação da plataforma, incluindo avaliação online, avaliação offline, regressão, certificação, datasets, judges, métricas, CLI, API, persistência de resultados e publicação de evidências.
|
||||||
|
|
||||||
### SPEC-007 — Observability
|
### [SPEC-007 — Observability](specs/SPEC-007-Observability.md)
|
||||||
|
|
||||||
Especifica o modelo de observabilidade, incluindo logs, traces, métricas, Langfuse, OpenTelemetry, eventos IC/NOC/GRL, dashboards, alertas, mascaramento de dados e geração de evidências operacionais.
|
Especifica o modelo de observabilidade, incluindo logs, traces, métricas, Langfuse, OpenTelemetry, eventos IC/NOC/GRL, dashboards, alertas, mascaramento de dados e geração de evidências operacionais.
|
||||||
|
|
||||||
### SPEC-008 — Deployment
|
### [SPEC-008 — Deployment](specs/SPEC-008-Deployment.md)
|
||||||
|
|
||||||
Descreve o processo de empacotamento e implantação da plataforma, incluindo componentes deployáveis, pipeline CI/CD, Kubernetes/OKE, Docker, secrets, autenticação OCI, health checks, rollback, smoke tests e etapa de certificação.
|
Descreve o processo de empacotamento e implantação da plataforma, incluindo componentes deployáveis, pipeline CI/CD, Kubernetes/OKE, Docker, secrets, autenticação OCI, health checks, rollback, smoke tests e etapa de certificação.
|
||||||
|
|
||||||
### SPEC-009 — Channel Gateway
|
### [SPEC-009 — Channel Gateway](specs/SPEC-009-Channel-Gateway.md)
|
||||||
|
|
||||||
Define o gateway de canais, responsável por normalizar payloads externos para o contrato canônico da plataforma e traduzir respostas para cada canal. Cobre modos de operação, idempotência, versionamento, segurança, erros e anti-patterns.
|
Define o gateway de canais, responsável por normalizar payloads externos para o contrato canônico da plataforma e traduzir respostas para cada canal. Cobre modos de operação, idempotência, versionamento, segurança, erros e anti-patterns.
|
||||||
|
|
||||||
### SPEC-010 — Agent Development
|
### [SPEC-010 — Agent Development](specs/SPEC-010-Agent-Development.md)
|
||||||
|
|
||||||
Descreve o padrão para desenvolvimento de agentes usando templates, configuração YAML, BusinessContext, MCP, guardrails, judges, RAG, memória, observabilidade e evals. Também diferencia responsabilidades do framework e do agente.
|
Descreve o padrão para desenvolvimento de agentes usando templates, configuração YAML, BusinessContext, MCP, guardrails, judges, RAG, memória, observabilidade e evals. Também diferencia responsabilidades do framework e do agente.
|
||||||
|
|
||||||
### SPEC-011 — Governance Model
|
### [SPEC-011 — Governance Model](specs/SPEC-011-Governance-Model.md)
|
||||||
|
|
||||||
Define o modelo de governança da plataforma, incluindo ownership, papéis e responsabilidades, RACI, governança de agentes, prompts, guardrails, judges, modelos, MCP, datasets, processo de aprovação e evidências obrigatórias.
|
Define o modelo de governança da plataforma, incluindo ownership, papéis e responsabilidades, RACI, governança de agentes, prompts, guardrails, judges, modelos, MCP, datasets, processo de aprovação e evidências obrigatórias.
|
||||||
|
|
||||||
### SPEC-012 — Canonical Contracts
|
### [SPEC-012 — Canonical Contracts](specs/SPEC-012-Canonical-Contracts.md)
|
||||||
|
|
||||||
Documenta os contratos canônicos da plataforma, como GatewayRequest, ChannelResponse, BusinessContext, AgentState, ToolInvocation, ToolResult, LLMRequest, LLMResponse, EvaluationRun e EventEnvelope. Também define regras de evolução desses contratos.
|
Documenta os contratos canônicos da plataforma, como GatewayRequest, ChannelResponse, BusinessContext, AgentState, ToolInvocation, ToolResult, LLMRequest, LLMResponse, EvaluationRun e EventEnvelope. Também define regras de evolução desses contratos.
|
||||||
|
|
||||||
### SPEC-013 — Versioning and Compatibility Model
|
### [SPEC-013 — Versioning and Compatibility Model](specs/SPEC-013-Versioning-and-Compatibility-Model.md)
|
||||||
|
|
||||||
Define o modelo de versionamento e compatibilidade da plataforma, incluindo Semantic Versioning, artefatos versionados, versionamento de contratos, matriz de compatibilidade, política de depreciação, migração e rollback.
|
Define o modelo de versionamento e compatibilidade da plataforma, incluindo Semantic Versioning, artefatos versionados, versionamento de contratos, matriz de compatibilidade, política de depreciação, migração e rollback.
|
||||||
|
|
||||||
### SPEC-014 — Templates and Agent Creation Model
|
### [SPEC-014 — Templates and Agent Creation Model](specs/SPEC-014-Templates-and-Agent-Creation-Model.md)
|
||||||
|
|
||||||
Descreve os templates oficiais e o modelo de criação de agentes do zero. Explica o que pertence ao framework, o que pertence ao agente, a estrutura padrão e o passo a passo para copiar template, definir escopo, registrar agente, configurar rotas, tools, BusinessContext, prompts, datasets e testes.
|
Descreve os templates oficiais e o modelo de criação de agentes do zero. Explica o que pertence ao framework, o que pertence ao agente, a estrutura padrão e o passo a passo para copiar template, definir escopo, registrar agente, configurar rotas, tools, BusinessContext, prompts, datasets e testes.
|
||||||
|
|
||||||
### SPEC-015 — Adoption and Eligibility Criteria
|
### [SPEC-015 — Adoption and Eligibility Criteria](specs/SPEC-015-Adoption-and-Eligibility-Criteria.md)
|
||||||
|
|
||||||
Define os critérios claros para adoção da plataforma, incluindo casos indicados, casos não indicados, critérios de entrada de negócio, arquitetura, segurança, qualidade, operação, processo de exceção e checklist de adoção.
|
Define os critérios claros para adoção da plataforma, incluindo casos indicados, casos não indicados, critérios de entrada de negócio, arquitetura, segurança, qualidade, operação, processo de exceção e checklist de adoção.
|
||||||
|
|
||||||
### SPEC-016 — Agent Development Lifecycle
|
### [SPEC-016 — Agent Development Lifecycle](specs/SPEC-016-Agent-Development-Lifecycle.md)
|
||||||
|
|
||||||
Descreve o ciclo de vida completo de desenvolvimento de agentes, desde discovery e definição de escopo até design, prompt, MCP, RAG, implementação, testes, avaliação, certificação, homologação e produção.
|
Descreve o ciclo de vida completo de desenvolvimento de agentes, desde discovery e definição de escopo até design, prompt, MCP, RAG, implementação, testes, avaliação, certificação, homologação e produção.
|
||||||
|
|
||||||
### SPEC-017 — Release Management and CI/CD
|
### [SPEC-017 — Release Management and CI/CD](specs/SPEC-017-Release-Management-and-CICD.md)
|
||||||
|
|
||||||
Define o modelo de release e CI/CD, incluindo pipeline padrão, stages, artefatos de release, gates de qualidade, estratégia de rollback, erros comuns e critérios de aceite.
|
Define o modelo de release e CI/CD, incluindo pipeline padrão, stages, artefatos de release, gates de qualidade, estratégia de rollback, erros comuns e critérios de aceite.
|
||||||
|
|
||||||
### SPEC-018 — Security and Identity Model
|
### [SPEC-018 — Security and Identity Model](specs/SPEC-018-Security-and-Identity-Model.md)
|
||||||
|
|
||||||
Especifica o modelo de segurança e identidade da plataforma, cobrindo autenticação, Workload Identity, autorização, secrets, proteção de dados, segurança em MCP, segurança em canais, auditoria e critérios de aceite.
|
Especifica o modelo de segurança e identidade da plataforma, cobrindo autenticação, Workload Identity, autorização, secrets, proteção de dados, segurança em MCP, segurança em canais, auditoria e critérios de aceite.
|
||||||
|
|
||||||
### SPEC-019 — Evaluation and Certification Framework
|
### [SPEC-019 — Evaluation and Certification Framework](specs/SPEC-019-Evaluation-and-Certification-Framework.md)
|
||||||
|
|
||||||
Detalha o framework de avaliação e certificação, incluindo arquitetura de avaliação, métricas, datasets, EvaluationRun, CLI, processo de certificação, evidências obrigatórias e critérios de aceite.
|
Detalha o framework de avaliação e certificação, incluindo arquitetura de avaliação, métricas, datasets, EvaluationRun, CLI, processo de certificação, evidências obrigatórias e critérios de aceite.
|
||||||
|
|
||||||
### SPEC-020 — Operational Readiness and SRE Model
|
### [SPEC-020 — Operational Readiness and SRE Model](specs/SPEC-020-Operational-Readiness-and-SRE-Model.md)
|
||||||
|
|
||||||
Define o modelo de readiness operacional e SRE da plataforma, incluindo componentes operados, health checks, readiness, SLOs, métricas, dashboards, alertas, runbooks, gestão de incidentes, capacidade e checklist de produção.
|
Define o modelo de readiness operacional e SRE da plataforma, incluindo componentes operados, health checks, readiness, SLOs, métricas, dashboards, alertas, runbooks, gestão de incidentes, capacidade e checklist de produção.
|
||||||
|
|
||||||
@@ -342,6 +342,7 @@ RAG_TOP_K=5
|
|||||||
EMBEDDING_PROVIDER=mock
|
EMBEDDING_PROVIDER=mock
|
||||||
|
|
||||||
ENABLE_LANGFUSE=false
|
ENABLE_LANGFUSE=false
|
||||||
|
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
|
||||||
LANGFUSE_HOST=http://localhost:3005
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
ENABLE_OTEL=false
|
ENABLE_OTEL=false
|
||||||
OTEL_SERVICE_NAME=ai-agent-template
|
OTEL_SERVICE_NAME=ai-agent-template
|
||||||
@@ -414,6 +415,7 @@ Para usar Langfuse:
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
ENABLE_LANGFUSE=true
|
ENABLE_LANGFUSE=true
|
||||||
|
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
|
||||||
LANGFUSE_PUBLIC_KEY=<public-key>
|
LANGFUSE_PUBLIC_KEY=<public-key>
|
||||||
LANGFUSE_SECRET_KEY=<secret-key>
|
LANGFUSE_SECRET_KEY=<secret-key>
|
||||||
LANGFUSE_HOST=http://localhost:3005
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
@@ -7862,6 +7864,64 @@ workflow concluído
|
|||||||
|
|
||||||
## 17. Build e execução local
|
## 17. Build e execução local
|
||||||
|
|
||||||
|
Vamos subir a estrutura completa de um agente:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend (5173)
|
||||||
|
↓
|
||||||
|
Agent Gateway (9000)
|
||||||
|
↓
|
||||||
|
Agent Template Backend / Runtime (8000)
|
||||||
|
↓
|
||||||
|
MCP Gateway (8300)
|
||||||
|
↓
|
||||||
|
Telecom MCP Server (8100)
|
||||||
|
Retail MCP Server (8200)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Portas Oficiais
|
||||||
|
|
||||||
|
| Componente | Porta |
|
||||||
|
|------------|--------|
|
||||||
|
| Frontend | 5173 |
|
||||||
|
| Agent Gateway | 9000 |
|
||||||
|
| Backend Runtime | 8000 |
|
||||||
|
| MCP Gateway | 8300 |
|
||||||
|
| Telecom MCP Server | 8100 |
|
||||||
|
| Retail MCP Server | 8200 |
|
||||||
|
|
||||||
|
### Variáveis Oficiais
|
||||||
|
|
||||||
|
### Agent Template Backend
|
||||||
|
|
||||||
|
ENABLE_MCP_TOOLS=true
|
||||||
|
|
||||||
|
MCP_GATEWAY_ENABLED=true
|
||||||
|
MCP_GATEWAY_URL=http://localhost:8300
|
||||||
|
MCP_GATEWAY_TIMEOUT_SECONDS=60
|
||||||
|
MCP_GATEWAY_AGENT_ID=telecom_contas
|
||||||
|
MCP_GATEWAY_TENANT_ID=default
|
||||||
|
|
||||||
|
### Agent Gateway
|
||||||
|
|
||||||
|
DEFAULT_AGENT_BACKEND_URL=http://localhost:8000
|
||||||
|
AGENT_GATEWAY_GOVERNANCE_CONFIG=config/gateway_governance.yaml
|
||||||
|
|
||||||
|
### MCP Gateway
|
||||||
|
|
||||||
|
MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
|
||||||
|
|
||||||
|
### Ordem de Inicialização
|
||||||
|
|
||||||
|
1. Telecom MCP Server
|
||||||
|
2. Retail MCP Server
|
||||||
|
3. MCP Gateway
|
||||||
|
4. Agent Template Backend
|
||||||
|
5. Agent Gateway
|
||||||
|
6. Frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Na raiz do projeto:
|
Na raiz do projeto:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -7881,8 +7941,8 @@ Dentro de `agent_template_backend`:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
cd agent_template_backend
|
cd templates/agent_template_backend
|
||||||
pip install -e ../agent_framework
|
pip install -e ../../libs/agent_framework
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
```
|
```
|
||||||
@@ -7949,7 +8009,7 @@ Se os MCP Servers forem processos Python separados, suba cada um em uma porta di
|
|||||||
Exemplo:
|
Exemplo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ../mcp_servers/financeiro_mcp_server
|
cd mcp/servers/financeiro_mcp_server
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
python -m uvicorn main:app --host 0.0.0.0 --port 8300 --reload
|
python -m uvicorn main:app --host 0.0.0.0 --port 8300 --reload
|
||||||
```
|
```
|
||||||
@@ -7967,7 +8027,34 @@ servers:
|
|||||||
> Você pode customizar para subir todos os seus mcp servers.
|
> Você pode customizar para subir todos os seus mcp servers.
|
||||||
> Execute: **bash ./scripts/run_mcp_servers.sh**
|
> Execute: **bash ./scripts/run_mcp_servers.sh**
|
||||||
|
|
||||||
### 18.3. Testar tool pelo backend
|
### 18.3. Subir o MCP Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/mcp_gateway
|
||||||
|
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
export MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
|
||||||
|
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8300 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Validações:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8300/health
|
||||||
|
curl http://localhost:8300/ready
|
||||||
|
curl http://localhost:8300/v1/tools
|
||||||
|
```
|
||||||
|
|
||||||
|
Teste:
|
||||||
|
|
||||||
|
curl -X POST http://localhost:8300/v1/tools/consultar_fatura/invoke
|
||||||
|
|
||||||
|
|
||||||
|
### 18.4. Testar tool pelo backend
|
||||||
|
|
||||||
Teste pelo backend, não diretamente pelo MCP. Assim você valida o caminho completo:
|
Teste pelo backend, não diretamente pelo MCP. Assim você valida o caminho completo:
|
||||||
|
|
||||||
@@ -7992,13 +8079,13 @@ curl -X POST http://localhost:8000/debug/mcp/call/consultar_titulo_financeiro \
|
|||||||
|
|
||||||
>**Nota:** No projeto existe também uma interface visual para testar:
|
>**Nota:** No projeto existe também uma interface visual para testar:
|
||||||
|
|
||||||
### 18.4. Subir Frontend para testes
|
### 18.5. Subir Frontend para testes
|
||||||
|
|
||||||
Install before the [npm](https://nodejs.org/) and:
|
Install before the [npm](https://nodejs.org/) and:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd agent_platform_oci
|
cd agent_platform_oci
|
||||||
cd agent_frontend
|
cd apps/agent_frontend
|
||||||
python -m http.server 5173
|
python -m http.server 5173
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -8007,7 +8094,7 @@ Abra http://localhost:5173.
|
|||||||
|
|
||||||
>**Nota:** O frontend está preparado para funcionar com o **Agent Gateway**. No projeto, consulte os capítulos 28 e 28.10 para experimentá-lo. Lembre-se apenas de trocar **Backend URL** para **http://localhost:8010** pois 8010 é a porta onde o **Agent Gateway** estará escutando.
|
>**Nota:** O frontend está preparado para funcionar com o **Agent Gateway**. No projeto, consulte os capítulos 28 e 28.10 para experimentá-lo. Lembre-se apenas de trocar **Backend URL** para **http://localhost:8010** pois 8010 é a porta onde o **Agent Gateway** estará escutando.
|
||||||
|
|
||||||
### 18.5. Como interpretar erros MCP
|
### 18.6. Como interpretar erros MCP
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Tool não encontrada → tools.yaml ou nome da tool errado.
|
Tool não encontrada → tools.yaml ou nome da tool errado.
|
||||||
@@ -8017,6 +8104,32 @@ Parâmetro obrigatório ausente → identity.yaml ou mcp_parameter_mapping.yaml
|
|||||||
Timeout → MCP lento, endpoint errado, VPN, DNS ou sistema real indisponível.
|
Timeout → MCP lento, endpoint errado, VPN, DNS ou sistema real indisponível.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 18.7. Subir Agent Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/agent_gateway
|
||||||
|
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
export DEFAULT_AGENT_BACKEND_URL=http://localhost:8000
|
||||||
|
export AGENT_GATEWAY_GOVERNANCE_CONFIG=config/gateway_governance.yaml
|
||||||
|
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 9000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Validações:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:9000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Teste:
|
||||||
|
|
||||||
|
curl -X POST http://localhost:9000/gateway/message
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 19. Build com Docker
|
## 19. Build com Docker
|
||||||
@@ -8442,6 +8555,7 @@ Verifique:
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
ENABLE_LANGFUSE=true
|
ENABLE_LANGFUSE=true
|
||||||
|
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
|
||||||
LANGFUSE_PUBLIC_KEY=<public-key>
|
LANGFUSE_PUBLIC_KEY=<public-key>
|
||||||
LANGFUSE_SECRET_KEY=<secret-key>
|
LANGFUSE_SECRET_KEY=<secret-key>
|
||||||
LANGFUSE_HOST=http://localhost:3005
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
|
|||||||
286
README_en.md
286
README_en.md
@@ -24,85 +24,86 @@ The Agent Platform OCI documentation is organized into numbered SPECs/SDDs, each
|
|||||||
|
|
||||||
>**/agent_platform_oci/specs**
|
>**/agent_platform_oci/specs**
|
||||||
|
|
||||||
### SPEC-001 — Architecture
|
### [SPEC-001 — Architecture](specs/SPEC-001-Architecture.md)
|
||||||
|
|
||||||
Defines the overall architecture of the platform, its main components, repository structure, logical and physical architecture, core contracts, main flow, non-functional requirements, and acceptance criteria.
|
Defines the overall platform architecture, its main components, repository structure, logical and physical architecture, core contracts, primary flow, non-functional requirements, and acceptance criteria.
|
||||||
|
|
||||||
### SPEC-002 — Agent Runtime
|
### [SPEC-002 — Agent Runtime](specs/SPEC-002-Agent-Runtime.md)
|
||||||
|
|
||||||
Describes the conversational execution runtime of the agents, including LangGraph, state, memory, checkpoints, routing, supervisor, BusinessContext, MCP integration, RAG, events, and error handling.
|
Describes the conversational execution runtime for agents, including LangGraph, state management, memory, checkpoints, routing, supervisor, BusinessContext, MCP integration, RAG, events, and error handling.
|
||||||
|
|
||||||
### SPEC-003 — AI Gateway
|
### [SPEC-003 — Agent Gateway](specs/SPEC-003-Agent-Gateway.md)
|
||||||
|
|
||||||
Specifies the gateway responsible for centralizing LLM and embedding calls, including request/response contracts, profiles, providers, OCI authentication, fallback, rate limiting, metrics, security, and observability.
|
Specifies the gateway responsible for centralizing LLM and embedding calls, including request/response contracts, profiles, providers, OCI authentication, fallback, rate limiting, metrics, security, and observability.
|
||||||
|
|
||||||
### SPEC-004 — MCP Gateway
|
### [SPEC-004 — MCP Gateway](specs/SPEC-004-MCP-Gateway.md)
|
||||||
|
|
||||||
Defines the MCP integration model, including tool catalog, routing, execution, authorization, cache, retry, timeout, parameter mapping, events, metrics, and standardized tool responses.
|
Defines the MCP integration model, including the tool catalog, routing, execution, authorization, caching, retries, timeouts, parameter mapping, events, metrics, and standardized tool responses.
|
||||||
|
|
||||||
### SPEC-005 — Guardrails
|
### [SPEC-005 — Guardrails](specs/SPEC-005-Guardrails.md)
|
||||||
|
|
||||||
Describes the platform guardrails model, covering input, output, tools, RAG, and final response policies. It also defines phases, execution modes, guardrail types, LLM profiles, base codes, events, tests, and acceptance criteria.
|
Describes the platform guardrail model, covering input, output, tool, RAG, and final response policies. It also defines phases, execution modes, guardrail types, LLM profiles, base codes, events, tests, and acceptance criteria.
|
||||||
|
|
||||||
### SPEC-006 — Evals
|
### [SPEC-006 — Evals](specs/SPEC-006-Evals.md)
|
||||||
|
|
||||||
Defines the platform evaluation layer, including online evaluation, offline evaluation, regression, certification, datasets, judges, metrics, CLI, API, result persistence, and evidence publication.
|
Defines the platform evaluation layer, including online evaluation, offline evaluation, regression testing, certification, datasets, judges, metrics, CLI, API, result persistence, and evidence publication.
|
||||||
|
|
||||||
### SPEC-007 — Observability
|
### [SPEC-007 — Observability](specs/SPEC-007-Observability.md)
|
||||||
|
|
||||||
Specifies the observability model, including logs, traces, metrics, Langfuse, OpenTelemetry, IC/NOC/GRL events, dashboards, alerts, data masking, and operational evidence generation.
|
Specifies the observability model, including logs, traces, metrics, Langfuse, OpenTelemetry, IC/NOC/GRL events, dashboards, alerts, data masking, and operational evidence generation.
|
||||||
|
|
||||||
### SPEC-008 — Deployment
|
### [SPEC-008 — Deployment](specs/SPEC-008-Deployment.md)
|
||||||
|
|
||||||
Describes the platform packaging and deployment process, including deployable components, CI/CD pipeline, Kubernetes/OKE, Docker, secrets, OCI authentication, health checks, rollback, smoke tests, and certification stage.
|
Describes the platform packaging and deployment process, including deployable components, CI/CD pipelines, Kubernetes/OKE, Docker, secrets, OCI authentication, health checks, rollback, smoke tests, and certification stages.
|
||||||
|
|
||||||
### SPEC-009 — Channel Gateway
|
### [SPEC-009 — Channel Gateway](specs/SPEC-009-Channel-Gateway.md)
|
||||||
|
|
||||||
Defines the channel gateway, responsible for normalizing external payloads into the platform’s canonical contract and translating responses for each channel. It covers operation modes, idempotency, versioning, security, errors, and anti-patterns.
|
Defines the channel gateway responsible for normalizing external payloads into the platform's canonical contract and translating responses for each channel. Covers operating modes, idempotency, versioning, security, errors, and anti-patterns.
|
||||||
|
|
||||||
### SPEC-010 — Agent Development
|
### [SPEC-010 — Agent Development](specs/SPEC-010-Agent-Development.md)
|
||||||
|
|
||||||
Describes the standard for developing agents using templates, YAML configuration, BusinessContext, MCP, guardrails, judges, RAG, memory, observability, and evals. It also differentiates the responsibilities of the framework and the agent.
|
Describes the standard for agent development using templates, YAML configuration, BusinessContext, MCP, guardrails, judges, RAG, memory, observability, and evaluations. It also differentiates framework and agent responsibilities.
|
||||||
|
|
||||||
### SPEC-011 — Governance Model
|
### [SPEC-011 — Governance Model](specs/SPEC-011-Governance-Model.md)
|
||||||
|
|
||||||
Defines the platform governance model, including ownership, roles and responsibilities, RACI, governance of agents, prompts, guardrails, judges, models, MCP, datasets, approval process, and mandatory evidence.
|
Defines the platform governance model, including ownership, roles and responsibilities, RACI, agent governance, prompts, guardrails, judges, models, MCP, datasets, approval processes, and mandatory evidence.
|
||||||
|
|
||||||
### SPEC-012 — Canonical Contracts
|
### [SPEC-012 — Canonical Contracts](specs/SPEC-012-Canonical-Contracts.md)
|
||||||
|
|
||||||
Documents the platform’s canonical contracts, such as GatewayRequest, ChannelResponse, BusinessContext, AgentState, ToolInvocation, ToolResult, LLMRequest, LLMResponse, EvaluationRun, and EventEnvelope. It also defines rules for evolving these contracts.
|
Documents the platform's canonical contracts, including GatewayRequest, ChannelResponse, BusinessContext, AgentState, ToolInvocation, ToolResult, LLMRequest, LLMResponse, EvaluationRun, and EventEnvelope. It also defines contract evolution rules.
|
||||||
|
|
||||||
### SPEC-013 — Versioning and Compatibility Model
|
### [SPEC-013 — Versioning and Compatibility Model](specs/SPEC-013-Versioning-and-Compatibility-Model.md)
|
||||||
|
|
||||||
Defines the platform versioning and compatibility model, including Semantic Versioning, versioned artifacts, contract versioning, compatibility matrix, deprecation policy, migration, and rollback.
|
Defines the platform versioning and compatibility model, including Semantic Versioning, versioned artifacts, contract versioning, compatibility matrices, deprecation policies, migration, and rollback.
|
||||||
|
|
||||||
### SPEC-014 — Templates and Agent Creation Model
|
### [SPEC-014 — Templates and Agent Creation Model](specs/SPEC-014-Templates-and-Agent-Creation-Model.md)
|
||||||
|
|
||||||
Describes the official templates and the model for creating agents from scratch. It explains what belongs to the framework, what belongs to the agent, the standard structure, and the step-by-step process to copy a template, define scope, register the agent, configure routes, tools, BusinessContext, prompts, datasets, and tests.
|
Describes the official templates and the agent creation model from scratch. Explains what belongs to the framework, what belongs to the agent, the standard structure, and the step-by-step process for copying templates, defining scope, registering agents, configuring routes, tools, BusinessContext, prompts, datasets, and tests.
|
||||||
|
|
||||||
### SPEC-015 — Adoption and Eligibility Criteria
|
### [SPEC-015 — Adoption and Eligibility Criteria](specs/SPEC-015-Adoption-and-Eligibility-Criteria.md)
|
||||||
|
|
||||||
Defines clear criteria for adopting the platform, including recommended use cases, non-recommended use cases, business entry criteria, architecture, security, quality, operation, exception process, and adoption checklist.
|
Defines clear platform adoption criteria, including recommended and non-recommended use cases, business entry criteria, architecture, security, quality, operations, exception processes, and adoption checklists.
|
||||||
|
|
||||||
### SPEC-016 — Agent Development Lifecycle
|
### [SPEC-016 — Agent Development Lifecycle](specs/SPEC-016-Agent-Development-Lifecycle.md)
|
||||||
|
|
||||||
Describes the complete agent development lifecycle, from discovery and scope definition to design, prompt, MCP, RAG, implementation, testing, evaluation, certification, homologation, and production.
|
Describes the complete agent development lifecycle, from discovery and scope definition through design, prompting, MCP, RAG, implementation, testing, evaluation, certification, validation, and production.
|
||||||
|
|
||||||
### SPEC-017 — Release Management and CI/CD
|
### [SPEC-017 — Release Management and CI/CD](specs/SPEC-017-Release-Management-and-CICD.md)
|
||||||
|
|
||||||
Defines the release and CI/CD model, including the standard pipeline, stages, release artifacts, quality gates, rollback strategy, common errors, and acceptance criteria.
|
Defines the release and CI/CD model, including standard pipelines, stages, release artifacts, quality gates, rollback strategies, common errors, and acceptance criteria.
|
||||||
|
|
||||||
### SPEC-018 — Security and Identity Model
|
### [SPEC-018 — Security and Identity Model](specs/SPEC-018-Security-and-Identity-Model.md)
|
||||||
|
|
||||||
Specifies the platform security and identity model, covering authentication, Workload Identity, authorization, secrets, data protection, MCP security, channel security, auditing, and acceptance criteria.
|
Specifies the platform security and identity model, covering authentication, Workload Identity, authorization, secrets, data protection, MCP security, channel security, auditing, and acceptance criteria.
|
||||||
|
|
||||||
### SPEC-019 — Evaluation and Certification Framework
|
### [SPEC-019 — Evaluation and Certification Framework](specs/SPEC-019-Evaluation-and-Certification-Framework.md)
|
||||||
|
|
||||||
Details the evaluation and certification framework, including evaluation architecture, metrics, datasets, EvaluationRun, CLI, certification process, mandatory evidence, and acceptance criteria.
|
Details the evaluation and certification framework, including evaluation architecture, metrics, datasets, EvaluationRun, CLI, certification processes, mandatory evidence, and acceptance criteria.
|
||||||
|
|
||||||
### SPEC-020 — Operational Readiness and SRE Model
|
### [SPEC-020 — Operational Readiness and SRE Model](specs/SPEC-020-Operational-Readiness-and-SRE-Model.md)
|
||||||
|
|
||||||
|
Defines the platform operational readiness and SRE model, including managed components, health checks, readiness, SLOs, metrics, dashboards, alerts, runbooks, incident management, capacity planning, and production checklists.
|
||||||
|
|
||||||
Defines the platform operational readiness and SRE model, including operated components, health checks, readiness, SLOs, metrics, dashboards, alerts, runbooks, incident management, capacity, and production checklist.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -339,6 +340,7 @@ RAG_TOP_K=5
|
|||||||
EMBEDDING_PROVIDER=mock
|
EMBEDDING_PROVIDER=mock
|
||||||
|
|
||||||
ENABLE_LANGFUSE=false
|
ENABLE_LANGFUSE=false
|
||||||
|
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
|
||||||
LANGFUSE_HOST=http://localhost:3005
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
ENABLE_OTEL=false
|
ENABLE_OTEL=false
|
||||||
OTEL_SERVICE_NAME=ai-agent-template
|
OTEL_SERVICE_NAME=ai-agent-template
|
||||||
@@ -411,6 +413,7 @@ To use Langfuse:
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
ENABLE_LANGFUSE=true
|
ENABLE_LANGFUSE=true
|
||||||
|
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
|
||||||
LANGFUSE_PUBLIC_KEY=<public-key>
|
LANGFUSE_PUBLIC_KEY=<public-key>
|
||||||
LANGFUSE_SECRET_KEY=<secret-key>
|
LANGFUSE_SECRET_KEY=<secret-key>
|
||||||
LANGFUSE_HOST=http://localhost:3005
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
@@ -7766,29 +7769,87 @@ workflow completed
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 17. Build and local execution
|
## 17. Local Build and Execution
|
||||||
|
|
||||||
At the root of the project:
|
Let's bring up the complete agent stack:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend (5173)
|
||||||
|
↓
|
||||||
|
Agent Gateway (9000)
|
||||||
|
↓
|
||||||
|
Agent Template Backend / Runtime (8000)
|
||||||
|
↓
|
||||||
|
MCP Gateway (8300)
|
||||||
|
↓
|
||||||
|
Telecom MCP Server (8100)
|
||||||
|
Retail MCP Server (8200)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Official Ports
|
||||||
|
|
||||||
|
| Component | Port |
|
||||||
|
|------------|--------|
|
||||||
|
| Frontend | 5173 |
|
||||||
|
| Agent Gateway | 9000 |
|
||||||
|
| Backend Runtime | 8000 |
|
||||||
|
| MCP Gateway | 8300 |
|
||||||
|
| Telecom MCP Server | 8100 |
|
||||||
|
| Retail MCP Server | 8200 |
|
||||||
|
|
||||||
|
### Official Variables
|
||||||
|
|
||||||
|
### Agent Template Backend
|
||||||
|
|
||||||
|
ENABLE_MCP_TOOLS=true
|
||||||
|
|
||||||
|
MCP_GATEWAY_ENABLED=true
|
||||||
|
MCP_GATEWAY_URL=http://localhost:8300
|
||||||
|
MCP_GATEWAY_TIMEOUT_SECONDS=60
|
||||||
|
MCP_GATEWAY_AGENT_ID=telecom_contas
|
||||||
|
MCP_GATEWAY_TENANT_ID=default
|
||||||
|
|
||||||
|
### Agent Gateway
|
||||||
|
|
||||||
|
DEFAULT_AGENT_BACKEND_URL=http://localhost:8000
|
||||||
|
AGENT_GATEWAY_GOVERNANCE_CONFIG=config/gateway_governance.yaml
|
||||||
|
|
||||||
|
### MCP Gateway
|
||||||
|
|
||||||
|
MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
|
||||||
|
|
||||||
|
### Startup Order
|
||||||
|
|
||||||
|
1. Telecom MCP Server
|
||||||
|
2. Retail MCP Server
|
||||||
|
3. MCP Gateway
|
||||||
|
4. Agent Template Backend
|
||||||
|
5. Agent Gateway
|
||||||
|
6. Frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
From the project root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd agent_platform_oci
|
cd agent_platform_oci
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
```
|
```
|
||||||
|
|
||||||
### 17.1. Before the commands: what does it mean to upload the backend?
|
### 17.1. Before the commands: what does starting the backend mean?
|
||||||
|
|
||||||
Bringing up the backend means starting the API that receives messages, normalizes the channel, resolves identity, opens a session, executes the workflow, and returns a response.
|
Starting the backend means launching the API that receives messages, normalizes the channel, resolves identity, opens a session, executes the workflow, and returns a response.
|
||||||
|
|
||||||
It can be uploaded even without a real MCP, as long as the configuration is in mock or the tools are not required for the test.
|
It can be started even without a real MCP, as long as the configuration is in mock mode or the tools are not required for the test.
|
||||||
|
|
||||||
### 17.2. Run local backend
|
### 17.2. Run the Backend Locally
|
||||||
|
|
||||||
Inside `agent_template_backend`:
|
Inside `agent_template_backend`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
cd agent_template_backend
|
cd templates/agent_template_backend
|
||||||
pip install -e ../agent_framework
|
pip install -e ../../libs/agent_framework
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
```
|
```
|
||||||
@@ -7800,7 +7861,7 @@ Windows PowerShell:
|
|||||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
### 17.3. Immediate validations
|
### 17.3. Immediate Validations
|
||||||
|
|
||||||
Check health:
|
Check health:
|
||||||
|
|
||||||
@@ -7820,42 +7881,42 @@ List known MCP tools:
|
|||||||
curl http://localhost:8000/debug/mcp/tools
|
curl http://localhost:8000/debug/mcp/tools
|
||||||
```
|
```
|
||||||
|
|
||||||
### 17.4. How to interpret the result
|
### 17.4. How to Interpret the Result
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/health ok → API is up.
|
/health ok → API started successfully.
|
||||||
/agents lista → agents.yaml was loaded.
|
/agents lists agents → agents.yaml was loaded.
|
||||||
/debug/mcp/tools → tools.yaml and mcp_servers.yaml were loaded.
|
/debug/mcp/tools → tools.yaml and mcp_servers.yaml were loaded.
|
||||||
```
|
```
|
||||||
|
|
||||||
If `/health` works but`/agents` does not list the agent, the problem is probably in `config/agents.yaml`. If `/debug/mcp/tools` does not show the tool, the problem is probably in `tools.yaml` or `mcp_servers.yaml`.
|
If `/health` works but `/agents` does not list the agent, the problem is probably in `config/agents.yaml`. If `/debug/mcp/tools` does not show the tool, the problem is probably in `tools.yaml` or `mcp_servers.yaml`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 18. Uploading MCP Servers
|
## 18. Starting MCP Servers
|
||||||
|
|
||||||
### 18.1. Before the commands: when do I need to upload MCP?
|
### 18.1. Before the commands: when do I need to start MCP?
|
||||||
|
|
||||||
You need to upload MCP when the chosen intent uses `mcp_tools` and the agent depends on these tools to respond.
|
You need to start MCP when the selected intent uses `mcp_tools` and the agent depends on those tools to respond.
|
||||||
|
|
||||||
You don't need to upload MCP just to test:
|
You do not need MCP running to test only:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
health check
|
health check
|
||||||
agent registration
|
agent registration
|
||||||
basic routing
|
basic routing
|
||||||
mock LLM without tools
|
mock LLM without tools
|
||||||
simple conversational flow without external query
|
simple conversational flow without external lookup
|
||||||
```
|
```
|
||||||
|
|
||||||
### 18.2. Upload local MCP Server
|
### 18.2. Start a Local MCP Server
|
||||||
|
|
||||||
If the MCP Servers are separate Python processes, upload each one on a different port.
|
If the MCP Servers are separate Python processes, start each one on a different port.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ../mcp_servers/financeiro_mcp_server
|
cd mcp/servers/financeiro_mcp_server
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
python -m uvicorn main:app --host 0.0.0.0 --port 8300 --reload
|
python -m uvicorn main:app --host 0.0.0.0 --port 8300 --reload
|
||||||
```
|
```
|
||||||
@@ -7864,18 +7925,45 @@ Then confirm that the endpoint configured in `config/mcp_servers.yaml` is correc
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
servers:
|
servers:
|
||||||
financeiro:
|
financeiro:
|
||||||
endpoint: http://localhost:8300/mcp
|
endpoint: http://localhost:8300/mcp
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** The**/scripts/ folder** has automated mcp server startup scripts for educational purposes.
|
> **Note:** The **/scripts/** folder contains automated MCP Server startup scripts for demonstration and educational purposes.
|
||||||
> The **/agent_template_backend folder** has 2 mcp servers configured, one on port 8100 and the other on 8200. These services are ready and configured to run if you want to test the circuit.
|
> The **/agent_template_backend** folder includes two MCP Servers already configured, one running on port **8100** and the other on port **8200**. These services are ready to run if you want to test the full flow.
|
||||||
> You can customize it to upload all your mcp servers.
|
> You can customize the setup to start all of your MCP Servers.
|
||||||
> Run: **bash ./scripts/run_mcp_servers.sh**
|
> Run: **bash ./scripts/run_mcp_servers.sh**
|
||||||
|
|
||||||
### 18.3. Test tool through the backend
|
### 18.3. Start the MCP Gateway
|
||||||
|
|
||||||
Test through the backend, not directly through the MCP. This way, you validate the complete path:
|
```bash
|
||||||
|
cd apps/mcp_gateway
|
||||||
|
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
export MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
|
||||||
|
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8300 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8300/health
|
||||||
|
curl http://localhost:8300/ready
|
||||||
|
curl http://localhost:8300/v1/tools
|
||||||
|
```
|
||||||
|
|
||||||
|
Test:
|
||||||
|
|
||||||
|
curl -X POST http://localhost:8300/v1/tools/consultar_fatura/invoke
|
||||||
|
|
||||||
|
|
||||||
|
### 18.4. Test a Tool Through the Backend
|
||||||
|
|
||||||
|
Test through the backend, not directly through MCP. This validates the full path:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
backend → MCP Tool Router → MCP Server → response
|
backend → MCP Tool Router → MCP Server → response
|
||||||
@@ -7885,42 +7973,71 @@ backend → MCP Tool Router → MCP Server → response
|
|||||||
curl -X POST http://localhost:8000/debug/mcp/call/consultar_titulo_financeiro \
|
curl -X POST http://localhost:8000/debug/mcp/call/consultar_titulo_financeiro \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"business_context": {
|
"business_context": {
|
||||||
"customer_key": "12345",
|
"customer_key": "12345",
|
||||||
"contract_key": "ABC-999",
|
"contract_key": "ABC-999",
|
||||||
"session_key": "sessao-teste"
|
"session_key": "sessao-teste"
|
||||||
},
|
},
|
||||||
"original_context": {
|
"original_context": {
|
||||||
"session_id": "sessao-teste"
|
"session_id": "sessao-teste"
|
||||||
}
|
}
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** In the project, there is also a visual interface for testing:
|
> **Note:** The project also includes a visual interface for testing:
|
||||||
|
|
||||||
### 18.4. Upload Frontend for testing
|
### 18.5. Start the Frontend for Testing
|
||||||
|
|
||||||
|
Install before the [npm](https://nodejs.org/) and:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd agent_platform_oci
|
cd agent_platform_oci
|
||||||
cd agent_frontend
|
cd apps/agent_frontend
|
||||||
python -m http.server 5173
|
python -m http.server 5173
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:5173.
|
Open http://localhost:5173.
|
||||||
|
|
||||||
|
|
||||||
> **Note:** The frontend is ready to work with the **Agent Gateway**. In the project, see chapters 28 and 28.10 to try it out. Just remember to change the **Backend URL** to **http://localhost:8010** because 8010 is the port where the **Agent Gateway** will be listening.
|
> **Note:** The frontend is designed to work with the **Agent Gateway**. In the project documentation, see chapters 28 and 28.10 for usage examples. Just remember to change **Backend URL** to **http://localhost:8010**, as 8010 is the port where the **Agent Gateway** will be listening.
|
||||||
|
|
||||||
### 18.5. How to interpret MCP errors
|
### 18.6. How to Interpret MCP Errors
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Tool not found → tools.yaml or wrong tool name.
|
Tool not found → tools.yaml or tool name is incorrect.
|
||||||
Server not found → mcp_servers.yaml does not have the mcp_server indicated by the tool.
|
Server not found → mcp_servers.yaml does not contain the MCP server referenced by the tool.
|
||||||
Connection refused → MCP Server is not running or wrong port.
|
Connection refused → MCP Server is not running or the port is incorrect.
|
||||||
Required parameter missing → identity.yaml or mcp_parameter_mapping.yaml incorrect.
|
Missing required parameter → identity.yaml or mcp_parameter_mapping.yaml is incorrect.
|
||||||
Timeout → Slow MCP, wrong endpoint, VPN, DNS or real system unavailable.
|
Timeout → Slow MCP, wrong endpoint, VPN, DNS, or target system unavailable.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 18.7. Start the Agent Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/agent_gateway
|
||||||
|
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
export DEFAULT_AGENT_BACKEND_URL=http://localhost:8000
|
||||||
|
export AGENT_GATEWAY_GOVERNANCE_CONFIG=config/gateway_governance.yaml
|
||||||
|
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 9000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:9000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Test:
|
||||||
|
|
||||||
|
curl -X POST http://localhost:9000/gateway/message
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 19. Build with Docker
|
## 19. Build with Docker
|
||||||
@@ -8346,6 +8463,7 @@ Check:
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
ENABLE_LANGFUSE=true
|
ENABLE_LANGFUSE=true
|
||||||
|
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
|
||||||
LANGFUSE_PUBLIC_KEY=<public-key>
|
LANGFUSE_PUBLIC_KEY=<public-key>
|
||||||
LANGFUSE_SECRET_KEY=<secret-key>
|
LANGFUSE_SECRET_KEY=<secret-key>
|
||||||
LANGFUSE_HOST=http://localhost:3005
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
|
|||||||
14
apps/agent_gateway/app/config/governance_loader.py
Normal file
14
apps/agent_gateway/app/config/governance_loader.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def load_gateway_governance_config(path: str | None = None) -> dict[str, Any]:
|
||||||
|
config_path = Path(path or os.getenv("AGENT_GATEWAY_GOVERNANCE_CONFIG", "config/gateway_governance.yaml"))
|
||||||
|
if not config_path.exists():
|
||||||
|
return {}
|
||||||
|
return yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||||
1
apps/agent_gateway/app/governance/__init__.py
Normal file
1
apps/agent_gateway/app/governance/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
14
apps/agent_gateway/app/governance/audit.py
Normal file
14
apps/agent_gateway/app/governance/audit.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent_gateway.governance")
|
||||||
|
|
||||||
|
|
||||||
|
def audit_event(name: str, payload: dict[str, Any]) -> None:
|
||||||
|
safe = dict(payload)
|
||||||
|
if "message" in safe:
|
||||||
|
safe["message_len"] = len(str(safe.pop("message") or ""))
|
||||||
|
logger.info("%s %s", name, json.dumps(safe, ensure_ascii=False, default=str))
|
||||||
11
apps/agent_gateway/app/governance/evaluation_hooks.py
Normal file
11
apps/agent_gateway/app/governance/evaluation_hooks.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class EvaluationHooks:
|
||||||
|
def before_backend_call(self, request_payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return request_payload
|
||||||
|
|
||||||
|
def after_backend_call(self, response_payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return response_payload
|
||||||
66
apps/agent_gateway/app/governance/model_policies.py
Normal file
66
apps/agent_gateway/app/governance/model_policies.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ModelPolicyError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ModelPolicyResolver:
|
||||||
|
def __init__(self, config: dict[str, Any]):
|
||||||
|
self.config = config or {}
|
||||||
|
|
||||||
|
def resolve_profile(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
agent_id: str | None,
|
||||||
|
operation: str,
|
||||||
|
requested_profile: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
profiles = self.config.get("profiles", {}) or {}
|
||||||
|
operation_profiles = self.config.get("operation_profiles", {}) or {}
|
||||||
|
|
||||||
|
profile_name = requested_profile or operation_profiles.get(operation) or "default"
|
||||||
|
profile = profiles.get(profile_name)
|
||||||
|
if not profile:
|
||||||
|
raise ModelPolicyError(f"Model profile not found: {profile_name}")
|
||||||
|
|
||||||
|
self._validate_policy(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
profile_name=profile_name,
|
||||||
|
profile=profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"profile": profile_name,
|
||||||
|
"provider": profile.get("provider"),
|
||||||
|
"model": profile.get("model"),
|
||||||
|
"parameters": {
|
||||||
|
k: v for k, v in profile.items()
|
||||||
|
if k not in {"provider", "model"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _validate_policy(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
agent_id: str | None,
|
||||||
|
profile_name: str,
|
||||||
|
profile: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
policies = self.config.get("policies", {}) or {}
|
||||||
|
tenant_policies = policies.get("tenants", {}) or {}
|
||||||
|
tenant_policy = tenant_policies.get(tenant_id) or tenant_policies.get("default") or {}
|
||||||
|
|
||||||
|
allowed_profiles = tenant_policy.get("allowed_profiles")
|
||||||
|
if allowed_profiles and profile_name not in allowed_profiles:
|
||||||
|
raise ModelPolicyError(f"Profile not allowed for tenant={tenant_id}: {profile_name}")
|
||||||
|
|
||||||
|
allowed_providers = tenant_policy.get("allowed_providers")
|
||||||
|
provider = profile.get("provider")
|
||||||
|
if allowed_providers and provider not in allowed_providers:
|
||||||
|
raise ModelPolicyError(f"Provider not allowed for tenant={tenant_id}: {provider}")
|
||||||
35
apps/agent_gateway/app/governance/rate_limit.py
Normal file
35
apps/agent_gateway/app/governance/rate_limit.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitExceeded(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryRateLimiter:
|
||||||
|
def __init__(self, config: dict[str, Any]):
|
||||||
|
self.config = config or {}
|
||||||
|
self.events: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
def check(self, *, tenant_id: str, agent_id: str | None, channel: str | None) -> None:
|
||||||
|
default_limit = ((self.config.get("default") or {}).get("requests_per_minute")) or 600
|
||||||
|
agent_limits = self.config.get("agents") or {}
|
||||||
|
channel_limits = self.config.get("channels") or {}
|
||||||
|
|
||||||
|
limit = default_limit
|
||||||
|
if agent_id and agent_id in agent_limits:
|
||||||
|
limit = agent_limits[agent_id].get("requests_per_minute", limit)
|
||||||
|
if channel and channel in channel_limits:
|
||||||
|
limit = min(limit, channel_limits[channel].get("requests_per_minute", limit))
|
||||||
|
|
||||||
|
key = f"{tenant_id}:{agent_id or '*'}:{channel or '*'}"
|
||||||
|
now = time.time()
|
||||||
|
bucket = self.events[key]
|
||||||
|
while bucket and bucket[0] < now - 60:
|
||||||
|
bucket.popleft()
|
||||||
|
if len(bucket) >= int(limit):
|
||||||
|
raise RateLimitExceeded(f"Gateway rate limit exceeded for {key}: {limit}/min")
|
||||||
|
bucket.append(now)
|
||||||
14
apps/agent_gateway/app/governance/usage.py
Normal file
14
apps/agent_gateway/app/governance/usage.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class UsageRecorder:
|
||||||
|
def record_gateway_request(self, payload: dict[str, Any]) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def record_model_policy(self, payload: dict[str, Any]) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def record_backend_response(self, payload: dict[str, Any]) -> None:
|
||||||
|
return None
|
||||||
78
apps/agent_gateway/app/governance_middleware.py
Normal file
78
apps/agent_gateway/app/governance_middleware.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.config.governance_loader import load_gateway_governance_config
|
||||||
|
from app.governance.audit import audit_event
|
||||||
|
from app.governance.evaluation_hooks import EvaluationHooks
|
||||||
|
from app.governance.model_policies import ModelPolicyError, ModelPolicyResolver
|
||||||
|
from app.governance.rate_limit import InMemoryRateLimiter, RateLimitExceeded
|
||||||
|
from app.governance.usage import UsageRecorder
|
||||||
|
|
||||||
|
|
||||||
|
class AgentGatewayGovernance:
|
||||||
|
def __init__(self, config: dict[str, Any] | None = None):
|
||||||
|
self.config = config if config is not None else load_gateway_governance_config()
|
||||||
|
self.model_resolver = ModelPolicyResolver((self.config.get("model_governance") or {}))
|
||||||
|
self.rate_limiter = InMemoryRateLimiter((self.config.get("rate_limits") or {}))
|
||||||
|
self.usage = UsageRecorder()
|
||||||
|
self.eval_hooks = EvaluationHooks()
|
||||||
|
|
||||||
|
def prepare_backend_request(self, gateway_request: dict[str, Any]) -> tuple[dict[str, Any], dict[str, str]]:
|
||||||
|
tenant_id = gateway_request.get("tenant_id") or "default"
|
||||||
|
agent_id = gateway_request.get("agent_id")
|
||||||
|
channel = gateway_request.get("channel")
|
||||||
|
payload = gateway_request.get("payload") or {}
|
||||||
|
metadata = payload.setdefault("metadata", {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.rate_limiter.check(tenant_id=tenant_id, agent_id=agent_id, channel=channel)
|
||||||
|
|
||||||
|
model_policy = self.model_resolver.resolve_profile(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
operation=metadata.get("operation") or "agent.final_answer",
|
||||||
|
requested_profile=metadata.get("llm_profile"),
|
||||||
|
)
|
||||||
|
metadata["model_policy"] = model_policy
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"X-Agent-Gateway-Governance": "enabled",
|
||||||
|
"X-Model-Profile": str(model_policy.get("profile") or ""),
|
||||||
|
"X-Model-Provider": str(model_policy.get("provider") or ""),
|
||||||
|
"X-Model-Name": str(model_policy.get("model") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
audit_event("agent_gateway.request.governed", {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"channel": channel,
|
||||||
|
"model_policy": model_policy,
|
||||||
|
"request_id": metadata.get("request_id"),
|
||||||
|
"message": payload.get("message"),
|
||||||
|
})
|
||||||
|
|
||||||
|
self.usage.record_gateway_request({
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"channel": channel,
|
||||||
|
"metadata": metadata,
|
||||||
|
})
|
||||||
|
|
||||||
|
governed = self.eval_hooks.before_backend_call(gateway_request)
|
||||||
|
return governed, headers
|
||||||
|
|
||||||
|
except RateLimitExceeded as exc:
|
||||||
|
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
||||||
|
except ModelPolicyError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
def process_backend_response(self, response_payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
response_payload = self.eval_hooks.after_backend_call(response_payload)
|
||||||
|
self.usage.record_backend_response(response_payload)
|
||||||
|
audit_event("agent_gateway.response.completed", {
|
||||||
|
"metadata": response_payload.get("metadata") if isinstance(response_payload, dict) else {},
|
||||||
|
})
|
||||||
|
return response_payload
|
||||||
40
apps/agent_gateway/app/routes/governed_proxy_example.py
Normal file
40
apps/agent_gateway/app/routes/governed_proxy_example.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
|
from app.governance_middleware import AgentGatewayGovernance
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
governance = AgentGatewayGovernance()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/gateway/message/governed")
|
||||||
|
async def governed_gateway_message(request: Request):
|
||||||
|
"""Example governed proxy route.
|
||||||
|
|
||||||
|
Use as reference to patch the existing /gateway/message handler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
body: dict[str, Any] = await request.json()
|
||||||
|
backend_url = os.getenv("DEFAULT_AGENT_BACKEND_URL", "http://localhost:8000")
|
||||||
|
|
||||||
|
governed_body, headers = governance.prepare_backend_request(body)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=90) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{backend_url.rstrip('/')}/gateway/message",
|
||||||
|
json=governed_body,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return governance.process_backend_response(data)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise HTTPException(status_code=exc.response.status_code, detail=exc.response.text) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
@@ -2,7 +2,7 @@ default_backend: contas
|
|||||||
|
|
||||||
backends:
|
backends:
|
||||||
contas:
|
contas:
|
||||||
url: http://localhost:8001
|
url: http://localhost:8000
|
||||||
description: Backend responsável por faturas, contas, pagamentos, consumo, segunda via e contestação.
|
description: Backend responsável por faturas, contas, pagamentos, consumo, segunda via e contestação.
|
||||||
domains: [contas, fatura, pagamento, consumo, contestacao]
|
domains: [contas, fatura, pagamento, consumo, contestacao]
|
||||||
keywords: [fatura, conta, boleto, pagamento, consumo, segunda via, contestar, contestação, valor, cobrança]
|
keywords: [fatura, conta, boleto, pagamento, consumo, segunda via, contestar, contestação, valor, cobrança]
|
||||||
@@ -14,7 +14,7 @@ backends:
|
|||||||
default_agent_id: telecom_contas
|
default_agent_id: telecom_contas
|
||||||
|
|
||||||
ofertas:
|
ofertas:
|
||||||
url: http://localhost:8002
|
url: http://localhost:8001
|
||||||
description: Backend responsável por ofertas, planos, upgrades, retenção e contratação.
|
description: Backend responsável por ofertas, planos, upgrades, retenção e contratação.
|
||||||
domains: [ofertas, planos, retenção, contratação]
|
domains: [ofertas, planos, retenção, contratação]
|
||||||
keywords: [oferta, plano, contratar, upgrade, desconto, promoção, pacote, retenção, cancelar serviço]
|
keywords: [oferta, plano, contratar, upgrade, desconto, promoção, pacote, retenção, cancelar serviço]
|
||||||
@@ -26,7 +26,7 @@ backends:
|
|||||||
default_agent_id: telecom_ofertas
|
default_agent_id: telecom_ofertas
|
||||||
|
|
||||||
suporte:
|
suporte:
|
||||||
url: http://localhost:8003
|
url: http://localhost:8002
|
||||||
description: Backend responsável por suporte técnico, falhas, rede, internet e atendimento operacional.
|
description: Backend responsável por suporte técnico, falhas, rede, internet e atendimento operacional.
|
||||||
domains: [suporte, técnico, rede, internet]
|
domains: [suporte, técnico, rede, internet]
|
||||||
keywords: [internet, sinal, rede, suporte, técnico, problema, falha, sem conexão, modem]
|
keywords: [internet, sinal, rede, suporte, técnico, problema, falha, sem conexão, modem]
|
||||||
|
|||||||
56
apps/agent_gateway/config/gateway_governance.yaml
Normal file
56
apps/agent_gateway/config/gateway_governance.yaml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
model_governance:
|
||||||
|
profiles:
|
||||||
|
default:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0.2
|
||||||
|
max_tokens: 2048
|
||||||
|
|
||||||
|
router:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 500
|
||||||
|
|
||||||
|
judge:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 800
|
||||||
|
|
||||||
|
operation_profiles:
|
||||||
|
router.intent: router
|
||||||
|
agent.final_answer: default
|
||||||
|
judge.response_quality: judge
|
||||||
|
|
||||||
|
policies:
|
||||||
|
tenants:
|
||||||
|
default:
|
||||||
|
allowed_providers:
|
||||||
|
- oci_openai
|
||||||
|
- oci_sdk
|
||||||
|
- mock
|
||||||
|
allowed_profiles:
|
||||||
|
- default
|
||||||
|
- router
|
||||||
|
- judge
|
||||||
|
|
||||||
|
rate_limits:
|
||||||
|
default:
|
||||||
|
requests_per_minute: 600
|
||||||
|
channels:
|
||||||
|
whatsapp:
|
||||||
|
requests_per_minute: 120
|
||||||
|
web:
|
||||||
|
requests_per_minute: 300
|
||||||
|
agents:
|
||||||
|
telecom_contas:
|
||||||
|
requests_per_minute: 180
|
||||||
|
|
||||||
|
backend_headers:
|
||||||
|
propagate_model_policy: true
|
||||||
|
propagate_trace_context: true
|
||||||
|
|
||||||
|
evaluation:
|
||||||
|
enabled: true
|
||||||
|
sample_rate: 1.0
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
FROM python:3.11-slim
|
|
||||||
WORKDIR /app
|
|
||||||
COPY apps/ai_gateway/requirements.txt /app/requirements.txt
|
|
||||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
|
||||||
COPY apps/ai_gateway /app
|
|
||||||
EXPOSE 9100
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9100"]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# AI Gateway
|
|
||||||
|
|
||||||
Camada desacoplada para abstração, roteamento, controle e governança de chamadas LLM.
|
|
||||||
|
|
||||||
Este componente não substitui o Agent Runtime. Ele centraliza políticas de modelo, seleção de provider, fallback, telemetria e controles corporativos.
|
|
||||||
|
|
||||||
## Rotas
|
|
||||||
|
|
||||||
- `GET /health`
|
|
||||||
- `GET /models`
|
|
||||||
- `POST /v1/chat/completions`
|
|
||||||
|
|
||||||
## Execução local
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/ai_gateway
|
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 9100 --reload
|
|
||||||
```
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import FastAPI, HTTPException
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class Message(BaseModel):
|
|
||||||
role: Literal["system", "user", "assistant", "tool"]
|
|
||||||
content: str | list[Any] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ChatCompletionRequest(BaseModel):
|
|
||||||
model: str | None = None
|
|
||||||
messages: list[Message]
|
|
||||||
temperature: float | None = None
|
|
||||||
max_tokens: int | None = None
|
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class ModelRoute(BaseModel):
|
|
||||||
provider: str
|
|
||||||
model: str
|
|
||||||
base_url: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Agent Framework OCI - AI Gateway", version="0.1.0")
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_route(requested_model: str | None, metadata: dict[str, Any]) -> ModelRoute:
|
|
||||||
provider = metadata.get("provider") or os.getenv("AI_GATEWAY_DEFAULT_PROVIDER", "oci_openai")
|
|
||||||
model = requested_model or metadata.get("profile_model") or os.getenv("AI_GATEWAY_DEFAULT_MODEL", "openai.gpt-4.1")
|
|
||||||
base_url = os.getenv("AI_GATEWAY_OPENAI_COMPAT_BASE_URL")
|
|
||||||
return ModelRoute(provider=provider, model=model, base_url=base_url)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health() -> dict[str, Any]:
|
|
||||||
return {"status": "ok", "component": "ai_gateway"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/models")
|
|
||||||
def models() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"default_provider": os.getenv("AI_GATEWAY_DEFAULT_PROVIDER", "oci_openai"),
|
|
||||||
"default_model": os.getenv("AI_GATEWAY_DEFAULT_MODEL", "openai.gpt-4.1"),
|
|
||||||
"purpose": "model routing, policy control and LLM abstraction",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/chat/completions")
|
|
||||||
async def chat_completions(payload: ChatCompletionRequest) -> dict[str, Any]:
|
|
||||||
route = resolve_route(payload.model, payload.metadata)
|
|
||||||
# Safe default: when no upstream is configured, return route decision only.
|
|
||||||
# Production deployments should configure AI_GATEWAY_OPENAI_COMPAT_BASE_URL and credentials.
|
|
||||||
if not route.base_url:
|
|
||||||
return {
|
|
||||||
"gateway": "ai_gateway",
|
|
||||||
"mode": "dry_run",
|
|
||||||
"route": route.model_dump(),
|
|
||||||
"message": "No upstream base URL configured. Set AI_GATEWAY_OPENAI_COMPAT_BASE_URL to proxy requests.",
|
|
||||||
}
|
|
||||||
|
|
||||||
api_key = os.getenv("AI_GATEWAY_API_KEY") or os.getenv("OCI_GENAI_API_KEY") or os.getenv("OPENAI_API_KEY")
|
|
||||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
||||||
body = payload.model_dump(exclude_none=True)
|
|
||||||
body["model"] = route.model
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=float(os.getenv("AI_GATEWAY_TIMEOUT_SECONDS", "60"))) as client:
|
|
||||||
resp = await client.post(f"{route.base_url.rstrip('/')}/chat/completions", json=body, headers=headers)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
if isinstance(data, dict):
|
|
||||||
data.setdefault("gateway", "ai_gateway")
|
|
||||||
data.setdefault("route", route.model_dump())
|
|
||||||
return data
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=f"AI upstream request failed: {exc}") from exc
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
fastapi>=0.111
|
|
||||||
uvicorn[standard]>=0.30
|
|
||||||
pydantic>=2
|
|
||||||
httpx>=0.27
|
|
||||||
PyYAML>=6
|
|
||||||
3
apps/mcp_gateway/.env.example
Normal file
3
apps/mcp_gateway/.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
|
||||||
|
MCP_GATEWAY_HOST=0.0.0.0
|
||||||
|
MCP_GATEWAY_PORT=8300
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY apps/mcp_gateway/requirements.txt /app/requirements.txt
|
COPY apps/mcp_gateway/requirements.txt /app/requirements.txt
|
||||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
COPY apps/mcp_gateway /app
|
|
||||||
EXPOSE 9200
|
COPY apps/mcp_gateway/app /app/app
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9200"]
|
COPY apps/mcp_gateway/config /app/config
|
||||||
|
|
||||||
|
ENV MCP_GATEWAY_CONFIG_PATH=/app/config/mcp_gateway.yaml
|
||||||
|
|
||||||
|
EXPOSE 8300
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8300"]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -1,74 +1,422 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, Header, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class ToolInvokeRequest(BaseModel):
|
class BusinessContext(BaseModel):
|
||||||
|
customer_key: str | None = None
|
||||||
|
contract_key: str | None = None
|
||||||
|
interaction_key: str | None = None
|
||||||
|
account_key: str | None = None
|
||||||
|
resource_key: str | None = None
|
||||||
|
session_key: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolInvocation(BaseModel):
|
||||||
|
tenant_id: str = "default"
|
||||||
|
agent_id: str
|
||||||
|
channel: str | None = None
|
||||||
|
tool_name: str
|
||||||
arguments: dict[str, Any] = Field(default_factory=dict)
|
arguments: dict[str, Any] = Field(default_factory=dict)
|
||||||
context: dict[str, Any] = Field(default_factory=dict)
|
business_context: BusinessContext = Field(default_factory=BusinessContext)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Agent Framework OCI - MCP Gateway", version="0.1.0")
|
class ToolResult(BaseModel):
|
||||||
|
tool_name: str
|
||||||
|
version: str | None = None
|
||||||
|
ok: bool
|
||||||
|
data: Any = None
|
||||||
|
error: str | None = None
|
||||||
|
cache: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
latency_ms: int = 0
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoveryResult(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
servers_scanned: int = 0
|
||||||
|
tools_discovered: int = 0
|
||||||
|
errors: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
tools: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> dict[str, Any]:
|
def load_config() -> dict[str, Any]:
|
||||||
config_path = Path(os.getenv("MCP_GATEWAY_CONFIG", "config/mcp_servers.yaml"))
|
path = Path(os.getenv("MCP_GATEWAY_CONFIG_PATH", "config/mcp_gateway.yaml"))
|
||||||
if not config_path.exists():
|
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
return {"servers": {}}
|
|
||||||
return yaml.safe_load(config_path.read_text()) or {"servers": {}}
|
|
||||||
|
|
||||||
|
|
||||||
def find_tool(tool_name: str) -> tuple[str, dict[str, Any]] | None:
|
config = load_config()
|
||||||
cfg = load_config()
|
static_tools: dict[str, dict[str, Any]] = dict(config.get("tools") or {})
|
||||||
for server_name, server in (cfg.get("servers") or {}).items():
|
discovered_tools: dict[str, dict[str, Any]] = {}
|
||||||
if tool_name in (server.get("tools") or []):
|
discovery_state: dict[str, Any] = {"last_sync": None, "errors": [], "tools": []}
|
||||||
return server_name, server
|
cache: dict[str, tuple[float, Any]] = {}
|
||||||
return None
|
app = FastAPI(title="Agent Platform OCI - MCP Gateway", version="1.1.0")
|
||||||
|
|
||||||
|
|
||||||
|
def audit(name: str, payload: dict[str, Any]) -> None:
|
||||||
|
print(json.dumps({"event": name, **payload}, ensure_ascii=False, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def auth_check(authorization: str | None) -> None:
|
||||||
|
auth = config.get("auth") or {}
|
||||||
|
if not auth.get("enabled", False):
|
||||||
|
return
|
||||||
|
if not authorization or not authorization.lower().startswith("bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Missing MCP Gateway bearer token")
|
||||||
|
token = authorization.split(" ", 1)[1]
|
||||||
|
if token not in (auth.get("static_tokens") or {}):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid MCP Gateway token")
|
||||||
|
|
||||||
|
|
||||||
|
def all_tools() -> dict[str, dict[str, Any]]:
|
||||||
|
merged = dict(discovered_tools)
|
||||||
|
# Static config wins over discovery so operators can override metadata safely.
|
||||||
|
merged.update(static_tools)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def map_arguments(tool_name: str, args: dict[str, Any], bc: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result = dict(args or {})
|
||||||
|
for source, target in ((config.get("parameter_mapping") or {}).get(tool_name) or {}).items():
|
||||||
|
if bc.get(source) is not None and target not in result:
|
||||||
|
result[target] = bc[source]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def cache_key(tenant_id: str, agent_id: str, tool_name: str, version: str, args: dict[str, Any]) -> str:
|
||||||
|
digest = hashlib.sha256(json.dumps(args, sort_keys=True, ensure_ascii=False, default=str).encode()).hexdigest()
|
||||||
|
return f"mcp:{tenant_id}:{agent_id}:{tool_name}:{version}:{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
async def post_with_retry(url: str, payload: dict[str, Any], timeout: int, retry: dict[str, Any]) -> Any:
|
||||||
|
attempts = int(retry.get("max_attempts", 1)) if retry.get("enabled", False) else 1
|
||||||
|
backoff_ms = int(retry.get("backoff_ms", 250))
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for attempt in range(attempts):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.post(url, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except Exception as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if attempt < attempts - 1:
|
||||||
|
await asyncio.sleep(backoff_ms / 1000)
|
||||||
|
raise RuntimeError(str(last_exc))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_json(url: str, timeout: int) -> Any:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def build_server_payload(tool_name: str, args: dict[str, Any], server: dict[str, Any], tool: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Build the payload expected by the downstream MCP server.
|
||||||
|
|
||||||
|
Supported protocols:
|
||||||
|
- legacy_http/framework_http: POST /mcp/tools/call with {tool_name, arguments}
|
||||||
|
- direct_http: POST to the configured endpoint with only the argument object
|
||||||
|
"""
|
||||||
|
protocol = str(tool.get("protocol") or server.get("protocol") or "legacy_http")
|
||||||
|
if protocol in {"legacy_http", "framework_http", "fastmcp_http"}:
|
||||||
|
return {"tool_name": tool_name, "arguments": args or {}}
|
||||||
|
return args or {}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_server_response(data: Any) -> tuple[bool, Any, str | None, dict[str, Any]]:
|
||||||
|
if isinstance(data, dict) and ("ok" in data or "result" in data or "error" in data):
|
||||||
|
ok = bool(data.get("ok", not data.get("error")))
|
||||||
|
return ok, data.get("result", data.get("data")), data.get("error"), data.get("metadata") or {}
|
||||||
|
return True, data, None, {}
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_name(raw: dict[str, Any]) -> str | None:
|
||||||
|
return raw.get("name") or raw.get("tool_name") or raw.get("id")
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_schema(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
schema = raw.get("input_schema") or raw.get("inputSchema") or raw.get("schema") or {}
|
||||||
|
if isinstance(schema, dict):
|
||||||
|
return schema
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tools_from_catalog(catalog: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Normalize common MCP/FastMCP/custom catalog shapes.
|
||||||
|
|
||||||
|
Accepted examples:
|
||||||
|
- {"tools": [{"name": "x", "description": "...", "input_schema": {...}}]}
|
||||||
|
- [{"name": "x", "inputSchema": {...}}]
|
||||||
|
- {"server_id": "s", "capabilities": {"tools": [...]}}
|
||||||
|
- {"data": {"tools": [...]}}
|
||||||
|
"""
|
||||||
|
if isinstance(catalog, list):
|
||||||
|
return [x for x in catalog if isinstance(x, dict)]
|
||||||
|
if not isinstance(catalog, dict):
|
||||||
|
return []
|
||||||
|
if isinstance(catalog.get("tools"), list):
|
||||||
|
return [x for x in catalog["tools"] if isinstance(x, dict)]
|
||||||
|
data = catalog.get("data")
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("tools"), list):
|
||||||
|
return [x for x in data["tools"] if isinstance(x, dict)]
|
||||||
|
caps = catalog.get("capabilities")
|
||||||
|
if isinstance(caps, dict) and isinstance(caps.get("tools"), list):
|
||||||
|
return [x for x in caps["tools"] if isinstance(x, dict)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_urls(server_id: str, server: dict[str, Any]) -> list[str]:
|
||||||
|
if server.get("manifest_url"):
|
||||||
|
return [str(server["manifest_url"])]
|
||||||
|
base = str(server.get("url", "")).rstrip("/")
|
||||||
|
if server.get("catalog_endpoint"):
|
||||||
|
return [f"{base}{server['catalog_endpoint']}"]
|
||||||
|
discovery = config.get("discovery") or {}
|
||||||
|
endpoints = server.get("discovery_endpoints") or discovery.get("default_catalog_endpoints") or [
|
||||||
|
"/.well-known/mcp-server.json",
|
||||||
|
"/manifest",
|
||||||
|
"/mcp/tools",
|
||||||
|
"/tools",
|
||||||
|
"/v1/tools",
|
||||||
|
]
|
||||||
|
return [f"{base}{endpoint}" for endpoint in endpoints]
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint_for_tool(server: dict[str, Any], raw_tool: dict[str, Any]) -> str:
|
||||||
|
if raw_tool.get("endpoint"):
|
||||||
|
return str(raw_tool["endpoint"])
|
||||||
|
protocol = str(raw_tool.get("protocol") or server.get("protocol") or "legacy_http")
|
||||||
|
if protocol in {"legacy_http", "framework_http", "fastmcp_http"}:
|
||||||
|
return str(server.get("invoke_endpoint") or "/tools/call")
|
||||||
|
name = _tool_name(raw_tool) or ""
|
||||||
|
return str(server.get("invoke_endpoint") or f"/tools/{name}")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_discovered_tool(server_id: str, server: dict[str, Any], raw_tool: dict[str, Any]) -> tuple[str, dict[str, Any]] | None:
|
||||||
|
name = _tool_name(raw_tool)
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
discovery = config.get("discovery") or {}
|
||||||
|
defaults = discovery.get("tool_defaults") or {}
|
||||||
|
tool_cfg: dict[str, Any] = {
|
||||||
|
"version": str(raw_tool.get("version") or defaults.get("version") or "1.0.0"),
|
||||||
|
"server": server_id,
|
||||||
|
"endpoint": _endpoint_for_tool(server, raw_tool),
|
||||||
|
"protocol": raw_tool.get("protocol") or server.get("protocol") or defaults.get("protocol") or "legacy_http",
|
||||||
|
"enabled": bool(raw_tool.get("enabled", defaults.get("enabled", True))),
|
||||||
|
"idempotent": bool(raw_tool.get("idempotent", defaults.get("idempotent", True))),
|
||||||
|
"cache_ttl_seconds": int(raw_tool.get("cache_ttl_seconds", defaults.get("cache_ttl_seconds", 0)) or 0),
|
||||||
|
"timeout_seconds": int(raw_tool.get("timeout_seconds", server.get("timeout_seconds", defaults.get("timeout_seconds", 30))) or 30),
|
||||||
|
"retry": raw_tool.get("retry") or defaults.get("retry") or {"enabled": False},
|
||||||
|
"allowed_agents": raw_tool.get("allowed_agents") or defaults.get("allowed_agents") or [],
|
||||||
|
"allowed_channels": raw_tool.get("allowed_channels") or defaults.get("allowed_channels") or [],
|
||||||
|
"required_business_keys": raw_tool.get("required_business_keys") or defaults.get("required_business_keys") or [],
|
||||||
|
"description": raw_tool.get("description") or raw_tool.get("doc") or "",
|
||||||
|
"input_schema": _tool_schema(raw_tool),
|
||||||
|
"source": "discovery",
|
||||||
|
}
|
||||||
|
return name, tool_cfg
|
||||||
|
|
||||||
|
|
||||||
|
async def discover_server(server_id: str, server: dict[str, Any]) -> tuple[list[tuple[str, dict[str, Any]]], list[dict[str, Any]]]:
|
||||||
|
errors: list[dict[str, Any]] = []
|
||||||
|
if not server.get("enabled", True):
|
||||||
|
return [], []
|
||||||
|
if not server.get("discover", False):
|
||||||
|
return [], []
|
||||||
|
timeout = int((config.get("discovery") or {}).get("timeout_seconds", server.get("timeout_seconds", 10)) or 10)
|
||||||
|
for url in _catalog_urls(server_id, server):
|
||||||
|
try:
|
||||||
|
catalog = await get_json(url, timeout=timeout)
|
||||||
|
raw_tools = _extract_tools_from_catalog(catalog)
|
||||||
|
normalized = []
|
||||||
|
for raw in raw_tools:
|
||||||
|
item = normalize_discovered_tool(server_id, server, raw)
|
||||||
|
if item:
|
||||||
|
normalized.append(item)
|
||||||
|
if normalized:
|
||||||
|
return normalized, errors
|
||||||
|
errors.append({"server": server_id, "url": url, "error": "catalog returned no tools"})
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append({"server": server_id, "url": url, "error": str(exc)})
|
||||||
|
return [], errors
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_discovery() -> DiscoveryResult:
|
||||||
|
discovered_tools.clear()
|
||||||
|
errors: list[dict[str, Any]] = []
|
||||||
|
tools_count = 0
|
||||||
|
scanned = 0
|
||||||
|
for server_id, server in (config.get("servers") or {}).items():
|
||||||
|
if not isinstance(server, dict) or not server.get("discover", False):
|
||||||
|
continue
|
||||||
|
scanned += 1
|
||||||
|
normalized, server_errors = await discover_server(server_id, server)
|
||||||
|
errors.extend(server_errors)
|
||||||
|
for name, tool_cfg in normalized:
|
||||||
|
discovered_tools[name] = tool_cfg
|
||||||
|
tools_count += 1
|
||||||
|
discovery_state.update({
|
||||||
|
"last_sync": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"errors": errors,
|
||||||
|
"tools": sorted(discovered_tools.keys()),
|
||||||
|
})
|
||||||
|
audit("mcp.discovery.completed", {"servers_scanned": scanned, "tools_discovered": tools_count, "errors": len(errors)})
|
||||||
|
return DiscoveryResult(ok=not errors, servers_scanned=scanned, tools_discovered=tools_count, errors=errors, tools=sorted(discovered_tools.keys()))
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_discovery() -> None:
|
||||||
|
discovery = config.get("discovery") or {}
|
||||||
|
if discovery.get("enabled", False) and discovery.get("sync_on_startup", True):
|
||||||
|
try:
|
||||||
|
await sync_discovery()
|
||||||
|
except Exception as exc:
|
||||||
|
audit("mcp.discovery.failed", {"error": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict[str, Any]:
|
async def health():
|
||||||
return {"status": "ok", "component": "mcp_gateway"}
|
return {"status": "ok", "service": "mcp_gateway", "version": "1.1.0"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/tools")
|
@app.get("/ready")
|
||||||
def tools() -> dict[str, Any]:
|
async def ready():
|
||||||
cfg = load_config()
|
enabled_tools = [k for k, v in all_tools().items() if v.get("enabled", True)]
|
||||||
result = []
|
return {"status": "ready", "tools": enabled_tools, "discovery": discovery_state}
|
||||||
for server_name, server in (cfg.get("servers") or {}).items():
|
|
||||||
for tool in server.get("tools") or []:
|
|
||||||
result.append({"name": tool, "server": server_name})
|
|
||||||
return {"tools": result}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/tools/{tool_name}/invoke")
|
@app.get("/v1/tools")
|
||||||
async def invoke_tool(tool_name: str, payload: ToolInvokeRequest) -> dict[str, Any]:
|
async def tools():
|
||||||
found = find_tool(tool_name)
|
return {"tools": [{"name": name, **cfg} for name, cfg in all_tools().items()]}
|
||||||
if not found:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Tool not registered in MCP Gateway: {tool_name}")
|
|
||||||
server_name, server = found
|
@app.get("/v1/tools/{tool_name}")
|
||||||
base_url = server.get("base_url")
|
async def tool_detail(tool_name: str):
|
||||||
if not base_url:
|
tool = all_tools().get(tool_name)
|
||||||
raise HTTPException(status_code=500, detail=f"Server {server_name} has no base_url")
|
if not tool:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Tool not found: {tool_name}")
|
||||||
|
return {"name": tool_name, **tool}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/discovery/servers")
|
||||||
|
async def discovery_servers():
|
||||||
|
servers = []
|
||||||
|
for server_id, server in (config.get("servers") or {}).items():
|
||||||
|
servers.append({
|
||||||
|
"id": server_id,
|
||||||
|
"enabled": server.get("enabled", True),
|
||||||
|
"discover": server.get("discover", False),
|
||||||
|
"url": server.get("url"),
|
||||||
|
"manifest_url": server.get("manifest_url"),
|
||||||
|
"catalog_endpoint": server.get("catalog_endpoint"),
|
||||||
|
})
|
||||||
|
return {"servers": servers, "state": discovery_state}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/discovery/sync", response_model=DiscoveryResult)
|
||||||
|
async def discovery_sync(authorization: str | None = Header(default=None)):
|
||||||
|
auth_check(authorization)
|
||||||
|
return await sync_discovery()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/tools/{tool_name}/invoke", response_model=ToolResult)
|
||||||
|
async def invoke(tool_name: str, invocation: ToolInvocation, authorization: str | None = Header(default=None)):
|
||||||
|
started = time.perf_counter()
|
||||||
|
auth_check(authorization)
|
||||||
|
|
||||||
|
tool = all_tools().get(tool_name)
|
||||||
|
if not tool or not tool.get("enabled", True):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Tool not found or disabled: {tool_name}")
|
||||||
|
|
||||||
|
if invocation.tool_name != tool_name:
|
||||||
|
raise HTTPException(status_code=422, detail="Path tool_name and body tool_name differ")
|
||||||
|
|
||||||
|
allowed_agents = tool.get("allowed_agents") or []
|
||||||
|
if allowed_agents and invocation.agent_id not in allowed_agents:
|
||||||
|
raise HTTPException(status_code=403, detail=f"Agent not allowed: {invocation.agent_id}")
|
||||||
|
|
||||||
|
allowed_channels = tool.get("allowed_channels") or []
|
||||||
|
if invocation.channel and allowed_channels and invocation.channel not in allowed_channels:
|
||||||
|
raise HTTPException(status_code=403, detail=f"Channel not allowed: {invocation.channel}")
|
||||||
|
|
||||||
|
bc = invocation.business_context.model_dump()
|
||||||
|
missing = [k for k in tool.get("required_business_keys", []) if not bc.get(k)]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(status_code=422, detail={"missing_business_keys": missing})
|
||||||
|
|
||||||
|
version = str(tool.get("version", "1.0.0"))
|
||||||
|
args = map_arguments(tool_name, invocation.arguments, bc)
|
||||||
|
|
||||||
|
ttl = int(tool.get("cache_ttl_seconds", 0) or 0)
|
||||||
|
ck = None
|
||||||
|
if tool.get("idempotent", False) and ttl > 0:
|
||||||
|
ck = cache_key(invocation.tenant_id, invocation.agent_id, tool_name, version, args)
|
||||||
|
cached = cache.get(ck)
|
||||||
|
if cached and cached[0] > time.time():
|
||||||
|
audit("mcp.cache.hit", {"tool": tool_name, "agent_id": invocation.agent_id})
|
||||||
|
return ToolResult(
|
||||||
|
tool_name=tool_name,
|
||||||
|
version=version,
|
||||||
|
ok=True,
|
||||||
|
data=cached[1],
|
||||||
|
cache={"hit": True, "key": ck, "ttl_seconds": ttl},
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
)
|
||||||
|
|
||||||
|
server = (config.get("servers") or {}).get(tool.get("server"))
|
||||||
|
if not server or not server.get("enabled", True):
|
||||||
|
raise HTTPException(status_code=503, detail=f"MCP server unavailable: {tool.get('server')}")
|
||||||
|
|
||||||
|
url = f"{server['url'].rstrip('/')}{tool.get('endpoint')}"
|
||||||
|
audit("mcp.tool.started", {"tool": tool_name, "version": version, "agent_id": invocation.agent_id, "server": tool.get("server")})
|
||||||
|
|
||||||
# Conventional endpoint. Concrete MCP servers may adapt this via an adapter later.
|
|
||||||
url = f"{base_url.rstrip('/')}/tools/{tool_name}/invoke"
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=float(os.getenv("MCP_GATEWAY_TIMEOUT_SECONDS", "30"))) as client:
|
raw_data = await post_with_retry(
|
||||||
resp = await client.post(url, json=payload.model_dump())
|
url=url,
|
||||||
resp.raise_for_status()
|
payload=build_server_payload(tool_name, args, server, tool),
|
||||||
data = resp.json()
|
timeout=int(tool.get("timeout_seconds") or server.get("timeout_seconds") or 30),
|
||||||
if isinstance(data, dict):
|
retry=tool.get("retry") or {},
|
||||||
data.setdefault("gateway", "mcp_gateway")
|
)
|
||||||
data.setdefault("server", server_name)
|
ok, data, error, server_metadata = normalize_server_response(raw_data)
|
||||||
data.setdefault("tool", tool_name)
|
if ck and ttl > 0 and ok:
|
||||||
return data
|
cache[ck] = (time.time() + ttl, data)
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=f"MCP upstream request failed: {exc}") from exc
|
latency_ms = int((time.perf_counter() - started) * 1000)
|
||||||
|
audit("mcp.tool.completed", {"tool": tool_name, "latency_ms": latency_ms, "ok": ok})
|
||||||
|
return ToolResult(
|
||||||
|
tool_name=tool_name,
|
||||||
|
version=version,
|
||||||
|
ok=ok,
|
||||||
|
data=data,
|
||||||
|
error=error,
|
||||||
|
cache={"hit": False, "key": ck, "ttl_seconds": ttl},
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
metadata={"server": tool.get("server"), "source": tool.get("source", "static"), **server_metadata},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
latency_ms = int((time.perf_counter() - started) * 1000)
|
||||||
|
audit("mcp.tool.failed", {"tool": tool_name, "latency_ms": latency_ms, "error": str(exc)})
|
||||||
|
return ToolResult(
|
||||||
|
tool_name=tool_name,
|
||||||
|
version=version,
|
||||||
|
ok=False,
|
||||||
|
error=str(exc),
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
metadata={"server": tool.get("server"), "source": tool.get("source", "static")},
|
||||||
|
)
|
||||||
|
|||||||
216
apps/mcp_gateway/config/mcp_gateway.yaml
Normal file
216
apps/mcp_gateway/config/mcp_gateway.yaml
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# Dedicated MCP Gateway configuration.
|
||||||
|
# The agent backend/framework calls this gateway; this gateway calls the final MCP servers.
|
||||||
|
|
||||||
|
|
||||||
|
# Discovery allows the gateway to sync tool catalogs from registered MCP servers.
|
||||||
|
# Static tools below remain supported and override discovered tools with the same name.
|
||||||
|
discovery:
|
||||||
|
enabled: true
|
||||||
|
sync_on_startup: true
|
||||||
|
timeout_seconds: 10
|
||||||
|
default_catalog_endpoints:
|
||||||
|
- /.well-known/mcp-server.json
|
||||||
|
- /manifest
|
||||||
|
- /mcp/tools
|
||||||
|
- /tools/list
|
||||||
|
- /tools
|
||||||
|
- /v1/tools
|
||||||
|
tool_defaults:
|
||||||
|
version: 1.0.0
|
||||||
|
protocol: legacy_http
|
||||||
|
enabled: true
|
||||||
|
idempotent: true
|
||||||
|
cache_ttl_seconds: 300
|
||||||
|
timeout_seconds: 30
|
||||||
|
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
|
||||||
|
allowed_agents: []
|
||||||
|
allowed_channels: []
|
||||||
|
required_business_keys: []
|
||||||
|
|
||||||
|
servers:
|
||||||
|
telecom:
|
||||||
|
enabled: true
|
||||||
|
discover: false
|
||||||
|
protocol: legacy_http
|
||||||
|
transport: http
|
||||||
|
# Local run: uvicorn mcp.servers.telecom_mcp_server.main:app --port 8100
|
||||||
|
url: http://localhost:8100/mcp
|
||||||
|
timeout_seconds: 30
|
||||||
|
|
||||||
|
retail:
|
||||||
|
enabled: true
|
||||||
|
discover: false
|
||||||
|
protocol: legacy_http
|
||||||
|
transport: http
|
||||||
|
# Local run: uvicorn mcp.servers.retail_mcp_server.main:app --port 8200
|
||||||
|
url: http://localhost:8200/mcp
|
||||||
|
timeout_seconds: 30
|
||||||
|
|
||||||
|
tools:
|
||||||
|
consultar_fatura:
|
||||||
|
version: 1.0.0
|
||||||
|
server: telecom
|
||||||
|
endpoint: /tools/call
|
||||||
|
protocol: legacy_http
|
||||||
|
enabled: true
|
||||||
|
idempotent: true
|
||||||
|
cache_ttl_seconds: 300
|
||||||
|
timeout_seconds: 30
|
||||||
|
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
|
||||||
|
allowed_agents: []
|
||||||
|
allowed_channels: []
|
||||||
|
required_business_keys: []
|
||||||
|
|
||||||
|
consultar_pagamentos:
|
||||||
|
version: 1.0.0
|
||||||
|
server: telecom
|
||||||
|
endpoint: /tools/call
|
||||||
|
protocol: legacy_http
|
||||||
|
enabled: true
|
||||||
|
idempotent: true
|
||||||
|
cache_ttl_seconds: 300
|
||||||
|
timeout_seconds: 30
|
||||||
|
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
|
||||||
|
allowed_agents: []
|
||||||
|
allowed_channels: []
|
||||||
|
required_business_keys: []
|
||||||
|
|
||||||
|
consultar_plano:
|
||||||
|
version: 1.0.0
|
||||||
|
server: telecom
|
||||||
|
endpoint: /tools/call
|
||||||
|
protocol: legacy_http
|
||||||
|
enabled: true
|
||||||
|
idempotent: true
|
||||||
|
cache_ttl_seconds: 300
|
||||||
|
timeout_seconds: 30
|
||||||
|
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
|
||||||
|
allowed_agents: []
|
||||||
|
allowed_channels: []
|
||||||
|
required_business_keys: []
|
||||||
|
|
||||||
|
listar_servicos:
|
||||||
|
version: 1.0.0
|
||||||
|
server: telecom
|
||||||
|
endpoint: /tools/call
|
||||||
|
protocol: legacy_http
|
||||||
|
enabled: true
|
||||||
|
idempotent: true
|
||||||
|
cache_ttl_seconds: 300
|
||||||
|
timeout_seconds: 30
|
||||||
|
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
|
||||||
|
allowed_agents: []
|
||||||
|
allowed_channels: []
|
||||||
|
required_business_keys: []
|
||||||
|
|
||||||
|
consultar_pedido:
|
||||||
|
version: 1.0.0
|
||||||
|
server: retail
|
||||||
|
endpoint: /tools/call
|
||||||
|
protocol: legacy_http
|
||||||
|
enabled: true
|
||||||
|
idempotent: true
|
||||||
|
cache_ttl_seconds: 300
|
||||||
|
timeout_seconds: 30
|
||||||
|
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
|
||||||
|
allowed_agents: []
|
||||||
|
allowed_channels: []
|
||||||
|
required_business_keys: []
|
||||||
|
|
||||||
|
consultar_entrega:
|
||||||
|
version: 1.0.0
|
||||||
|
server: retail
|
||||||
|
endpoint: /tools/call
|
||||||
|
protocol: legacy_http
|
||||||
|
enabled: true
|
||||||
|
idempotent: true
|
||||||
|
cache_ttl_seconds: 300
|
||||||
|
timeout_seconds: 30
|
||||||
|
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
|
||||||
|
allowed_agents: []
|
||||||
|
allowed_channels: []
|
||||||
|
required_business_keys: []
|
||||||
|
|
||||||
|
solicitar_troca:
|
||||||
|
version: 1.0.0
|
||||||
|
server: retail
|
||||||
|
endpoint: /tools/call
|
||||||
|
protocol: legacy_http
|
||||||
|
enabled: true
|
||||||
|
idempotent: false
|
||||||
|
cache_ttl_seconds: 0
|
||||||
|
timeout_seconds: 30
|
||||||
|
retry: {enabled: false}
|
||||||
|
allowed_agents: []
|
||||||
|
allowed_channels: []
|
||||||
|
required_business_keys: []
|
||||||
|
|
||||||
|
solicitar_devolucao:
|
||||||
|
version: 1.0.0
|
||||||
|
server: retail
|
||||||
|
endpoint: /tools/call
|
||||||
|
protocol: legacy_http
|
||||||
|
enabled: true
|
||||||
|
idempotent: false
|
||||||
|
cache_ttl_seconds: 0
|
||||||
|
timeout_seconds: 30
|
||||||
|
retry: {enabled: false}
|
||||||
|
allowed_agents: []
|
||||||
|
allowed_channels: []
|
||||||
|
required_business_keys: []
|
||||||
|
|
||||||
|
# Optional mapping if the backend sends canonical BusinessContext directly to the gateway.
|
||||||
|
# In the normal framework path, the framework already maps before calling the gateway.
|
||||||
|
parameter_mapping:
|
||||||
|
consultar_fatura:
|
||||||
|
customer_key: msisdn
|
||||||
|
contract_key: invoice_id
|
||||||
|
interaction_key: ura_call_id
|
||||||
|
session_key: session_id
|
||||||
|
consultar_pagamentos:
|
||||||
|
customer_key: msisdn
|
||||||
|
interaction_key: ura_call_id
|
||||||
|
session_key: session_id
|
||||||
|
consultar_plano:
|
||||||
|
customer_key: msisdn
|
||||||
|
resource_key: asset_id
|
||||||
|
contract_key: asset_id
|
||||||
|
session_key: session_id
|
||||||
|
listar_servicos:
|
||||||
|
customer_key: msisdn
|
||||||
|
session_key: session_id
|
||||||
|
consultar_pedido:
|
||||||
|
customer_key: customer_id
|
||||||
|
contract_key: order_id
|
||||||
|
session_key: session_id
|
||||||
|
consultar_entrega:
|
||||||
|
contract_key: order_id
|
||||||
|
session_key: session_id
|
||||||
|
solicitar_troca:
|
||||||
|
contract_key: order_id
|
||||||
|
session_key: session_id
|
||||||
|
solicitar_devolucao:
|
||||||
|
contract_key: order_id
|
||||||
|
session_key: session_id
|
||||||
|
|
||||||
|
auth:
|
||||||
|
enabled: false
|
||||||
|
static_tokens:
|
||||||
|
runtime-local-token:
|
||||||
|
agents: []
|
||||||
|
|
||||||
|
|
||||||
|
# Example of a dynamically discovered MCP server.
|
||||||
|
# Uncomment and adjust the URL to plug a new server that exposes a catalog/manifest.
|
||||||
|
# servers:
|
||||||
|
# nf_items:
|
||||||
|
# enabled: true
|
||||||
|
# discover: true
|
||||||
|
# protocol: legacy_http
|
||||||
|
# transport: http
|
||||||
|
# url: http://localhost:8400/mcp
|
||||||
|
# # Optional. If omitted, the gateway tries the discovery.default_catalog_endpoints.
|
||||||
|
# # manifest_url: http://localhost:8400/.well-known/mcp-server.json
|
||||||
|
# # catalog_endpoint: /tools
|
||||||
|
# # invoke_endpoint: /tools/call
|
||||||
|
# timeout_seconds: 30
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
fastapi>=0.111
|
fastapi>=0.110
|
||||||
uvicorn[standard]>=0.30
|
uvicorn[standard]>=0.27
|
||||||
pydantic>=2
|
pydantic>=2.6
|
||||||
|
PyYAML>=6.0
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
PyYAML>=6
|
|
||||||
|
|||||||
20
deploy/docker/docker-compose.mcp-gateway.yml
Normal file
20
deploy/docker/docker-compose.mcp-gateway.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
mcp_gateway:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: apps/mcp_gateway/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8300:8300"
|
||||||
|
depends_on:
|
||||||
|
- mock_telecom_mcp
|
||||||
|
|
||||||
|
mock_telecom_mcp:
|
||||||
|
image: python:3.12-slim
|
||||||
|
working_dir: /app
|
||||||
|
command: >
|
||||||
|
sh -c "pip install -r requirements.txt &&
|
||||||
|
uvicorn app:app --host 0.0.0.0 --port 8001"
|
||||||
|
volumes:
|
||||||
|
- ../../mcp/servers/mock_telecom_mcp:/app
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
@@ -3,34 +3,34 @@ kind: Deployment
|
|||||||
metadata:
|
metadata:
|
||||||
name: mcp-gateway
|
name: mcp-gateway
|
||||||
labels:
|
labels:
|
||||||
app.kubernetes.io/name: mcp-gateway
|
app: mcp-gateway
|
||||||
app.kubernetes.io/part-of: agent-framework-oci
|
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 2
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app.kubernetes.io/name: mcp-gateway
|
app: mcp-gateway
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app.kubernetes.io/name: mcp-gateway
|
app: mcp-gateway
|
||||||
app.kubernetes.io/part-of: agent-framework-oci
|
|
||||||
spec:
|
spec:
|
||||||
serviceAccountName: agent-framework-oci
|
serviceAccountName: mcp-gateway-sa
|
||||||
containers:
|
containers:
|
||||||
- name: mcp-gateway
|
- name: mcp-gateway
|
||||||
image: mcp-gateway:latest
|
image: registry.example.com/agent-platform/mcp-gateway:1.0.0
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 9200
|
- containerPort: 8300
|
||||||
|
env:
|
||||||
|
- name: MCP_GATEWAY_CONFIG_PATH
|
||||||
|
value: /app/config/mcp_gateway.yaml
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /ready
|
||||||
port: 9200
|
port: 8300
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /health
|
||||||
port: 9200
|
port: 8300
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
@@ -38,8 +38,7 @@ metadata:
|
|||||||
name: mcp-gateway
|
name: mcp-gateway
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app.kubernetes.io/name: mcp-gateway
|
app: mcp-gateway
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- port: 8300
|
||||||
port: 9200
|
targetPort: 8300
|
||||||
targetPort: 9200
|
|
||||||
|
|||||||
156
docs/MCP_GATEWAY_DISCOVERY.md
Normal file
156
docs/MCP_GATEWAY_DISCOVERY.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# MCP Gateway — Server Discovery and Catalog Sync
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
This evolution allows the MCP Gateway to discover tools from registered MCP Servers by reading a manifest or catalog endpoint.
|
||||||
|
|
||||||
|
The framework still points to a single MCP Gateway:
|
||||||
|
|
||||||
|
```env
|
||||||
|
MCP_GATEWAY_ENABLED=true
|
||||||
|
MCP_GATEWAY_URL=http://localhost:8300
|
||||||
|
MCP_GATEWAY_TIMEOUT_SECONDS=60
|
||||||
|
```
|
||||||
|
|
||||||
|
The MCP Gateway can point to many MCP Servers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Agent Framework
|
||||||
|
-> MCP Gateway
|
||||||
|
-> telecom_mcp_server
|
||||||
|
-> retail_mcp_server
|
||||||
|
-> nf_items_mcp_server
|
||||||
|
-> any other MCP Server
|
||||||
|
```
|
||||||
|
|
||||||
|
## What is automatic
|
||||||
|
|
||||||
|
After a server is registered in `apps/mcp_gateway/config/mcp_gateway.yaml` with `discover: true`, the gateway can:
|
||||||
|
|
||||||
|
- call its manifest/catalog endpoint;
|
||||||
|
- normalize the returned tool list;
|
||||||
|
- publish the tools in `GET /v1/tools`;
|
||||||
|
- execute the discovered tool through `POST /v1/tools/{tool_name}/invoke`.
|
||||||
|
|
||||||
|
## What is still explicit
|
||||||
|
|
||||||
|
The gateway does not scan the network or GitHub by itself. You still register the MCP Server endpoint in YAML.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
servers:
|
||||||
|
nf_items:
|
||||||
|
enabled: true
|
||||||
|
discover: true
|
||||||
|
protocol: legacy_http
|
||||||
|
transport: http
|
||||||
|
url: http://localhost:8400/mcp
|
||||||
|
catalog_endpoint: /tools
|
||||||
|
invoke_endpoint: /tools/call
|
||||||
|
timeout_seconds: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
If `catalog_endpoint` is omitted, the gateway tries:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/.well-known/mcp-server.json
|
||||||
|
/manifest
|
||||||
|
/mcp/tools
|
||||||
|
/tools/list
|
||||||
|
/tools
|
||||||
|
/v1/tools
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected manifest/catalog formats
|
||||||
|
|
||||||
|
The gateway accepts common shapes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server_id": "nf_items",
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "buscar_notas_por_criterios",
|
||||||
|
"description": "Search invoice items by criteria.",
|
||||||
|
"input_schema": {
|
||||||
|
"cliente": "string",
|
||||||
|
"estado": "string",
|
||||||
|
"preco": "number",
|
||||||
|
"ean": "string",
|
||||||
|
"margem": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It also accepts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"tools": [...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"data": {"tools": [...]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"capabilities": {"tools": [...]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## New endpoints
|
||||||
|
|
||||||
|
### List discovery servers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8300/v1/discovery/servers | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
### Force catalog sync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8300/v1/discovery/sync | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
### List merged static + discovered tools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8300/v1/tools | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
## Precedence rule
|
||||||
|
|
||||||
|
Static tools configured under `tools:` override discovered tools with the same name. This allows operations teams to override timeout, cache, allowed agents, required business keys, and endpoint behavior safely.
|
||||||
|
|
||||||
|
## Plugging a new MCP Server
|
||||||
|
|
||||||
|
1. Start the MCP Server.
|
||||||
|
2. Confirm that it exposes a catalog or manifest endpoint.
|
||||||
|
3. Add it under `servers:` in `mcp_gateway.yaml` with `discover: true`.
|
||||||
|
4. Restart the MCP Gateway or call `POST /v1/discovery/sync`.
|
||||||
|
5. Confirm the tool appears in `GET /v1/tools`.
|
||||||
|
6. Invoke the tool through the gateway.
|
||||||
|
|
||||||
|
## Example invocation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8300/v1/tools/buscar_notas_por_criterios/invoke \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"tenant_id": "default",
|
||||||
|
"agent_id": "telecom_contas",
|
||||||
|
"channel": "web",
|
||||||
|
"tool_name": "buscar_notas_por_criterios",
|
||||||
|
"arguments": {
|
||||||
|
"cliente": "CLIENTE-001",
|
||||||
|
"estado": "SP",
|
||||||
|
"preco": 100.0,
|
||||||
|
"ean": "7890000000000",
|
||||||
|
"margem": 0.05
|
||||||
|
},
|
||||||
|
"business_context": {
|
||||||
|
"session_key": "session-001"
|
||||||
|
}
|
||||||
|
}' | jq
|
||||||
|
```
|
||||||
@@ -10,8 +10,8 @@ Esta entrega reorganiza o projeto para uma arquitetura corporativa modular, pres
|
|||||||
| `agent_gateway/` | `apps/agent_gateway/` |
|
| `agent_gateway/` | `apps/agent_gateway/` |
|
||||||
| `channel_gateway/` | `apps/channel_gateway/` |
|
| `channel_gateway/` | `apps/channel_gateway/` |
|
||||||
| `agent_frontend/` | `apps/agent_frontend/` |
|
| `agent_frontend/` | `apps/agent_frontend/` |
|
||||||
| `agent_template_backend/` | `templates/backend/` |
|
| `agent_template_backend/` | `templates/agent_template_backend/` |
|
||||||
| `agent_template_backend_day_zero/` | `templates/backend_day_zero/` |
|
| `agent_template_backend_day_zero/` | `templates/agent_template_backend_day_zero/` |
|
||||||
| `mcp_servers/` | `mcp/servers/` |
|
| `mcp_servers/` | `mcp/servers/` |
|
||||||
| `agent_certification_tests/` | `evals/certification/` |
|
| `agent_certification_tests/` | `evals/certification/` |
|
||||||
| `agent_framework_oci_evaluator/evaluator/` | `evals/offline/evaluator/` |
|
| `agent_framework_oci_evaluator/evaluator/` | `evals/offline/evaluator/` |
|
||||||
|
|||||||
@@ -1,30 +1,16 @@
|
|||||||
# MCP servers registry.
|
# Logical MCP server registry used by the framework for tool ownership.
|
||||||
# transport=http keeps the legacy framework mock contract:
|
# With MCP_GATEWAY_ENABLED=true, the framework does NOT call these endpoints directly;
|
||||||
# GET <endpoint>/tools/list
|
# it calls MCP_GATEWAY_URL and the dedicated gateway routes to the final servers.
|
||||||
# POST <endpoint>/tools/call
|
# Keep these logical names aligned with config/tools.yaml mcp_server values.
|
||||||
# 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:
|
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:
|
telecom:
|
||||||
enabled: true
|
enabled: true
|
||||||
transport: http
|
transport: http
|
||||||
endpoint: http://localhost:8100/mcp
|
endpoint: http://localhost:8100/mcp
|
||||||
description: Telecom legacy HTTP mock MCP server
|
description: Logical Telecom MCP server. Direct endpoint is kept only for fallback when MCP_GATEWAY_ENABLED=false.
|
||||||
|
|
||||||
retail:
|
retail:
|
||||||
enabled: true
|
enabled: true
|
||||||
transport: http
|
transport: http
|
||||||
endpoint: http://localhost:8200/mcp
|
endpoint: http://localhost:8200/mcp
|
||||||
description: Retail legacy HTTP mock MCP server
|
description: Logical Retail MCP server. Direct endpoint is kept only for fallback when MCP_GATEWAY_ENABLED=false.
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ class Settings(BaseSettings):
|
|||||||
OCI_EMBEDDING_MODEL: str = 'cohere.embed-multilingual-v3.0'
|
OCI_EMBEDDING_MODEL: str = 'cohere.embed-multilingual-v3.0'
|
||||||
|
|
||||||
ENABLE_LANGFUSE: bool = False
|
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_PUBLIC_KEY: str | None = None
|
||||||
LANGFUSE_SECRET_KEY: str | None = None
|
LANGFUSE_SECRET_KEY: str | None = None
|
||||||
LANGFUSE_HOST: str = 'https://cloud.langfuse.com'
|
LANGFUSE_HOST: str = 'https://cloud.langfuse.com'
|
||||||
@@ -170,6 +173,15 @@ class Settings(BaseSettings):
|
|||||||
IDENTITY_CONFIG_PATH: str = './config/identity.yaml'
|
IDENTITY_CONFIG_PATH: str = './config/identity.yaml'
|
||||||
MCP_PARAMETER_MAPPING_PATH: str = './config/mcp_parameter_mapping.yaml'
|
MCP_PARAMETER_MAPPING_PATH: str = './config/mcp_parameter_mapping.yaml'
|
||||||
MCP_TOOL_TIMEOUT_SECONDS: int = 30
|
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'
|
DEFAULT_CHANNEL: str = 'web'
|
||||||
# Agent Framework channel input mode.
|
# Agent Framework channel input mode.
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -74,20 +74,23 @@ class MockLLMProvider(LLMProvider):
|
|||||||
profile_found = kwargs.get("profile_found")
|
profile_found = kwargs.get("profile_found")
|
||||||
profiles_enabled = kwargs.get("profiles_enabled")
|
profiles_enabled = kwargs.get("profiles_enabled")
|
||||||
profiles_path = kwargs.get("profiles_path")
|
profiles_path = kwargs.get("profiles_path")
|
||||||
last = messages[-1].get("content", "") if messages else ""
|
llm_metadata = {"provider": "mock", "profile_name": profile_name, "component": component_name, "model": model, "profile_source": profile_source, "profile_found": profile_found, "profiles_enabled": profiles_enabled, "profiles_path": profiles_path}
|
||||||
answer = f"[mock-llm] Resposta simulada para: {last[:300]}"
|
async with _maybe_generation(
|
||||||
usage = {"prompt_tokens": max(1, len(str(messages))//4), "completion_tokens": max(1, len(answer)//4), "total_tokens": max(2, (len(str(messages))+len(answer))//4), "cost_usd": 0.0, "cost_brl": 0.0}
|
self.telemetry,
|
||||||
|
name=generation_name,
|
||||||
|
model=model,
|
||||||
|
input=messages,
|
||||||
|
metadata=llm_metadata,
|
||||||
|
model_parameters={},
|
||||||
|
) as generation:
|
||||||
|
last = messages[-1].get("content", "") if messages else ""
|
||||||
|
answer = f"[mock-llm] Resposta simulada para: {last[:300]}"
|
||||||
|
usage = {"prompt_tokens": max(1, len(str(messages))//4), "completion_tokens": max(1, len(answer)//4), "total_tokens": max(2, (len(str(messages))+len(answer))//4), "cost_usd": 0.0, "cost_brl": 0.0}
|
||||||
|
generation.set_output(answer)
|
||||||
|
generation.set_usage(usage)
|
||||||
|
generation.set_metadata(**usage)
|
||||||
if self.usage_repository:
|
if self.usage_repository:
|
||||||
await self.usage_repository.record(UsageRecord.from_usage("mock", model, generation_name, usage, {"provider":"mock", "profile_name": profile_name, "component": component_name, "model": model, "profile_source": profile_source, "profile_found": profile_found, "profiles_enabled": profiles_enabled, "profiles_path": profiles_path}))
|
await self.usage_repository.record(UsageRecord.from_usage("mock", model, generation_name, usage, llm_metadata))
|
||||||
if self.telemetry:
|
|
||||||
await self.telemetry.generation(
|
|
||||||
name=generation_name,
|
|
||||||
model=model,
|
|
||||||
input=messages,
|
|
||||||
output=answer,
|
|
||||||
metadata={"provider": "mock", "profile_name": profile_name, "component": component_name, "model": model, "profile_source": profile_source, "profile_found": profile_found, "profiles_enabled": profiles_enabled, "profiles_path": profiles_path, **usage},
|
|
||||||
usage=usage,
|
|
||||||
)
|
|
||||||
return answer
|
return answer
|
||||||
|
|
||||||
|
|
||||||
@@ -259,6 +262,22 @@ class OCICompatibleOpenAIProvider(LLMProvider):
|
|||||||
for optional_key in ("top_p", "frequency_penalty", "presence_penalty"):
|
for optional_key in ("top_p", "frequency_penalty", "presence_penalty"):
|
||||||
if effective.get(optional_key) is not None:
|
if effective.get(optional_key) is not None:
|
||||||
request_kwargs[optional_key] = effective[optional_key]
|
request_kwargs[optional_key] = effective[optional_key]
|
||||||
|
model_parameters = {
|
||||||
|
key: value
|
||||||
|
for key, value in request_kwargs.items()
|
||||||
|
if key not in {"model", "messages"} and value is not None
|
||||||
|
}
|
||||||
|
llm_metadata = {
|
||||||
|
"provider": provider,
|
||||||
|
"model": model,
|
||||||
|
"component": component_name,
|
||||||
|
"profile_name": resolved_profile_name,
|
||||||
|
"requested_profile_name": requested_profile_name,
|
||||||
|
"profile_source": profile_source,
|
||||||
|
"profile_found": profile_found,
|
||||||
|
"profiles_enabled": bool(effective.get("profiles_enabled")),
|
||||||
|
"profiles_path": effective.get("profiles_path"),
|
||||||
|
}
|
||||||
|
|
||||||
async with _maybe_span(
|
async with _maybe_span(
|
||||||
self.telemetry,
|
self.telemetry,
|
||||||
@@ -275,47 +294,35 @@ class OCICompatibleOpenAIProvider(LLMProvider):
|
|||||||
profiles_enabled=bool(effective.get("profiles_enabled")),
|
profiles_enabled=bool(effective.get("profiles_enabled")),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
resp = await client.chat.completions.create(**request_kwargs)
|
async with _maybe_generation(
|
||||||
answer = resp.choices[0].message.content or ""
|
self.telemetry,
|
||||||
|
name=generation_name,
|
||||||
|
model=model,
|
||||||
|
input=messages,
|
||||||
|
metadata=llm_metadata,
|
||||||
|
model_parameters=model_parameters,
|
||||||
|
) as generation:
|
||||||
|
resp = await client.chat.completions.create(**request_kwargs)
|
||||||
|
answer = resp.choices[0].message.content or ""
|
||||||
|
|
||||||
usage_metadata = self.token_collector.enrich(model, getattr(resp, "usage", None))
|
usage_metadata = self.token_collector.enrich(model, getattr(resp, "usage", None))
|
||||||
usage_metadata.update({
|
usage_metadata.update({
|
||||||
"profile_name": resolved_profile_name,
|
"profile_name": resolved_profile_name,
|
||||||
"requested_profile_name": requested_profile_name,
|
"requested_profile_name": requested_profile_name,
|
||||||
"profile_source": profile_source,
|
"profile_source": profile_source,
|
||||||
"profile_found": profile_found,
|
"profile_found": profile_found,
|
||||||
"component": component_name,
|
"component": component_name,
|
||||||
"model": model,
|
"model": model,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"temperature": temperature,
|
**model_parameters,
|
||||||
"max_tokens": max_tokens,
|
})
|
||||||
})
|
generation.set_output(answer)
|
||||||
llm_metadata = {
|
generation.set_usage(usage_metadata)
|
||||||
"provider": provider,
|
generation.set_metadata(**usage_metadata)
|
||||||
"model": model,
|
|
||||||
"component": component_name,
|
|
||||||
"profile_name": resolved_profile_name,
|
|
||||||
"requested_profile_name": requested_profile_name,
|
|
||||||
"profile_source": profile_source,
|
|
||||||
"profile_found": profile_found,
|
|
||||||
"profiles_enabled": bool(effective.get("profiles_enabled")),
|
|
||||||
"profiles_path": effective.get("profiles_path"),
|
|
||||||
"temperature": temperature,
|
|
||||||
"max_tokens": max_tokens,
|
|
||||||
}
|
|
||||||
if self.usage_repository:
|
if self.usage_repository:
|
||||||
await self.usage_repository.record(
|
await self.usage_repository.record(
|
||||||
UsageRecord.from_usage(provider, model, generation_name, usage_metadata, llm_metadata)
|
UsageRecord.from_usage(provider, model, generation_name, usage_metadata, llm_metadata)
|
||||||
)
|
)
|
||||||
if self.telemetry:
|
|
||||||
await self.telemetry.generation(
|
|
||||||
name=generation_name,
|
|
||||||
model=model,
|
|
||||||
input=messages,
|
|
||||||
output=answer,
|
|
||||||
metadata={**llm_metadata, **usage_metadata},
|
|
||||||
usage=usage_metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
return answer
|
return answer
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -550,6 +557,19 @@ class OCISDKProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
service_endpoint = self._resolve_endpoint(self.settings, endpoint)
|
service_endpoint = self._resolve_endpoint(self.settings, endpoint)
|
||||||
|
model_parameters = {
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
}
|
||||||
|
llm_metadata = {
|
||||||
|
"provider": "oci_sdk",
|
||||||
|
"model": model,
|
||||||
|
"endpoint_id": endpoint_id,
|
||||||
|
"service_endpoint": service_endpoint,
|
||||||
|
"component": component_name,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"auth_mode": getattr(self.settings, "OCI_AUTH_MODE", "config_file"),
|
||||||
|
}
|
||||||
|
|
||||||
async with _maybe_span(
|
async with _maybe_span(
|
||||||
self.telemetry,
|
self.telemetry,
|
||||||
@@ -575,26 +595,30 @@ class OCISDKProvider(LLMProvider):
|
|||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await asyncio.to_thread(client.chat, details)
|
async with _maybe_generation(
|
||||||
answer = self._extract_answer(response)
|
self.telemetry,
|
||||||
|
name=generation_name,
|
||||||
|
model=model,
|
||||||
|
input=messages,
|
||||||
|
metadata=llm_metadata,
|
||||||
|
model_parameters=model_parameters,
|
||||||
|
) as generation:
|
||||||
|
response = await asyncio.to_thread(client.chat, details)
|
||||||
|
answer = self._extract_answer(response)
|
||||||
|
|
||||||
usage_metadata = {
|
usage_metadata = {
|
||||||
"prompt_tokens": max(1, len(str(messages)) // 4),
|
"prompt_tokens": max(1, len(str(messages)) // 4),
|
||||||
"completion_tokens": max(1, len(answer) // 4),
|
"completion_tokens": max(1, len(answer) // 4),
|
||||||
"total_tokens": max(2, (len(str(messages)) + len(answer)) // 4),
|
"total_tokens": max(2, (len(str(messages)) + len(answer)) // 4),
|
||||||
"cost_usd": 0.0,
|
"cost_usd": 0.0,
|
||||||
"cost_brl": 0.0,
|
"cost_brl": 0.0,
|
||||||
"estimated_usage": True,
|
"estimated_usage": True,
|
||||||
"provider": "oci_sdk",
|
**llm_metadata,
|
||||||
"model": model,
|
**model_parameters,
|
||||||
"endpoint_id": endpoint_id,
|
}
|
||||||
"service_endpoint": service_endpoint,
|
generation.set_output(answer)
|
||||||
"component": component_name,
|
generation.set_usage(usage_metadata)
|
||||||
"profile_name": profile_name,
|
generation.set_metadata(**usage_metadata)
|
||||||
"auth_mode": getattr(self.settings, "OCI_AUTH_MODE", "config_file"),
|
|
||||||
}
|
|
||||||
|
|
||||||
llm_metadata = dict(usage_metadata)
|
|
||||||
|
|
||||||
if self.usage_repository:
|
if self.usage_repository:
|
||||||
await self.usage_repository.record(
|
await self.usage_repository.record(
|
||||||
@@ -607,16 +631,6 @@ class OCISDKProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.telemetry:
|
|
||||||
await self.telemetry.generation(
|
|
||||||
name=generation_name,
|
|
||||||
model=model,
|
|
||||||
input=messages,
|
|
||||||
output=answer,
|
|
||||||
metadata=llm_metadata,
|
|
||||||
usage=usage_metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
return answer
|
return answer
|
||||||
|
|
||||||
|
|
||||||
@@ -656,3 +670,36 @@ class _maybe_span:
|
|||||||
if self.cm:
|
if self.cm:
|
||||||
return await self.cm.__aexit__(exc_type, exc, tb)
|
return await self.cm.__aexit__(exc_type, exc, tb)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _NoopGeneration:
|
||||||
|
def set_output(self, output: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_usage(self, usage: dict[str, Any] | None) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_metadata(self, **metadata: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_model_parameters(self, **model_parameters: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _maybe_generation:
|
||||||
|
def __init__(self, telemetry, **attrs: Any):
|
||||||
|
self.telemetry = telemetry
|
||||||
|
self.attrs = attrs
|
||||||
|
self.cm = None
|
||||||
|
self.noop = _NoopGeneration()
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
if not self.telemetry or not hasattr(self.telemetry, "generation_span"):
|
||||||
|
return self.noop
|
||||||
|
self.cm = self.telemetry.generation_span(**self.attrs)
|
||||||
|
return await self.cm.__aenter__()
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
if self.cm:
|
||||||
|
return await self.cm.__aexit__(exc_type, exc, tb)
|
||||||
|
return False
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from agent_framework.identity import MCPParameterMapper
|
|||||||
from .registry import MCPRegistry
|
from .registry import MCPRegistry
|
||||||
from .client import MCPHttpClient
|
from .client import MCPHttpClient
|
||||||
from .models import MCPToolResult
|
from .models import MCPToolResult
|
||||||
|
from agent_framework.gateways import MCPGatewayClient
|
||||||
|
|
||||||
logger = logging.getLogger("agent_framework.mcp.tool_router")
|
logger = logging.getLogger("agent_framework.mcp.tool_router")
|
||||||
|
|
||||||
@@ -30,12 +31,26 @@ class MCPToolRouter:
|
|||||||
settings.TOOLS_CONFIG_PATH,
|
settings.TOOLS_CONFIG_PATH,
|
||||||
)
|
)
|
||||||
self.client = MCPHttpClient(timeout_seconds=settings.MCP_TOOL_TIMEOUT_SECONDS)
|
self.client = MCPHttpClient(timeout_seconds=settings.MCP_TOOL_TIMEOUT_SECONDS)
|
||||||
|
self.gateway_enabled = bool(getattr(settings, "MCP_GATEWAY_ENABLED", False))
|
||||||
|
self.gateway_agent_id = getattr(settings, "MCP_GATEWAY_AGENT_ID", "telecom_contas")
|
||||||
|
self.gateway_tenant_id = getattr(settings, "MCP_GATEWAY_TENANT_ID", "default")
|
||||||
|
self.gateway_client = (
|
||||||
|
MCPGatewayClient(
|
||||||
|
base_url=getattr(settings, "MCP_GATEWAY_URL", "http://localhost:8300"),
|
||||||
|
token=getattr(settings, "MCP_GATEWAY_TOKEN", None),
|
||||||
|
timeout_seconds=getattr(settings, "MCP_GATEWAY_TIMEOUT_SECONDS", settings.MCP_TOOL_TIMEOUT_SECONDS),
|
||||||
|
)
|
||||||
|
if self.gateway_enabled
|
||||||
|
else None
|
||||||
|
)
|
||||||
self.parameter_mapper = MCPParameterMapper.from_yaml(
|
self.parameter_mapper = MCPParameterMapper.from_yaml(
|
||||||
getattr(settings, "MCP_PARAMETER_MAPPING_PATH", "./config/mcp_parameter_mapping.yaml")
|
getattr(settings, "MCP_PARAMETER_MAPPING_PATH", "./config/mcp_parameter_mapping.yaml")
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"MCPToolRouter carregado enabled=%s servers=%s tools=%s mapper=%s",
|
"MCPToolRouter carregado enabled=%s gateway_enabled=%s gateway_url=%s servers=%s tools=%s mapper=%s",
|
||||||
self.enabled,
|
self.enabled,
|
||||||
|
self.gateway_enabled,
|
||||||
|
getattr(settings, "MCP_GATEWAY_URL", None),
|
||||||
list(self.registry.servers.keys()),
|
list(self.registry.servers.keys()),
|
||||||
list(self.registry.tools.keys()),
|
list(self.registry.tools.keys()),
|
||||||
getattr(settings, "MCP_PARAMETER_MAPPING_PATH", None),
|
getattr(settings, "MCP_PARAMETER_MAPPING_PATH", None),
|
||||||
@@ -116,27 +131,55 @@ class MCPToolRouter:
|
|||||||
bool(mapped_arguments.get("invoice_id") or mapped_arguments.get("current_invoice_number")),
|
bool(mapped_arguments.get("invoice_id") or mapped_arguments.get("current_invoice_number")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _execute() -> MCPToolResult:
|
||||||
|
if self.gateway_enabled and self.gateway_client:
|
||||||
|
response = await self.gateway_client.invoke_tool(
|
||||||
|
tenant_id=self.gateway_tenant_id,
|
||||||
|
agent_id=self.gateway_agent_id,
|
||||||
|
channel=getattr(self.settings, "DEFAULT_CHANNEL", "web"),
|
||||||
|
tool_name=tool_name,
|
||||||
|
arguments=mapped_arguments,
|
||||||
|
business_context={},
|
||||||
|
metadata={"routed_by": "agent_framework.mcp.tool_router", "logical_server": server.name},
|
||||||
|
)
|
||||||
|
return MCPToolResult(
|
||||||
|
tool_name=tool_name,
|
||||||
|
server_name="mcp_gateway",
|
||||||
|
ok=bool(response.get("ok", False)),
|
||||||
|
result=response.get("data"),
|
||||||
|
error=response.get("error"),
|
||||||
|
metadata={
|
||||||
|
"transport": "mcp_gateway",
|
||||||
|
"logical_server": server.name,
|
||||||
|
**(response.get("metadata") or {}),
|
||||||
|
"cache": response.get("cache") or {},
|
||||||
|
"latency_ms": response.get("latency_ms"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return await self.client.call_tool(server, tool_name, mapped_arguments)
|
||||||
|
|
||||||
if self.telemetry:
|
if self.telemetry:
|
||||||
async with self.telemetry.span(
|
async with self.telemetry.span(
|
||||||
"mcp.tool_call",
|
"mcp.tool_call",
|
||||||
tool_name=tool_name,
|
tool_name=tool_name,
|
||||||
mcp_server=server.name,
|
mcp_server=("mcp_gateway" if self.gateway_enabled else server.name),
|
||||||
input=mapped_arguments,
|
input=mapped_arguments,
|
||||||
tags=["mcp", "tool"],
|
tags=["mcp", "tool", "mcp_gateway" if self.gateway_enabled else "mcp_server"],
|
||||||
):
|
):
|
||||||
result = await self.client.call_tool(server, tool_name, mapped_arguments)
|
result = await _execute()
|
||||||
await self.telemetry.event(
|
await self.telemetry.event(
|
||||||
"mcp.tool_call.completed",
|
"mcp.tool_call.completed",
|
||||||
{
|
{
|
||||||
"tool_name": tool_name,
|
"tool_name": tool_name,
|
||||||
"server": server.name,
|
"server": "mcp_gateway" if self.gateway_enabled else server.name,
|
||||||
|
"logical_server": server.name,
|
||||||
"ok": result.ok,
|
"ok": result.ok,
|
||||||
"error": result.error,
|
"error": result.error,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return await self.client.call_tool(server, tool_name, mapped_arguments)
|
return await _execute()
|
||||||
|
|
||||||
async def call(
|
async def call(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ _workflow_id: ContextVar[str | None] = ContextVar("workflow_id", default=None)
|
|||||||
_message_id: ContextVar[str | None] = ContextVar("message_id", default=None)
|
_message_id: ContextVar[str | None] = ContextVar("message_id", default=None)
|
||||||
_trace_id: ContextVar[str | None] = ContextVar("trace_id", default=None)
|
_trace_id: ContextVar[str | None] = ContextVar("trace_id", default=None)
|
||||||
_current_observation_id: ContextVar[str | None] = ContextVar("current_observation_id", default=None)
|
_current_observation_id: ContextVar[str | None] = ContextVar("current_observation_id", default=None)
|
||||||
|
_current_span_events: ContextVar[list[dict[str, Any]] | None] = ContextVar("current_span_events", default=None)
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ObservabilityContext:
|
class ObservabilityContext:
|
||||||
@@ -66,6 +67,31 @@ def reset_current_observation_id(token) -> None:
|
|||||||
_current_observation_id.set(None)
|
_current_observation_id.set(None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_span_events() -> list[dict[str, Any]] | None:
|
||||||
|
"""Return the mutable aggregate-event bucket for the active macro span."""
|
||||||
|
return _current_span_events.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_current_span_events(events: list[dict[str, Any]] | None):
|
||||||
|
"""Set aggregate-event bucket and return ContextVar token."""
|
||||||
|
return _current_span_events.set(events)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_current_span_events(token) -> None:
|
||||||
|
"""Restore previous aggregate-event bucket."""
|
||||||
|
try:
|
||||||
|
_current_span_events.reset(token)
|
||||||
|
except Exception:
|
||||||
|
_current_span_events.set(None)
|
||||||
|
|
||||||
|
|
||||||
|
def record_current_span_event(event: dict[str, Any]) -> None:
|
||||||
|
"""Append an event summary to the active macro span, if one exists."""
|
||||||
|
events = _current_span_events.get()
|
||||||
|
if events is not None:
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
|
||||||
def set_observability_context(**kwargs: Any) -> ObservabilityContext:
|
def set_observability_context(**kwargs: Any) -> ObservabilityContext:
|
||||||
if not kwargs.get("request_id") and not _request_id.get():
|
if not kwargs.get("request_id") and not _request_id.get():
|
||||||
kwargs["request_id"] = str(uuid4())
|
kwargs["request_id"] = str(uuid4())
|
||||||
@@ -82,7 +108,7 @@ def set_observability_context(**kwargs: Any) -> ObservabilityContext:
|
|||||||
|
|
||||||
|
|
||||||
def clear_observability_context() -> None:
|
def clear_observability_context() -> None:
|
||||||
for var in (_request_id, _session_id, _user_id, _tenant_id, _agent_id, _channel, _ura_call_id, _workflow_id, _message_id, _trace_id, _current_observation_id):
|
for var in (_request_id, _session_id, _user_id, _tenant_id, _agent_id, _channel, _ura_call_id, _workflow_id, _message_id, _trace_id, _current_observation_id, _current_span_events):
|
||||||
var.set(None)
|
var.set(None)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,36 @@ import time
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_LANGGRAPH_STEP_ORDER = {
|
||||||
|
"__start__": 0,
|
||||||
|
"input_guardrails": 1,
|
||||||
|
"routing_decision": 2,
|
||||||
|
"billing_agent": 3,
|
||||||
|
"product_agent": 3,
|
||||||
|
"orders_agent": 3,
|
||||||
|
"support_agent": 3,
|
||||||
|
"handoff": 3,
|
||||||
|
"supervisor_agent": 3,
|
||||||
|
"output_supervisor": 4,
|
||||||
|
"output_guardrails": 5,
|
||||||
|
"judge": 6,
|
||||||
|
"supervisor_review": 7,
|
||||||
|
"persist": 8,
|
||||||
|
"__end__": 9,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _langgraph_step(name: str, state: dict[str, Any]) -> int:
|
||||||
|
explicit_steps = state.get("langgraph_steps")
|
||||||
|
if isinstance(explicit_steps, dict) and name in explicit_steps:
|
||||||
|
try:
|
||||||
|
return int(explicit_steps[name])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return _LANGGRAPH_STEP_ORDER.get(name, 50)
|
||||||
|
|
||||||
|
|
||||||
class LangGraphDeepTelemetry:
|
class LangGraphDeepTelemetry:
|
||||||
"""Eventos profundos do LangGraph no padrão FIRST.
|
"""Eventos profundos do LangGraph no padrão FIRST.
|
||||||
|
|
||||||
@@ -16,7 +46,16 @@ class LangGraphDeepTelemetry:
|
|||||||
async def node(self, name: str, state: dict[str, Any] | None = None):
|
async def node(self, name: str, state: dict[str, Any] | None = None):
|
||||||
state=state or {}
|
state=state or {}
|
||||||
session_id=state.get('conversation_key') or state.get('session_id')
|
session_id=state.get('conversation_key') or state.get('session_id')
|
||||||
payload={'node': name, 'session_id': session_id, 'agent_id': state.get('agent_id'), 'tenant_id': state.get('tenant_id'), 'input_size': len(str(state.get('user_text') or state.get('sanitized_input') or ''))}
|
payload={
|
||||||
|
'node': name,
|
||||||
|
'langgraph_node': name,
|
||||||
|
'langgraph_step': _langgraph_step(name, state),
|
||||||
|
'framework': 'langgraph',
|
||||||
|
'session_id': session_id,
|
||||||
|
'agent_id': state.get('agent_id'),
|
||||||
|
'tenant_id': state.get('tenant_id'),
|
||||||
|
'input_size': len(str(state.get('user_text') or state.get('sanitized_input') or '')),
|
||||||
|
}
|
||||||
start=time.time()
|
start=time.time()
|
||||||
await self.telemetry.event('langgraph.node.started', payload, kind='langgraph')
|
await self.telemetry.event('langgraph.node.started', payload, kind='langgraph')
|
||||||
async with self.telemetry.span(f'langgraph.node.{name}', **payload):
|
async with self.telemetry.span(f'langgraph.node.{name}', **payload):
|
||||||
|
|||||||
@@ -15,15 +15,20 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from .context import (
|
from .context import (
|
||||||
context_metadata,
|
context_metadata,
|
||||||
get_current_observation_id,
|
get_current_observation_id,
|
||||||
|
get_current_span_events,
|
||||||
get_observability_context,
|
get_observability_context,
|
||||||
|
record_current_span_event,
|
||||||
reset_current_observation_id,
|
reset_current_observation_id,
|
||||||
|
reset_current_span_events,
|
||||||
set_current_observation_id,
|
set_current_observation_id,
|
||||||
|
set_current_span_events,
|
||||||
set_observability_context,
|
set_observability_context,
|
||||||
)
|
)
|
||||||
from .event_bus import TelemetryEventBus
|
from .event_bus import TelemetryEventBus
|
||||||
@@ -32,6 +37,24 @@ from .otel import OpenTelemetryProvider
|
|||||||
logger = logging.getLogger("agent_framework.telemetry")
|
logger = logging.getLogger("agent_framework.telemetry")
|
||||||
|
|
||||||
_LANGFUSE_OBSERVATION_TYPES = {"span", "generation", "agent", "tool", "chain", "retriever", "embedding", "evaluator", "guardrail"}
|
_LANGFUSE_OBSERVATION_TYPES = {"span", "generation", "agent", "tool", "chain", "retriever", "embedding", "evaluator", "guardrail"}
|
||||||
|
_LANGFUSE_START_OBSERVATION_KWARGS = {
|
||||||
|
"trace_context",
|
||||||
|
"name",
|
||||||
|
"as_type",
|
||||||
|
"input",
|
||||||
|
"output",
|
||||||
|
"metadata",
|
||||||
|
"version",
|
||||||
|
"level",
|
||||||
|
"status_message",
|
||||||
|
"completion_start_time",
|
||||||
|
"model",
|
||||||
|
"model_parameters",
|
||||||
|
"usage_details",
|
||||||
|
"cost_details",
|
||||||
|
"prompt",
|
||||||
|
"end_on_exit",
|
||||||
|
}
|
||||||
|
|
||||||
def _langfuse_type(kind: str | None) -> str:
|
def _langfuse_type(kind: str | None) -> str:
|
||||||
# Langfuse SDKs do not accept arbitrary event types such as "event"; FIRST pattern
|
# Langfuse SDKs do not accept arbitrary event types such as "event"; FIRST pattern
|
||||||
@@ -42,6 +65,19 @@ def _langfuse_type(kind: str | None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
_LANGFUSE_TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
_LANGFUSE_TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||||
|
_COMPACT_SUPPRESSED_SPAN_PREFIXES = (
|
||||||
|
"llm.chat_completion",
|
||||||
|
"workflow.agent.",
|
||||||
|
"workflow.handoff",
|
||||||
|
"workflow.input_guardrails",
|
||||||
|
"workflow.judge",
|
||||||
|
"workflow.output_guardrails",
|
||||||
|
"workflow.output_supervisor",
|
||||||
|
"workflow.persist",
|
||||||
|
"workflow.routing_decision",
|
||||||
|
"workflow.supervisor_review",
|
||||||
|
)
|
||||||
|
_COMPACT_VISIBLE_EVENT_PREFIXES = ("AGA.", "NOC.")
|
||||||
|
|
||||||
|
|
||||||
def _raw_correlation_id(attrs: dict[str, Any] | None = None) -> str | None:
|
def _raw_correlation_id(attrs: dict[str, Any] | None = None) -> str | None:
|
||||||
@@ -100,6 +136,7 @@ def _inject_langfuse_trace_context(kwargs: dict[str, Any], attrs: dict[str, Any]
|
|||||||
which grouped everything in one trace while flattening the tree.
|
which grouped everything in one trace while flattening the tree.
|
||||||
"""
|
"""
|
||||||
attrs = attrs or kwargs.get("metadata") or {}
|
attrs = attrs or kwargs.get("metadata") or {}
|
||||||
|
ignore_current_parent = bool(attrs.get("_ignore_current_parent") or kwargs.get("_ignore_current_parent"))
|
||||||
raw_id = _raw_correlation_id(attrs)
|
raw_id = _raw_correlation_id(attrs)
|
||||||
trace_id = _langfuse_trace_id(raw_id)
|
trace_id = _langfuse_trace_id(raw_id)
|
||||||
parent_id = (
|
parent_id = (
|
||||||
@@ -107,7 +144,7 @@ def _inject_langfuse_trace_context(kwargs: dict[str, Any], attrs: dict[str, Any]
|
|||||||
or attrs.get("parent_span_id")
|
or attrs.get("parent_span_id")
|
||||||
or kwargs.get("parent_observation_id")
|
or kwargs.get("parent_observation_id")
|
||||||
or kwargs.get("parent_span_id")
|
or kwargs.get("parent_span_id")
|
||||||
or get_current_observation_id()
|
or (None if ignore_current_parent else get_current_observation_id())
|
||||||
)
|
)
|
||||||
if trace_id:
|
if trace_id:
|
||||||
trace_context = dict(kwargs.get("trace_context") or {})
|
trace_context = dict(kwargs.get("trace_context") or {})
|
||||||
@@ -121,6 +158,8 @@ def _inject_langfuse_trace_context(kwargs: dict[str, Any], attrs: dict[str, Any]
|
|||||||
metadata.setdefault("langfuse_trace_id", trace_id)
|
metadata.setdefault("langfuse_trace_id", trace_id)
|
||||||
if parent_id:
|
if parent_id:
|
||||||
metadata.setdefault("parent_observation_id", str(parent_id))
|
metadata.setdefault("parent_observation_id", str(parent_id))
|
||||||
|
metadata.pop("_ignore_current_parent", None)
|
||||||
|
kwargs.pop("_ignore_current_parent", None)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
@@ -150,6 +189,128 @@ def _extract_observation_id(observation: Any) -> str | None:
|
|||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_compact_visible_event(name: str) -> bool:
|
||||||
|
return str(name or "").startswith(_COMPACT_VISIBLE_EVENT_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
class _SpanHandle:
|
||||||
|
"""Mutable handle yielded by Telemetry.span for setting final output."""
|
||||||
|
|
||||||
|
def __init__(self, observation: Any | None = None) -> None:
|
||||||
|
self.observation = observation
|
||||||
|
self.output: Any = None
|
||||||
|
self.has_output = False
|
||||||
|
self.metadata: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def set_observation(self, observation: Any | None) -> None:
|
||||||
|
self.observation = observation
|
||||||
|
|
||||||
|
def set_output(self, output: Any) -> None:
|
||||||
|
self.output = output
|
||||||
|
self.has_output = True
|
||||||
|
|
||||||
|
def set_metadata(self, **metadata: Any) -> None:
|
||||||
|
self.metadata.update({k: v for k, v in metadata.items() if v is not None})
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
if self.observation is None:
|
||||||
|
raise AttributeError(name)
|
||||||
|
return getattr(self.observation, name)
|
||||||
|
|
||||||
|
|
||||||
|
class _GenerationHandle:
|
||||||
|
"""Mutable handle yielded by Telemetry.generation_span."""
|
||||||
|
|
||||||
|
def __init__(self, observation: Any | None = None) -> None:
|
||||||
|
self.observation = observation
|
||||||
|
self.output: Any = None
|
||||||
|
self.has_output = False
|
||||||
|
self.metadata: dict[str, Any] = {}
|
||||||
|
self.usage: dict[str, Any] | None = None
|
||||||
|
self.model_parameters: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def set_observation(self, observation: Any | None) -> None:
|
||||||
|
self.observation = observation
|
||||||
|
|
||||||
|
def set_output(self, output: Any) -> None:
|
||||||
|
self.output = output
|
||||||
|
self.has_output = True
|
||||||
|
|
||||||
|
def set_usage(self, usage: dict[str, Any] | None) -> None:
|
||||||
|
self.usage = dict(usage or {})
|
||||||
|
|
||||||
|
def set_metadata(self, **metadata: Any) -> None:
|
||||||
|
self.metadata.update({k: v for k, v in metadata.items() if v is not None})
|
||||||
|
|
||||||
|
def set_model_parameters(self, **model_parameters: Any) -> None:
|
||||||
|
self.model_parameters.update({k: v for k, v in model_parameters.items() if v is not None})
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
if self.observation is None:
|
||||||
|
raise AttributeError(name)
|
||||||
|
return getattr(self.observation, name)
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_details_from_usage(usage: dict[str, Any] | None) -> dict[str, int] | None:
|
||||||
|
if not isinstance(usage, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def int_value(*keys: str) -> int | None:
|
||||||
|
for key in keys:
|
||||||
|
value = usage.get(key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
input_tokens = int_value("input", "input_tokens", "prompt_tokens")
|
||||||
|
output_tokens = int_value("output", "output_tokens", "completion_tokens")
|
||||||
|
total_tokens = int_value("total", "total_tokens")
|
||||||
|
|
||||||
|
# Langfuse self-hosted versions may sum all custom usage keys into totalUsage.
|
||||||
|
# Send split fields only when available; send total only when there is no split.
|
||||||
|
details: dict[str, int] = {}
|
||||||
|
if input_tokens is not None:
|
||||||
|
details["input"] = input_tokens
|
||||||
|
if output_tokens is not None:
|
||||||
|
details["output"] = output_tokens
|
||||||
|
if not details and total_tokens is not None:
|
||||||
|
details["total"] = total_tokens
|
||||||
|
return details or None
|
||||||
|
|
||||||
|
|
||||||
|
def _cost_details_from_usage(usage: dict[str, Any] | None) -> dict[str, float] | None:
|
||||||
|
if not isinstance(usage, dict):
|
||||||
|
return None
|
||||||
|
details: dict[str, float] = {}
|
||||||
|
if usage.get("cost_usd") is not None:
|
||||||
|
try:
|
||||||
|
details["total"] = float(usage["cost_usd"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if usage.get("cost_brl") is not None:
|
||||||
|
try:
|
||||||
|
details["total_brl"] = float(usage["cost_brl"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return details or None
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_mapping(value: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
clean = {k: v for k, v in value.items() if v is not None}
|
||||||
|
return clean or None
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_iso_ms() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
class Telemetry:
|
class Telemetry:
|
||||||
def __init__(self, settings):
|
def __init__(self, settings):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -188,6 +349,15 @@ class Telemetry:
|
|||||||
def is_enabled(self) -> bool:
|
def is_enabled(self) -> bool:
|
||||||
return bool(self.enabled and self.langfuse)
|
return bool(self.enabled and self.langfuse)
|
||||||
|
|
||||||
|
def is_compact_mode(self) -> bool:
|
||||||
|
mode = getattr(self.settings, "LANGFUSE_TRACE_MODE", "verbose") or "verbose"
|
||||||
|
return str(mode).lower() == "compact"
|
||||||
|
|
||||||
|
def _should_emit_langfuse_span(self, name: str) -> bool:
|
||||||
|
if not self.is_compact_mode():
|
||||||
|
return True
|
||||||
|
return not str(name).startswith(_COMPACT_SUPPRESSED_SPAN_PREFIXES)
|
||||||
|
|
||||||
def bind_context(self, **kwargs: Any):
|
def bind_context(self, **kwargs: Any):
|
||||||
return set_observability_context(**kwargs)
|
return set_observability_context(**kwargs)
|
||||||
|
|
||||||
@@ -199,6 +369,10 @@ class Telemetry:
|
|||||||
"""Cria span correlacionado em logs, Langfuse e OpenTelemetry."""
|
"""Cria span correlacionado em logs, Langfuse e OpenTelemetry."""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
attrs = context_metadata(attrs)
|
attrs = context_metadata(attrs)
|
||||||
|
attrs.setdefault("_span_name", name)
|
||||||
|
is_root_span = bool(attrs.get("_root_span")) or name == "agent.gateway_message"
|
||||||
|
if self.is_compact_mode() and is_root_span and not attrs.get("parent_observation_id"):
|
||||||
|
attrs["_ignore_current_parent"] = True
|
||||||
if not attrs.get("request_id"):
|
if not attrs.get("request_id"):
|
||||||
attrs["request_id"] = str(uuid4())
|
attrs["request_id"] = str(uuid4())
|
||||||
if not attrs.get("trace_id"):
|
if not attrs.get("trace_id"):
|
||||||
@@ -206,45 +380,90 @@ class Telemetry:
|
|||||||
set_observability_context(request_id=attrs.get("request_id"), trace_id=attrs.get("trace_id"))
|
set_observability_context(request_id=attrs.get("request_id"), trace_id=attrs.get("trace_id"))
|
||||||
observation_cm = None
|
observation_cm = None
|
||||||
observation = None
|
observation = None
|
||||||
|
handle = _SpanHandle()
|
||||||
observation_token = None
|
observation_token = None
|
||||||
parent_observation_id = attrs.get("parent_observation_id") or get_current_observation_id()
|
propagation_cm = None
|
||||||
|
legacy_io_update: dict[str, Any] | None = None
|
||||||
|
ignore_current_parent = bool(attrs.get("_ignore_current_parent"))
|
||||||
|
parent_observation_id = attrs.get("parent_observation_id")
|
||||||
|
if not parent_observation_id and not ignore_current_parent:
|
||||||
|
parent_observation_id = get_current_observation_id()
|
||||||
if parent_observation_id:
|
if parent_observation_id:
|
||||||
attrs.setdefault("parent_observation_id", str(parent_observation_id))
|
attrs.setdefault("parent_observation_id", str(parent_observation_id))
|
||||||
logger.info("span.start %s %s", name, _safe(attrs))
|
logger.info("span.start %s %s", name, _safe(attrs))
|
||||||
|
|
||||||
otel_cm = self.otel.span(name, attrs)
|
otel_cm = self.otel.span(name, attrs)
|
||||||
otel_span = otel_cm.__enter__()
|
otel_span = otel_cm.__enter__()
|
||||||
if self.is_enabled():
|
emit_langfuse_span = self.is_enabled() and self._should_emit_langfuse_span(name)
|
||||||
|
span_events: list[dict[str, Any]] | None = [] if emit_langfuse_span and self.is_compact_mode() else None
|
||||||
|
span_events_token = set_current_span_events(span_events) if span_events is not None else None
|
||||||
|
observation_metadata = {k: v for k, v in attrs.items() if k != "input" and not str(k).startswith("_")}
|
||||||
|
if emit_langfuse_span:
|
||||||
observation_cm = self._start_observation(
|
observation_cm = self._start_observation(
|
||||||
name=name,
|
name=name,
|
||||||
as_type="span",
|
as_type="span",
|
||||||
input=attrs.get("input"),
|
input=attrs.get("input"),
|
||||||
metadata={k: v for k, v in attrs.items() if k != "input"},
|
metadata=observation_metadata,
|
||||||
|
_ignore_current_parent=attrs.get("_ignore_current_parent"),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
if observation_cm is not None:
|
if observation_cm is not None:
|
||||||
observation = observation_cm.__enter__()
|
observation = observation_cm.__enter__()
|
||||||
|
handle.set_observation(observation)
|
||||||
observation_id = _extract_observation_id(observation)
|
observation_id = _extract_observation_id(observation)
|
||||||
if observation_id:
|
if observation_id:
|
||||||
observation_token = set_current_observation_id(observation_id)
|
observation_token = set_current_observation_id(observation_id)
|
||||||
attrs.setdefault("observation_id", observation_id)
|
attrs.setdefault("observation_id", observation_id)
|
||||||
self._update_trace_from_attrs(observation, attrs)
|
if is_root_span:
|
||||||
|
self._update_trace_from_attrs(observation, attrs)
|
||||||
|
self._set_trace_io(observation, input=attrs.get("input"))
|
||||||
|
propagation_cm = self._start_trace_attribute_propagation(name, attrs)
|
||||||
|
if propagation_cm is not None:
|
||||||
|
propagation_cm.__enter__()
|
||||||
# Publish span.started only after the Langfuse observation is current,
|
# Publish span.started only after the Langfuse observation is current,
|
||||||
# so secondary analytics/exporters can attach it as a child instead
|
# so secondary analytics/exporters can attach it as a child instead
|
||||||
# of creating a sibling/root entry.
|
# of creating a sibling/root entry.
|
||||||
await self.event_bus.publish(f"{name}.started", attrs, kind="span")
|
await self.event_bus.publish(f"{name}.started", attrs, kind="span")
|
||||||
yield observation
|
yield handle
|
||||||
duration_ms = int((time.time() - start) * 1000)
|
duration_ms = int((time.time() - start) * 1000)
|
||||||
out = {"status": "ok", "duration_ms": duration_ms}
|
status = {"status": "ok", "duration_ms": duration_ms}
|
||||||
self._update_observation(observation, output=out, metadata={"duration_ms": duration_ms})
|
out = handle.output if handle.has_output else status
|
||||||
|
metadata = {**observation_metadata, **status, **handle.metadata}
|
||||||
|
if span_events is not None:
|
||||||
|
metadata["aggregated_event_count"] = len(span_events)
|
||||||
|
metadata["aggregated_events"] = span_events
|
||||||
|
self._update_observation(observation, input=attrs.get("input"), output=out, metadata=metadata)
|
||||||
|
if is_root_span:
|
||||||
|
self._set_trace_io(observation, input=attrs.get("input"), output=out)
|
||||||
|
legacy_io_update = {
|
||||||
|
"input": attrs.get("input"),
|
||||||
|
"output": out,
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
if otel_span is not None:
|
if otel_span is not None:
|
||||||
otel_span.set_attribute("duration_ms", duration_ms)
|
otel_span.set_attribute("duration_ms", duration_ms)
|
||||||
await self.event_bus.publish(f"{name}.completed", {**attrs, **out}, kind="span")
|
completed_payload = {**attrs, **status}
|
||||||
|
if handle.has_output:
|
||||||
|
completed_payload["output"] = out
|
||||||
|
await self.event_bus.publish(f"{name}.completed", completed_payload, kind="span")
|
||||||
logger.info("span.end %s duration_ms=%s", name, duration_ms)
|
logger.info("span.end %s duration_ms=%s", name, duration_ms)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
duration_ms = int((time.time() - start) * 1000)
|
duration_ms = int((time.time() - start) * 1000)
|
||||||
out = {"status": "error", "error": str(exc), "duration_ms": duration_ms}
|
out = {"status": "error", "error": str(exc), "duration_ms": duration_ms}
|
||||||
self._update_observation(observation, level="ERROR", status_message=str(exc), output=out, metadata={"duration_ms": duration_ms})
|
metadata = {**observation_metadata, "duration_ms": duration_ms}
|
||||||
|
if span_events is not None:
|
||||||
|
metadata["aggregated_event_count"] = len(span_events)
|
||||||
|
metadata["aggregated_events"] = span_events
|
||||||
|
self._update_observation(observation, level="ERROR", status_message=str(exc), input=attrs.get("input"), output=out, metadata=metadata)
|
||||||
|
if is_root_span:
|
||||||
|
self._set_trace_io(observation, input=attrs.get("input"), output=out)
|
||||||
|
legacy_io_update = {
|
||||||
|
"input": attrs.get("input"),
|
||||||
|
"output": out,
|
||||||
|
"metadata": metadata,
|
||||||
|
"level": "ERROR",
|
||||||
|
"status_message": str(exc),
|
||||||
|
}
|
||||||
if otel_span is not None:
|
if otel_span is not None:
|
||||||
try:
|
try:
|
||||||
otel_span.record_exception(exc)
|
otel_span.record_exception(exc)
|
||||||
@@ -255,11 +474,23 @@ class Telemetry:
|
|||||||
logger.exception("span.error %s %s", name, exc)
|
logger.exception("span.error %s %s", name, exc)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
if propagation_cm is not None:
|
||||||
|
try: propagation_cm.__exit__(None, None, None)
|
||||||
|
except Exception: logger.debug("Falha ao encerrar propagação Langfuse", exc_info=True)
|
||||||
if observation_cm is not None:
|
if observation_cm is not None:
|
||||||
try: observation_cm.__exit__(None, None, None)
|
try: observation_cm.__exit__(None, None, None)
|
||||||
except Exception: logger.exception("Falha ao finalizar span Langfuse %s", name)
|
except Exception: logger.exception("Falha ao finalizar span Langfuse %s", name)
|
||||||
|
if legacy_io_update is not None:
|
||||||
|
self._legacy_observation_update(
|
||||||
|
observation,
|
||||||
|
observation_type="span",
|
||||||
|
name=name,
|
||||||
|
**legacy_io_update,
|
||||||
|
)
|
||||||
if observation_token is not None:
|
if observation_token is not None:
|
||||||
reset_current_observation_id(observation_token)
|
reset_current_observation_id(observation_token)
|
||||||
|
if span_events_token is not None:
|
||||||
|
reset_current_span_events(span_events_token)
|
||||||
try: otel_cm.__exit__(None, None, None)
|
try: otel_cm.__exit__(None, None, None)
|
||||||
except Exception: logger.debug("Falha ao fechar span OTEL", exc_info=True)
|
except Exception: logger.debug("Falha ao fechar span OTEL", exc_info=True)
|
||||||
|
|
||||||
@@ -267,6 +498,24 @@ class Telemetry:
|
|||||||
payload = context_metadata(payload or {})
|
payload = context_metadata(payload or {})
|
||||||
logger.info("event %s %s", name, _safe(payload))
|
logger.info("event %s %s", name, _safe(payload))
|
||||||
await self.event_bus.publish(name, payload, kind=kind)
|
await self.event_bus.publish(name, payload, kind=kind)
|
||||||
|
if self.is_compact_mode():
|
||||||
|
if get_current_span_events() is not None:
|
||||||
|
record_current_span_event({
|
||||||
|
"name": name,
|
||||||
|
"kind": kind,
|
||||||
|
"payload": payload,
|
||||||
|
})
|
||||||
|
if not _is_compact_visible_event(name) or not self.is_enabled():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
metadata = {**payload, "event_kind": kind}
|
||||||
|
cm = self._start_observation(name=name, as_type="span", input=payload, metadata=metadata)
|
||||||
|
if cm is not None:
|
||||||
|
with cm as obs:
|
||||||
|
self._update_observation(obs, input=payload, output={"status": "ok"}, metadata=metadata)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Falha ao enviar event compacto via observation")
|
||||||
|
return
|
||||||
if not self.is_enabled():
|
if not self.is_enabled():
|
||||||
return
|
return
|
||||||
# IMPORTANT: do not call ``langfuse.event(...)`` directly here. In SDK
|
# IMPORTANT: do not call ``langfuse.event(...)`` directly here. In SDK
|
||||||
@@ -274,54 +523,179 @@ class Telemetry:
|
|||||||
# a new trace row for every telemetry event. We create a correlated
|
# a new trace row for every telemetry event. We create a correlated
|
||||||
# observation instead, using request_id/trace_id as the stable trace id.
|
# observation instead, using request_id/trace_id as the stable trace id.
|
||||||
try:
|
try:
|
||||||
cm = self._start_observation(name=name, as_type=_langfuse_type(kind), metadata={**payload, "event_kind": kind})
|
metadata = {**payload, "event_kind": kind}
|
||||||
|
if self.is_compact_mode():
|
||||||
|
metadata["_ignore_current_parent"] = True
|
||||||
|
cm = self._start_observation(name=name, as_type=_langfuse_type(kind), metadata=metadata)
|
||||||
if cm is not None:
|
if cm is not None:
|
||||||
with cm: pass
|
with cm: pass
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Falha ao enviar event via observation")
|
logger.exception("Falha ao enviar event via observation")
|
||||||
|
|
||||||
async def generation(self, name: str, model: str, input: list | dict | str, output: str,
|
@asynccontextmanager
|
||||||
metadata: dict[str, Any] | None = None, usage: dict[str, Any] | None = None):
|
async def generation_span(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
model: str,
|
||||||
|
input: list | dict | str,
|
||||||
|
*,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
usage: dict[str, Any] | None = None,
|
||||||
|
model_parameters: dict[str, Any] | None = None,
|
||||||
|
):
|
||||||
metadata = context_metadata(metadata or {})
|
metadata = context_metadata(metadata or {})
|
||||||
# Keep the actual LLM model visible both in Langfuse's generation.model field
|
# Keep the actual LLM model visible both in Langfuse's generation.model field
|
||||||
# and in metadata for filtering/debugging across SDK versions.
|
# and in metadata for filtering/debugging across SDK versions.
|
||||||
metadata.setdefault("model", model)
|
metadata.setdefault("model", model)
|
||||||
metadata.setdefault("llm_model", model)
|
metadata.setdefault("llm_model", model)
|
||||||
metadata.setdefault("component", metadata.get("profile_name") or name)
|
metadata.setdefault("component", metadata.get("profile_name") or name)
|
||||||
if usage:
|
clean_model_parameters = _clean_mapping(model_parameters)
|
||||||
metadata["usage"] = usage
|
if clean_model_parameters:
|
||||||
logger.info("generation %s model=%s component=%s profile=%s metadata=%s", name, model, metadata.get("component"), metadata.get("profile_name"), _safe(metadata))
|
metadata.setdefault("model_parameters", clean_model_parameters)
|
||||||
await self.event_bus.publish(name, {"model": model, "llm_model": model, "output_chars": len(output or ""), **metadata}, kind="generation")
|
handle = _GenerationHandle()
|
||||||
if not self.is_enabled():
|
observation_cm = None
|
||||||
return
|
observation = None
|
||||||
|
observation_token = None
|
||||||
|
legacy_io_update: dict[str, Any] | None = None
|
||||||
|
logger.info("generation.start %s model=%s component=%s profile=%s metadata=%s", name, model, metadata.get("component"), metadata.get("profile_name"), _safe(metadata))
|
||||||
try:
|
try:
|
||||||
kwargs = dict(name=name, as_type="generation", input=input, output=output, model=model, metadata=metadata)
|
if self.is_enabled():
|
||||||
if usage:
|
|
||||||
kwargs["usage"] = usage
|
|
||||||
kwargs["usage_details"] = {k: usage.get(k) for k in ("prompt_tokens", "completion_tokens", "total_tokens", "cached_tokens", "reasoning_tokens") if k in usage}
|
|
||||||
|
|
||||||
# Prefer current/correlated generation APIs. Avoid raw
|
|
||||||
# ``langfuse.generation(...)`` first because it can create a separate
|
|
||||||
# trace row per LLM call when no current observation exists.
|
|
||||||
if hasattr(self.langfuse, "start_as_current_generation"):
|
|
||||||
clean = {k: v for k, v in kwargs.items() if k != "as_type" and v is not None}
|
|
||||||
clean = _inject_langfuse_trace_context(clean, metadata)
|
|
||||||
try:
|
try:
|
||||||
with self.langfuse.start_as_current_generation(**clean) as obs:
|
observation_cm = self._start_observation(
|
||||||
self._update_observation(obs, output=output, model=model, metadata=metadata)
|
name=name,
|
||||||
return
|
as_type="generation",
|
||||||
except TypeError:
|
input=input,
|
||||||
clean.pop("trace_context", None)
|
model=model,
|
||||||
with self.langfuse.start_as_current_generation(**clean) as obs:
|
model_parameters=clean_model_parameters,
|
||||||
self._update_observation(obs, output=output, model=model, metadata=metadata)
|
usage_details=_usage_details_from_usage(usage),
|
||||||
return
|
cost_details=_cost_details_from_usage(usage),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
if observation_cm is not None:
|
||||||
|
observation = observation_cm.__enter__()
|
||||||
|
handle.set_observation(observation)
|
||||||
|
observation_id = _extract_observation_id(observation)
|
||||||
|
if observation_id:
|
||||||
|
observation_token = set_current_observation_id(observation_id)
|
||||||
|
except Exception:
|
||||||
|
observation_cm = None
|
||||||
|
observation = None
|
||||||
|
logger.exception("Falha ao iniciar generation Langfuse %s", name)
|
||||||
|
yield handle
|
||||||
|
final_usage = handle.usage if handle.usage is not None else usage
|
||||||
|
final_model_parameters = {
|
||||||
|
**(clean_model_parameters or {}),
|
||||||
|
**handle.model_parameters,
|
||||||
|
} or None
|
||||||
|
final_metadata = {**metadata, **handle.metadata}
|
||||||
|
if final_usage:
|
||||||
|
final_metadata["usage"] = final_usage
|
||||||
|
output = handle.output if handle.has_output else None
|
||||||
|
usage_details = _usage_details_from_usage(final_usage)
|
||||||
|
cost_details = _cost_details_from_usage(final_usage)
|
||||||
|
self._update_observation(
|
||||||
|
observation,
|
||||||
|
input=input,
|
||||||
|
output=output,
|
||||||
|
model=model,
|
||||||
|
metadata=final_metadata,
|
||||||
|
model_parameters=final_model_parameters,
|
||||||
|
usage_details=usage_details,
|
||||||
|
cost_details=cost_details,
|
||||||
|
)
|
||||||
|
legacy_io_update = {
|
||||||
|
"input": input,
|
||||||
|
"output": output,
|
||||||
|
"model": model,
|
||||||
|
"metadata": final_metadata,
|
||||||
|
"model_parameters": final_model_parameters,
|
||||||
|
"usage_details": usage_details,
|
||||||
|
"cost_details": cost_details,
|
||||||
|
}
|
||||||
|
await self.event_bus.publish(
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
"model": model,
|
||||||
|
"llm_model": model,
|
||||||
|
"output_chars": len(output or "") if isinstance(output, str) else 0,
|
||||||
|
**final_metadata,
|
||||||
|
},
|
||||||
|
kind="generation",
|
||||||
|
)
|
||||||
|
logger.info("generation.end %s model=%s", name, model)
|
||||||
|
except Exception as exc:
|
||||||
|
final_usage = handle.usage if handle.usage is not None else usage
|
||||||
|
final_model_parameters = {
|
||||||
|
**(clean_model_parameters or {}),
|
||||||
|
**handle.model_parameters,
|
||||||
|
} or None
|
||||||
|
final_metadata = {**metadata, **handle.metadata}
|
||||||
|
if final_usage:
|
||||||
|
final_metadata["usage"] = final_usage
|
||||||
|
usage_details = _usage_details_from_usage(final_usage)
|
||||||
|
cost_details = _cost_details_from_usage(final_usage)
|
||||||
|
output = handle.output if handle.has_output else None
|
||||||
|
self._update_observation(
|
||||||
|
observation,
|
||||||
|
level="ERROR",
|
||||||
|
status_message=str(exc),
|
||||||
|
input=input,
|
||||||
|
output=output,
|
||||||
|
model=model,
|
||||||
|
metadata=final_metadata,
|
||||||
|
model_parameters=final_model_parameters,
|
||||||
|
usage_details=usage_details,
|
||||||
|
cost_details=cost_details,
|
||||||
|
)
|
||||||
|
legacy_io_update = {
|
||||||
|
"input": input,
|
||||||
|
"output": output,
|
||||||
|
"model": model,
|
||||||
|
"metadata": final_metadata,
|
||||||
|
"model_parameters": final_model_parameters,
|
||||||
|
"usage_details": usage_details,
|
||||||
|
"cost_details": cost_details,
|
||||||
|
"level": "ERROR",
|
||||||
|
"status_message": str(exc),
|
||||||
|
}
|
||||||
|
await self.event_bus.publish(f"{name}.failed", {"model": model, "llm_model": model, "error": str(exc), **final_metadata}, kind="generation")
|
||||||
|
logger.exception("generation.error %s model=%s exc=%s", name, model, exc)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if observation_cm is not None:
|
||||||
|
try: observation_cm.__exit__(None, None, None)
|
||||||
|
except Exception: logger.exception("Falha ao finalizar generation Langfuse %s", name)
|
||||||
|
if legacy_io_update is not None:
|
||||||
|
self._legacy_observation_update(
|
||||||
|
observation,
|
||||||
|
observation_type="generation",
|
||||||
|
name=name,
|
||||||
|
**legacy_io_update,
|
||||||
|
)
|
||||||
|
if observation_token is not None:
|
||||||
|
reset_current_observation_id(observation_token)
|
||||||
|
|
||||||
cm = self._start_observation(**kwargs)
|
async def generation(
|
||||||
if cm is not None:
|
self,
|
||||||
with cm as obs:
|
name: str,
|
||||||
self._update_observation(obs, output=output, model=model, metadata=metadata)
|
model: str,
|
||||||
except Exception:
|
input: list | dict | str,
|
||||||
logger.exception("Falha ao registrar generation no Langfuse")
|
output: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
usage: dict[str, Any] | None = None,
|
||||||
|
model_parameters: dict[str, Any] | None = None,
|
||||||
|
):
|
||||||
|
async with self.generation_span(
|
||||||
|
name=name,
|
||||||
|
model=model,
|
||||||
|
input=input,
|
||||||
|
metadata=metadata,
|
||||||
|
usage=usage,
|
||||||
|
model_parameters=model_parameters,
|
||||||
|
) as generation:
|
||||||
|
generation.set_output(output)
|
||||||
|
if usage:
|
||||||
|
generation.set_usage(usage)
|
||||||
|
|
||||||
async def rag_event(self, name: str, query: str, results_count: int, metadata: dict[str, Any] | None = None):
|
async def rag_event(self, name: str, query: str, results_count: int, metadata: dict[str, Any] | None = None):
|
||||||
await self.event(f"rag.{name}", {"query": query, "results_count": results_count, **(metadata or {})}, kind="rag")
|
await self.event(f"rag.{name}", {"query": query, "results_count": results_count, **(metadata or {})}, kind="rag")
|
||||||
@@ -364,10 +738,16 @@ class Telemetry:
|
|||||||
def _start_observation(self, **kwargs):
|
def _start_observation(self, **kwargs):
|
||||||
if not self.is_enabled(): return None
|
if not self.is_enabled(): return None
|
||||||
if hasattr(self.langfuse, "start_as_current_observation"):
|
if hasattr(self.langfuse, "start_as_current_observation"):
|
||||||
clean = {k: v for k, v in kwargs.items() if v is not None}
|
clean = {k: v for k, v in kwargs.items() if v is not None and k in _LANGFUSE_START_OBSERVATION_KWARGS}
|
||||||
if "as_type" in clean:
|
if "as_type" in clean:
|
||||||
clean["as_type"] = _langfuse_type(clean.get("as_type"))
|
clean["as_type"] = _langfuse_type(clean.get("as_type"))
|
||||||
clean = _inject_langfuse_trace_context(clean, clean.get("metadata") or {})
|
if self.is_compact_mode():
|
||||||
|
clean.pop("_ignore_current_parent", None)
|
||||||
|
else:
|
||||||
|
clean = _inject_langfuse_trace_context(clean, clean.get("metadata") or {})
|
||||||
|
metadata = clean.get("metadata")
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
clean["metadata"] = {k: v for k, v in metadata.items() if not str(k).startswith("_")}
|
||||||
try:
|
try:
|
||||||
return self.langfuse.start_as_current_observation(**clean)
|
return self.langfuse.start_as_current_observation(**clean)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -413,9 +793,66 @@ class Telemetry:
|
|||||||
if hasattr(observation, "update"): observation.update(**clean)
|
if hasattr(observation, "update"): observation.update(**clean)
|
||||||
except Exception: logger.debug("Observation update não suportado", exc_info=True)
|
except Exception: logger.debug("Observation update não suportado", exc_info=True)
|
||||||
|
|
||||||
|
def _legacy_observation_update(self, observation, *, observation_type: str, name: str, **kwargs):
|
||||||
|
"""Compatibility fallback for Langfuse servers that drop OTEL observation I/O."""
|
||||||
|
if not self.is_enabled() or not bool(getattr(self.settings, "LANGFUSE_LEGACY_IO_FALLBACK", True)):
|
||||||
|
return
|
||||||
|
if observation is None:
|
||||||
|
return
|
||||||
|
obs_id = _extract_observation_id(observation)
|
||||||
|
trace_id = getattr(observation, "trace_id", None)
|
||||||
|
if not obs_id or not trace_id:
|
||||||
|
return
|
||||||
|
api = getattr(self.langfuse, "api", None)
|
||||||
|
ingestion = getattr(api, "ingestion", None)
|
||||||
|
if ingestion is None or not hasattr(ingestion, "batch"):
|
||||||
|
return
|
||||||
|
|
||||||
|
clean = {k: v for k, v in kwargs.items() if v is not None}
|
||||||
|
if not any(k in clean for k in ("input", "output", "metadata")):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if hasattr(self.langfuse, "flush"):
|
||||||
|
self.langfuse.flush()
|
||||||
|
|
||||||
|
if observation_type == "generation":
|
||||||
|
from langfuse.api.ingestion.types import (
|
||||||
|
IngestionEvent_GenerationUpdate,
|
||||||
|
UpdateGenerationBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
body = UpdateGenerationBody(id=str(obs_id), trace_id=str(trace_id), name=name, **clean)
|
||||||
|
event = IngestionEvent_GenerationUpdate(
|
||||||
|
id=str(uuid4()),
|
||||||
|
timestamp=_utc_iso_ms(),
|
||||||
|
body=body,
|
||||||
|
metadata={"source": "agent_framework", "fallback": "legacy_observation_io"},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
from langfuse.api.ingestion.types import IngestionEvent_SpanUpdate, UpdateSpanBody
|
||||||
|
|
||||||
|
body = UpdateSpanBody(id=str(obs_id), trace_id=str(trace_id), name=name, **clean)
|
||||||
|
event = IngestionEvent_SpanUpdate(
|
||||||
|
id=str(uuid4()),
|
||||||
|
timestamp=_utc_iso_ms(),
|
||||||
|
body=body,
|
||||||
|
metadata={"source": "agent_framework", "fallback": "legacy_observation_io"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = ingestion.batch(
|
||||||
|
batch=[event],
|
||||||
|
metadata={"source": "agent_framework", "fallback": "legacy_observation_io"},
|
||||||
|
)
|
||||||
|
if getattr(response, "errors", None):
|
||||||
|
logger.debug("Langfuse legacy I/O fallback retornou erros: %s", response.errors)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Falha no fallback legado de input/output Langfuse", exc_info=True)
|
||||||
|
|
||||||
def _update_trace_from_attrs(self, observation, attrs: dict[str, Any]):
|
def _update_trace_from_attrs(self, observation, attrs: dict[str, Any]):
|
||||||
if observation is None: return
|
if observation is None: return
|
||||||
trace_attrs = {}
|
trace_attrs = {}
|
||||||
|
if attrs.get("_span_name"):
|
||||||
|
trace_attrs["name"] = attrs["_span_name"]
|
||||||
for key in ("session_id", "user_id"):
|
for key in ("session_id", "user_id"):
|
||||||
if attrs.get(key): trace_attrs[key] = attrs[key]
|
if attrs.get(key): trace_attrs[key] = attrs[key]
|
||||||
if attrs.get("input"): trace_attrs["input"] = attrs["input"]
|
if attrs.get("input"): trace_attrs["input"] = attrs["input"]
|
||||||
@@ -427,6 +864,43 @@ class Telemetry:
|
|||||||
if hasattr(observation, "update_trace"): observation.update_trace(**trace_attrs)
|
if hasattr(observation, "update_trace"): observation.update_trace(**trace_attrs)
|
||||||
except Exception: logger.debug("Trace update não suportado", exc_info=True)
|
except Exception: logger.debug("Trace update não suportado", exc_info=True)
|
||||||
|
|
||||||
|
def _set_trace_io(self, observation, *, input: Any | None = None, output: Any | None = None):
|
||||||
|
if observation is None: return
|
||||||
|
try:
|
||||||
|
if hasattr(observation, "set_trace_io"):
|
||||||
|
observation.set_trace_io(input=input, output=output)
|
||||||
|
return
|
||||||
|
if hasattr(observation, "update_trace"):
|
||||||
|
payload = {}
|
||||||
|
if input is not None:
|
||||||
|
payload["input"] = input
|
||||||
|
if output is not None:
|
||||||
|
payload["output"] = output
|
||||||
|
if payload:
|
||||||
|
observation.update_trace(**payload)
|
||||||
|
except Exception: logger.debug("Trace input/output update não suportado", exc_info=True)
|
||||||
|
|
||||||
|
def _start_trace_attribute_propagation(self, name: str, attrs: dict[str, Any]):
|
||||||
|
if not self.is_enabled() or not hasattr(self.langfuse, "propagate_attributes"):
|
||||||
|
return None
|
||||||
|
metadata = {
|
||||||
|
k: attrs.get(k)
|
||||||
|
for k in ("request_id", "trace_id", "agent_id", "tenant_id", "channel", "message_id", "ura_call_id", "workflow_id")
|
||||||
|
if attrs.get(k)
|
||||||
|
}
|
||||||
|
tags = attrs.get("tags") if isinstance(attrs.get("tags"), list) else None
|
||||||
|
try:
|
||||||
|
return self.langfuse.propagate_attributes(
|
||||||
|
user_id=str(attrs["user_id"]) if attrs.get("user_id") is not None else None,
|
||||||
|
session_id=str(attrs["session_id"]) if attrs.get("session_id") is not None else None,
|
||||||
|
metadata=metadata or None,
|
||||||
|
tags=[str(tag) for tag in tags] if tags else None,
|
||||||
|
trace_name=name,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Trace attribute propagation não suportada", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
class _LegacyObservationContext:
|
class _LegacyObservationContext:
|
||||||
def __init__(self, observation): self.observation = observation
|
def __init__(self, observation): self.observation = observation
|
||||||
def __enter__(self): return self.observation
|
def __enter__(self): return self.observation
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agent_framework.gateways import MCPGatewayClient
|
||||||
|
|
||||||
|
|
||||||
|
class MCPGatewayRuntimeMixin:
|
||||||
|
mcp_gateway_client: MCPGatewayClient | None = None
|
||||||
|
|
||||||
|
async def _invoke_mcp_gateway_tool(
|
||||||
|
self,
|
||||||
|
state: dict[str, Any],
|
||||||
|
tool_name: str,
|
||||||
|
arguments: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not self.mcp_gateway_client:
|
||||||
|
raise RuntimeError("MCP Gateway client not configured")
|
||||||
|
|
||||||
|
result = await self.mcp_gateway_client.invoke_tool(
|
||||||
|
tenant_id=state.get("tenant_id", "default"),
|
||||||
|
agent_id=state.get("agent_id") or state.get("route") or "unknown",
|
||||||
|
channel=state.get("channel"),
|
||||||
|
tool_name=tool_name,
|
||||||
|
arguments=arguments or {},
|
||||||
|
business_context=state.get("business_context") or {},
|
||||||
|
metadata={
|
||||||
|
"session_id": state.get("session_id"),
|
||||||
|
"conversation_key": state.get("conversation_key"),
|
||||||
|
"trace_id": (state.get("metadata") or {}).get("trace_id"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
state.setdefault("mcp_results", []).append(result)
|
||||||
|
return result
|
||||||
29
mcp/servers/mock_telecom_mcp/app.py
Normal file
29
mcp/servers/mock_telecom_mcp/app.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
app = FastAPI(title="Mock Telecom MCP Server")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "service": "mock_telecom_mcp"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/tools/consultar_fatura")
|
||||||
|
async def consultar_fatura(payload: dict):
|
||||||
|
return {
|
||||||
|
"invoice_id": payload.get("invoice_id") or "INV-001",
|
||||||
|
"msisdn": payload.get("msisdn"),
|
||||||
|
"valor_total": 249.90,
|
||||||
|
"vencimento": "2026-06-10",
|
||||||
|
"status": "ABERTA",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/tools/consultar_pagamentos")
|
||||||
|
async def consultar_pagamentos(payload: dict):
|
||||||
|
return {
|
||||||
|
"msisdn": payload.get("msisdn"),
|
||||||
|
"pagamentos": [
|
||||||
|
{"data": "2026-06-05", "valor": 249.90, "status": "CONFIRMADO"}
|
||||||
|
],
|
||||||
|
}
|
||||||
2
mcp/servers/mock_telecom_mcp/requirements.txt
Normal file
2
mcp/servers/mock_telecom_mcp/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fastapi>=0.110
|
||||||
|
uvicorn[standard]>=0.27
|
||||||
@@ -31,6 +31,19 @@ TOOLS = {
|
|||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok", "server": "retail_mcp_server"}
|
return {"status": "ok", "server": "retail_mcp_server"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/.well-known/mcp-server.json")
|
||||||
|
async def well_known_manifest():
|
||||||
|
return {"server_id": "retail", "protocol": "legacy_http", "invoke_endpoint": "/tools/call", "tools": [{"name": name, **cfg} for name, cfg in TOOLS.items()]}
|
||||||
|
|
||||||
|
@app.get("/manifest")
|
||||||
|
async def manifest():
|
||||||
|
return await well_known_manifest()
|
||||||
|
|
||||||
|
@app.get("/mcp/tools")
|
||||||
|
async def tools_catalog():
|
||||||
|
return {"tools": [{"name": name, **cfg} for name, cfg in TOOLS.items()]}
|
||||||
|
|
||||||
@app.get("/mcp/tools/list")
|
@app.get("/mcp/tools/list")
|
||||||
async def list_tools():
|
async def list_tools():
|
||||||
return {"tools": [{"name": name, **cfg} for name, cfg in TOOLS.items()]}
|
return {"tools": [{"name": name, **cfg} for name, cfg in TOOLS.items()]}
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ TOOLS = {
|
|||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok", "server": "telecom_mcp_server"}
|
return {"status": "ok", "server": "telecom_mcp_server"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/.well-known/mcp-server.json")
|
||||||
|
async def well_known_manifest():
|
||||||
|
return {"server_id": "telecom", "protocol": "legacy_http", "invoke_endpoint": "/tools/call", "tools": [{"name": name, **cfg} for name, cfg in TOOLS.items()]}
|
||||||
|
|
||||||
|
@app.get("/manifest")
|
||||||
|
async def manifest():
|
||||||
|
return await well_known_manifest()
|
||||||
|
|
||||||
|
@app.get("/mcp/tools")
|
||||||
|
async def tools_catalog():
|
||||||
|
return {"tools": [{"name": name, **cfg} for name, cfg in TOOLS.items()]}
|
||||||
|
|
||||||
@app.get("/mcp/tools/list")
|
@app.get("/mcp/tools/list")
|
||||||
async def list_tools():
|
async def list_tools():
|
||||||
return {"tools": [{"name": name, **cfg} for name, cfg in TOOLS.items()]}
|
return {"tools": [{"name": name, **cfg} for name, cfg in TOOLS.items()]}
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ requires-python = ">=3.10"
|
|||||||
[tool.agent_framework_oci.layout]
|
[tool.agent_framework_oci.layout]
|
||||||
libs = ["libs/agent_framework"]
|
libs = ["libs/agent_framework"]
|
||||||
apps = ["apps/agent_gateway", "apps/channel_gateway", "apps/ai_gateway", "apps/mcp_gateway"]
|
apps = ["apps/agent_gateway", "apps/channel_gateway", "apps/ai_gateway", "apps/mcp_gateway"]
|
||||||
templates = ["templates/backend", "templates/backend_day_zero"]
|
templates = ["templates/agent_template_backend", "templates/agent_template_backend_day_zero"]
|
||||||
evals = ["evals/offline", "evals/certification"]
|
evals = ["evals/offline", "evals/certification"]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/../apps/mcp_gateway"
|
cd "$(dirname "$0")/../apps/mcp_gateway"
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 9200 --reload
|
uvicorn app.main:app --host 0.0.0.0 --port 8300 --reload
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|||||||
|
|
||||||
python -m venv "$ROOT/.venv"
|
python -m venv "$ROOT/.venv"
|
||||||
source "$ROOT/.venv/bin/activate"
|
source "$ROOT/.venv/bin/activate"
|
||||||
pip install -r "$ROOT/mcp_servers/telecom_mcp_server/requirements.txt"
|
pip install -r "$ROOT/mcp/servers/telecom_mcp_server/requirements.txt"
|
||||||
pip install -r "$ROOT/mcp_servers/retail_mcp_server/requirements.txt"
|
pip install -r "$ROOT/mcp/servers/retail_mcp_server/requirements.txt"
|
||||||
|
|
||||||
uvicorn --app-dir "$ROOT/mcp_servers/telecom_mcp_server" main:app --host 0.0.0.0 --port 8100 &
|
uvicorn --app-dir "$ROOT/mcp/servers/telecom_mcp_server" main:app --host 0.0.0.0 --port 8100 &
|
||||||
PID1=$!
|
PID1=$!
|
||||||
uvicorn --app-dir "$ROOT/mcp_servers/retail_mcp_server" main:app --host 0.0.0.0 --port 8200 &
|
uvicorn --app-dir "$ROOT/mcp/servers/retail_mcp_server" main:app --host 0.0.0.0 --port 8200 &
|
||||||
PID2=$!
|
PID2=$!
|
||||||
|
|
||||||
echo "Telecom MCP em http://localhost:8100"
|
echo "Telecom MCP em http://localhost:8100"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ A Agent Platform OCI é composta por componentes reutilizáveis, aplicações de
|
|||||||
| `mcp/servers` | Apps | MCP servers de domínio. |
|
| `mcp/servers` | Apps | MCP servers de domínio. |
|
||||||
| `evals/offline` | App/Lib | Avaliação offline/batch. |
|
| `evals/offline` | App/Lib | Avaliação offline/batch. |
|
||||||
| `evals/certification` | Suite | Certificação técnica e funcional. |
|
| `evals/certification` | Suite | Certificação técnica e funcional. |
|
||||||
| `templates/backend` | Template | Scaffold para novos agentes. |
|
| `templates/agent_template_backend` | Template | Scaffold para novos agentes. |
|
||||||
| `specs` | Documentação | Contratos SDD versionados. |
|
| `specs` | Documentação | Contratos SDD versionados. |
|
||||||
| `deploy` | Operação | Docker, Kubernetes e Helm. |
|
| `deploy` | Operação | Docker, Kubernetes e Helm. |
|
||||||
|
|
||||||
@@ -40,8 +40,8 @@ agent_platform_oci/
|
|||||||
│ ├── offline/
|
│ ├── offline/
|
||||||
│ └── certification/
|
│ └── certification/
|
||||||
├── templates/
|
├── templates/
|
||||||
│ ├── backend/
|
│ ├── agent_template_backend/
|
||||||
│ └── backend_day_zero/
|
│ └── agent_template_backend_day_zero/
|
||||||
├── specs/
|
├── specs/
|
||||||
├── deploy/
|
├── deploy/
|
||||||
│ ├── docker/
|
│ ├── docker/
|
||||||
@@ -76,7 +76,7 @@ flowchart LR
|
|||||||
| Agent Backend / Runtime | 8000 | Kubernetes Deployment | Horizontal com storage externo |
|
| Agent Backend / Runtime | 8000 | Kubernetes Deployment | Horizontal com storage externo |
|
||||||
| Channel Gateway | 7000 | Kubernetes Deployment | Horizontal |
|
| Channel Gateway | 7000 | Kubernetes Deployment | Horizontal |
|
||||||
| AI Gateway | 9100 | Kubernetes Deployment | Horizontal |
|
| AI Gateway | 9100 | Kubernetes Deployment | Horizontal |
|
||||||
| MCP Gateway | 9200 | Kubernetes Deployment | Horizontal |
|
| MCP Gateway | 8300 | Kubernetes Deployment | Horizontal |
|
||||||
| MCP Servers | 8001+ | Kubernetes Deployment | Por domínio |
|
| MCP Servers | 8001+ | Kubernetes Deployment | Por domínio |
|
||||||
| Evaluator API | 9300 | Deployment/CronJob | Por carga batch |
|
| Evaluator API | 9300 | Deployment/CronJob | Por carga batch |
|
||||||
| Frontend Demo | 5173 | Opcional | Não crítico |
|
| Frontend Demo | 5173 | Opcional | Não crítico |
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
# SPEC-003 — AI Gateway
|
|
||||||
|
|
||||||
## Escopo
|
|
||||||
|
|
||||||
O AI Gateway executa chamadas de inferência e embedding por contrato padronizado. Ele centraliza provider, modelo, profile, política, fallback, uso, custo, latência e telemetria.
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
| Método | Endpoint | Descrição |
|
|
||||||
|---|---|---|
|
|
||||||
| `POST` | `/v1/chat/completions` | Geração de texto/chat. |
|
|
||||||
| `POST` | `/v1/embeddings` | Geração de embeddings. |
|
|
||||||
| `GET` | `/v1/models` | Lista modelos disponíveis. |
|
|
||||||
| `GET` | `/v1/profiles` | Lista profiles configurados. |
|
|
||||||
| `GET` | `/health` | Health check. |
|
|
||||||
| `GET` | `/ready` | Readiness check. |
|
|
||||||
|
|
||||||
## LLMRequest
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tenant_id": "default",
|
|
||||||
"agent_id": "telecom_contas",
|
|
||||||
"profile": "judge",
|
|
||||||
"component": "judge",
|
|
||||||
"operation": "chat_completion",
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": "Você é um avaliador."},
|
|
||||||
{"role": "user", "content": "Avalie a resposta."}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"temperature": 0,
|
|
||||||
"max_tokens": 800
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"request_id": "req-001",
|
|
||||||
"trace_id": "trace-001",
|
|
||||||
"session_id": "default:telecom_contas:session-001"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## LLMResponse
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"provider": "oci_openai",
|
|
||||||
"model": "openai.gpt-4.1",
|
|
||||||
"profile": "judge",
|
|
||||||
"content": "Resultado da geração.",
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 1200,
|
|
||||||
"output_tokens": 300,
|
|
||||||
"total_tokens": 1500
|
|
||||||
},
|
|
||||||
"latency_ms": 820,
|
|
||||||
"finish_reason": "stop",
|
|
||||||
"metadata": {
|
|
||||||
"fallback_used": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Profiles
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
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
|
|
||||||
|
|
||||||
judge:
|
|
||||||
provider: oci_openai
|
|
||||||
model: openai.gpt-4.1
|
|
||||||
temperature: 0
|
|
||||||
max_tokens: 800
|
|
||||||
|
|
||||||
rag_generation:
|
|
||||||
provider: oci_openai
|
|
||||||
model: openai.gpt-4.1
|
|
||||||
temperature: 0.1
|
|
||||||
max_tokens: 1800
|
|
||||||
```
|
|
||||||
|
|
||||||
## Providers
|
|
||||||
|
|
||||||
| Provider | Autenticação | Uso |
|
|
||||||
|---|---|---|
|
|
||||||
| `mock` | Nenhuma | Testes locais. |
|
|
||||||
| `oci_openai` | API Key / endpoint compatible | OCI GenAI OpenAI-Compatible. |
|
|
||||||
| `oci_sdk` | OCI Auth Mode | SDK nativo OCI. |
|
|
||||||
| `openai_compatible` | API Key | Endpoint compatível OpenAI. |
|
|
||||||
|
|
||||||
## OCI Authentication
|
|
||||||
|
|
||||||
| Variável | Valores |
|
|
||||||
|---|---|
|
|
||||||
| `LLM_PROVIDER` | `mock`, `oci_openai`, `oci_sdk`, `openai_compatible` |
|
|
||||||
| `OCI_AUTH_MODE` | `config_file`, `instance_principal`, `resource_principal`, `workload_identity` |
|
|
||||||
| `OCI_GENAI_API_KEY` | API key para provider compatível |
|
|
||||||
|
|
||||||
## Fallback
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
fallback:
|
|
||||||
enabled: true
|
|
||||||
policies:
|
|
||||||
default:
|
|
||||||
chain:
|
|
||||||
- profile: default
|
|
||||||
- profile: default_low_cost
|
|
||||||
judge:
|
|
||||||
enabled: false
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rate Limit
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
rate_limits:
|
|
||||||
default:
|
|
||||||
requests_per_minute: 600
|
|
||||||
tokens_per_minute: 1000000
|
|
||||||
agents:
|
|
||||||
telecom_contas:
|
|
||||||
requests_per_minute: 120
|
|
||||||
```
|
|
||||||
|
|
||||||
## Eventos
|
|
||||||
|
|
||||||
| Evento | Descrição |
|
|
||||||
|---|---|
|
|
||||||
| `ai.request.received` | Requisição recebida. |
|
|
||||||
| `ai.profile.resolved` | Profile resolvido. |
|
|
||||||
| `ai.provider.selected` | Provider selecionado. |
|
|
||||||
| `ai.completion.started` | Chamada iniciada. |
|
|
||||||
| `ai.completion.completed` | Chamada concluída. |
|
|
||||||
| `ai.fallback.used` | Fallback utilizado. |
|
|
||||||
| `ai.request.failed` | Falha de inferência. |
|
|
||||||
|
|
||||||
## Métricas
|
|
||||||
|
|
||||||
| Métrica | Dimensões |
|
|
||||||
|---|---|
|
|
||||||
| `ai_requests_total` | provider, model, profile, tenant, agent, status |
|
|
||||||
| `ai_latency_ms` | provider, model, profile |
|
|
||||||
| `ai_tokens_total` | provider, model, input/output |
|
|
||||||
| `ai_cost_estimated` | provider, model, tenant, agent |
|
|
||||||
| `ai_fallback_total` | source_profile, fallback_profile |
|
|
||||||
|
|
||||||
## Segurança
|
|
||||||
|
|
||||||
- API keys não são gravadas em logs.
|
|
||||||
- Prompts podem ser mascarados conforme política.
|
|
||||||
- Providers são autorizados por tenant.
|
|
||||||
- Workload Identity é usada em Kubernetes quando configurada.
|
|
||||||
- Modelos bloqueados por política retornam erro controlado.
|
|
||||||
|
|
||||||
|
|
||||||
## Requisitos Não Funcionais
|
|
||||||
|
|
||||||
| Categoria | Requisito |
|
|
||||||
|---|---|
|
|
||||||
| Disponibilidade | Componentes deployáveis expõem `/health` e `/ready`. |
|
|
||||||
| Escalabilidade | Apps stateless escalam horizontalmente. Estado conversacional fica em repositórios externos. |
|
|
||||||
| Segurança | Segredos são fornecidos por secret store ou Kubernetes Secrets. |
|
|
||||||
| Observabilidade | Logs, métricas e traces usam correlação por request_id, trace_id, session_id, tenant_id e agent_id. |
|
|
||||||
| Auditabilidade | Decisões de rota, guardrail, judge, MCP e LLM são rastreáveis. |
|
|
||||||
| Portabilidade | Execução suportada em local, Docker Compose e Kubernetes/OKE. |
|
|
||||||
| Configuração | Comportamento variável é controlado por `.env` e YAML versionado. |
|
|
||||||
|
|
||||||
|
|
||||||
## Critérios de Aceite
|
|
||||||
|
|
||||||
- [ ] Endpoint de chat completion aceita LLMRequest.
|
|
||||||
- [ ] Endpoint de embeddings aceita EmbeddingRequest.
|
|
||||||
- [ ] Profiles são resolvidos por `llm_profiles.yaml`.
|
|
||||||
- [ ] Provider/model/profile aparecem em logs e traces.
|
|
||||||
- [ ] Fallback é explícito e rastreável.
|
|
||||||
- [ ] Rate limit é aplicado por tenant/agente.
|
|
||||||
- [ ] OCI Auth funciona por ambiente.
|
|
||||||
- [ ] Modelo não autorizado é rejeitado.
|
|
||||||
- [ ] Uso de tokens é registrado.
|
|
||||||
- [ ] Erros retornam payload padronizado.
|
|
||||||
|
|
||||||
|
|
||||||
## Glossário
|
|
||||||
|
|
||||||
| Termo | Definição |
|
|
||||||
|---|---|
|
|
||||||
| Agent Platform | Plataforma composta por runtime, gateways, evaluator, templates, contratos e componentes operacionais. |
|
|
||||||
| Agent Framework | Biblioteca/core reutilizável com contratos, guardrails, judges, memória, telemetria, providers e utilitários. |
|
|
||||||
| Agent Runtime | Motor de execução de agentes baseado em LangGraph, estado, sessão, memória, checkpoints, roteamento e ciclo de vida. |
|
|
||||||
| Agent Gateway | Aplicação deployável de entrada, roteamento e orquestração entre backends/agentes. |
|
|
||||||
| Channel Gateway | Aplicação ou módulo de normalização de payloads de canais para GatewayRequest. |
|
|
||||||
| AI Gateway | Aplicação de governança, roteamento e abstração de chamadas LLM/embedding. |
|
|
||||||
| MCP Gateway | Aplicação de governança e roteamento de tools MCP. |
|
|
||||||
| Evaluator | Camada de avaliação online/offline, regressão e certificação. |
|
|
||||||
| Business Context | Conjunto de chaves canônicas de negócio: customer_key, contract_key, interaction_key, account_key, resource_key e session_key. |
|
|
||||||
273
specs/SPEC-003-Agent-Gateway.md
Normal file
273
specs/SPEC-003-Agent-Gateway.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# SPEC-003 — Agent Gateway
|
||||||
|
|
||||||
|
## Escopo
|
||||||
|
|
||||||
|
O Agent Gateway é o ponto único de entrada da plataforma para canais e consumidores externos.
|
||||||
|
|
||||||
|
Sua responsabilidade é receber mensagens, gerenciar sessões globais, resolver o backend/agente responsável, executar roteamento, realizar handoff entre agentes/backends e encaminhar eventos SSE.
|
||||||
|
|
||||||
|
O Agent Gateway não executa inferência LLM nem embeddings. Essas capacidades pertencem ao Runtime e ao Agent Framework.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsabilidades
|
||||||
|
|
||||||
|
### Entrada Única da Plataforma
|
||||||
|
|
||||||
|
```text
|
||||||
|
Web
|
||||||
|
WhatsApp
|
||||||
|
Voice
|
||||||
|
Teams
|
||||||
|
Slack
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Agent Gateway
|
||||||
|
|
|
||||||
|
+--> Agent Backend A
|
||||||
|
|
|
||||||
|
+--> Agent Backend B
|
||||||
|
|
|
||||||
|
+--> Agent Backend C
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gerenciamento de Sessões
|
||||||
|
|
||||||
|
Responsável por:
|
||||||
|
|
||||||
|
- Criação de sessões
|
||||||
|
- Recuperação de sessões
|
||||||
|
- Atualização de contexto global
|
||||||
|
- Persistência de metadados de sessão
|
||||||
|
- Correlação de requisições
|
||||||
|
|
||||||
|
Exemplo:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "default:telecom_contas:123",
|
||||||
|
"tenant_id": "default",
|
||||||
|
"active_backend": "telecom_contas",
|
||||||
|
"active_agent": "telecom_contas",
|
||||||
|
"turn_count": 12,
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Routing
|
||||||
|
|
||||||
|
Resolve qual backend deve processar a mensagem.
|
||||||
|
|
||||||
|
Exemplo:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
backends:
|
||||||
|
telecom_contas:
|
||||||
|
url: http://backend-contas:8000
|
||||||
|
|
||||||
|
telecom_ofertas:
|
||||||
|
url: http://backend-ofertas:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Critérios possíveis:
|
||||||
|
|
||||||
|
- Backend padrão
|
||||||
|
- Regras YAML
|
||||||
|
- Intenção detectada
|
||||||
|
- Contexto da sessão
|
||||||
|
- Router LLM (opcional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
Permite transferência entre agentes ou backends.
|
||||||
|
|
||||||
|
Exemplo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Contas
|
||||||
|
|
|
||||||
|
+--> Ofertas
|
||||||
|
|
|
||||||
|
+--> Retenção
|
||||||
|
```
|
||||||
|
|
||||||
|
O handoff deve preservar:
|
||||||
|
|
||||||
|
- session_id
|
||||||
|
- conversation_key
|
||||||
|
- business context
|
||||||
|
- histórico da conversa
|
||||||
|
- metadados de correlação
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSE Proxy
|
||||||
|
|
||||||
|
Responsável por encaminhar eventos de streaming para clientes.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Método | Endpoint |
|
||||||
|
|----------|----------|
|
||||||
|
| POST | /gateway/message |
|
||||||
|
| POST | /gateway/message/sse |
|
||||||
|
| GET | /gateway/events/{session_id} |
|
||||||
|
|
||||||
|
Eventos SSE suportados:
|
||||||
|
|
||||||
|
- connected
|
||||||
|
- workflow.started
|
||||||
|
- message.responded
|
||||||
|
- workflow.completed
|
||||||
|
- flow.end
|
||||||
|
- error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Discovery
|
||||||
|
|
||||||
|
Pode operar com catálogo estático ou descoberta dinâmica.
|
||||||
|
|
||||||
|
### Catálogo Estático
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
backends:
|
||||||
|
telecom_contas:
|
||||||
|
url: http://contas:8000
|
||||||
|
|
||||||
|
telecom_ofertas:
|
||||||
|
url: http://ofertas:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Descoberta Dinâmica
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service_discovery:
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Capacidades:
|
||||||
|
|
||||||
|
- Registro automático
|
||||||
|
- Health check periódico
|
||||||
|
- Atualização de catálogo
|
||||||
|
- Sincronização de metadados
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health e Operação
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Método | Endpoint |
|
||||||
|
|----------|----------|
|
||||||
|
| GET | /health |
|
||||||
|
| GET | /ready |
|
||||||
|
| GET | /backends |
|
||||||
|
| GET | /debug/sessions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contrato GatewayRequest
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenant_id": "default",
|
||||||
|
"agent_id": "telecom_contas",
|
||||||
|
"session_id": "default:telecom_contas:123",
|
||||||
|
"message": "Quero consultar minha fatura",
|
||||||
|
"business_context": {
|
||||||
|
"customer_key": "11999999999"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"request_id": "req-001",
|
||||||
|
"trace_id": "trace-001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contrato GatewayResponse
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "default:telecom_contas:123",
|
||||||
|
"backend": "telecom_contas",
|
||||||
|
"agent": "telecom_contas",
|
||||||
|
"message": "Sua fatura está disponível.",
|
||||||
|
"metadata": {
|
||||||
|
"request_id": "req-001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Eventos
|
||||||
|
|
||||||
|
| Evento | Descrição |
|
||||||
|
|----------|----------|
|
||||||
|
| agent.gateway.request.received | Requisição recebida |
|
||||||
|
| agent.gateway.session.created | Sessão criada |
|
||||||
|
| agent.gateway.backend.selected | Backend selecionado |
|
||||||
|
| agent.gateway.handoff.started | Handoff iniciado |
|
||||||
|
| agent.gateway.handoff.completed | Handoff concluído |
|
||||||
|
| agent.gateway.sse.connected | Cliente SSE conectado |
|
||||||
|
| agent.gateway.request.failed | Falha de processamento |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Métricas
|
||||||
|
|
||||||
|
| Métrica | Dimensões |
|
||||||
|
|----------|----------|
|
||||||
|
| gateway_requests_total | tenant, backend, agent, status |
|
||||||
|
| gateway_sessions_active | tenant |
|
||||||
|
| gateway_backend_selection_total | backend |
|
||||||
|
| gateway_handoff_total | origem, destino |
|
||||||
|
| gateway_latency_ms | backend |
|
||||||
|
| gateway_sse_connections | backend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segurança
|
||||||
|
|
||||||
|
- Autenticação obrigatória quando configurada.
|
||||||
|
- Propagação de identidade entre gateways.
|
||||||
|
- Máscara de dados sensíveis em logs.
|
||||||
|
- Correlação por request_id, trace_id e session_id.
|
||||||
|
- Controle de acesso por tenant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requisitos Não Funcionais
|
||||||
|
|
||||||
|
| Categoria | Requisito |
|
||||||
|
|----------|----------|
|
||||||
|
| Disponibilidade | Expor /health e /ready |
|
||||||
|
| Escalabilidade | Stateless com escala horizontal |
|
||||||
|
| Observabilidade | Logs, métricas e traces |
|
||||||
|
| Auditabilidade | Todas as decisões de roteamento rastreáveis |
|
||||||
|
| Segurança | Segredos externos e mascaramento |
|
||||||
|
| Portabilidade | Local, Docker e Kubernetes |
|
||||||
|
| Configuração | YAML e variáveis de ambiente |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critérios de Aceite
|
||||||
|
|
||||||
|
- [ ] Recebe mensagens de múltiplos canais.
|
||||||
|
- [ ] Seleciona backend corretamente.
|
||||||
|
- [ ] Mantém sessão global.
|
||||||
|
- [ ] Encaminha SSE.
|
||||||
|
- [ ] Executa handoff.
|
||||||
|
- [ ] Preserva Business Context.
|
||||||
|
- [ ] Suporta múltiplos backends.
|
||||||
|
- [ ] Permite descoberta dinâmica.
|
||||||
|
- [ ] Expõe health e readiness.
|
||||||
|
- [ ] Gera métricas e telemetria.
|
||||||
@@ -172,7 +172,7 @@ Itens considerados:
|
|||||||
```bash
|
```bash
|
||||||
curl -f http://agent-runtime:8000/health
|
curl -f http://agent-runtime:8000/health
|
||||||
curl -f http://agent-gateway:9000/health
|
curl -f http://agent-gateway:9000/health
|
||||||
curl -f http://mcp-gateway:9200/health
|
curl -f http://mcp-gateway:8300/health
|
||||||
curl -f http://ai-gateway:9100/health
|
curl -f http://ai-gateway:9100/health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Esta SPEC define o padrão para criação de agentes usando templates, configura
|
|||||||
## Estrutura do Template
|
## Estrutura do Template
|
||||||
|
|
||||||
```text
|
```text
|
||||||
templates/backend/
|
templates/agent_template_backend/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── main.py
|
│ ├── main.py
|
||||||
│ ├── state.py
|
│ ├── state.py
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Sem template:
|
|||||||
| Template | Uso |
|
| Template | Uso |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| backend | Criação de agentes com runtime. |
|
| backend | Criação de agentes com runtime. |
|
||||||
| backend_day_zero | Bootstrap acelerado com exemplos. |
|
| agent_template_backend_day_zero | Bootstrap acelerado com exemplos. |
|
||||||
| mcp_server | Criação de MCP server. |
|
| mcp_server | Criação de MCP server. |
|
||||||
| channel_adapter | Adapter de canal quando aplicável. |
|
| channel_adapter | Adapter de canal quando aplicável. |
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ my_agent/
|
|||||||
## Passo 1 — Copiar template
|
## Passo 1 — Copiar template
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -R templates/backend financeiro_agent
|
cp -R templates/agent_template_backend financeiro_agent
|
||||||
cd financeiro_agent
|
cd financeiro_agent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ OCI_GENAI_MODEL=openai.gpt-4.1
|
|||||||
OCI_GENAI_API_KEY=sk-ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6
|
OCI_GENAI_API_KEY=sk-ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6
|
||||||
OCI_GENAI_PROJECT_OCID=
|
OCI_GENAI_PROJECT_OCID=
|
||||||
|
|
||||||
# OCI_AUTH_MODE=config_file|instance_principal|resource_principal
|
|
||||||
OCI_AUTH_MODE=config_file
|
|
||||||
# OCI SDK / signer / profiles
|
# OCI SDK / signer / profiles
|
||||||
OCI_CONFIG_FILE=~/.oci/config
|
OCI_CONFIG_FILE=~/.oci/config
|
||||||
OCI_PROFILE=DEFAULT
|
OCI_PROFILE=DEFAULT
|
||||||
@@ -37,9 +35,10 @@ OCI_REGION=us-chicago-1
|
|||||||
# Persistência
|
# Persistência
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# Opções: memory, autonomous, mongodb
|
# Opções: memory, autonomous, mongodb
|
||||||
SESSION_REPOSITORY_PROVIDER=autonomous
|
SESSION_REPOSITORY_PROVIDER=sqlite
|
||||||
MEMORY_REPOSITORY_PROVIDER=autonomous
|
MEMORY_REPOSITORY_PROVIDER=sqlite
|
||||||
CHECKPOINT_REPOSITORY_PROVIDER=autonomous
|
CHECKPOINT_REPOSITORY_PROVIDER=sqlite
|
||||||
|
SQLITE_DB_PATH=./data/agent_framework.db
|
||||||
|
|
||||||
# Autonomous Database
|
# Autonomous Database
|
||||||
ADB_USER=admin
|
ADB_USER=admin
|
||||||
@@ -60,8 +59,8 @@ ENABLE_REDIS_CACHE=false
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
# RAG / Vector / Graph
|
# RAG / Vector / Graph
|
||||||
###############################################################################
|
###############################################################################
|
||||||
VECTOR_STORE_PROVIDER=memory
|
VECTOR_STORE_PROVIDER=sqlite
|
||||||
GRAPH_STORE_PROVIDER=memory
|
GRAPH_STORE_PROVIDER=sqlite
|
||||||
RAG_TOP_K=5
|
RAG_TOP_K=5
|
||||||
EMBEDDING_PROVIDER=mock
|
EMBEDDING_PROVIDER=mock
|
||||||
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
|
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
|
||||||
@@ -71,12 +70,14 @@ RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json
|
|||||||
# Observabilidade
|
# Observabilidade
|
||||||
###############################################################################
|
###############################################################################
|
||||||
ENABLE_LANGFUSE=true
|
ENABLE_LANGFUSE=true
|
||||||
LANGFUSE_PUBLIC_KEY=pk-lf-bd9b0c7e-2b8b-4e5b-a382-284a9b4413b3
|
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
|
||||||
LANGFUSE_SECRET_KEY=sk-lf-5f5cc18d-0bb5-424e-b5d0-cb3664d58c20
|
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
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
ENABLE_OTEL=false
|
ENABLE_OTEL=false
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
OTEL_SERVICE_NAME=ai-agent-template
|
OTEL_SERVICE_NAME=ai-agent-template
|
||||||
|
ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# Analytics / Observer corporativo
|
# Analytics / Observer corporativo
|
||||||
@@ -84,7 +85,7 @@ OTEL_SERVICE_NAME=ai-agent-template
|
|||||||
# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo.
|
# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo.
|
||||||
ENABLE_ANALYTICS=false
|
ENABLE_ANALYTICS=false
|
||||||
# Providers aceitos: oci_streaming,pubsub,noop
|
# Providers aceitos: oci_streaming,pubsub,noop
|
||||||
ANALYTICS_PROVIDERS=oci_streaming
|
ANALYTICS_PROVIDERS=pubsub
|
||||||
# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente.
|
# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente.
|
||||||
AGENT_PUBSUB_TOPIC=
|
AGENT_PUBSUB_TOPIC=
|
||||||
GCP_PUBSUB_TOPIC_PATH=
|
GCP_PUBSUB_TOPIC_PATH=
|
||||||
@@ -149,7 +150,7 @@ MCP_TOOL_TIMEOUT_SECONDS=30
|
|||||||
ROUTING_MODE=router
|
ROUTING_MODE=router
|
||||||
|
|
||||||
# Usage/cost accounting
|
# Usage/cost accounting
|
||||||
USAGE_REPOSITORY_PROVIDER=autonomous
|
USAGE_REPOSITORY_PROVIDER=sqlite
|
||||||
IDENTITY_CONFIG_PATH=./config/identity.yaml
|
IDENTITY_CONFIG_PATH=./config/identity.yaml
|
||||||
MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml
|
MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml
|
||||||
|
|
||||||
@@ -165,3 +166,15 @@ MEMORY_MAX_SUMMARY_CHARS=6000
|
|||||||
MEMORY_SUMMARY_USE_LLM=true
|
MEMORY_SUMMARY_USE_LLM=true
|
||||||
MEMORY_INJECT_RECENT_MESSAGES=true
|
MEMORY_INJECT_RECENT_MESSAGES=true
|
||||||
MEMORY_INJECT_SUMMARY=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
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ logger.info("Framework channel input mode: %s", gateway.input_mode)
|
|||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def observability_context_middleware(request: Request, call_next):
|
async def observability_context_middleware(request: Request, call_next):
|
||||||
|
clear_observability_context()
|
||||||
request_id = request.headers.get("x-request-id") or str(uuid4())
|
request_id = request.headers.get("x-request-id") or str(uuid4())
|
||||||
set_observability_context(
|
set_observability_context(
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
@@ -99,6 +100,8 @@ async def observability_context_middleware(request: Request, call_next):
|
|||||||
"duration_ms": int((time.time() - started) * 1000),
|
"duration_ms": int((time.time() - started) * 1000),
|
||||||
}, kind="http")
|
}, kind="http")
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
clear_observability_context()
|
||||||
|
|
||||||
|
|
||||||
class GatewayRequest(BaseModel):
|
class GatewayRequest(BaseModel):
|
||||||
@@ -108,6 +111,31 @@ class GatewayRequest(BaseModel):
|
|||||||
tenant_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]]:
|
def _resolve_identity(req: GatewayRequest, msg) -> tuple[AgentIdentity, dict, BusinessContext, list[str]]:
|
||||||
payload = req.payload or {}
|
payload = req.payload or {}
|
||||||
context = dict(msg.context or {})
|
context = dict(msg.context or {})
|
||||||
@@ -143,9 +171,11 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
|
|||||||
msg = await gateway.normalize(req.channel, req.payload)
|
msg = await gateway.normalize(req.channel, req.payload)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from 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)
|
identity, normalized_context, business_context, missing_identity_keys = _resolve_identity(req, msg)
|
||||||
agent_session_id = identity.conversation_key()
|
agent_session_id = identity.conversation_key()
|
||||||
message_id = (req.payload or {}).get("message_id") or str(uuid4())
|
message_id = payload.get("message_id") or str(uuid4())
|
||||||
|
workflow_id = _extract_workflow_id(payload)
|
||||||
set_observability_context(
|
set_observability_context(
|
||||||
session_id=agent_session_id,
|
session_id=agent_session_id,
|
||||||
user_id=msg.user_id,
|
user_id=msg.user_id,
|
||||||
@@ -153,7 +183,8 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
|
|||||||
agent_id=identity.agent_id,
|
agent_id=identity.agent_id,
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
ura_call_id=(req.payload or {}).get("ura_call_id") or normalized_context.get("ura_call_id") or business_context.interaction_key,
|
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)
|
stream = sse_hub.stream_for(agent_session_id)
|
||||||
@@ -209,34 +240,56 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
|
|||||||
await sse_hub.emit(agent_session_id, "message.received", {"session_id": agent_session_id, "role": "user"}) if emit_sse else None
|
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)]
|
history = [m.model_dump(mode="json") for m in await memory.list(agent_session_id)]
|
||||||
|
|
||||||
trace_input = {
|
cms_input = {
|
||||||
|
"channel": req.channel,
|
||||||
|
"tenant_id": req.tenant_id,
|
||||||
|
"agent_id": req.agent_id,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
trace_context = {
|
||||||
"text": msg.text,
|
"text": msg.text,
|
||||||
"channel": msg.channel,
|
"channel": msg.channel,
|
||||||
"channel_id": msg.channel_id,
|
"channel_id": msg.channel_id,
|
||||||
"tenant_id": identity.tenant_id,
|
"tenant_id": identity.tenant_id,
|
||||||
"agent_id": identity.agent_id,
|
"agent_id": identity.agent_id,
|
||||||
"conversation_key": agent_session_id,
|
"conversation_key": agent_session_id,
|
||||||
|
"workflow_id": workflow_id,
|
||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
"business_context": business_context.model_dump(),
|
"business_context": business_context.model_dump(),
|
||||||
"identity_missing": missing_identity_keys,
|
"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(
|
async with telemetry.span(
|
||||||
"agent.gateway_message",
|
root_span_name,
|
||||||
session_id=agent_session_id,
|
session_id=agent_session_id,
|
||||||
user_id=session.user_id,
|
user_id=session.user_id,
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
input=trace_input,
|
workflow_id=workflow_id,
|
||||||
tags=["agent-template", msg.channel, f"agent:{identity.agent_id}", f"tenant:{identity.tenant_id}"],
|
input=cms_input,
|
||||||
):
|
tags=root_tags,
|
||||||
await telemetry.event("gateway.message.received", trace_input)
|
_root_span=True,
|
||||||
await sse_hub.emit(agent_session_id, "workflow.started", trace_input) if emit_sse else None
|
) 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(
|
result = await workflow.ainvoke(
|
||||||
{
|
{
|
||||||
"tenant_id": identity.tenant_id,
|
"tenant_id": identity.tenant_id,
|
||||||
"agent_id": identity.agent_id,
|
"agent_id": identity.agent_id,
|
||||||
"session_id": agent_session_id,
|
"session_id": agent_session_id,
|
||||||
"conversation_key": agent_session_id,
|
"conversation_key": agent_session_id,
|
||||||
|
"workflow_id": workflow_id,
|
||||||
"agent_profile": normalized_context["agent_profile"],
|
"agent_profile": normalized_context["agent_profile"],
|
||||||
"user_text": msg.text,
|
"user_text": msg.text,
|
||||||
"history": history,
|
"history": history,
|
||||||
@@ -246,6 +299,7 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
|
|||||||
"original_session_id": msg.session_id,
|
"original_session_id": msg.session_id,
|
||||||
"session_id": agent_session_id,
|
"session_id": agent_session_id,
|
||||||
"conversation_key": agent_session_id,
|
"conversation_key": agent_session_id,
|
||||||
|
"workflow_id": workflow_id,
|
||||||
"user_id": session.user_id,
|
"user_id": session.user_id,
|
||||||
"channel": msg.channel,
|
"channel": msg.channel,
|
||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
@@ -256,66 +310,68 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
await checkpoints.put(agent_session_id, {"state": result, "message_id": message_id})
|
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
|
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 ""
|
answer = result.get("final_answer") or result.get("answer") or ""
|
||||||
await memory.append(
|
await memory.append(
|
||||||
agent_session_id,
|
agent_session_id,
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content=answer,
|
content=answer,
|
||||||
metadata={
|
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,
|
"tenant_id": identity.tenant_id,
|
||||||
"agent_id": identity.agent_id,
|
"agent_id": identity.agent_id,
|
||||||
"message_id": f"assistant-{message_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"),
|
"route": result.get("route"),
|
||||||
"intent": result.get("intent"),
|
"intent": result.get("intent"),
|
||||||
"route_decision": result.get("route_decision"),
|
"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"),
|
"judges": result.get("judge_results"),
|
||||||
|
"guardrails": result.get("guardrail_decisions"),
|
||||||
},
|
},
|
||||||
),
|
)
|
||||||
)
|
rendered = await gateway.render(response)
|
||||||
|
root_span.set_output(rendered)
|
||||||
await telemetry.event(
|
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
|
||||||
"gateway.message.responded",
|
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
|
||||||
"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,
|
|
||||||
"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)
|
|
||||||
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")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
)
|
||||||
@@ -6,6 +6,7 @@ class AgentState(TypedDict, total=False):
|
|||||||
agent_id: str
|
agent_id: str
|
||||||
session_id: str
|
session_id: str
|
||||||
conversation_key: str
|
conversation_key: str
|
||||||
|
workflow_id: str
|
||||||
agent_profile: dict[str, Any]
|
agent_profile: dict[str, Any]
|
||||||
user_text: str
|
user_text: str
|
||||||
sanitized_input: str
|
sanitized_input: str
|
||||||
|
|||||||
@@ -320,41 +320,36 @@ class AgentWorkflow:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def billing_agent(self, state):
|
async def billing_agent(self, state):
|
||||||
async with self.langgraph_telemetry.node("billing_agent", state):
|
async with self.telemetry.span(
|
||||||
async with self.telemetry.span(
|
"workflow.agent.billing",
|
||||||
"workflow.agent.billing",
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
input={"intent": state.get("intent")},
|
||||||
input={"intent": state.get("intent")},
|
):
|
||||||
):
|
return await self.billing.run(state)
|
||||||
return await self.billing.run(state)
|
|
||||||
|
|
||||||
async def product_agent(self, state):
|
async def product_agent(self, state):
|
||||||
async with self.langgraph_telemetry.node("product_agent", state):
|
async with self.telemetry.span(
|
||||||
async with self.telemetry.span(
|
"workflow.agent.product",
|
||||||
"workflow.agent.product",
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
input={"intent": state.get("intent")},
|
||||||
input={"intent": state.get("intent")},
|
):
|
||||||
):
|
return await self.product.run(state)
|
||||||
return await self.product.run(state)
|
|
||||||
|
|
||||||
async def orders_agent(self, state):
|
async def orders_agent(self, state):
|
||||||
async with self.langgraph_telemetry.node("orders_agent", state):
|
async with self.telemetry.span(
|
||||||
async with self.telemetry.span(
|
"workflow.agent.orders",
|
||||||
"workflow.agent.orders",
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
input={"intent": state.get("intent")},
|
||||||
input={"intent": state.get("intent")},
|
):
|
||||||
):
|
return await self.orders.run(state)
|
||||||
return await self.orders.run(state)
|
|
||||||
|
|
||||||
|
|
||||||
async def support_agent(self, state):
|
async def support_agent(self, state):
|
||||||
async with self.langgraph_telemetry.node("support_agent", state):
|
async with self.telemetry.span(
|
||||||
async with self.telemetry.span(
|
"workflow.agent.support",
|
||||||
"workflow.agent.support",
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
input={"intent": state.get("intent")},
|
||||||
input={"intent": state.get("intent")},
|
):
|
||||||
):
|
return await self.support.run(state)
|
||||||
return await self.support.run(state)
|
|
||||||
|
|
||||||
async def supervisor_agent(self, state):
|
async def supervisor_agent(self, state):
|
||||||
"""Executa um ou mais agentes no modo supervisor e consolida a resposta.
|
"""Executa um ou mais agentes no modo supervisor e consolida a resposta.
|
||||||
|
|||||||
Binary file not shown.
@@ -25,8 +25,6 @@ OCI_GENAI_MODEL=openai.gpt-4.1
|
|||||||
OCI_GENAI_API_KEY=sk-ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6
|
OCI_GENAI_API_KEY=sk-ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6
|
||||||
OCI_GENAI_PROJECT_OCID=
|
OCI_GENAI_PROJECT_OCID=
|
||||||
|
|
||||||
# OCI_AUTH_MODE=config_file|instance_principal|resource_principal
|
|
||||||
OCI_AUTH_MODE=config_file
|
|
||||||
# OCI SDK / signer / profiles
|
# OCI SDK / signer / profiles
|
||||||
OCI_CONFIG_FILE=~/.oci/config
|
OCI_CONFIG_FILE=~/.oci/config
|
||||||
OCI_PROFILE=DEFAULT
|
OCI_PROFILE=DEFAULT
|
||||||
@@ -37,9 +35,10 @@ OCI_REGION=us-chicago-1
|
|||||||
# Persistência
|
# Persistência
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# Opções: memory, autonomous, mongodb
|
# Opções: memory, autonomous, mongodb
|
||||||
SESSION_REPOSITORY_PROVIDER=autonomous
|
SESSION_REPOSITORY_PROVIDER=sqlite
|
||||||
MEMORY_REPOSITORY_PROVIDER=autonomous
|
MEMORY_REPOSITORY_PROVIDER=sqlite
|
||||||
CHECKPOINT_REPOSITORY_PROVIDER=autonomous
|
CHECKPOINT_REPOSITORY_PROVIDER=sqlite
|
||||||
|
SQLITE_DB_PATH=./data/agent_framework.db
|
||||||
|
|
||||||
# Autonomous Database
|
# Autonomous Database
|
||||||
ADB_USER=admin
|
ADB_USER=admin
|
||||||
@@ -60,8 +59,8 @@ ENABLE_REDIS_CACHE=false
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
# RAG / Vector / Graph
|
# RAG / Vector / Graph
|
||||||
###############################################################################
|
###############################################################################
|
||||||
VECTOR_STORE_PROVIDER=memory
|
VECTOR_STORE_PROVIDER=sqlite
|
||||||
GRAPH_STORE_PROVIDER=memory
|
GRAPH_STORE_PROVIDER=sqlite
|
||||||
RAG_TOP_K=5
|
RAG_TOP_K=5
|
||||||
EMBEDDING_PROVIDER=mock
|
EMBEDDING_PROVIDER=mock
|
||||||
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
|
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
|
||||||
@@ -71,12 +70,14 @@ RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json
|
|||||||
# Observabilidade
|
# Observabilidade
|
||||||
###############################################################################
|
###############################################################################
|
||||||
ENABLE_LANGFUSE=true
|
ENABLE_LANGFUSE=true
|
||||||
LANGFUSE_PUBLIC_KEY=pk-lf-bd9b0c7e-2b8b-4e5b-a382-284a9b4413b3
|
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
|
||||||
LANGFUSE_SECRET_KEY=sk-lf-5f5cc18d-0bb5-424e-b5d0-cb3664d58c20
|
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
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
ENABLE_OTEL=false
|
ENABLE_OTEL=false
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
OTEL_SERVICE_NAME=ai-agent-template
|
OTEL_SERVICE_NAME=ai-agent-template
|
||||||
|
ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# Analytics / Observer corporativo
|
# Analytics / Observer corporativo
|
||||||
@@ -84,7 +85,7 @@ OTEL_SERVICE_NAME=ai-agent-template
|
|||||||
# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo.
|
# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo.
|
||||||
ENABLE_ANALYTICS=false
|
ENABLE_ANALYTICS=false
|
||||||
# Providers aceitos: oci_streaming,pubsub,noop
|
# Providers aceitos: oci_streaming,pubsub,noop
|
||||||
ANALYTICS_PROVIDERS=oci_streaming
|
ANALYTICS_PROVIDERS=pubsub
|
||||||
# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente.
|
# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente.
|
||||||
AGENT_PUBSUB_TOPIC=
|
AGENT_PUBSUB_TOPIC=
|
||||||
GCP_PUBSUB_TOPIC_PATH=
|
GCP_PUBSUB_TOPIC_PATH=
|
||||||
@@ -121,9 +122,6 @@ PROMPT_POLICY_PATH=./config/prompt_policy.yaml
|
|||||||
# Gateway de canais
|
# Gateway de canais
|
||||||
###############################################################################
|
###############################################################################
|
||||||
DEFAULT_CHANNEL=web
|
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_VOICE_ADAPTER=true
|
||||||
ENABLE_WHATSAPP_ADAPTER=true
|
ENABLE_WHATSAPP_ADAPTER=true
|
||||||
ENABLE_TEXT_ADAPTER=true
|
ENABLE_TEXT_ADAPTER=true
|
||||||
@@ -149,7 +147,7 @@ MCP_TOOL_TIMEOUT_SECONDS=30
|
|||||||
ROUTING_MODE=router
|
ROUTING_MODE=router
|
||||||
|
|
||||||
# Usage/cost accounting
|
# Usage/cost accounting
|
||||||
USAGE_REPOSITORY_PROVIDER=autonomous
|
USAGE_REPOSITORY_PROVIDER=sqlite
|
||||||
IDENTITY_CONFIG_PATH=./config/identity.yaml
|
IDENTITY_CONFIG_PATH=./config/identity.yaml
|
||||||
MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml
|
MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml
|
||||||
|
|
||||||
@@ -165,3 +163,15 @@ MEMORY_MAX_SUMMARY_CHARS=6000
|
|||||||
MEMORY_SUMMARY_USE_LLM=true
|
MEMORY_SUMMARY_USE_LLM=true
|
||||||
MEMORY_INJECT_RECENT_MESSAGES=true
|
MEMORY_INJECT_RECENT_MESSAGES=true
|
||||||
MEMORY_INJECT_SUMMARY=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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import logging
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -52,7 +52,7 @@ summary_memory = create_conversation_summary_memory(settings, message_history=me
|
|||||||
sessions = create_session_repository(settings)
|
sessions = create_session_repository(settings)
|
||||||
checkpoints = create_checkpoint_repository(settings)
|
checkpoints = create_checkpoint_repository(settings)
|
||||||
cache = create_cache(settings, telemetry=telemetry)
|
cache = create_cache(settings, telemetry=telemetry)
|
||||||
gateway = ChannelGateway()
|
gateway = ChannelGateway(input_mode=settings.FRAMEWORK_CHANNEL_INPUT_MODE)
|
||||||
analytics = create_analytics_publisher(settings)
|
analytics = create_analytics_publisher(settings)
|
||||||
observer = TelemetryBackedAgentObserver(telemetry=telemetry)
|
observer = TelemetryBackedAgentObserver(telemetry=telemetry)
|
||||||
configure_global_observer({
|
configure_global_observer({
|
||||||
@@ -70,9 +70,11 @@ logger.info("LLM provider carregado: %s", llm.__class__.__name__)
|
|||||||
logger.info("Langfuse habilitado: %s host=%s", telemetry.is_enabled(), settings.LANGFUSE_HOST)
|
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("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("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")
|
@app.middleware("http")
|
||||||
async def observability_context_middleware(request: Request, call_next):
|
async def observability_context_middleware(request: Request, call_next):
|
||||||
|
clear_observability_context()
|
||||||
request_id = request.headers.get("x-request-id") or str(uuid4())
|
request_id = request.headers.get("x-request-id") or str(uuid4())
|
||||||
set_observability_context(
|
set_observability_context(
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
@@ -98,6 +100,8 @@ async def observability_context_middleware(request: Request, call_next):
|
|||||||
"duration_ms": int((time.time() - started) * 1000),
|
"duration_ms": int((time.time() - started) * 1000),
|
||||||
}, kind="http")
|
}, kind="http")
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
clear_observability_context()
|
||||||
|
|
||||||
|
|
||||||
class GatewayRequest(BaseModel):
|
class GatewayRequest(BaseModel):
|
||||||
@@ -107,6 +111,31 @@ class GatewayRequest(BaseModel):
|
|||||||
tenant_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]]:
|
def _resolve_identity(req: GatewayRequest, msg) -> tuple[AgentIdentity, dict, BusinessContext, list[str]]:
|
||||||
payload = req.payload or {}
|
payload = req.payload or {}
|
||||||
context = dict(msg.context or {})
|
context = dict(msg.context or {})
|
||||||
@@ -138,10 +167,15 @@ def _resolve_identity(req: GatewayRequest, msg) -> tuple[AgentIdentity, dict, Bu
|
|||||||
|
|
||||||
|
|
||||||
async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False) -> dict:
|
async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False) -> dict:
|
||||||
msg = await gateway.normalize(req.channel, req.payload)
|
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)
|
identity, normalized_context, business_context, missing_identity_keys = _resolve_identity(req, msg)
|
||||||
agent_session_id = identity.conversation_key()
|
agent_session_id = identity.conversation_key()
|
||||||
message_id = (req.payload or {}).get("message_id") or str(uuid4())
|
message_id = payload.get("message_id") or str(uuid4())
|
||||||
|
workflow_id = _extract_workflow_id(payload)
|
||||||
set_observability_context(
|
set_observability_context(
|
||||||
session_id=agent_session_id,
|
session_id=agent_session_id,
|
||||||
user_id=msg.user_id,
|
user_id=msg.user_id,
|
||||||
@@ -149,7 +183,8 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
|
|||||||
agent_id=identity.agent_id,
|
agent_id=identity.agent_id,
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
ura_call_id=(req.payload or {}).get("ura_call_id") or normalized_context.get("ura_call_id") or business_context.interaction_key,
|
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)
|
stream = sse_hub.stream_for(agent_session_id)
|
||||||
@@ -205,34 +240,56 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
|
|||||||
await sse_hub.emit(agent_session_id, "message.received", {"session_id": agent_session_id, "role": "user"}) if emit_sse else None
|
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)]
|
history = [m.model_dump(mode="json") for m in await memory.list(agent_session_id)]
|
||||||
|
|
||||||
trace_input = {
|
cms_input = {
|
||||||
|
"channel": req.channel,
|
||||||
|
"tenant_id": req.tenant_id,
|
||||||
|
"agent_id": req.agent_id,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
trace_context = {
|
||||||
"text": msg.text,
|
"text": msg.text,
|
||||||
"channel": msg.channel,
|
"channel": msg.channel,
|
||||||
"channel_id": msg.channel_id,
|
"channel_id": msg.channel_id,
|
||||||
"tenant_id": identity.tenant_id,
|
"tenant_id": identity.tenant_id,
|
||||||
"agent_id": identity.agent_id,
|
"agent_id": identity.agent_id,
|
||||||
"conversation_key": agent_session_id,
|
"conversation_key": agent_session_id,
|
||||||
|
"workflow_id": workflow_id,
|
||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
"business_context": business_context.model_dump(),
|
"business_context": business_context.model_dump(),
|
||||||
"identity_missing": missing_identity_keys,
|
"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(
|
async with telemetry.span(
|
||||||
"agent.gateway_message",
|
root_span_name,
|
||||||
session_id=agent_session_id,
|
session_id=agent_session_id,
|
||||||
user_id=session.user_id,
|
user_id=session.user_id,
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
input=trace_input,
|
workflow_id=workflow_id,
|
||||||
tags=["agent-template", msg.channel, f"agent:{identity.agent_id}", f"tenant:{identity.tenant_id}"],
|
input=cms_input,
|
||||||
):
|
tags=root_tags,
|
||||||
await telemetry.event("gateway.message.received", trace_input)
|
_root_span=True,
|
||||||
await sse_hub.emit(agent_session_id, "workflow.started", trace_input) if emit_sse else None
|
) 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(
|
result = await workflow.ainvoke(
|
||||||
{
|
{
|
||||||
"tenant_id": identity.tenant_id,
|
"tenant_id": identity.tenant_id,
|
||||||
"agent_id": identity.agent_id,
|
"agent_id": identity.agent_id,
|
||||||
"session_id": agent_session_id,
|
"session_id": agent_session_id,
|
||||||
"conversation_key": agent_session_id,
|
"conversation_key": agent_session_id,
|
||||||
|
"workflow_id": workflow_id,
|
||||||
"agent_profile": normalized_context["agent_profile"],
|
"agent_profile": normalized_context["agent_profile"],
|
||||||
"user_text": msg.text,
|
"user_text": msg.text,
|
||||||
"history": history,
|
"history": history,
|
||||||
@@ -242,6 +299,7 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
|
|||||||
"original_session_id": msg.session_id,
|
"original_session_id": msg.session_id,
|
||||||
"session_id": agent_session_id,
|
"session_id": agent_session_id,
|
||||||
"conversation_key": agent_session_id,
|
"conversation_key": agent_session_id,
|
||||||
|
"workflow_id": workflow_id,
|
||||||
"user_id": session.user_id,
|
"user_id": session.user_id,
|
||||||
"channel": msg.channel,
|
"channel": msg.channel,
|
||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
@@ -252,66 +310,68 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
await checkpoints.put(agent_session_id, {"state": result, "message_id": message_id})
|
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
|
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 ""
|
answer = result.get("final_answer") or result.get("answer") or ""
|
||||||
await memory.append(
|
await memory.append(
|
||||||
agent_session_id,
|
agent_session_id,
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content=answer,
|
content=answer,
|
||||||
metadata={
|
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,
|
"tenant_id": identity.tenant_id,
|
||||||
"agent_id": identity.agent_id,
|
"agent_id": identity.agent_id,
|
||||||
"message_id": f"assistant-{message_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"),
|
"route": result.get("route"),
|
||||||
"intent": result.get("intent"),
|
"intent": result.get("intent"),
|
||||||
"route_decision": result.get("route_decision"),
|
"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"),
|
"judges": result.get("judge_results"),
|
||||||
|
"guardrails": result.get("guardrail_decisions"),
|
||||||
},
|
},
|
||||||
),
|
)
|
||||||
)
|
rendered = await gateway.render(response)
|
||||||
|
root_span.set_output(rendered)
|
||||||
await telemetry.event(
|
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
|
||||||
"gateway.message.responded",
|
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
|
||||||
"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,
|
|
||||||
"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)
|
|
||||||
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")
|
@app.get("/health")
|
||||||
@@ -331,6 +391,8 @@ async def health():
|
|||||||
"usage_repository": settings.USAGE_REPOSITORY_PROVIDER,
|
"usage_repository": settings.USAGE_REPOSITORY_PROVIDER,
|
||||||
"identity_config_path": settings.IDENTITY_CONFIG_PATH,
|
"identity_config_path": settings.IDENTITY_CONFIG_PATH,
|
||||||
"mcp_parameter_mapping_path": settings.MCP_PARAMETER_MAPPING_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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -354,6 +416,8 @@ async def debug_env():
|
|||||||
"AGENTS_CONFIG_PATH": settings.AGENTS_CONFIG_PATH,
|
"AGENTS_CONFIG_PATH": settings.AGENTS_CONFIG_PATH,
|
||||||
"ROUTING_CONFIG_PATH": settings.ROUTING_CONFIG_PATH,
|
"ROUTING_CONFIG_PATH": settings.ROUTING_CONFIG_PATH,
|
||||||
"ROUTING_MODE": settings.ROUTING_MODE,
|
"ROUTING_MODE": settings.ROUTING_MODE,
|
||||||
|
"FRAMEWORK_CHANNEL_INPUT_MODE": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
|
||||||
|
"CHANNEL_GATEWAY_MODE": settings.CHANNEL_GATEWAY_MODE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
)
|
||||||
@@ -6,6 +6,7 @@ class AgentState(TypedDict, total=False):
|
|||||||
agent_id: str
|
agent_id: str
|
||||||
session_id: str
|
session_id: str
|
||||||
conversation_key: str
|
conversation_key: str
|
||||||
|
workflow_id: str
|
||||||
agent_profile: dict[str, Any]
|
agent_profile: dict[str, Any]
|
||||||
user_text: str
|
user_text: str
|
||||||
sanitized_input: str
|
sanitized_input: str
|
||||||
|
|||||||
@@ -187,6 +187,16 @@ class AgentWorkflow:
|
|||||||
input=state.get("user_text"),
|
input=state.get("user_text"),
|
||||||
):
|
):
|
||||||
history_texts = [m.get("content", "") for m in state.get("history", [])]
|
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(
|
sanitized, decisions = await self.guardrails.run_input(
|
||||||
state["user_text"],
|
state["user_text"],
|
||||||
{
|
{
|
||||||
@@ -199,6 +209,19 @@ class AgentWorkflow:
|
|||||||
)
|
)
|
||||||
for _decision in decisions:
|
for _decision in decisions:
|
||||||
await self.guardrail_telemetry.evaluated("input", _decision)
|
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:
|
if not _decision.allowed:
|
||||||
await self.guardrail_telemetry.blocked("input", _decision)
|
await self.guardrail_telemetry.blocked("input", _decision)
|
||||||
await self.telemetry.event(
|
await self.telemetry.event(
|
||||||
@@ -210,6 +233,18 @@ class AgentWorkflow:
|
|||||||
"decisions": [d.model_dump() for d in decisions],
|
"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):
|
if any(not d.allowed for d in decisions):
|
||||||
return {
|
return {
|
||||||
"sanitized_input": sanitized,
|
"sanitized_input": sanitized,
|
||||||
@@ -285,41 +320,36 @@ class AgentWorkflow:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def billing_agent(self, state):
|
async def billing_agent(self, state):
|
||||||
async with self.langgraph_telemetry.node("billing_agent", state):
|
async with self.telemetry.span(
|
||||||
async with self.telemetry.span(
|
"workflow.agent.billing",
|
||||||
"workflow.agent.billing",
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
input={"intent": state.get("intent")},
|
||||||
input={"intent": state.get("intent")},
|
):
|
||||||
):
|
return await self.billing.run(state)
|
||||||
return await self.billing.run(state)
|
|
||||||
|
|
||||||
async def product_agent(self, state):
|
async def product_agent(self, state):
|
||||||
async with self.langgraph_telemetry.node("product_agent", state):
|
async with self.telemetry.span(
|
||||||
async with self.telemetry.span(
|
"workflow.agent.product",
|
||||||
"workflow.agent.product",
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
input={"intent": state.get("intent")},
|
||||||
input={"intent": state.get("intent")},
|
):
|
||||||
):
|
return await self.product.run(state)
|
||||||
return await self.product.run(state)
|
|
||||||
|
|
||||||
async def orders_agent(self, state):
|
async def orders_agent(self, state):
|
||||||
async with self.langgraph_telemetry.node("orders_agent", state):
|
async with self.telemetry.span(
|
||||||
async with self.telemetry.span(
|
"workflow.agent.orders",
|
||||||
"workflow.agent.orders",
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
input={"intent": state.get("intent")},
|
||||||
input={"intent": state.get("intent")},
|
):
|
||||||
):
|
return await self.orders.run(state)
|
||||||
return await self.orders.run(state)
|
|
||||||
|
|
||||||
|
|
||||||
async def support_agent(self, state):
|
async def support_agent(self, state):
|
||||||
async with self.langgraph_telemetry.node("support_agent", state):
|
async with self.telemetry.span(
|
||||||
async with self.telemetry.span(
|
"workflow.agent.support",
|
||||||
"workflow.agent.support",
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
input={"intent": state.get("intent")},
|
||||||
input={"intent": state.get("intent")},
|
):
|
||||||
):
|
return await self.support.run(state)
|
||||||
return await self.support.run(state)
|
|
||||||
|
|
||||||
async def supervisor_agent(self, state):
|
async def supervisor_agent(self, state):
|
||||||
"""Executa um ou mais agentes no modo supervisor e consolida a resposta.
|
"""Executa um ou mais agentes no modo supervisor e consolida a resposta.
|
||||||
@@ -419,6 +449,21 @@ class AgentWorkflow:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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}:
|
if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
|
||||||
final_answer = decision.candidate
|
final_answer = decision.candidate
|
||||||
elif decision.action == RailAction.HANDOVER:
|
elif decision.action == RailAction.HANDOVER:
|
||||||
@@ -457,11 +502,36 @@ class AgentWorkflow:
|
|||||||
session_id=state.get("conversation_key") or state.get("session_id"),
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
input=state.get("answer"),
|
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(
|
final, decisions = await self.guardrails.run_output(
|
||||||
state["answer"], state.get("context", {})
|
state["answer"], state.get("context", {})
|
||||||
)
|
)
|
||||||
for _decision in decisions:
|
for _decision in decisions:
|
||||||
await self.guardrail_telemetry.evaluated("output", _decision)
|
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:
|
if not _decision.allowed:
|
||||||
await self.guardrail_telemetry.blocked("output", _decision)
|
await self.guardrail_telemetry.blocked("output", _decision)
|
||||||
await self.telemetry.event(
|
await self.telemetry.event(
|
||||||
@@ -473,6 +543,18 @@ class AgentWorkflow:
|
|||||||
"decisions": [d.model_dump() for d in decisions],
|
"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 {
|
return {
|
||||||
"final_answer": final,
|
"final_answer": final,
|
||||||
"guardrail_decisions": state.get("guardrail_decisions", [])
|
"guardrail_decisions": state.get("guardrail_decisions", [])
|
||||||
|
|||||||
Binary file not shown.
194
tests/unit/test_telemetry_langfuse_compact.py
Normal file
194
tests/unit/test_telemetry_langfuse_compact.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent_framework.observability.context import clear_observability_context
|
||||||
|
from agent_framework.observability.telemetry import Telemetry
|
||||||
|
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
ENABLE_LANGFUSE = False
|
||||||
|
ENABLE_OTEL = False
|
||||||
|
LANGFUSE_TRACE_MODE = "compact"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeObservation:
|
||||||
|
_next_id = 1
|
||||||
|
|
||||||
|
def __init__(self, kwargs):
|
||||||
|
self.kwargs = kwargs
|
||||||
|
self.id = f"obs-{FakeObservation._next_id}"
|
||||||
|
self.trace_id = "trace-123"
|
||||||
|
FakeObservation._next_id += 1
|
||||||
|
self.updates = []
|
||||||
|
self.trace_updates = []
|
||||||
|
self.trace_io_updates = []
|
||||||
|
|
||||||
|
def update(self, **kwargs):
|
||||||
|
self.updates.append(kwargs)
|
||||||
|
|
||||||
|
def update_trace(self, **kwargs):
|
||||||
|
self.trace_updates.append(kwargs)
|
||||||
|
|
||||||
|
def set_trace_io(self, **kwargs):
|
||||||
|
self.trace_io_updates.append(kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeContextManager:
|
||||||
|
def __init__(self, observation):
|
||||||
|
self.observation = observation
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self.observation
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class FakePropagationContext:
|
||||||
|
def __init__(self, owner, kwargs):
|
||||||
|
self.owner = owner
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.owner.propagations.append(self.kwargs)
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLangfuse:
|
||||||
|
def __init__(self, *, legacy_api: bool = False):
|
||||||
|
self.observations = []
|
||||||
|
self.propagations = []
|
||||||
|
self.flush_count = 0
|
||||||
|
self.api = FakeApi() if legacy_api else None
|
||||||
|
|
||||||
|
def start_as_current_observation(self, **kwargs):
|
||||||
|
observation = FakeObservation(kwargs)
|
||||||
|
self.observations.append(observation)
|
||||||
|
return FakeContextManager(observation)
|
||||||
|
|
||||||
|
def propagate_attributes(self, **kwargs):
|
||||||
|
return FakePropagationContext(self, kwargs)
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
self.flush_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
class FakeIngestionResponse:
|
||||||
|
errors = []
|
||||||
|
successes = []
|
||||||
|
|
||||||
|
|
||||||
|
class FakeIngestion:
|
||||||
|
def __init__(self):
|
||||||
|
self.batches = []
|
||||||
|
|
||||||
|
def batch(self, *, batch, metadata=None):
|
||||||
|
self.batches.append({"batch": batch, "metadata": metadata})
|
||||||
|
return FakeIngestionResponse()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeApi:
|
||||||
|
def __init__(self):
|
||||||
|
self.ingestion = FakeIngestion()
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry_with_fake_langfuse(*, legacy_api: bool = False):
|
||||||
|
FakeObservation._next_id = 1
|
||||||
|
telemetry = Telemetry(Settings())
|
||||||
|
telemetry.enabled = True
|
||||||
|
telemetry.langfuse = FakeLangfuse(legacy_api=legacy_api)
|
||||||
|
return telemetry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compact_keeps_root_output_and_shows_only_aga_noc_events():
|
||||||
|
clear_observability_context()
|
||||||
|
telemetry = telemetry_with_fake_langfuse()
|
||||||
|
|
||||||
|
async with telemetry.span("agent.gateway_message", session_id="s1", input={"request": "cms"}, _root_span=True) as span:
|
||||||
|
await telemetry.event("IC.INTERNAL", {"step": "hidden"}, kind="ic")
|
||||||
|
await telemetry.event("NOC.001", {"step": "visible"}, kind="noc")
|
||||||
|
await telemetry.event("AGA.010", {"step": "visible"}, kind="ic")
|
||||||
|
span.set_output({"answer": "ok"})
|
||||||
|
|
||||||
|
names = [obs.kwargs["name"] for obs in telemetry.langfuse.observations]
|
||||||
|
assert names == ["agent.gateway_message", "NOC.001", "AGA.010"]
|
||||||
|
|
||||||
|
root = telemetry.langfuse.observations[0]
|
||||||
|
assert root.updates[-1]["input"] == {"request": "cms"}
|
||||||
|
assert root.updates[-1]["output"] == {"answer": "ok"}
|
||||||
|
assert root.trace_io_updates[-1] == {"input": {"request": "cms"}, "output": {"answer": "ok"}}
|
||||||
|
assert telemetry.langfuse.observations[1].kwargs.get("trace_context") is None
|
||||||
|
assert telemetry.langfuse.observations[2].kwargs.get("trace_context") is None
|
||||||
|
assert telemetry.langfuse.propagations[-1]["trace_name"] == "agent.gateway_message"
|
||||||
|
|
||||||
|
aggregated = root.updates[-1]["metadata"]["aggregated_events"]
|
||||||
|
assert [event["name"] for event in aggregated] == ["IC.INTERNAL", "NOC.001", "AGA.010"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compact_generation_records_io_model_parameters_and_usage_details():
|
||||||
|
clear_observability_context()
|
||||||
|
telemetry = telemetry_with_fake_langfuse()
|
||||||
|
|
||||||
|
async with telemetry.span("agent.gateway_message", session_id="s1", input={"request": "cms"}, _root_span=True):
|
||||||
|
async with telemetry.generation_span(
|
||||||
|
name="llm.test",
|
||||||
|
model="test-model",
|
||||||
|
input=[{"role": "user", "content": "ping"}],
|
||||||
|
metadata={"profile_name": "test"},
|
||||||
|
model_parameters={"temperature": 0.2, "max_tokens": 100},
|
||||||
|
) as generation:
|
||||||
|
generation.set_output("pong")
|
||||||
|
generation.set_usage({"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2, "cost_usd": 0.01})
|
||||||
|
|
||||||
|
generation = telemetry.langfuse.observations[1]
|
||||||
|
assert generation.kwargs["name"] == "llm.test"
|
||||||
|
assert generation.kwargs["as_type"] == "generation"
|
||||||
|
assert generation.kwargs["input"] == [{"role": "user", "content": "ping"}]
|
||||||
|
assert generation.kwargs["model"] == "test-model"
|
||||||
|
assert generation.kwargs["model_parameters"] == {"temperature": 0.2, "max_tokens": 100}
|
||||||
|
assert "usage" not in generation.kwargs
|
||||||
|
assert "usage_details" not in generation.kwargs
|
||||||
|
assert generation.updates[-1]["input"] == [{"role": "user", "content": "ping"}]
|
||||||
|
assert generation.updates[-1]["output"] == "pong"
|
||||||
|
assert generation.updates[-1]["usage_details"] == {"input": 1, "output": 1}
|
||||||
|
assert generation.updates[-1]["cost_details"] == {"total": 0.01}
|
||||||
|
assert generation.kwargs.get("trace_context") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_io_fallback_updates_same_root_and_generation_observations():
|
||||||
|
clear_observability_context()
|
||||||
|
telemetry = telemetry_with_fake_langfuse(legacy_api=True)
|
||||||
|
|
||||||
|
async with telemetry.span("agent.gateway_message", session_id="s1", input={"request": "cms"}, _root_span=True) as root:
|
||||||
|
async with telemetry.generation_span(
|
||||||
|
name="llm.test",
|
||||||
|
model="test-model",
|
||||||
|
input=[{"role": "user", "content": "ping"}],
|
||||||
|
model_parameters={"temperature": 0.2},
|
||||||
|
) as generation:
|
||||||
|
generation.set_output("pong")
|
||||||
|
generation.set_usage({"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5})
|
||||||
|
root.set_output({"answer": "ok"})
|
||||||
|
|
||||||
|
batches = telemetry.langfuse.api.ingestion.batches
|
||||||
|
assert [event.type for item in batches for event in item["batch"]] == ["generation-update", "span-update"]
|
||||||
|
|
||||||
|
generation_event = batches[0]["batch"][0]
|
||||||
|
assert generation_event.body.id == "obs-2"
|
||||||
|
assert generation_event.body.trace_id == "trace-123"
|
||||||
|
assert generation_event.body.input == [{"role": "user", "content": "ping"}]
|
||||||
|
assert generation_event.body.output == "pong"
|
||||||
|
assert generation_event.body.usage_details == {"input": 2, "output": 3}
|
||||||
|
|
||||||
|
root_event = batches[1]["batch"][0]
|
||||||
|
assert root_event.body.id == "obs-1"
|
||||||
|
assert root_event.body.trace_id == "trace-123"
|
||||||
|
assert root_event.body.input == {"request": "cms"}
|
||||||
|
assert root_event.body.output == {"answer": "ok"}
|
||||||
|
assert len(telemetry.langfuse.observations) == 2
|
||||||
Reference in New Issue
Block a user