Compare commits

...

4 Commits

Author SHA1 Message Date
T3782834
a472daa1e4 adjustments: transaction parameter extraction 2026-08-21 23:06:54 -03:00
T3782834
d93efd8972 adjustments: transaction parameter extraction 2026-08-21 22:44:36 -03:00
727997aa41 Adding transaction state 2026-08-21 17:59:06 -03:00
2496e831a1 Adding transaction state 2026-08-21 16:47:13 -03:00
37 changed files with 1508 additions and 123 deletions

View File

@@ -1,32 +1,102 @@
# Transaction parameter precedence fix # Precedência transacional + extração LLM de parâmetros
Correção para a regressão em que uma resposta curta que preenchia um parâmetro pendente (ex.: `R$ 71,99` para `valor`) era classificada pelo LLM Router como uma nova intent e interrompia a transação. Esta correção remove a extração textual hardcoded de parâmetros transacionais e faz a coleta de `policy.requires` por um extrator LLM genérico.
## Regra aplicada ## Regra de precedência
Durante `COLLECTING_PARAMETERS` a ordem passa a ser: Enquanto existir uma transação ativa, o framework trata o turno nesta ordem:
1. resposta compatível com parâmetro pendente -> mantém a política de estado e continua a transação;
2. cancelamento explícito -> cancela a transação;
3. nova intenção clara/pergunta explícita -> interrompe a transação e volta ao roteamento normal;
4. caso ambíguo -> permanece em clarificação.
Exemplo corrigido:
```text ```text
não fiz essa contratação TIM CTRL Redes Sociais 8.0 ACTIVE_TRANSACTION
-> informe valor |
R$ 71,99 +-- COLLECTING_PARAMETERS
-> valor=71.99; continua contestar_cobranca; executa pré-validação | |
| +-- LLM tenta extrair SOMENTE os parâmetros ainda pendentes
| |
| +-- extraiu >= 1 ?
| |
| +-- SIM -> continua a transação; NÃO avalia intent_shift
| |
| +-- NÃO -> libera EnterpriseRouter para avaliar intent_shift
|
+-- AWAITING_CONFIRMATION
|
+-- reconhece confirmação/rejeição explícita
|
+-- reconheceu ?
|
+-- SIM -> continua/cancela a transação; NÃO avalia intent_shift
|
+-- NÃO -> libera EnterpriseRouter para avaliar intent_shift
``` ```
A mensagem `R$ 71,99` não pode mais virar `contas_invoice_explanation` enquanto `valor` estiver pendente. ## TransactionParameterExtractor
## Testes Novo componente:
Foram adicionados testes para: `libs/agent_framework/src/agent_framework/runtime/transaction_parameters.py`
- valor monetário com LLM sugerindo outra intent; A extração textual dos parâmetros de negócio é feita exclusivamente por LLM. O componente recebe:
- entidade curta como resposta de parâmetro;
- pergunta clara durante coleta ainda interrompendo a transação; - nome da tool/transação ativa;
- regressões existentes de intent-shift e transactional tool flow. - parâmetros atualmente pendentes;
- argumentos já conhecidos;
- schema/tipos declarados em `tools.yaml` quando disponíveis;
- descrição da tool;
- mensagem atual do usuário.
Ele não conhece nomes de domínio como `order_id`, `reason`, `subject`, `valor`, TIM ou retail. Não há regex de entidades de negócio.
A LLM pode interpretar, por exemplo:
- `PED-1001` quando só há um parâmetro compatível pendente;
- `o pedido é PED-1001`;
- `PED-1001, desisti da compra` preenchendo dois parâmetros no mesmo turno;
- respostas com o nome do parâmetro seguido do valor;
- respostas apenas com o valor, quando semanticamente inequívocas.
Em caso de dúvida, o prompt manda retornar `null`. Uma nova solicitação não deve ser transformada em valor de parâmetro.
## Separação de responsabilidades
`tool_policies.yaml` continua sendo a fonte de verdade para `requires`.
`tools.yaml` pode fornecer tipos via `args_schema` e descrição da tool para melhorar a interpretação sem introduzir código específico de domínio.
`mcp_parameter_mapping.yaml` continua responsável pelos parâmetros auxiliares/contrato MCP. As strategies do mapper são explicitamente excluídas dos campos presentes em `policy.requires`, para não misturar extração MCP com coleta transacional.
O `EnterpriseRouter` usa o mesmo extrator LLM apenas como *probe* de precedência. Se pelo menos um parâmetro pendente for encontrado, o turno permanece no estado transacional. Os valores extraídos são colocados no metadata da decisão e reutilizados pelo runtime, evitando uma segunda chamada LLM no mesmo turno.
## Profile LLM
Foi adicionado aos templates:
```yaml
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8
```
Generation/component:
- `llm.transaction_parameter_extraction`
- `transaction_parameter_extraction`
## Limpeza de estado
Em `intent_shift`, `transaction_pre_validation` da transação abandonada é removido para não contaminar a nova transação. O resultado de pre-validation continua preservado enquanto pertence à própria transação para auditoria.
## Testes adicionados
`tests/test_transaction_parameter_llm_precedence.py`
Cobertura:
1. dois parâmetros extraídos no mesmo turno;
2. um parâmetro preenchido ganha precedência sobre keyword que indicaria outra intent;
3. nenhum parâmetro encontrado libera `intent_shift`;
4. ausência do antigo `_extract_action_arguments()` hardcoded;
5. confirmação `sim` ganha precedência sobre intent shift.

View File

@@ -26,6 +26,8 @@ class AgentState(TypedDict, total=False):
available_mcp_tools: list[str] available_mcp_tools: list[str]
selected_tool_call: dict[str, Any] selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any] pending_tool_call: dict[str, Any]
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
transaction_status: str transaction_status: str
confirmation_required: bool confirmation_required: bool
confirmation_received: bool confirmation_received: bool

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -26,6 +26,8 @@ class AgentState(TypedDict, total=False):
available_mcp_tools: list[str] available_mcp_tools: list[str]
selected_tool_call: dict[str, Any] selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any] pending_tool_call: dict[str, Any]
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
transaction_status: str transaction_status: str
confirmation_required: bool confirmation_required: bool
confirmation_received: bool confirmation_received: bool

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -26,6 +26,8 @@ class AgentState(TypedDict, total=False):
available_mcp_tools: list[str] available_mcp_tools: list[str]
selected_tool_call: dict[str, Any] selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any] pending_tool_call: dict[str, Any]
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
transaction_status: str transaction_status: str
confirmation_required: bool confirmation_required: bool
confirmation_received: bool confirmation_received: bool

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -26,6 +26,8 @@ class AgentState(TypedDict, total=False):
available_mcp_tools: list[str] available_mcp_tools: list[str]
selected_tool_call: dict[str, Any] selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any] pending_tool_call: dict[str, Any]
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
transaction_status: str transaction_status: str
confirmation_required: bool confirmation_required: bool
confirmation_received: bool confirmation_received: bool

View File

@@ -72,3 +72,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -26,6 +26,8 @@ class AgentState(TypedDict, total=False):
available_mcp_tools: list[str] available_mcp_tools: list[str]
selected_tool_call: dict[str, Any] selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any] pending_tool_call: dict[str, Any]
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
transaction_status: str transaction_status: str
confirmation_required: bool confirmation_required: bool
confirmation_received: bool confirmation_received: bool

View File

