Compare commits

..

3 Commits

174 changed files with 1807 additions and 180 deletions

View File

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

View File

@@ -25,12 +25,203 @@ from typing import Any, AsyncIterator, Iterator
from .checkpoint_repository import create_checkpoint_repository from .checkpoint_repository import create_checkpoint_repository
def _jsonable(value: Any) -> Any: def _parse_legacy_json_container(value: Any, expected: type) -> Any:
"""Recover containers that older JSON backends persisted as JSON strings.
This is intentionally field-scoped: ordinary business strings must stay
strings, even if their text happens to look like JSON.
"""
current = value
for _ in range(3):
if isinstance(current, expected):
return current
if not isinstance(current, str):
break
text = current.strip()
if not text:
break
if expected is dict and not text.startswith("{"):
break
if expected is list and not text.startswith("["):
break
try: try:
json.dumps(value, default=str) current = json.loads(text)
except Exception:
break
return current if isinstance(current, expected) else expected()
def _strict_json_value(value: Any, *, path: str = "$") -> Any:
"""Convert to repository-safe JSON without ever falling back to ``str``.
``default=str`` is unsafe for LangGraph checkpoints: runtime/task objects can
become ordinary strings and later be consumed as typed values by Pregel.
Keep native JSON containers recursively and fail loudly for an unsupported
object instead of corrupting it silently.
"""
if value is None or isinstance(value, (str, int, float, bool)):
return value return value
except TypeError: if isinstance(value, dict):
return json.loads(json.dumps(value, default=str)) return {
str(key): _strict_json_value(item, path=f"{path}.{key}")
for key, item in value.items()
}
if isinstance(value, (list, tuple)):
return [
_strict_json_value(item, path=f"{path}[{idx}]")
for idx, item in enumerate(value)
]
# Common durable scalar types that JSON does not know natively.
if isinstance(value, uuid.UUID):
return str(value)
try:
from datetime import date, datetime
if isinstance(value, (date, datetime)):
return value.isoformat()
except Exception:
pass
try:
from enum import Enum
if isinstance(value, Enum):
return _strict_json_value(value.value, path=path)
except Exception:
pass
if hasattr(value, "model_dump") and callable(value.model_dump):
return _strict_json_value(value.model_dump(), path=path)
raise TypeError(
f"Checkpoint contém valor não serializável em {path}: "
f"{type(value).__module__}.{type(value).__qualname__}"
)
def _normalize_checkpoint(checkpoint: Any) -> dict[str, Any]:
checkpoint = _parse_legacy_json_container(checkpoint, dict)
if not isinstance(checkpoint, dict):
return {}
out = dict(checkpoint)
out["channel_values"] = _parse_legacy_json_container(out.get("channel_values"), dict)
out["channel_versions"] = _parse_legacy_json_container(out.get("channel_versions"), dict)
raw_seen = _parse_legacy_json_container(out.get("versions_seen"), dict)
out["versions_seen"] = {
str(node): _parse_legacy_json_container(versions, dict)
for node, versions in raw_seen.items()
}
if "pending_sends" in out:
out["pending_sends"] = _parse_legacy_json_container(out.get("pending_sends"), list)
if "updated_channels" in out and isinstance(out.get("updated_channels"), str):
out["updated_channels"] = _parse_legacy_json_container(out.get("updated_channels"), list)
return out
def _normalize_metadata(metadata: Any) -> dict[str, Any]:
value = _parse_legacy_json_container(metadata, dict)
return value if isinstance(value, dict) else {}
def _normalize_config(config: Any) -> dict[str, Any]:
value = _parse_legacy_json_container(config, dict)
if not isinstance(value, dict):
return {}
out = dict(value)
out["configurable"] = _parse_legacy_json_container(out.get("configurable"), dict)
return out
_EPHEMERAL_RUNTIME_KEYS = {"__pregel_runtime", "__pregel_store"}
def _strip_runtime_refs(value: Any) -> Any:
"""Recursively remove process-local runtime/store references only.
Checkpoints may legitimately contain LangGraph internal channels whose names
also start with ``__pregel_`` (for example task channels). Those are durable
graph state and must be preserved. The corruption that triggers
``str.override`` is specifically a runtime/store object captured inside a
nested RunnableConfig and later stringified by the JSON repository.
"""
if isinstance(value, dict):
return {
key: _strip_runtime_refs(item)
for key, item in value.items()
if str(key) not in _EPHEMERAL_RUNTIME_KEYS
}
if isinstance(value, list):
return [_strip_runtime_refs(item) for item in value]
if isinstance(value, tuple):
return tuple(_strip_runtime_refs(item) for item in value)
return value
def _durable_config(config: dict[str, Any] | None) -> dict[str, Any]:
"""Return a checkpoint-safe copy of a LangGraph RunnableConfig.
LangGraph injects ephemeral private values such as ``__pregel_runtime`` and
``__pregel_store`` under ``configurable`` while a graph is running. They are
process-local and must never cross the durable checkpoint boundary.
The scrub is recursive because task/pending-write config fragments may be
nested below regular config fields in newer LangGraph versions.
"""
if not isinstance(config, dict):
return {}
cleaned = _strip_runtime_refs(config)
if not isinstance(cleaned, dict):
return {}
configurable = cleaned.get("configurable")
if isinstance(configurable, dict):
cleaned = dict(cleaned)
cleaned["configurable"] = {
key: value
for key, value in configurable.items()
if not str(key).startswith("__pregel_")
}
return cleaned
def _canonical_checkpoint_config(
payload: dict[str, Any],
request_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Rebuild the RunnableConfig returned to LangGraph from durable IDs only.
Official LangGraph savers do not re-bind the full config that happened to be
present when a checkpoint was written. They reconstruct a fresh config from
``thread_id``, ``checkpoint_ns`` and ``checkpoint_id``. Doing the same here
prevents a historical/factory-time runtime value from being rebound into a
new execution while remaining backward compatible with existing rows.
"""
requested = _durable_config(request_config)
stored = _durable_config(_normalize_config(payload.get("config")) if isinstance(payload, dict) else None)
req_cfg = requested.get("configurable") if isinstance(requested.get("configurable"), dict) else {}
stored_cfg = stored.get("configurable") if isinstance(stored.get("configurable"), dict) else {}
checkpoint = payload.get("checkpoint") if isinstance(payload, dict) else {}
checkpoint = checkpoint if isinstance(checkpoint, dict) else {}
thread_id = (
req_cfg.get("thread_id")
or stored_cfg.get("thread_id")
or payload.get("thread_id")
or "default"
)
checkpoint_ns = req_cfg.get("checkpoint_ns")
if checkpoint_ns is None:
checkpoint_ns = stored_cfg.get("checkpoint_ns", "")
requested_checkpoint_id = req_cfg.get("checkpoint_id")
checkpoint_id = (
requested_checkpoint_id
or payload.get("checkpoint_id")
or checkpoint.get("id")
or stored_cfg.get("checkpoint_id")
)
configurable: dict[str, Any] = {
"thread_id": str(thread_id),
"checkpoint_ns": str(checkpoint_ns or ""),
}
if checkpoint_id not in (None, ""):
configurable["checkpoint_id"] = str(checkpoint_id)
return {"configurable": configurable}
def _thread_id(config: dict[str, Any] | None) -> str: def _thread_id(config: dict[str, Any] | None) -> str:
@@ -85,6 +276,7 @@ class RepositoryCheckpointSaver(BaseCheckpointSaver):
"""Checkpoint saver nativo para LangGraph usando os repositories do framework.""" """Checkpoint saver nativo para LangGraph usando os repositories do framework."""
def __init__(self, settings, repository=None): def __init__(self, settings, repository=None):
super().__init__()
self.settings = settings self.settings = settings
self.repository = repository or create_checkpoint_repository(settings) self.repository = repository or create_checkpoint_repository(settings)
self._loop: asyncio.AbstractEventLoop | None = None self._loop: asyncio.AbstractEventLoop | None = None
@@ -100,20 +292,40 @@ class RepositoryCheckpointSaver(BaseCheckpointSaver):
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
return ex.submit(lambda: asyncio.run(coro)).result() return ex.submit(lambda: asyncio.run(coro)).result()
def _make_tuple(self, payload: dict[str, Any] | None): def _make_tuple(
self,
payload: dict[str, Any] | None,
request_config: dict[str, Any] | None = None,
):
if not payload: if not payload:
return None return None
config = payload.get("config") or {"configurable": {"thread_id": payload.get("thread_id")}} # Second-stage protection: never re-bind the full persisted RunnableConfig.
checkpoint = payload.get("checkpoint") or {} # Rebuild only the durable identifiers, as official LangGraph savers do.
metadata = payload.get("metadata") or {} config = _canonical_checkpoint_config(payload, request_config)
parent_config = payload.get("parent_config") checkpoint = _strip_runtime_refs(_normalize_checkpoint(payload.get("checkpoint") or {}))
pending_writes = _normalize_pending_writes(payload.get("pending_writes") or []) metadata = _strip_runtime_refs(_normalize_metadata(payload.get("metadata") or {}))
raw_parent_config = payload.get("parent_config")
if isinstance(raw_parent_config, dict):
parent_payload = {
"thread_id": payload.get("thread_id"),
"config": raw_parent_config,
"checkpoint_id": (raw_parent_config.get("configurable") or {}).get("checkpoint_id")
if isinstance(raw_parent_config.get("configurable"), dict)
else None,
"checkpoint": {},
}
parent_config = _canonical_checkpoint_config(parent_payload)
else:
parent_config = None
pending_writes = _normalize_pending_writes(
_strip_runtime_refs(payload.get("pending_writes") or [])
)
try: try:
from langgraph.checkpoint.base import CheckpointTuple from langgraph.checkpoint.base import CheckpointTuple
return CheckpointTuple(config=config, checkpoint=checkpoint, metadata=metadata, parent_config=parent_config, pending_writes=pending_writes) return CheckpointTuple(config=config, checkpoint=checkpoint, metadata=metadata, parent_config=parent_config, pending_writes=pending_writes)
except Exception: except Exception:
return { return {
"config": config, "config": _durable_config(config),
"checkpoint": checkpoint, "checkpoint": checkpoint,
"metadata": metadata, "metadata": metadata,
"parent_config": parent_config, "parent_config": parent_config,
@@ -121,7 +333,10 @@ class RepositoryCheckpointSaver(BaseCheckpointSaver):
} }
async def aget_tuple(self, config: dict[str, Any]): async def aget_tuple(self, config: dict[str, Any]):
return self._make_tuple(await self.repository.get_latest(_thread_id(config))) return self._make_tuple(
await self.repository.get_latest(_thread_id(config)),
request_config=config,
)
def get_tuple(self, config: dict[str, Any]): def get_tuple(self, config: dict[str, Any]):
return self._run(self.aget_tuple(config)) return self._run(self.aget_tuple(config))
@@ -129,20 +344,24 @@ class RepositoryCheckpointSaver(BaseCheckpointSaver):
async def aput(self, config: dict[str, Any], checkpoint: dict[str, Any], metadata: dict[str, Any] | None = None, new_versions: dict[str, Any] | None = None): async def aput(self, config: dict[str, Any], checkpoint: dict[str, Any], metadata: dict[str, Any] | None = None, new_versions: dict[str, Any] | None = None):
thread_id = _thread_id(config) thread_id = _thread_id(config)
checkpoint_id = _checkpoint_id(checkpoint) checkpoint_id = _checkpoint_id(checkpoint)
clean_config = _durable_config(config)
clean_cfg = clean_config.get("configurable") if isinstance(clean_config.get("configurable"), dict) else {}
checkpoint_ns = str(clean_cfg.get("checkpoint_ns") or "")
# Return a fresh canonical config. Never feed process-local/factory-time
# configurable values back into the next LangGraph super-step.
next_config = { next_config = {
**(config or {}),
"configurable": { "configurable": {
**((config or {}).get("configurable") or {}),
"thread_id": thread_id, "thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": checkpoint_id, "checkpoint_id": checkpoint_id,
}, }
} }
await self.repository.put(thread_id, { await self.repository.put(thread_id, {
"thread_id": thread_id, "thread_id": thread_id,
"config": _jsonable(next_config), "config": _strict_json_value(next_config, path="$.config"),
"checkpoint": _jsonable(checkpoint), "checkpoint": _strict_json_value(_strip_runtime_refs(_normalize_checkpoint(checkpoint)), path="$.checkpoint"),
"metadata": _jsonable(metadata or {}), "metadata": _strict_json_value(_strip_runtime_refs(_normalize_metadata(metadata or {})), path="$.metadata"),
"new_versions": _jsonable(new_versions or {}), "new_versions": _strict_json_value(_strip_runtime_refs(new_versions or {}), path="$.new_versions"),
"checkpoint_id": checkpoint_id, "checkpoint_id": checkpoint_id,
}) })
return next_config return next_config
@@ -153,19 +372,46 @@ class RepositoryCheckpointSaver(BaseCheckpointSaver):
async def aput_writes(self, config: dict[str, Any], writes: list[tuple[str, Any]], task_id: str, task_path: str = ""): async def aput_writes(self, config: dict[str, Any], writes: list[tuple[str, Any]], task_id: str, task_path: str = ""):
thread_id = _thread_id(config) thread_id = _thread_id(config)
try: try:
latest = await self.repository.get_latest(thread_id) or {"thread_id": thread_id, "config": config, "checkpoint": {}, "metadata": {}} latest = await self.repository.get_latest(thread_id) or {"thread_id": thread_id, "config": _durable_config(config), "checkpoint": {}, "metadata": {}}
except: except:
latest = { latest = {
"thread_id": thread_id, "thread_id": thread_id,
"config": config, "config": _durable_config(config),
"checkpoint": {}, "checkpoint": {},
"metadata": {}, "metadata": {},
"pending_writes": [], "pending_writes": [],
} }
if isinstance(latest, dict):
# Do not keep extending a persisted RunnableConfig across super-steps.
# Rebuild the same canonical config that aget_tuple() will expose.
latest["config"] = _canonical_checkpoint_config(latest, config)
if isinstance(latest.get("checkpoint"), dict):
latest["checkpoint"] = _strip_runtime_refs(latest.get("checkpoint"))
if isinstance(latest.get("metadata"), dict):
latest["metadata"] = _strip_runtime_refs(latest.get("metadata"))
if isinstance(latest.get("parent_config"), dict):
parent_payload = {
"thread_id": latest.get("thread_id") or thread_id,
"config": latest.get("parent_config"),
"checkpoint_id": (latest.get("parent_config", {}).get("configurable") or {}).get("checkpoint_id")
if isinstance(latest.get("parent_config", {}).get("configurable"), dict)
else None,
"checkpoint": {},
}
latest["parent_config"] = _canonical_checkpoint_config(parent_payload)
pending = list(latest.get("pending_writes") or []) pending = list(latest.get("pending_writes") or [])
for channel, value in writes or []: for channel, value in writes or []:
pending.append({"task_id": task_id, "task_path": task_path, "channel": channel, "value": _jsonable(value)}) # Writes may contain nested task/RunnableConfig fragments. Scrub the
# private runtime before the repository's JSON ``default=str`` layer.
durable_value = _strip_runtime_refs(value)
pending.append({
"task_id": task_id,
"task_path": task_path,
"channel": channel,
"value": _strict_json_value(durable_value, path=f"$.pending_writes[{task_id}].{channel}"),
})
latest["pending_writes"] = pending latest["pending_writes"] = pending
await self.repository.put(thread_id, latest) await self.repository.put(thread_id, latest)

View File

@@ -74,6 +74,16 @@ def _format_conversation_history(
msgs.pop() msgs.pop()
lines: list[str] = [] lines: list[str] = []
for msg in msgs: for msg in msgs:
if isinstance(msg, dict):
role = str(msg.get("role") or msg.get("type") or "").lower()
if role in {"system", "tool", "function"}:
continue
if role == "human":
role = "user"
elif role in {"ai", "bot"}:
role = "assistant"
content = _message_content_to_str(msg.get("content", ""))
else:
cls = type(msg).__name__ cls = type(msg).__name__
if cls in _SKIPPED_CLASSES: if cls in _SKIPPED_CLASSES:
continue continue

View File

@@ -48,6 +48,10 @@ Decida na ordem, PARE no primeiro match:
casa aqui, siga para o passo 5. casa aqui, siga para o passo 5.
Vale o pedido generico ("quero cancelar", "todos") sobre o que a conversa Vale o pedido generico ("quero cancelar", "todos") sobre o que a conversa
trata, e vale confirmar ou pedir permissao para executar essa acao. trata, e vale confirmar ou pedir permissao para executar essa acao.
IMPORTANTE: se o cliente acabou de PEDIR cancelamento/contestacao/ajuste do
mesmo alvo, a fala do agente que apenas pede CONFIRMACAO da transacao e
allowed=true. A confirmacao NAO precisa repetir a justificativa do cliente
("nao reconheco", "esta caro" etc.); o pedido transacional anterior basta.
Vale tambem trocar uma variante transacional por outra DA MESMA FAMILIA sobre Vale tambem trocar uma variante transacional por outra DA MESMA FAMILIA sobre
o MESMO escopo, sempre limitada ao valor JA COBRADO no item (ressarcimento <-> o MESMO escopo, sempre limitada ao valor JA COBRADO no item (ressarcimento <->
devolucao <-> reembolso <-> cancelamento <-> credito em fatura): negar o dobro e devolucao <-> reembolso <-> cancelamento <-> credito em fatura): negar o dobro e

View File

@@ -80,6 +80,13 @@ _REWRITE_INSTRUCTIONS_BY_CODE: dict[str, str] = {
"pergunta de confirmação direta e curta, mencionando o serviço ou ação " "pergunta de confirmação direta e curta, mencionando o serviço ou ação "
"pendente. Sem executar nem prometer ação." "pendente. Sem executar nem prometer ação."
), ),
"FRASEOLOGIA": (
"Preserve integralmente os fatos, valores, nomes de produtos e o resultado "
"de negócio já informado. Reescreva SOMENTE o trecho apontado como "
"fraseologia inadequada, trocando vocabulário de implementação, processo "
"interno, categoria técnica ou operação por linguagem natural de cliente. "
"Não invente ação, não altere o resultado e não acrescente oferta."
),
} }

View File

@@ -48,8 +48,13 @@ A) Termos e rotulos proibidos (o cliente nao deve ouvi-los):
so pelo nome e valor. a menos que seja perguntado diretamente sobre. so pelo nome e valor. a menos que seja perguntado diretamente sobre.
Alguns itens possuem o nome parecido com códigos, como BEMOBI_GAM ESMENSALM Alguns itens possuem o nome parecido com códigos, como BEMOBI_GAM ESMENSALM
São PERMITIDOS. Pois seu nome do produto é dessa forma. São PERMITIDOS. Pois seu nome do produto é dessa forma.
A3. nomes de ferramentas/tools, JSON, chaves tecnicas, checklist interno ou A3. nomes de ferramentas/tools, JSON, chaves tecnicas, parametros/chaves de
raciocinio expostos ao cliente -> falar so o resultado, em linguagem natural. implementacao, checklist interno, estados do workflow ou raciocinio interno
expostos ao cliente -> falar so o resultado ou fazer a pergunta necessaria
em linguagem natural. Exemplos de termos internos proibidos: "subject",
"asset_id", "invoice_id", "tool", "workflow", "route", "intent",
"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION" e nomes de tools como
"cancelar_vas_avulso" / "contestar_cobranca".
A4. Dizer que vai encaminhar uma jornada adequada, dizer que vai encaminhar para um especialista. A4. Dizer que vai encaminhar uma jornada adequada, dizer que vai encaminhar para um especialista.
Preferivel dizer que não pode ajudar sobre isso Preferivel dizer que não pode ajudar sobre isso
A5. Dizer que está "fora do escopo". Preferivel dizer "Sobre X não posso ajudar com isso" A5. Dizer que está "fora do escopo". Preferivel dizer "Sobre X não posso ajudar com isso"
@@ -64,7 +69,9 @@ B) Construcoes proibidas:
B4. orientar o cliente a procurar atendimento ou outro canal: "entre em contato B4. orientar o cliente a procurar atendimento ou outro canal: "entre em contato
com a central", "ligue para o atendimento", "fale com um atendente", com a central", "ligue para o atendimento", "fale com um atendente",
"procure uma loja", "acesse o app/site para resolver" -> resolver a duvida "procure uma loja", "acesse o app/site para resolver" -> resolver a duvida
aqui mesmo, sem encaminhar o cliente para outro canal. aqui mesmo, sem encaminhar o cliente para outro canal. ATENCAO: pedir para
o cliente tentar ou solicitar novamente NESTA MESMA CONVERSA, sem citar
central, loja, app, site, telefone, atendente ou outro canal, NAO viola B4.
C) Ofertas e promessas proibidas (revisao humana — sobrepoe outros rails): C) Ofertas e promessas proibidas (revisao humana — sobrepoe outros rails):
C1. oferecer plano mais barato, troca, migracao ou rebaixe de plano (inclusive C1. oferecer plano mais barato, troca, migracao ou rebaixe de plano (inclusive
@@ -72,6 +79,20 @@ C) Ofertas e promessas proibidas (revisao humana — sobrepoe outros rails):
C2. conceder ressarcimento em dobro -> usar a fala fixa de ajuste na fatura. C2. conceder ressarcimento em dobro -> usar a fala fixa de ajuste na fatura.
NAO marque FRASEOLOGIA (fraseados OBRIGATORIOS — sempre OK): NAO marque FRASEOLOGIA (fraseados OBRIGATORIOS — sempre OK):
- perguntas ou pedidos de DADOS DE NEGOCIO que o cliente conhece e que sao
necessarios para continuar o atendimento. Isso NAO expoe raciocinio nem
processo interno. Exemplos SEMPRE OK: "Para prosseguir, informe valor.",
"Qual foi o valor da cobranca?", "Informe a data da cobranca.",
"Qual servico voce deseja cancelar?", "Qual e o nome do produto?".
Nao confunda o nome natural do dado de negocio ("valor", "data", "servico",
"cobranca", "fatura", "produto") com o nome tecnico da chave interna
("subject", "asset_id", "invoice_id" etc.).
- confirmacoes de uma acao ja em andamento em linguagem natural, por exemplo
"Voce confirma o cancelamento do servico TIM Fashion?", sao interacao normal
com o cliente e NAO constituem exposicao de processo interno.
- em caso de falha tecnica, orientar a repetir a mesma solicitacao aqui mesmo,
por exemplo "Se desejar tentar novamente, solicite o cancelamento novamente",
e permitido; isso NAO e encaminhamento para outro canal.
- "incluso no seu plano" / "faz parte do seu plano" / "beneficio incluso". - "incluso no seu plano" / "faz parte do seu plano" / "beneficio incluso".
- citar o servico por nome e valor SEM rotulo de origem. - citar o servico por nome e valor SEM rotulo de origem.
- a fala fixa de ressarcimento ("Por aqui, nao consigo seguir com o - a fala fixa de ressarcimento ("Por aqui, nao consigo seguir com o

View File

@@ -10,6 +10,7 @@ from .rail_result import RailResult
from .parallel_executor import ParallelRailExecutor from .parallel_executor import ParallelRailExecutor
from .llm_rails import LLMOutputGRLRail from .llm_rails import LLMOutputGRLRail
from .config_loader import load_guardrails_config from .config_loader import load_guardrails_config
from .framework_llm_client import classify_with_framework_llm
logger = logging.getLogger("agent_framework.guardrails.output_supervisor") logger = logging.getLogger("agent_framework.guardrails.output_supervisor")
@@ -122,11 +123,81 @@ class OutputSupervisor:
) )
) )
# FRASEOLOGIA é um rail de wording. Quando ele for o único rail impeditivo,
# não descarte uma resposta factual/grounded: faça uma única reescrita
# cirúrgica, depois submeta o texto reescrito a TODOS os rails novamente.
# A flag no contexto impede loop infinito caso a nova versão continue
# inadequada.
phraseology_block = next(
(r for r in results if str(r.code or "").upper() == "FRASEOLOGIA" and r.action == RailAction.BLOCK),
None,
)
other_impediments = [
r for r in results
if r is not phraseology_block and r.action in {RailAction.BLOCK, RailAction.RETRY, RailAction.HANDOVER}
]
if (
phraseology_block is not None
and not other_impediments
and int(ctx.get("__phraseology_rewrite_attempt", 0)) < 1
):
rewritten = await self._rewrite_phraseology(candidate, phraseology_block, ctx)
if rewritten and rewritten.strip() and rewritten.strip() != candidate.strip():
rewrite_ctx = dict(ctx)
rewrite_ctx["__phraseology_rewrite_attempt"] = 1
rewrite_ctx["phraseology_original_candidate"] = candidate
rewrite_ctx["phraseology_original_reason"] = phraseology_block.reason
decision = await self.evaluate(rewritten.strip(), rewrite_ctx)
decision.results.insert(0, RailResult(
code="FRASEOLOGIA_REWRITE",
action=RailAction.OBSERVE,
reason=phraseology_block.reason,
metadata={
"rewritten": True,
"original_code": "FRASEOLOGIA",
"rewrite_attempt": 1,
},
))
decision.metadata = {
**dict(decision.metadata or {}),
"phraseology_rewritten": True,
"phraseology_rewrite_attempts": 1,
}
return decision
decision = self.aggregate(candidate, list(results), ctx) decision = self.aggregate(candidate, list(results), ctx)
await self._emit_events(results, decision, ctx) await self._emit_events(results, decision, ctx)
await self._emit_final(decision, ctx) await self._emit_final(decision, ctx)
return decision return decision
async def _rewrite_phraseology(self, candidate: str, result: RailResult, context: dict[str, Any]) -> str | None:
"""Reescreve apenas wording bloqueado por FRASEOLOGIA.
A saída é sempre reavaliada por ``evaluate`` antes de ser liberada. Uma
falha do LLM ou uma resposta vazia mantém o comportamento fail-closed.
"""
try:
rewrite_context = {
**dict(context or {}),
"guardrail_code": "FRASEOLOGIA",
"guardrail_reason": result.reason,
}
out = await classify_with_framework_llm(
self.llm,
"FALLBACK",
{"text": candidate, "context": rewrite_context},
profile_name="grl",
component_name="guardrail.fraseologia.rewrite",
generation_name="guardrail.fraseologia.rewrite",
)
# O prompt FALLBACK usa ``reason`` como texto final reescrito.
rewritten = str(out.get("reason") or "").strip()
return rewritten or None
except Exception:
logger.exception("output_supervisor.phraseology_rewrite_failed")
return None
def aggregate(self, candidate: str, results: list[RailResult], context: dict[str, Any] | None = None) -> RailDecisionV2: def aggregate(self, candidate: str, results: list[RailResult], context: dict[str, Any] | None = None) -> RailDecisionV2:
ctx = context or {} ctx = context or {}
final_action = max((r.action for r in results), key=lambda a: _SEVERITY.get(a, 0), default=RailAction.ALLOW) final_action = max((r.action for r in results), key=lambda a: _SEVERITY.get(a, 0), default=RailAction.ALLOW)

View File

@@ -304,13 +304,55 @@ class PrematureActionRail(Guardrail):
class ProactiveOfferRail(Guardrail): class ProactiveOfferRail(Guardrail):
"""AOFERTA calibrado: bloqueia oferta proativa não solicitada no output.""" """AOFERTA calibrado: bloqueia oferta proativa não solicitada no output.
Estados transacionais determinísticos de continuidade não são uma nova
oferta do agente. Quando o runtime já abriu uma transação e está apenas
coletando parâmetros obrigatórios ou aguardando confirmação, AOFERTA deve
permitir a mensagem sem consultar a LLM. Outros rails de saída (por exemplo
FRASEOLOGIA) continuam sendo executados normalmente pelo pipeline.
"""
code = "AOFERTA" code = "AOFERTA"
stage = "output" stage = "output"
_TRANSACTION_CONTINUATION_STATUSES = {
"COLLECTING_PARAMETERS",
"AWAITING_CONFIRMATION",
}
@classmethod
def _transaction_continuation_status(cls, ctx: dict[str, Any]) -> str | None:
status = str(ctx.get("transaction_status") or "").strip().upper()
if status in cls._TRANSACTION_CONTINUATION_STATUSES:
return status
# Compatibilidade com callers que ainda só expõem o estado por meio
# dos resultados das tools. O runtime transacional já grava o status
# nesses resultados; não inferimos pelo texto da resposta.
for result in reversed(list(ctx.get("mcp_results") or ctx.get("tool_result") or [])):
if not isinstance(result, dict):
continue
result_status = str(result.get("transaction_status") or "").strip().upper()
if result_status in cls._TRANSACTION_CONTINUATION_STATUSES:
return result_status
return None
async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision: async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision:
ctx = _ctx(context) ctx = _ctx(context)
continuation_status = self._transaction_continuation_status(ctx)
if continuation_status:
return RailDecision(
code=self.code,
allowed=True,
reason=f"continuidade_transacional:{continuation_status}",
sanitized_text=text,
metadata={
"mechanism": "deterministic_transaction_bypass",
"transaction_status": continuation_status,
"calibrated": True,
},
)
out = await classify_with_framework_llm( out = await classify_with_framework_llm(
_llm(ctx), _llm(ctx),
"AOFERTA", "AOFERTA",

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