@@ -0,0 +1,87 @@
profiles:
default:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
max_tokens: 2048
supervisor:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
route_continuity:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 80
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
guardrail:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 600
grl:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
judge:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 800
rag_rewriter:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 300
rag_compressor:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 1200
rag_generation:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.1
max_tokens: 1800
summary_memory:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.1
max_tokens: 1200
noc:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
billing_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
product_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
backoffice_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
mcp_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 80
timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -26,6 +26,8 @@ class AgentState(TypedDict, total=False):
available_mcp_tools: list[str] available_mcp_tools: list[str]
selected_tool_call: dict[str, Any] selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any] pending_tool_call: dict[str, Any]
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
transaction_status: str transaction_status: str
confirmation_required: bool confirmation_required: bool
confirmation_received: bool confirmation_received: bool

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -26,6 +26,8 @@ class AgentState(TypedDict, total=False):
available_mcp_tools: list[str] available_mcp_tools: list[str]
selected_tool_call: dict[str, Any] selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any] pending_tool_call: dict[str, Any]
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
transaction_status: str transaction_status: str
confirmation_required: bool confirmation_required: bool
confirmation_received: bool confirmation_received: bool

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -26,6 +26,8 @@ class AgentState(TypedDict, total=False):
available_mcp_tools: list[str] available_mcp_tools: list[str]
selected_tool_call: dict[str, Any] selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any] pending_tool_call: dict[str, Any]
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
transaction_status: str transaction_status: str
transaction_evidence: list[dict[str, Any]] transaction_evidence: list[dict[str, Any]]
last_transaction_evidence: dict[str, Any] last_transaction_evidence: dict[str, Any]

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -26,6 +26,8 @@ class AgentState(TypedDict, total=False):
available_mcp_tools: list[str] available_mcp_tools: list[str]
selected_tool_call: dict[str, Any] selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any] pending_tool_call: dict[str, Any]
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
transaction_status: str transaction_status: str
transaction_pre_validation: dict[str, Any] transaction_pre_validation: dict[str, Any]
transaction_evidence: list[dict[str, Any]] transaction_evidence: list[dict[str, Any]]

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -0,0 +1,224 @@
# Guia do Desenvolvedor — Estado e Transações Multi-turno
Este documento define o contrato operacional para transações multi-turno no Agent Framework OCI. Ele é normativo para hosts e templates que utilizam `AgentRuntime`, checkpoint LangGraph e tools transacionais.
## 1. Objetivo
Uma transação pode atravessar vários turnos. Exemplo:
```text
Usuário: quero cancelar o pedido
Framework: informe o número do pedido
Usuário: PED-1001
Framework: confirma o cancelamento?
Usuário: sim
Framework: executa a tool
```
O framework precisa preservar a transação entre todos esses turnos sem depender de reclassificação por LLM, keyword routing ou reextração de parâmetros já obtidos.
## 2. Fonte canônica do estado transacional
O estado canônico da transação em andamento é `active_transaction`.
```python
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
```
Todo `AgentState` usado por um host que habilita transações multi-turno **DEVE** declarar os dois campos. Como o LangGraph usa o schema do state para persistência/checkpoint, um campo criado apenas dinamicamente pelo runtime não é um contrato durável seguro.
Exemplo mínimo:
```python
from typing import Any, TypedDict
class AgentState(TypedDict, total=False):
# ...campos normais...
selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any]
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
transaction_status: str
missing_parameters: list[str]
confirmation_required: bool
confirmation_received: bool
```
## 3. Papel de cada campo
| Campo | Papel | Regra |
|---|---|---|
| `active_transaction` | Fonte canônica da transação ativa | Deve sobreviver a checkpoint/resume enquanto a transação estiver ativa. |
| `last_transaction` | Snapshot da última transação terminal | Usado para auditoria, evidência e continuidade controlada; não reativa automaticamente a transação. |
| `transaction_status` | Estado lógico atual | Ex.: `COLLECTING_PARAMETERS`, `AWAITING_CONFIRMATION`, `COMPLETED`, `CANCELLED`, `OUT_OF_SCOPE`. |
| `missing_parameters` | Parâmetros ainda necessários | Deve refletir o estado canônico da transação, não apenas a mensagem corrente. |
| `selected_tool_call` | Estado auxiliar/compatibilidade | Não deve substituir `active_transaction` como fonte canônica. |
| `pending_tool_call` | Estado auxiliar/compatibilidade | Pode ser usado por compatibilidade, mas não como latch principal. |
| `next_state` | Orientação de roteamento do workflow | Ajuda a manter o nó/agente correto durante coleta/confirmação. |
| `transaction_pre_validation` | Evidência de pré-validação | Mantém resultado de validação antes da confirmação/execução. |
| `transaction_evidence` | Evidências da execução | Mantém resultados e trilha de execução da transação. |
## 4. Ciclo de vida recomendado
```text
IDLE
↓ intenção transacional
COLLECTING_PARAMETERS
↓ parâmetros completos
PRE_VALIDATION (quando configurado)
↓ elegível
AWAITING_CONFIRMATION
↓ confirmação positiva
EXECUTING
COMPLETED
```
Saídas terminais alternativas:
```text
CANCELLED
OUT_OF_SCOPE
FAILED
```
O runtime pode representar algumas fases internamente sem um `transaction_status` público separado. O requisito é preservar o latch e não perder argumentos já coletados.
## 5. Merge incremental de parâmetros
Uma resposta posterior deve complementar a transação existente, nunca recriá-la apenas a partir do texto atual.
```python
existing = dict((state.get("active_transaction") or {}).get("arguments") or {})
new_values = {"valor": "71.99"}
arguments = {**existing, **new_values}
```
Exemplo esperado:
```text
Turno 1: subject = "TIM CTRL Redes Sociais 8.0"
Turno 2: valor = "71.99"
Resultado: subject + valor permanecem disponíveis
```
## 6. Precedência de roteamento durante transação
Quando existe `active_transaction` em `COLLECTING_PARAMETERS`, a mensagem deve primeiro ser avaliada como possível resposta aos parâmetros pendentes.
Precedência normativa:
1. parâmetro pendente claramente preenchido → continuar a transação;
2. cancelamento/abandono explícito → cancelar a transação;
3. nova intenção inequívoca → interromper a transação e rotear;
4. keyword genérica do mesmo domínio/agente → **não** interromper a transação;
5. mensagem ambígua → manter a transação e clarificar.
Exemplos:
| Estado atual | Mensagem | Resultado correto |
|---|---|---|
| `retail_order_cancel`, falta `order_id` | `PED-1001` | Continua cancelamento e preenche `order_id`. |
| `retail_order_cancel`, falta `order_id` | `o pedido é o PED-1001` | Continua cancelamento; `pedido` não deve virar tracking. |
| contestação, falta `valor` | `R$ 71,99` | Continua contestação e preenche `valor`. |
| cancelamento pendente | `esquece, quero ver minha fatura` | Interrupção explícita permitida. |
| cancelamento pendente | `quero rastrear pedido` | Mudança inequívoca para tracking permitida. |
## 7. Checkpoint e retomada
Antes de executar roteamento normal, o host deve restaurar o checkpoint usando a mesma identidade de conversa (`tenant_id`, `agent_id`, `session_id`/`conversation_key` conforme contrato do host).
Após a restauração:
```text
active_transaction existe
status ativo?
↓ sim
retomar a transação antes de keyword routing / continuity LLM
```
Um estado `COLLECTING_PARAMETERS` sem `active_transaction` deve ser tratado como inconsistência de estado e observado/diagnosticado; não deve silenciosamente reiniciar a tool a partir da mensagem corrente.
## 8. O que pertence ao framework e ao agente
Framework:
- persistência do latch;
- merge de argumentos;
- estados de coleta/confirmação;
- precedência de retomada;
- confirmação determinística;
- idempotência e evidência;
- checkpoint/resume.
Agente:
- definição das tools de domínio;
- parâmetros obrigatórios e mensagens de domínio;
- regras de elegibilidade específicas;
- pre-validation específica, quando houver;
- resposta final ao cliente.
O agente não deve implementar um segundo motor transacional paralelo ao `AgentRuntime`.
## 9. Checklist para novos hosts/templates
- [ ] `AgentState` declara `active_transaction`.
- [ ] `AgentState` declara `last_transaction`.
- [ ] `transaction_status` e `missing_parameters` fazem parte do state quando usados.
- [ ] O host usa checkpoint compatível com o schema do state.
- [ ] A mesma `conversation_key` é usada entre turnos da mesma conversa.
- [ ] Parâmetros já coletados são mesclados com novos valores.
- [ ] Respostas a parâmetros têm precedência sobre keyword routing genérico.
- [ ] Mudança explícita de intenção continua possível.
- [ ] O agente usa `transaction_state_patch(state)` ao retornar respostas transacionais quando o template o exige.
- [ ] Existem testes multi-turno para coleta, confirmação, interrupção e resume.
## 10. Testes regressivos mínimos
```text
A. cancelamento de pedido
1. "quero cancelar pedido"
2. "o pedido é o PED-1001"
Esperado: continua retail_order_cancel; não vira retail_order_tracking.
B. contestação
1. "não contratei TIM CTRL Redes Sociais 8.0"
2. "R$ 71,99"
Esperado: subject e valor chegam juntos à pre-validation.
C. interrupção explícita
1. iniciar transação e deixar parâmetro pendente
2. "esquece, quero ver minha fatura"
Esperado: transação é interrompida e nova intenção é roteada.
D. checkpoint/resume
1. iniciar transação
2. persistir/checkpoint
3. reconstruir execução usando a mesma conversation_key
4. fornecer o parâmetro faltante
Esperado: active_transaction é restaurado e concluído sem reiniciar a tool.
```
## 11. Anti-patterns
- reconstruir a transação somente a partir da última mensagem;
- usar `selected_tool_call` como única fonte do latch;
- remover `active_transaction` do `AgentState` por parecer redundante;
- permitir uma keyword genérica como `pedido` interromper coleta de `order_id`;
- armazenar parâmetros apenas em variáveis locais do nó;
- duplicar confirmação transacional no prompt do agente;
- limpar o latch antes do estado terminal.
## 12. Referências no projeto
- `specs/SPEC-002-Agent-Runtime.md`
- `specs/SPEC-010-Agent-Development.md`
- `templates/agent_template_backend/app/state.py`
- `libs/agent_framework/src/agent_framework/runtime/agent_runtime.py`
- `libs/agent_framework/src/agent_framework/routing/enterprise_router.py`
- `Tuning-Performance/Deterministic_Transactional_Workflow/`
- `Tuning-Performance/Transaction_Pre_Validation/`
- `Tuning-Performance/Transaction_Evidence/`

View File

@@ -0,0 +1,160 @@
# Developer Guide — Multi-turn Transaction State
This document defines the operational contract for multi-turn transactions in Agent Framework OCI. It is normative for hosts and templates that use `AgentRuntime`, LangGraph checkpoints, and transactional tools.
## 1. Goal
A transaction may span multiple turns:
```text
User: cancel my order
Framework: provide the order number
User: PED-1001
Framework: confirm cancellation?
User: yes
Framework: execute the tool
```
The framework must preserve the transaction across all turns without relying on LLM reclassification, keyword routing, or re-extraction of parameters already collected.
## 2. Canonical transaction state
The canonical in-flight transaction is `active_transaction`.
```python
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
```
Every `AgentState` used by a host that enables multi-turn transactions **MUST** declare both fields. LangGraph uses the state schema for checkpoint persistence, so a field created dynamically by the runtime alone is not a safe durable contract.
Minimal example:
```python
from typing import Any, TypedDict
class AgentState(TypedDict, total=False):
# ...normal fields...
selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any]
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
transaction_status: str
missing_parameters: list[str]
confirmation_required: bool
confirmation_received: bool
```
## 3. Field responsibilities
| Field | Responsibility | Rule |
|---|---|---|
| `active_transaction` | Canonical in-flight transaction | Must survive checkpoint/resume while active. |
| `last_transaction` | Snapshot of the latest terminal transaction | Used for audit/evidence; does not automatically reactivate a transaction. |
| `transaction_status` | Current logical status | E.g. `COLLECTING_PARAMETERS`, `AWAITING_CONFIRMATION`, `COMPLETED`, `CANCELLED`, `OUT_OF_SCOPE`. |
| `missing_parameters` | Parameters still required | Must reflect canonical transaction state, not only the current message. |
| `selected_tool_call` | Auxiliary/backward-compatible state | Must not replace `active_transaction` as canonical state. |
| `pending_tool_call` | Auxiliary/backward-compatible state | May support compatibility but is not the primary latch. |
| `next_state` | Workflow routing guidance | Keeps the correct node/agent during collection/confirmation. |
| `transaction_pre_validation` | Pre-validation evidence | Stores validation before confirmation/execution. |
| `transaction_evidence` | Execution evidence | Stores results and the transaction execution trail. |
## 4. Recommended lifecycle
```text
IDLE
↓ transactional intent
COLLECTING_PARAMETERS
↓ complete parameters
PRE_VALIDATION (when configured)
↓ eligible
AWAITING_CONFIRMATION
↓ positive confirmation
EXECUTING
COMPLETED
```
Alternative terminal outcomes include `CANCELLED`, `OUT_OF_SCOPE`, and `FAILED`.
## 5. Incremental parameter merge
A later answer must complement the existing transaction instead of rebuilding it from the latest text only.
```python
existing = dict((state.get("active_transaction") or {}).get("arguments") or {})
new_values = {"amount": "71.99"}
arguments = {**existing, **new_values}
```
Previously collected arguments must remain available on subsequent turns.
## 6. Routing precedence during an active transaction
When `active_transaction` is in `COLLECTING_PARAMETERS`, the message must first be evaluated as a possible answer to pending parameters.
Normative precedence:
1. clearly fills a pending parameter → continue transaction;
2. explicit cancel/abandon → cancel transaction;
3. unambiguous new intent → interrupt and route;
4. generic keyword in the same domain/agent → **do not** interrupt;
5. ambiguous message → keep transaction and clarify.
| Current state | Message | Correct result |
|---|---|---|
| `retail_order_cancel`, missing `order_id` | `PED-1001` | Continue cancellation and fill `order_id`. |
| `retail_order_cancel`, missing `order_id` | `the order is PED-1001` | Continue cancellation; `order` must not switch to tracking. |
| contestation, missing `amount` | `R$ 71.99` | Continue contestation and fill amount. |
| pending cancellation | `forget it, show my bill` | Explicit interruption is allowed. |
| pending cancellation | `track my order` | Unambiguous shift to tracking is allowed. |
## 7. Checkpoint and resume
Before normal routing, restore the checkpoint with the same conversation identity (`tenant_id`, `agent_id`, `session_id`/`conversation_key` according to the host contract).
An active transaction must be resumed before generic keyword routing or LLM continuity. `COLLECTING_PARAMETERS` without `active_transaction` should be treated as inconsistent state and diagnosed rather than silently restarting the tool.
## 8. Framework vs. agent responsibility
Framework owns latch persistence, argument merge, collection/confirmation states, resume precedence, deterministic confirmation, idempotency/evidence, and checkpoint/resume.
The agent owns domain tools, required parameters, domain messages, domain eligibility/pre-validation, and customer-facing final responses. It must not create a parallel transaction engine.
## 9. New host/template checklist
- [ ] `AgentState` declares `active_transaction`.
- [ ] `AgentState` declares `last_transaction`.
- [ ] `transaction_status` and `missing_parameters` are declared when used.
- [ ] Checkpoint provider is compatible with the state schema.
- [ ] The same conversation identity is reused across turns.
- [ ] New parameters are merged with previously collected arguments.
- [ ] Pending parameter answers take precedence over generic keyword routing.
- [ ] Explicit intent shifts remain possible.
- [ ] Transactional agent responses propagate `transaction_state_patch(state)` where required by the template.
- [ ] Multi-turn tests cover collection, confirmation, interruption, and resume.
## 10. Minimum regression tests
Test order cancellation with a pending `order_id`, contestation with a subject collected on the first turn and amount on the second, explicit interruption to a different intent, and checkpoint/resume using the same conversation identity.
## 11. Anti-patterns
- rebuilding the transaction from only the latest message;
- using `selected_tool_call` as the only latch source;
- removing `active_transaction` because it appears redundant;
- allowing a generic keyword such as `order` to interrupt `order_id` collection;
- keeping parameters only in node-local variables;
- duplicating transaction confirmation in the agent prompt;
- clearing the latch before a terminal state.
## 12. Project references
- `specs/SPEC-002-Agent-Runtime.md`
- `specs/SPEC-010-Agent-Development.md`
- `templates/agent_template_backend/app/state.py`
- `libs/agent_framework/src/agent_framework/runtime/agent_runtime.py`
- `libs/agent_framework/src/agent_framework/routing/enterprise_router.py`
- `Tuning-Performance/Deterministic_Transactional_Workflow/`
- `Tuning-Performance/Transaction_Pre_Validation/`
- `Tuning-Performance/Transaction_Evidence/`

View File

@@ -1,100 +1,80 @@
# Optional file. If this file is absent, the backend keeps using .env exactly as before.
# If present, each inference point can override provider/model/params.
# Put this files in the same .env file folder
profiles: profiles:
default: default:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1 model: openai.gpt-4.1
temperature: 0.2 temperature: 0.2
max_tokens: 2048 max_tokens: 2048
# Workflow/routing
supervisor: supervisor:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1 model: openai.gpt-4.1
temperature: 0 temperature: 0
max_tokens: 700 max_tokens: 700
# Lightweight semantic continuity classifier. Choose the smallest/fastest
# model approved for the environment. The framework references this profile
# through ROUTE_STICKINESS_LLM_PROFILE.
route_continuity: route_continuity:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1-mini model: openai.gpt-4.1-mini
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
router: router:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1 model: openai.gpt-4.1
temperature: 0 temperature: 0
max_tokens: 500 max_tokens: 500
# Safety / evaluation
guardrail: guardrail:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1 model: openai.gpt-4.1
temperature: 0 temperature: 0
max_tokens: 600 max_tokens: 600
grl: grl:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1 model: openai.gpt-4.1
temperature: 0 temperature: 0
max_tokens: 700 max_tokens: 700
judge: judge:
provider: oci_openai provider: oci_openai
model: xopenai.gpt-4.1 model: xopenai.gpt-4.1
temperature: 0 temperature: 0
max_tokens: 800 max_tokens: 800
# RAG
rag_rewriter: rag_rewriter:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1 model: openai.gpt-4.1
temperature: 0 temperature: 0
max_tokens: 300 max_tokens: 300
rag_compressor: rag_compressor:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1 model: openai.gpt-4.1
temperature: 0 temperature: 0
max_tokens: 1200 max_tokens: 1200
rag_generation: rag_generation:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1 model: openai.gpt-4.1
temperature: 0.1 temperature: 0.1
max_tokens: 1800 max_tokens: 1800
# Memory / operations
summary_memory: summary_memory:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1 model: openai.gpt-4.1
temperature: 0.1 temperature: 0.1
max_tokens: 1200 max_tokens: 1200
noc: noc:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1 model: openai.gpt-4.1
temperature: 0 temperature: 0
max_tokens: 700 max_tokens: 700
# Agent-specific overrides
billing_agent: billing_agent:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1 model: openai.gpt-4.1
temperature: 0.2 temperature: 0.2
product_agent: product_agent:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1 model: openai.gpt-4.1
temperature: 0.2 temperature: 0.2
backoffice_agent: backoffice_agent:
provider: oci_openai provider: oci_openai
model: openai.gpt-4.1 model: openai.gpt-4.1
temperature: 0.2 temperature: 0.2
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -9,6 +9,7 @@ from typing import Any
from .config_loader import load_intents, load_router_defaults, load_state_policies from .config_loader import load_intents, load_router_defaults, load_state_policies
from .continuity import SemanticRouteContinuity from .continuity import SemanticRouteContinuity
from .models import IntentDefinition, RouteDecision, RouterStatePolicy from .models import IntentDefinition, RouteDecision, RouterStatePolicy
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, parse_transaction_confirmation
logger = logging.getLogger("agent_framework.routing") logger = logging.getLogger("agent_framework.routing")
@@ -78,6 +79,12 @@ class EnterpriseRouter:
# pendente antes de executar a nova intent. # pendente antes de executar a nova intent.
state_decision = self._route_by_state(current_state) state_decision = self._route_by_state(current_state)
if state_decision: if state_decision:
consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=state_decision
)
if consumed is not None:
await self._emit(consumed, state)
return consumed
interruption = await self._transaction_state_interruption_candidate( interruption = await self._transaction_state_interruption_candidate(
state, text=str(text), state_decision=state_decision state, text=str(text), state_decision=state_decision
) )
@@ -109,6 +116,17 @@ class EnterpriseRouter:
method="state", method="state",
next_state=tx_status, next_state=tx_status,
) )
consumed = await self._transaction_parameter_precedence(
state, text=str(text), state_decision=synthetic
)
if consumed is not None:
consumed.metadata = {
**(consumed.metadata or {}),
"transaction_state_recovered": True,
}
await self._emit(consumed, state)
return consumed
interruption = await self._transaction_state_interruption_candidate( interruption = await self._transaction_state_interruption_candidate(
state, text=str(text), state_decision=synthetic state, text=str(text), state_decision=synthetic
) )
@@ -186,6 +204,63 @@ class EnterpriseRouter:
return decision return decision
async def _transaction_parameter_precedence(
self,
state: dict[str, Any],
*,
text: str,
state_decision: RouteDecision,
) -> RouteDecision | None:
"""Consume a turn as transaction parameters before evaluating intent shift.
Only COLLECTING_PARAMETERS participates. The LLM extracts values for the
currently missing parameters; if at least one value is found, the state
route wins deterministically and intent-shift classification is skipped.
"""
tx_status = str(state.get("transaction_status") or "").strip().upper()
if tx_status == "AWAITING_CONFIRMATION":
confirmation = parse_transaction_confirmation(text)
if confirmation is None:
return None
state_decision.metadata = {
**(state_decision.metadata or {}),
"transaction_turn_consumed": True,
"transaction_confirmation_decision": confirmation,
"transaction_confirmation_source": "deterministic",
}
return state_decision
if tx_status != "COLLECTING_PARAMETERS":
return None
missing = [str(name) for name in (state.get("missing_parameters") or []) if str(name).strip()]
if not missing:
return None
active = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {}
tool_name = str(active.get("tool_name") or ((state.get("selected_tool_call") or {}).get("tool_name") if isinstance(state.get("selected_tool_call"), dict) else "") or "").strip()
if not tool_name:
return None
known = dict(active.get("arguments") or {})
schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else {}
description = str(active.get("tool_description") or "")
values = await extract_transaction_parameters(
self.llm,
text=text,
tool_name=tool_name,
missing_parameters=missing,
known_arguments=known,
parameter_schema=schema,
tool_description=description,
)
if not values:
return None
state_decision.metadata = {
**(state_decision.metadata or {}),
"transaction_turn_consumed": True,
"transaction_parameter_values": values,
"transaction_parameter_source": "llm",
"transaction_parameter_missing_before": missing,
}
return state_decision
async def _transaction_state_interruption_candidate( async def _transaction_state_interruption_candidate(
self, self,
state: dict[str, Any], state: dict[str, Any],
@@ -222,6 +297,7 @@ class EnterpriseRouter:
"interruption_source": "configured_routing", "interruption_source": "configured_routing",
} }
return candidate return candidate
else:
return None return None
if not (self.enable_llm_router and self.llm is not None): if not (self.enable_llm_router and self.llm is not None):

View File

@@ -10,6 +10,7 @@ from typing import Any, Iterable, Mapping
from agent_framework.memory.summary_memory import MemoryContext, render_recent_messages from agent_framework.memory.summary_memory import MemoryContext, render_recent_messages
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, parse_transaction_confirmation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -571,6 +572,7 @@ class AgentRuntimeMixin:
state: dict[str, Any], state: dict[str, Any],
*, *,
overwrite_from_message: bool = False, overwrite_from_message: bool = False,
exclude_fields: Iterable[str] = (),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Executa regras ``extract`` declaradas para a tool escolhida. """Executa regras ``extract`` declaradas para a tool escolhida.
@@ -586,11 +588,14 @@ class AgentRuntimeMixin:
return dict(arguments or {}) return dict(arguments or {})
resolved = dict(arguments or {}) resolved = dict(arguments or {})
excluded = {str(name) for name in (exclude_fields or ())}
runtime = self.get_runtime_context(state) runtime = self.get_runtime_context(state)
message = runtime.sanitized_input or runtime.original_text or runtime.user_text message = runtime.sanitized_input or runtime.original_text or runtime.user_text
llm = getattr(self, "llm", None) llm = getattr(self, "llm", None)
for field_name, rule in rules.items(): for field_name, rule in rules.items():
if str(field_name) in excluded:
continue
from_message = str(rule.get("from") or "message").lower() == "message" from_message = str(rule.get("from") or "message").lower() == "message"
if not from_message: if not from_message:
continue continue
@@ -1233,46 +1238,59 @@ class AgentRuntimeMixin:
@staticmethod @staticmethod
def _confirmation_decision(text: str) -> str | None: def _confirmation_decision(text: str) -> str | None:
normalized = " ".join((text or "").strip().lower().split()) return parse_transaction_confirmation(text)
normalized = re.sub(r"[.!?]+$", "", normalized).strip()
if normalized in {"sim", "confirmo", "sim, confirmo", "pode fazer", "pode prosseguir", "sim, desejo", "sim, desejo trocar", "sim, confirmo a devolução", "sim, confirmo a troca"}:
return "confirm"
if normalized in {"não", "nao", "cancelar", "cancele", "não confirmo", "nao confirmo"}:
return "reject"
return None
@staticmethod def _transaction_parameter_schema(self, tool_name: str, policy: dict[str, Any] | None = None) -> dict[str, Any]:
def _extract_action_arguments(text: str) -> dict[str, Any]: """Return generic schema metadata for transactional required parameters."""
"""Extrai apenas entidades explicitamente informadas na mensagem. cfg = self._tool_config(tool_name)
raw_schema = dict(getattr(cfg, "args_schema", {}) or {}) if cfg is not None else {}
required = [str(name) for name in ((policy or {}).get("requires") or getattr(cfg, "requires", []) or [])]
if not required:
return raw_schema
return {name: raw_schema.get(name, "string") for name in required}
Não usa a mensagem inteira como ``reason``: frases como "quero devolver def _transaction_tool_description(self, tool_name: str) -> str:
uma compra" expressam a ação, mas não necessariamente o motivo. Defaults cfg = self._tool_config(tool_name)
declarados no mapper continuam sendo aplicados por ``build_tool_arguments``. return str(getattr(cfg, "description", "") or "") if cfg is not None else ""
async def _extract_transaction_parameters(
self,
state: dict[str, Any],
*,
tool_name: str,
missing_parameters: list[str],
known_arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Use the dedicated LLM extractor for pending transaction parameters.
A route decision may already contain the extraction performed by the
router solely to enforce parameter-before-intent-shift precedence. Reuse
it to avoid a second LLM call in the same turn.
""" """
raw = text or "" route_meta = ((state.get("route_decision") or {}).get("metadata") or {}) if isinstance(state.get("route_decision"), dict) else {}
args: dict[str, Any] = {} cached = route_meta.get("transaction_parameter_values")
match = re.search( if isinstance(cached, dict):
r"(?:pedido|ordem)\s*(?:n[ºo°.]?\s*)?(?:é\s*(?:o\s*)?|[:#=-]\s*)?([A-Za-z0-9_-]+)", allowed = set(str(x) for x in missing_parameters)
raw, reused = {str(k): v for k, v in cached.items() if str(k) in allowed and v not in _EMPTY_VALUES}
flags=re.IGNORECASE, if reused:
) return reused
if match:
args["order_id"] = match.group(1)
reason_match = re.search( active = self._active_transaction(state) or {}
r"(?:porque|pois|motivo\s*[:=-]?|por\s+(?:arrependimento|defeito|erro|atraso)|me\s+arrependi(?:\s+da\s+compra)?|arrependimento)\s*(.*)", schema = active.get("parameter_schema") if isinstance(active.get("parameter_schema"), dict) else None
raw, if not schema:
flags=re.IGNORECASE, policy = self._resolve_tool_execution_policy(tool_name, known_arguments or {})
schema = self._transaction_parameter_schema(tool_name, policy)
description = str(active.get("tool_description") or self._transaction_tool_description(tool_name) or "")
text = state.get("sanitized_input") or state.get("user_text") or ""
return await extract_transaction_parameters(
getattr(self, "llm", None),
text=str(text),
tool_name=tool_name,
missing_parameters=list(missing_parameters or []),
known_arguments=known_arguments or {},
parameter_schema=schema,
tool_description=description,
) )
if reason_match:
reason = reason_match.group(1).strip(" .,:;-")
if not reason:
matched_phrase = reason_match.group(0).strip(" .,:;-")
if re.search(r"me\s+arrependi|arrependimento", matched_phrase, flags=re.IGNORECASE):
reason = "Arrependimento da compra"
if reason:
args["reason"] = reason
return args
def _transactional_action_match(self, text: str, tools: list[str] | None = None) -> str | None: def _transactional_action_match(self, text: str, tools: list[str] | None = None) -> str | None:
"""Detecta solicitação transacional usando metadados de tools.yaml. """Detecta solicitação transacional usando metadados de tools.yaml.
@@ -1515,12 +1533,19 @@ class AgentRuntimeMixin:
) -> dict[str, Any]: ) -> dict[str, Any]:
current = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {} current = state.get("active_transaction") if isinstance(state.get("active_transaction"), dict) else {}
txid = transaction_id or current.get("transaction_id") or str(uuid.uuid4()) txid = transaction_id or current.get("transaction_id") or str(uuid.uuid4())
if str(current.get("tool_name") or "") != str(tool_name):
state["transaction_pre_validation"] = None
cfg = self._tool_config(tool_name)
policy = self._resolve_tool_execution_policy(tool_name, arguments or {})
tx = { tx = {
"transaction_id": txid, "transaction_id": txid,
"tool_name": tool_name, "tool_name": tool_name,
"arguments": dict(arguments or {}), "arguments": dict(arguments or {}),
"status": status, "status": status,
"started_from_intent": current.get("started_from_intent") or state.get("intent"), "started_from_intent": current.get("started_from_intent") or state.get("intent"),
"requires": list(policy.get("requires") or getattr(cfg, "requires", []) or []),
"parameter_schema": self._transaction_parameter_schema(tool_name, policy),
"tool_description": self._transaction_tool_description(tool_name),
} }
state["active_transaction"] = tx state["active_transaction"] = tx
return tx return tx
@@ -2057,6 +2082,7 @@ class AgentRuntimeMixin:
if active_before_interruption and interruption == "intent_shift": if active_before_interruption and interruption == "intent_shift":
interrupted_tool = active_before_interruption.get("tool_name") interrupted_tool = active_before_interruption.get("tool_name")
self._finish_active_transaction(state, "CANCELLED") self._finish_active_transaction(state, "CANCELLED")
state["transaction_pre_validation"] = None
state["tool_policy_result"] = { state["tool_policy_result"] = {
"action": "cancelled_by_intent_shift", "action": "cancelled_by_intent_shift",
"tool_name": interrupted_tool, "tool_name": interrupted_tool,
@@ -2080,28 +2106,40 @@ class AgentRuntimeMixin:
tool_name = selected.get("tool_name") tool_name = selected.get("tool_name")
if tool_name: if tool_name:
previous_args = dict(selected.get("arguments") or {}) previous_args = dict(selected.get("arguments") or {})
new_args = self.build_tool_arguments( policy = self._resolve_tool_execution_policy(tool_name, previous_args)
missing_before = self._missing_required_arguments(policy, previous_args)
# Parâmetros TRANSACIONAIS são interpretados exclusivamente pelo
# extrator LLM genérico. Não existem regexes/nome de entidade
# hardcoded no framework. O extrator recebe apenas os parâmetros
# ainda pendentes da policy e pode consumir um ou vários no turno.
extracted = await self._extract_transaction_parameters(
state, state,
tool_name=tool_name, tool_name=tool_name,
intent=state.get("intent"), missing_parameters=missing_before,
aliases=aliases, known_arguments=previous_args,
extra_args=self._extract_action_arguments(text),
) )
# Durante coleta incremental, valores de contexto podem ainda conter arguments = {**previous_args, **extracted}
# parâmetros de uma operação anterior. O que já foi coletado para a
# transação pendente prevalece; o turno atual só preenche lacunas.
non_empty_new = {k: v for k, v in new_args.items() if v not in (None, "", [], {})}
arguments = {**non_empty_new, **previous_args}
# Campos de envelope pertencem ao turno corrente e devem permanecer # Argumentos estruturados já presentes no contexto são aceitos de
# atualizados, mesmo quando os parâmetros de negócio ficam congelados. # forma genérica (não são parsing textual). Para required fields,
for per_turn_key in ("query", "operator_instructions", "interaction_key"): # só completam lacunas que a fala atual/LLM não preencheu; valores
if non_empty_new.get(per_turn_key) not in (None, "", [], {}): # previamente coletados nunca são sobrescritos.
arguments[per_turn_key] = non_empty_new[per_turn_key] contextual = self.build_tool_arguments(
state, tool_name=tool_name, intent=state.get("intent"), aliases=aliases
# Reutiliza o contrato declarativo para preencher somente os campos )
# ainda faltantes; campos previamente coletados não são sobrescritos. required_set = set(str(name) for name in (policy.get("requires") or []))
arguments = await self._extract_mcp_parameters(tool_name, arguments, state) for key, value in contextual.items():
if value in _EMPTY_VALUES:
continue
if key in required_set:
if arguments.get(key) in _EMPTY_VALUES:
arguments[key] = value
else:
arguments[key] = value
arguments = await self._extract_mcp_parameters(
tool_name, arguments, state, exclude_fields=policy.get("requires") or []
)
policy = self._resolve_tool_execution_policy(tool_name, arguments) policy = self._resolve_tool_execution_policy(tool_name, arguments)
missing = self._missing_required_arguments(policy, arguments) missing = self._missing_required_arguments(policy, arguments)
if missing: if missing:
@@ -2250,23 +2288,46 @@ class AgentRuntimeMixin:
if not selected_action: if not selected_action:
return results return results
explicit_action_args = self._extract_action_arguments(text)
action_args = self.build_tool_arguments( action_args = self.build_tool_arguments(
state, state,
tool_name=selected_action, tool_name=selected_action,
intent=state.get("intent"), intent=state.get("intent"),
aliases=aliases, aliases=aliases,
extra_args=explicit_action_args,
) )
# Nova transação: parâmetros declarados ``from: message`` não podem ser # Campos que o contrato MCP declara como vindos da mensagem corrente não
# herdados de context.tool_arguments de uma operação anterior. # podem herdar valores textuais de uma transação anterior. Isto é apenas
# uma regra de freshness do envelope MCP; a extração de policy.requires
# continua exclusivamente no TransactionParameterExtractor LLM abaixo.
action_args = self._drop_stale_message_extracted_arguments( action_args = self._drop_stale_message_extracted_arguments(
selected_action, action_args, explicit_fields=explicit_action_args.keys() selected_action, action_args, explicit_fields=()
) )
# A mensagem atual é a fonte de verdade para esses campos no primeiro policy = self._resolve_tool_execution_policy(selected_action, action_args)
# turno transacional. required = [str(name) for name in (policy.get("requires") or [])]
# Valores já estruturados no contexto podem satisfazer requirements sem
# parsing textual. Para qualquer required field ainda ausente, a fala do
# usuário é interpretada exclusivamente pelo extrator LLM transacional.
missing_initial = self._missing_required_arguments(policy, action_args)
# No primeiro turno, a fala atual pode fornecer/corrigir qualquer required
# field, inclusive um valor que exista no contexto estruturado mas pertença
# a uma transação anterior. O extrator continua restrito ao contrato
# ``requires`` e só sobrescreve quando a LLM realmente extrai um valor.
extracted_initial = await self._extract_transaction_parameters(
state,
tool_name=selected_action,
missing_parameters=required,
known_arguments={k: v for k, v in action_args.items() if k not in set(required)},
)
action_args.update(extracted_initial)
# O mapper MCP continua responsável somente por parâmetros auxiliares que
# não pertencem ao contrato transacional.
action_args = await self._extract_mcp_parameters( action_args = await self._extract_mcp_parameters(
selected_action, action_args, state, overwrite_from_message=True selected_action,
action_args,
state,
overwrite_from_message=True,
exclude_fields=required,
) )
policy = self._resolve_tool_execution_policy(selected_action, action_args) policy = self._resolve_tool_execution_policy(selected_action, action_args)
selected = {"tool_name": selected_action, "arguments": action_args} selected = {"tool_name": selected_action, "arguments": action_args}

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import re
from typing import Any
def confirmation_decision(text: str) -> str | None:
"""Classifica respostas explícitas ao estado AWAITING_CONFIRMATION.
Esta função é compartilhada pelo router (precedência antes de intent_shift)
e pelo runtime (execução/cancelamento efetivo), garantindo que ambos
reconheçam exatamente o mesmo conjunto de respostas.
"""
normalized = " ".join((text or "").strip().lower().split())
normalized = re.sub(r"[.!?]+$", "", normalized).strip()
if normalized in {
"sim",
"confirmo",
"sim, confirmo",
"pode fazer",
"pode prosseguir",
"sim, desejo",
"sim, desejo trocar",
"sim, confirmo a devolução",
"sim, confirmo a troca",
}:
return "confirm"
if normalized in {"não", "nao", "cancelar", "cancele", "não confirmo", "nao confirmo"}:
return "reject"
return None
def extract_action_arguments(text: str) -> dict[str, Any]:
"""Extrai entidades explicitamente informadas em ações transacionais.
É usada tanto pelo runtime quanto pelo probe de precedência do router. Não
transforma a mensagem inteira em motivo: só captura valores explicitamente
identificáveis no turno atual.
"""
raw = text or ""
args: dict[str, Any] = {}
match = re.search(
r"(?:pedido|ordem)\s*(?:n[ºo°.]?\s*)?(?:é\s*(?:o\s*)?|[:#=-]\s*)?([A-Za-z0-9_-]+)",
raw,
flags=re.IGNORECASE,
)
if match:
args["order_id"] = match.group(1)
reason_match = re.search(
r"(?:porque|pois|motivo\s*[:=-]?|por\s+(?:arrependimento|defeito|erro|atraso)|me\s+arrependi(?:\s+da\s+compra)?|arrependimento)\s*(.*)",
raw,
flags=re.IGNORECASE,
)
if reason_match:
reason = reason_match.group(1).strip(" .,:;-")
if not reason:
matched_phrase = reason_match.group(0).strip(" .,:;-")
if re.search(r"me\s+arrependi|arrependimento", matched_phrase, flags=re.IGNORECASE):
reason = "Arrependimento da compra"
if reason:
args["reason"] = reason
return args

View File

@@ -0,0 +1,181 @@
from __future__ import annotations
import json
import logging
import re
from typing import Any, Mapping
logger = logging.getLogger(__name__)
_EMPTY_VALUES = (None, "", {}, [])
def _response_text(response: Any) -> str:
if response is None:
return ""
if isinstance(response, str):
return response
if isinstance(response, dict):
return str(response.get("content") or response.get("text") or response.get("answer") or "")
return str(getattr(response, "content", None) or getattr(response, "text", None) or response)
def _coerce(value: Any, declared_type: Any) -> Any:
if value in _EMPTY_VALUES:
return None
type_name = str(declared_type or "string").strip().lower()
try:
if type_name in {"integer", "int"}:
return int(value)
if type_name in {"number", "float", "double"}:
return float(value)
if type_name in {"boolean", "bool"}:
if isinstance(value, bool):
return value
normalized = str(value).strip().lower()
if normalized in {"true", "1", "yes", "sim"}:
return True
if normalized in {"false", "0", "no", "não", "nao"}:
return False
return None
if type_name in {"array", "list"}:
return value if isinstance(value, list) else [value]
if type_name in {"object", "dict", "map"}:
return value if isinstance(value, dict) else None
return str(value).strip()
except (TypeError, ValueError):
return None
def parse_transaction_confirmation(text: str) -> str | None:
"""Recognize an explicit confirmation/rejection before intent-shift routing.
This is intentionally small and domain-neutral. Parameter interpretation is
LLM-only; confirmation remains a deterministic control token so an explicit
yes/no cannot be reclassified as a new intent.
"""
normalized = " ".join(str(text or "").strip().lower().split())
normalized = re.sub(r"[.!?]+$", "", normalized).strip()
if normalized in {
"sim", "confirmo", "sim, confirmo", "pode fazer", "pode prosseguir",
"sim, desejo", "sim, desejo trocar", "sim, confirmo a devolução",
"sim, confirmo a troca",
}:
return "confirm"
if normalized in {"não", "nao", "cancelar", "cancele", "não confirmo", "nao confirmo"}:
return "reject"
return None
async def extract_transaction_parameters(
llm: Any,
*,
text: str,
tool_name: str,
missing_parameters: list[str],
known_arguments: Mapping[str, Any] | None = None,
parameter_schema: Mapping[str, Any] | None = None,
tool_description: str | None = None,
) -> dict[str, Any]:
"""Extract values for pending transactional parameters using the LLM only.
This component intentionally contains no domain/entity regexes and no
knowledge of parameter names such as ``order_id`` or ``reason``. The
transaction runtime supplies the pending parameter names and optional schema;
the LLM only interprets the current user turn. State/control-flow decisions
remain deterministic outside this function.
"""
pending = [str(name) for name in (missing_parameters or []) if str(name).strip()]
message = str(text or "").strip()
if not pending or not message or llm is None:
return {}
schema = dict(parameter_schema or {})
known = {
str(key): value
for key, value in dict(known_arguments or {}).items()
if value not in _EMPTY_VALUES and str(key) not in pending
}
field_spec = {
name: {
"type": schema.get(name, "string") if not isinstance(schema.get(name), dict) else schema.get(name, {}).get("type", "string"),
"description": None if not isinstance(schema.get(name), dict) else schema.get(name, {}).get("description"),
}
for name in pending
}
output_shape = {name: None for name in pending}
prompt = (
"Você extrai parâmetros PENDENTES de uma transação ativa. "
"Sua única tarefa é interpretar a mensagem atual e devolver valores para os parâmetros pendentes. "
"Não decida roteamento, intenção, confirmação ou execução da transação.\n\n"
"REGRAS OBRIGATÓRIAS:\n"
"1. Extraia SOMENTE parâmetros listados em pending_parameters.\n"
"2. Não invente valores e não transforme uma nova solicitação/intenção do usuário em valor de parâmetro.\n"
"3. Se nenhum parâmetro pendente foi realmente informado, devolva null para todos.\n"
"4. Se houver apenas um parâmetro pendente, uma resposta contendo apenas um valor pode ser associada a ele quando isso for semanticamente inequívoco.\n"
"5. Se houver vários parâmetros pendentes, extraia todos os que estiverem presentes no mesmo turno.\n"
"6. O nome do parâmetro não precisa aparecer literalmente na fala; use a semântica, o nome da transação e o schema para associar valores.\n"
"7. Em caso de dúvida, prefira null.\n"
"8. Responda SOMENTE JSON válido, sem markdown, sem explicação e sem chaves extras.\n\n"
f"transaction_tool: {tool_name}\n"
f"transaction_description: {tool_description or ''}\n"
f"pending_parameters: {json.dumps(pending, ensure_ascii=False)}\n"
f"parameter_schema: {json.dumps(field_spec, ensure_ascii=False, default=str)}\n"
f"known_arguments: {json.dumps(known, ensure_ascii=False, default=str)}\n"
f"user_message: {message}\n"
f"Formato obrigatório: {json.dumps(output_shape, ensure_ascii=False)}"
)
try:
response = await llm.ainvoke(
[{"role": "user", "content": prompt}],
profile_name="transaction_parameter_extraction",
component_name="transaction_parameter_extraction",
generation_name="llm.transaction_parameter_extraction",
temperature=0.0,
max_tokens=max(120, min(500, 80 + 60 * len(pending))),
)
except TypeError:
# Compatibilidade com doubles/testes e providers mínimos que aceitam
# apenas messages.
response = await llm.ainvoke([{"role": "user", "content": prompt}])
except Exception as exc:
logger.warning(
"transaction.parameter.llm_extract_failed tool=%s pending=%s error=%s",
tool_name,
pending,
exc,
)
return {}
raw = _response_text(response).strip()
if raw.startswith("```"):
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.IGNORECASE | re.DOTALL).strip()
try:
payload = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
logger.warning(
"transaction.parameter.llm_invalid_json tool=%s pending=%s raw=%r",
tool_name,
pending,
raw[:240],
)
return {}
if not isinstance(payload, dict):
return {}
extracted: dict[str, Any] = {}
for name in pending:
value = payload.get(name)
declared = field_spec.get(name, {}).get("type", "string")
coerced = _coerce(value, declared)
if coerced not in _EMPTY_VALUES:
extracted[name] = coerced
logger.info(
"transaction.parameter.llm_extracted tool=%s pending=%s consumed=%s",
tool_name,
pending,
sorted(extracted),
)
return extracted

View File

@@ -208,6 +208,21 @@ rag:
| `RUNTIME_TIMEOUT` | Timeout geral | resposta controlada | | `RUNTIME_TIMEOUT` | Timeout geral | resposta controlada |
## Contrato Durável de Estado Transacional
Hosts que utilizam `AgentRuntime` com transações multi-turno DEVEM declarar no `AgentState` os campos `active_transaction` e `last_transaction`. O primeiro é a fonte canônica da transação em andamento e deve sobreviver a checkpoint/resume; o segundo mantém o snapshot da última transação terminal.
```python
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
```
`selected_tool_call` e `pending_tool_call` são campos auxiliares/compatibilidade e não substituem o latch canônico. Durante `COLLECTING_PARAMETERS`, a retomada da transação e o consumo de parâmetros pendentes têm precedência sobre keyword routing genérico. Uma mudança de intenção só deve interromper a transação quando for inequívoca ou explicitamente solicitada pelo usuário.
O contrato completo, ciclo de vida, precedência de roteamento, checklist e testes regressivos estão em [`docs/TRANSACTION_STATE_DEVELOPER_GUIDE.md`](../docs/TRANSACTION_STATE_DEVELOPER_GUIDE.md).
## Requisitos Não Funcionais ## Requisitos Não Funcionais
| Categoria | Requisito | | Categoria | Requisito |
@@ -233,6 +248,8 @@ rag:
- [ ] Output guardrails executam antes da resposta final. - [ ] Output guardrails executam antes da resposta final.
- [ ] Judges geram JudgeResult. - [ ] Judges geram JudgeResult.
- [ ] Memória e checkpoint são persistidos conforme provider. - [ ] Memória e checkpoint são persistidos conforme provider.
- [ ] Hosts transacionais declaram `active_transaction` e `last_transaction` no `AgentState`.
- [ ] Durante `COLLECTING_PARAMETERS`, respostas a parâmetros pendentes têm precedência sobre keyword routing genérico.
- [ ] Erros geram NOC e resposta controlada. - [ ] Erros geram NOC e resposta controlada.

View File

@@ -215,6 +215,21 @@ dataset:
groundedness: 0.70 groundedness: 0.70
``` ```
## Contrato obrigatório para agentes transacionais
Ao criar um agente que usa tools transacionais do framework, o desenvolvedor não deve criar um motor paralelo de coleta/confirmação. Deve reutilizar `AgentRuntime` e garantir que o `AgentState` do host mantenha o latch durável:
```python
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
```
Durante uma transação ativa, parâmetros já coletados são preservados e novos valores são mesclados incrementalmente. Em `COLLECTING_PARAMETERS`, uma resposta que satisfaz um parâmetro pendente tem precedência sobre keywords genéricas. Mudanças de intenção explícitas e inequívocas continuam permitidas.
Antes de publicar um novo template/host, execute os cenários multi-turno descritos no [`Transaction State Developer Guide`](../docs/TRANSACTION_STATE_DEVELOPER_GUIDE.md).
## Testes ## Testes
| Teste | Escopo | | Teste | Escopo |
@@ -273,6 +288,7 @@ dataset:
## Critérios de Aceite ## Critérios de Aceite
- [ ] Novo agente é criado sem alterar core do framework. - [ ] Novo agente é criado sem alterar core do framework.
- [ ] Se houver transações multi-turno, `AgentState` declara `active_transaction` e `last_transaction`.
- [ ] Configuração ocorre por YAML e `.env`. - [ ] Configuração ocorre por YAML e `.env`.
- [ ] Agente usa BusinessContext. - [ ] Agente usa BusinessContext.
- [ ] Agente acessa MCP por router/gateway. - [ ] Agente acessa MCP por router/gateway.

View File

@@ -26,6 +26,8 @@ class AgentState(TypedDict, total=False):
available_mcp_tools: list[str] available_mcp_tools: list[str]
selected_tool_call: dict[str, Any] selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any] pending_tool_call: dict[str, Any]
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
transaction_status: str transaction_status: str
transaction_pre_validation: dict[str, Any] transaction_pre_validation: dict[str, Any]
transaction_evidence: list[dict[str, Any]] transaction_evidence: list[dict[str, Any]]

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -26,6 +26,8 @@ class AgentState(TypedDict, total=False):
available_mcp_tools: list[str] available_mcp_tools: list[str]
selected_tool_call: dict[str, Any] selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any] pending_tool_call: dict[str, Any]
active_transaction: dict[str, Any]
last_transaction: dict[str, Any]
transaction_status: str transaction_status: str
transaction_pre_validation: dict[str, Any] transaction_pre_validation: dict[str, Any]
confirmation_required: bool confirmation_required: bool

View File

@@ -78,3 +78,10 @@ profiles:
temperature: 0 temperature: 0
max_tokens: 80 max_tokens: 80
timeout_seconds: 5 timeout_seconds: 5
transaction_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 500
timeout_seconds: 8

View File

@@ -0,0 +1,293 @@
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from agent_framework.routing.enterprise_router import EnterpriseRouter
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
class _SemanticLLM:
"""Test double: parameter extraction + intent-shift classification."""
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"] if isinstance(messages[-1], dict) else str(messages[-1])
profile = kwargs.get("profile_name")
if profile == "transaction_parameter_extraction" or "pending_parameters:" in prompt:
marker = "user_message: "
user = prompt.split(marker, 1)[1].split("\nFormato obrigatório:", 1)[0].strip() if marker in prompt else ""
pending_raw = prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0]
pending = json.loads(pending_raw)
values = {name: None for name in pending}
low = user.lower()
if "ped-1001" in low and "order_id" in values:
values["order_id"] = "PED-1001"
if "desisti" in low and "reason" in values:
values["reason"] = "desisti da compra"
if low.strip() == "71,99" and "valor" in values:
values["valor"] = 71.99
if low.strip() == "tim music" and "subject" in values:
values["subject"] = "TIM Music"
return json.dumps(values, ensure_ascii=False)
# Router LLM fallback: treat fatura as a real intent shift.
if "fatura" in prompt.lower():
return json.dumps({
"decision": "SHIFT",
"intent": "billing_invoice_explanation",
"agent": "billing_agent",
"confidence": 0.98,
"reason": "nova intenção de fatura",
})
return json.dumps({
"decision": "CONTINUE",
"intent": None,
"agent": None,
"confidence": 0.95,
"reason": "continua transação",
})
class _Router:
def __init__(self):
self.registry = SimpleNamespace(
tools={},
get_tool=self.get_tool,
)
def get_tool(self, name):
data = {
"solicitar_devolucao": SimpleNamespace(
name="solicitar_devolucao",
description="Abre uma solicitação de devolução de pedido.",
selection_keywords=["devolver pedido", "devolução", "devolver"],
args_schema={"order_id": "string", "reason": "string"},
requires=["order_id", "reason"],
confirmation_required=True,
tool_type="action",
),
"cancelar_pedido": SimpleNamespace(
name="cancelar_pedido",
description="Cancela um pedido.",
selection_keywords=["cancelar pedido", "cancelar compra"],
args_schema={"order_id": "string"},
requires=["order_id"],
confirmation_required=True,
tool_type="action",
),
}
return data.get(name)
def resolve_execution_policy(self, tool_name, arguments=None):
cfg = self.get_tool(tool_name)
if not cfg:
return {"operation_type": "read_only", "require_confirmation": False, "requires": []}
return {
"operation_type": "transactional",
"require_confirmation": True,
"requires": list(cfg.requires),
"policy_source": "test",
}
def parameter_extract_rules(self, tool_name):
# Deliberately has MCP mappings for the same fields: transactional fields
# must be excluded from this mechanism by the runtime.
return {
"order_id": {"from": "message", "strategy": "regex", "pattern": r"pedido\\s+(\\w+)"},
"reason": {"from": "message", "strategy": "regex", "pattern": r"motivo\\s+(.+)"},
}
def validate_execution_policy(self, tool_name, arguments=None):
return True, None, self.resolve_execution_policy(tool_name, arguments)
class _Runtime(AgentRuntimeMixin):
def __init__(self):
self.tool_router = _Router()
self.llm = _SemanticLLM()
self.calls = []
async def _call_mcp_tool(self, tool_name, arguments, state):
self.calls.append((tool_name, dict(arguments)))
return {"ok": True, "tool_name": tool_name, "result": {"status": "OK"}}
@pytest.mark.asyncio
async def test_transaction_extractor_handles_multiple_parameters_without_hardcoded_regex():
runtime = _Runtime()
state = {
"user_text": "quero devolver pedido PED-1001 porque desisti da compra",
"sanitized_input": "quero devolver pedido PED-1001 porque desisti da compra",
"mcp_tools": ["solicitar_devolucao"],
"route": "support_agent",
"intent": "retail_support_exchange_return",
}
result = await runtime.execute_tools_for_intent(state)
assert result[-1]["awaiting_confirmation"] is True
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
args = state["pending_tool_call"]["arguments"]
assert args["order_id"] == "PED-1001"
assert args["reason"] == "desisti da compra"
@pytest.mark.asyncio
async def test_collecting_one_parameter_consumes_turn_before_intent_shift(tmp_path):
routing = tmp_path / "routing.yaml"
routing.write_text(
"""
router:
fallback_agent: support_agent
confidence_threshold: 0.70
state_policies:
- state: COLLECTING_SUPPORT_PARAMETERS
agent: support_agent
intents:
- name: retail_order_tracking
agent: orders_agent
priority: 20
keywords: [pedido]
- name: retail_support_exchange_return
agent: support_agent
priority: 30
keywords: [devolver pedido]
- name: billing_invoice_explanation
agent: billing_agent
priority: 40
keywords: [fatura]
""",
encoding="utf-8",
)
settings = SimpleNamespace(
ROUTING_CONFIG_PATH=str(routing),
ENABLE_LLM_ROUTER=True,
ENABLE_ROUTE_STICKINESS=False,
)
router = EnterpriseRouter(settings, llm=_SemanticLLM())
state = {
"user_text": "o numero do pedido é PED-1001",
"sanitized_input": "o numero do pedido é PED-1001",
"next_state": "COLLECTING_SUPPORT_PARAMETERS",
"transaction_status": "COLLECTING_PARAMETERS",
"missing_parameters": ["order_id", "reason"],
"active_agent": "support_agent",
"intent": "state:COLLECTING_SUPPORT_PARAMETERS",
"active_transaction": {
"tool_name": "solicitar_devolucao",
"arguments": {},
"status": "COLLECTING_PARAMETERS",
"started_from_intent": "retail_support_exchange_return",
"parameter_schema": {"order_id": "string", "reason": "string"},
"tool_description": "Abre uma solicitação de devolução de pedido.",
},
}
decision = await router.route(state)
assert decision.agent == "support_agent"
assert decision.intent == "state:COLLECTING_SUPPORT_PARAMETERS"
assert decision.metadata["transaction_turn_consumed"] is True
assert decision.metadata["transaction_parameter_values"] == {"order_id": "PED-1001"}
assert "transaction_interruption" not in decision.metadata
@pytest.mark.asyncio
async def test_no_parameter_found_allows_intent_shift(tmp_path):
routing = tmp_path / "routing.yaml"
routing.write_text(
"""
router:
fallback_agent: support_agent
confidence_threshold: 0.70
state_policies:
- state: COLLECTING_SUPPORT_PARAMETERS
agent: support_agent
intents:
- name: retail_support_exchange_return
agent: support_agent
priority: 20
keywords: [devolver pedido]
- name: billing_invoice_explanation
agent: billing_agent
priority: 40
keywords: [fatura]
""",
encoding="utf-8",
)
settings = SimpleNamespace(
ROUTING_CONFIG_PATH=str(routing),
ENABLE_LLM_ROUTER=True,
ENABLE_ROUTE_STICKINESS=False,
)
router = EnterpriseRouter(settings, llm=_SemanticLLM())
state = {
"user_text": "esquece isso, quero ver minha fatura",
"sanitized_input": "esquece isso, quero ver minha fatura",
"next_state": "COLLECTING_SUPPORT_PARAMETERS",
"transaction_status": "COLLECTING_PARAMETERS",
"missing_parameters": ["order_id", "reason"],
"active_agent": "support_agent",
"intent": "state:COLLECTING_SUPPORT_PARAMETERS",
"active_transaction": {
"tool_name": "solicitar_devolucao",
"arguments": {},
"status": "COLLECTING_PARAMETERS",
"started_from_intent": "retail_support_exchange_return",
"parameter_schema": {"order_id": "string", "reason": "string"},
},
}
decision = await router.route(state)
assert decision.intent == "billing_invoice_explanation"
assert decision.agent == "billing_agent"
assert decision.metadata["transaction_interruption"] == "intent_shift"
def test_hardcoded_action_argument_extractor_removed():
from pathlib import Path
source = Path("libs/agent_framework/src/agent_framework/runtime/agent_runtime.py").read_text(encoding="utf-8")
assert "def _extract_action_arguments" not in source
assert "pedido|ordem" not in source
assert "reason_match" not in source
@pytest.mark.asyncio
async def test_confirmation_is_consumed_before_intent_shift(tmp_path):
routing = tmp_path / "routing.yaml"
routing.write_text(
"""
router:
fallback_agent: support_agent
confidence_threshold: 0.70
state_policies:
- state: WAITING_SUPPORT_CONFIRMATION
agent: support_agent
intents:
- name: generic_yes_intent
agent: other_agent
priority: 50
keywords: [sim]
""",
encoding="utf-8",
)
settings = SimpleNamespace(
ROUTING_CONFIG_PATH=str(routing),
ENABLE_LLM_ROUTER=True,
ENABLE_ROUTE_STICKINESS=False,
)
router = EnterpriseRouter(settings, llm=_SemanticLLM())
state = {
"user_text": "sim",
"sanitized_input": "sim",
"next_state": "WAITING_SUPPORT_CONFIRMATION",
"transaction_status": "AWAITING_CONFIRMATION",
"active_agent": "support_agent",
"active_transaction": {
"tool_name": "solicitar_devolucao",
"arguments": {"order_id": "PED-1001", "reason": "desisti"},
"status": "AWAITING_CONFIRMATION",
"started_from_intent": "retail_support_exchange_return",
},
}
decision = await router.route(state)
assert decision.agent == "support_agent"
assert decision.metadata["transaction_turn_consumed"] is True
assert decision.metadata["transaction_confirmation_decision"] == "confirm"
assert "transaction_interruption" not in decision.metadata

View File

@@ -34,6 +34,20 @@ intents:
""" """
class _ParameterLLM:
async def ainvoke(self, messages, **kwargs):
import json
prompt = messages[-1]["content"]
if kwargs.get("profile_name") == "transaction_parameter_extraction":
pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0])
user = prompt.split("user_message: ", 1)[1].split("\nFormato obrigatório:", 1)[0].strip()
out = {name: None for name in pending}
if len(pending) == 1 and user not in {"quero rastrear pedido", "quero ver minha fatura"}:
out[pending[0]] = user
return json.dumps(out, ensure_ascii=False)
return '{}'
def _router(tmp_path, *, stickiness=True): def _router(tmp_path, *, stickiness=True):
routing = tmp_path / "routing.yaml" routing = tmp_path / "routing.yaml"
routing.write_text(ROUTING_YAML, encoding="utf-8") routing.write_text(ROUTING_YAML, encoding="utf-8")
@@ -42,7 +56,7 @@ def _router(tmp_path, *, stickiness=True):
ENABLE_LLM_ROUTER=False, ENABLE_LLM_ROUTER=False,
ENABLE_ROUTE_STICKINESS=stickiness, ENABLE_ROUTE_STICKINESS=stickiness,
) )
return EnterpriseRouter(settings) return EnterpriseRouter(settings, llm=_ParameterLLM())
def _active_tx(status="COLLECTING_PARAMETERS", arguments=None): def _active_tx(status="COLLECTING_PARAMETERS", arguments=None):
@@ -55,7 +69,7 @@ def _active_tx(status="COLLECTING_PARAMETERS", arguments=None):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("message", ["PED-1001", "12345", "R$ 71,99", "10/09/2026"]) @pytest.mark.parametrize("message", ["PED-1001", "o pedido é o PED-1001", "12345", "R$ 71,99", "10/09/2026"])
async def test_matrix_collecting_parameter_answers_keep_transaction(message, tmp_path): async def test_matrix_collecting_parameter_answers_keep_transaction(message, tmp_path):
router = _router(tmp_path) router = _router(tmp_path)
state = { state = {
@@ -75,6 +89,25 @@ async def test_matrix_collecting_parameter_answers_keep_transaction(message, tmp
assert (decision.metadata or {}).get("transaction_interruption") is None assert (decision.metadata or {}).get("transaction_interruption") is None
@pytest.mark.asyncio
async def test_matrix_specific_same_agent_intent_shift_still_preempts_collection(tmp_path):
router = _router(tmp_path)
state = {
"user_text": "quero rastrear pedido",
"sanitized_input": "quero rastrear pedido",
"next_state": "COLLECTING_ORDER_PARAMETERS",
"transaction_status": "COLLECTING_PARAMETERS",
"missing_parameters": ["order_id"],
"active_transaction": _active_tx(arguments={}),
"active_agent": "orders_agent",
"intent": "state:COLLECTING_ORDER_PARAMETERS",
"route_decision": {"agent": "orders_agent", "intent": "retail_order_cancel"},
}
decision = await router.route(state)
assert decision.intent == "retail_order_tracking"
assert decision.metadata["transaction_interruption"] == "intent_shift"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_matrix_missing_next_state_recovers_active_transaction_before_stickiness(tmp_path): async def test_matrix_missing_next_state_recovers_active_transaction_before_stickiness(tmp_path):
router = _router(tmp_path) router = _router(tmp_path)

View File

@@ -30,20 +30,45 @@ import pytest
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
class _TransactionTestLLM:
async def ainvoke(self, messages, **kwargs):
import json
prompt = messages[-1]["content"]
if kwargs.get("profile_name") == "transaction_parameter_extraction" or "pending_parameters:" in prompt:
pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0])
user = prompt.split("user_message: ", 1)[1].split("\nFormato obrigatório:", 1)[0].strip()
out = {name: None for name in pending}
low = user.lower()
if "order_id" in out:
import re
m = re.search(r"\b(?:ped[- ]?)?(\d+)\b", low, re.I)
if m:
out["order_id"] = ("PED-" + m.group(1)) if "ped" in m.group(0).lower() else m.group(1)
if "reason" in out and ("arrepend" in low or "desisti" in low):
out["reason"] = "Arrependimento da compra" if "arrepend" in low else "desisti da compra"
return {"content": json.dumps(out, ensure_ascii=False)}
return {"content": "{}"}
class _PolicyRouter: class _PolicyRouter:
def __init__(self): def __init__(self):
from types import SimpleNamespace from types import SimpleNamespace
self.registry = SimpleNamespace( self.registry = SimpleNamespace(
tools={"consultar_pedido": object(), "solicitar_devolucao": object()}, tools={"consultar_pedido": object(), "solicitar_devolucao": object()},
get_tool=lambda name: { get_tool=lambda name: {
"consultar_pedido": SimpleNamespace(selection_keywords=["consultar pedido", "pedido"]), "consultar_pedido": SimpleNamespace(selection_keywords=["consultar pedido", "pedido"], args_schema={}, requires=[]),
"solicitar_devolucao": SimpleNamespace(selection_keywords=["devolver pedido", "devolver", "devolução", "arrependimento"]), "solicitar_devolucao": SimpleNamespace(
selection_keywords=["devolver pedido", "devolver", "devolução", "arrependimento"],
args_schema={"order_id": "string", "reason": "string"},
requires=["order_id", "reason"],
description="Solicita devolução de pedido",
),
}.get(name), }.get(name),
) )
def resolve_execution_policy(self, tool_name, arguments=None): def resolve_execution_policy(self, tool_name, arguments=None):
if tool_name == "solicitar_devolucao": if tool_name == "solicitar_devolucao":
return {"operation_type": "transactional", "require_confirmation": True, "policy_source": "test"} return {"operation_type": "transactional", "require_confirmation": True, "requires": ["order_id", "reason"], "policy_source": "test"}
return {"operation_type": "read_only", "require_confirmation": False, "policy_source": "test"} return {"operation_type": "read_only", "require_confirmation": False, "policy_source": "test"}
def validate_execution_policy(self, tool_name, arguments=None): def validate_execution_policy(self, tool_name, arguments=None):
@@ -56,6 +81,7 @@ class _PolicyRouter:
class _Runtime(AgentRuntimeMixin): class _Runtime(AgentRuntimeMixin):
def __init__(self): def __init__(self):
self.tool_router = _PolicyRouter() self.tool_router = _PolicyRouter()
self.llm = _TransactionTestLLM()
self.calls = [] self.calls = []
async def _call_mcp_tool(self, tool_name, arguments, state): async def _call_mcp_tool(self, tool_name, arguments, state):
@@ -163,14 +189,20 @@ async def test_collecting_parameters_does_not_replace_collected_subject_with_sta
class _InitialContestLLM: class _InitialContestLLM:
async def ainvoke(self, messages, **kwargs): async def ainvoke(self, messages, **kwargs):
prompt = messages[0]["content"] import json
if "Campo: subject" in prompt: prompt = messages[-1]["content"]
return {"content": '{"subject": "TIM CTRL Redes Sociais 8.0"}'} if kwargs.get("profile_name") == "transaction_parameter_extraction":
if "Campo: valor" in prompt: pending = json.loads(prompt.split("pending_parameters: ", 1)[1].split("\n", 1)[0])
return {"content": '{"valor": null}'} out = {name: None for name in pending}
if "subject" in out:
out["subject"] = "TIM CTRL Redes Sociais 8.0"
return {"content": json.dumps(out, ensure_ascii=False)}
if kwargs.get("profile_name") == "mcp_parameter_extraction":
if "Campo: motivo" in prompt: if "Campo: motivo" in prompt:
return {"content": '{"motivo": "não contratei"}'} return {"content": '{"motivo": "não contratei"}'}
return {"content": '{}'} if "Campo: valor" in prompt:
return {"content": '{"valor": null}'}
return {"content": "{}"}
class _InitialContestRouter(_ContestPolicyRouter): class _InitialContestRouter(_ContestPolicyRouter):