bugfix: max_tokens hardcoded

This commit is contained in:
2026-09-19 00:30:51 -03:00
parent f942610bc5
commit 01b44773ec
43 changed files with 464 additions and 34 deletions

View File

@@ -0,0 +1,29 @@
# Multi-item result preservation fix
## Objective
Preserve the terminal outcome of each item executed by the primary transactional tool. A failure in a later auxiliary/composite step must not erase successful item outcomes or turn an already-completed multi-item operation into a total failure.
## Framework-only change
No agent/template business logic is required for this fix. The runtime now recognizes an explicit structural contract on the requested tool: a `results` array with two or more entries carrying boolean `success` or `ok` fields.
For workflow-backed tools the authoritative source is preferentially:
`workflow.output[tool_name].results`
The framework derives one of:
- `SUCCESS`
- `PARTIAL_SUCCESS`
- `FAILED`
and publishes a generic `metadata.multi_item` summary with item counts.
When one or more primary items completed successfully but the outer wrapper is `ok=false` because a later auxiliary/post-processing step failed, the normalized result preserves the original failure as `original_ok=false` / `secondary_error` while exposing the requested operation as operationally successful (`ok=true`).
The LLM context also receives an explicit generic instruction that successful per-item terminal evidence cannot be overwritten by a secondary error and that mixed outcomes must be reported individually.
## Regression tests
Added `tests/test_multi_item_result_normalization.py` covering:
- 2 success + 1 failure -> `PARTIAL_SUCCESS`;
- all primary items successful + later secondary failure -> primary `SUCCESS` preserved;
- single-item behavior unchanged;
- unrelated nested tool results cannot override failure of the requested primary tool.

View File

@@ -15,11 +15,17 @@ profiles:
temperature: 0
max_tokens: 80
timeout_seconds: 5
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
guardrail:
provider: oci_openai
model: openai.gpt-4.1

View File

@@ -15,11 +15,17 @@ profiles:
temperature: 0
max_tokens: 80
timeout_seconds: 5
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
guardrail:
provider: oci_openai
model: openai.gpt-4.1

View File

@@ -9,11 +9,18 @@ profiles:
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
guardrail:
provider: oci_openai
model: openai.gpt-4.1

View File

@@ -15,11 +15,17 @@ profiles:
temperature: 0
max_tokens: 80
timeout_seconds: 5
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
guardrail:
provider: oci_openai
model: openai.gpt-4.1

View File

@@ -9,11 +9,18 @@ profiles:
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
guardrail:
provider: oci_openai
model: openai.gpt-4.1

View File

@@ -15,11 +15,17 @@ profiles:
temperature: 0
max_tokens: 80
timeout_seconds: 5
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
guardrail:
provider: oci_openai
model: openai.gpt-4.1

View File

@@ -15,11 +15,17 @@ profiles:
temperature: 0
max_tokens: 80
timeout_seconds: 5
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
guardrail:
provider: oci_openai
model: openai.gpt-4.1

View File

@@ -15,11 +15,17 @@ profiles:
temperature: 0
max_tokens: 80
timeout_seconds: 5
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
guardrail:
provider: oci_openai
model: openai.gpt-4.1

View File

@@ -15,11 +15,17 @@ profiles:
temperature: 0
max_tokens: 80
timeout_seconds: 5
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
guardrail:
provider: oci_openai
model: openai.gpt-4.1

View File

@@ -15,11 +15,17 @@ profiles:
temperature: 0
max_tokens: 80
timeout_seconds: 5
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
guardrail:
provider: oci_openai
model: openai.gpt-4.1

View File

@@ -0,0 +1,58 @@
# Correção — `max_tokens` por `llm_profiles.yaml` / `.env`
## Objetivo
Remover `max_tokens` efetivos fixados nos pontos de inferência do framework sem perder compatibilidade com os budgets anteriores.
A precedência usada pelos pontos corrigidos passa a ser:
1. `max_tokens` do profile selecionado em `llm_profiles.yaml`;
2. `max_tokens` do profile `default` do mesmo YAML, quando aplicável ao resolver;
3. `LLM_MAX_TOKENS` configurado no ambiente / `.env`;
4. valor legado do ponto de inferência, usado somente como fallback de compatibilidade.
O parâmetro interno `fallback_max_tokens` **não é uma nova configuração do usuário**. Ele apenas preserva o valor histórico quando não existe configuração externa.
## Pontos corrigidos
- Enterprise Router:
- transaction parameter relevance: fallback legado `256`;
- transaction intent shift: fallback legado `512`;
- multi-intent: fallback legado `768`;
- routing normal: fallback legado `512`.
- MCP parameter extraction: fallback `80`.
- Processing interruption classifier: fallback `8`.
- LLM guardrail: fallback `600`.
- RAG query rewrite: fallback `300`.
- RAG context compression: mantém a fórmula histórica `max(512, max_chars // 3)` apenas como fallback.
- Conversation summary memory: mantém a fórmula histórica `max(256, max_summary_chars // 4)` apenas como fallback.
## Atualização de `llm_profiles.yaml`
Como os quatro pontos do Enterprise Router usam o mesmo profile `router`, o profile foi ajustado para o **maior budget histórico**, conforme solicitado:
```yaml
router:
max_tokens: 768
```
Também foram garantidos os profiles explícitos:
```yaml
processing_interruption_classifier:
max_tokens: 8
mcp_parameter_extraction:
max_tokens: 80
```
Os profiles já existentes foram mantidos:
- `guardrail: 600`
- `rag_rewriter: 300`
- `rag_compressor: 1200`
- `summary_memory: 1200`
## Compatibilidade
Se `llm_profiles.yaml` não estiver disponível e `LLM_MAX_TOKENS` não estiver definido, cada ponto corrigido continua usando seu budget histórico. Portanto a correção não transforma a ausência de configuração em `2048` apenas por causa do default global de `Settings`.

View File

@@ -9,3 +9,11 @@ profiles:
model: mock-judge
temperature: 0
max_tokens: 300
mcp_parameter_extraction:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 80
timeout_seconds: 5

View File

@@ -58,7 +58,7 @@ async def classify_processing_interruption(
response = await llm.ainvoke(
[{"role": "system", "content": prompt}],
temperature=0,
max_tokens=8,
fallback_max_tokens=8,
profile_name=profile_name,
component_name=profile_name,
generation_name=f"llm.{profile_name}",

View File

@@ -47,7 +47,7 @@ class LLMGuardrailRail(Guardrail):
{"role": "user", "content": prompt},
],
temperature=0,
max_tokens=600,
fallback_max_tokens=600,
profile_name=self.profile_name,
component_name=self.profile_name,
generation_name=f"llm.{self.profile_name}",

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import copy
import logging
import os
import re
from pathlib import Path
from typing import Any
@@ -164,6 +165,66 @@ class LLMProfileResolver:
return effective
def resolve_configured_value(
self,
profile_name: str | None,
key: str,
*,
env_var: str | None = None,
fallback: Any = None,
) -> Any:
"""Resolve one configuration value with an explicit compatibility fallback.
Precedence is intentionally aligned with the framework configuration model:
1. specific ``llm_profiles.yaml`` profile;
2. YAML ``default`` profile;
3. explicitly configured general environment variable;
4. the caller-provided legacy fallback.
The last step is important for old inference points that historically had a
local constant. Pydantic's Settings object always has framework defaults, so
checking only ``settings.LLM_MAX_TOKENS`` would make an *unset* environment
indistinguishable from the global default.
"""
selected_name = self.normalize_profile_name(profile_name)
if self.enabled:
specific_profile = self._profiles.get(selected_name) or {}
default_profile = self._profiles.get("default") or {}
if key in specific_profile and specific_profile.get(key) is not None:
return specific_profile.get(key)
if key in default_profile and default_profile.get(key) is not None:
return default_profile.get(key)
if env_var:
raw = os.getenv(env_var)
if raw is not None and str(raw).strip() != "":
raw = str(raw).strip()
try:
if isinstance(fallback, bool):
return raw.lower() in {"1", "true", "yes", "on"}
if isinstance(fallback, int) and not isinstance(fallback, bool):
return int(raw)
if isinstance(fallback, float):
return float(raw)
except (TypeError, ValueError):
logger.warning(
"Invalid %s=%r for profile=%s key=%s; using legacy fallback=%r",
env_var, raw, selected_name, key, fallback,
)
return fallback
return raw
# Also honor a value explicitly supplied when Settings was instantiated
# programmatically, without treating the class default as configuration.
field_name = env_var or ""
fields_set = getattr(self.settings, "model_fields_set", set()) or set()
if field_name and field_name in fields_set:
value = getattr(self.settings, field_name, None)
if value is not None:
return value
return fallback
def normalize_profile_name(self, profile_name: str | None) -> str:
return _canonical_profile_name(profile_name)

View File

@@ -370,6 +370,14 @@ class OCICompatibleOpenAIProvider(LLMProvider):
profile_name = kwargs.pop("profile_name", None)
component_name = kwargs.pop("component_name", None) or kwargs.pop("component", None) or profile_name or "default"
generation_name = kwargs.pop("generation_name", None) or f"llm.{component_name}"
fallback_max_tokens = kwargs.pop("fallback_max_tokens", None)
if fallback_max_tokens is not None:
kwargs["max_tokens"] = self.profile_resolver.resolve_configured_value(
profile_name,
"max_tokens",
env_var="LLM_MAX_TOKENS",
fallback=fallback_max_tokens,
)
generation_name, generation_mapping_meta = _normalize_generation_name(self.telemetry, generation_name)
effective = self.profile_resolver.resolve(profile_name, **kwargs)
provider = str(effective.get("provider") or self.provider_name)

View File

@@ -167,7 +167,7 @@ class ConversationSummaryMemory:
summary = await self.llm.ainvoke([
{"role": "system", "content": "Você resume memória conversacional para agentes corporativos."},
{"role": "user", "content": prompt},
], max_tokens=max(256, max_summary_chars // 4), temperature=0.1, profile_name="summary_memory", component_name="summary_memory", generation_name="llm.summary_memory")
], fallback_max_tokens=max(256, max_summary_chars // 4), temperature=0.1, profile_name="summary_memory", component_name="summary_memory", generation_name="llm.summary_memory")
summary = (summary or "").strip()
if not summary:
return self._deterministic_summary(previous_summary=previous_summary, messages=messages, max_chars=max_summary_chars)

View File

@@ -84,7 +84,7 @@ class RagService:
{'role': 'user', 'content': prompt},
],
temperature=0,
max_tokens=300,
fallback_max_tokens=300,
profile_name=profile_name,
component_name=profile_name,
generation_name=f"llm.{profile_name}",
@@ -112,7 +112,7 @@ class RagService:
{'role': 'user', 'content': prompt},
],
temperature=0,
max_tokens=max(512, max_chars // 3),
fallback_max_tokens=max(512, max_chars // 3),
profile_name=profile_name,
component_name=profile_name,
generation_name=f"llm.{profile_name}",

View File

@@ -1045,7 +1045,7 @@ class EnterpriseRouter:
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
],
temperature=0.0,
max_tokens=256,
fallback_max_tokens=256,
profile_name="router",
component_name="router.transaction_parameter_relevance",
generation_name="llm.transaction_parameter_relevance",
@@ -1328,7 +1328,7 @@ class EnterpriseRouter:
{"role": "user", "content": json.dumps(user, ensure_ascii=False)},
],
temperature=0.0,
max_tokens=512,
fallback_max_tokens=512,
profile_name="router",
component_name="router",
generation_name="llm.transaction_intent_shift",
@@ -1674,7 +1674,7 @@ class EnterpriseRouter:
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
],
temperature=0.0,
max_tokens=768,
fallback_max_tokens=768,
profile_name="router",
component_name="router.multi_intent",
generation_name="llm.router.multi_intent",
@@ -1730,7 +1730,7 @@ class EnterpriseRouter:
{"role": "user", "content": json.dumps(user, ensure_ascii=False)},
],
temperature=0.0,
max_tokens=512,
fallback_max_tokens=512,
profile_name="router",
component_name="router",
generation_name="llm.router",

View File

@@ -849,7 +849,7 @@ class AgentRuntimeMixin:
component_name="mcp_parameter_extraction",
generation_name="llm.mcp_parameter_extraction",
temperature=0.0,
max_tokens=80,
fallback_max_tokens=80,
)
raw = self._llm_response_text(response).strip()
payload = parse_json_object(raw)

View File

@@ -15,11 +15,17 @@ profiles:
temperature: 0
max_tokens: 80
timeout_seconds: 5
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
guardrail:
provider: oci_openai
model: openai.gpt-4.1
@@ -72,6 +78,13 @@ profiles:
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

View File

@@ -15,11 +15,18 @@ profiles:
temperature: 0
max_tokens: 700
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
# Safety / evaluation
guardrail:
@@ -87,3 +94,11 @@ profiles:
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

View File

@@ -58,7 +58,7 @@ async def classify_processing_interruption(
response = await llm.ainvoke(
[{"role": "system", "content": prompt}],
temperature=0,
max_tokens=8,
fallback_max_tokens=8,
profile_name=profile_name,
component_name=profile_name,
generation_name=f"llm.{profile_name}",

View File

@@ -49,7 +49,7 @@ class LLMGuardrailRail(Guardrail):
{"role": "user", "content": prompt},
],
temperature=0,
max_tokens=600,
fallback_max_tokens=600,
profile_name=self.profile_name,
component_name=self.profile_name,
generation_name=f"llm.{self.profile_name}",

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import copy
import logging
import os
import re
from pathlib import Path
from typing import Any
@@ -164,6 +165,66 @@ class LLMProfileResolver:
return effective
def resolve_configured_value(
self,
profile_name: str | None,
key: str,
*,
env_var: str | None = None,
fallback: Any = None,
) -> Any:
"""Resolve one configuration value with an explicit compatibility fallback.
Precedence is intentionally aligned with the framework configuration model:
1. specific ``llm_profiles.yaml`` profile;
2. YAML ``default`` profile;
3. explicitly configured general environment variable;
4. the caller-provided legacy fallback.
The last step is important for old inference points that historically had a
local constant. Pydantic's Settings object always has framework defaults, so
checking only ``settings.LLM_MAX_TOKENS`` would make an *unset* environment
indistinguishable from the global default.
"""
selected_name = self.normalize_profile_name(profile_name)
if self.enabled:
specific_profile = self._profiles.get(selected_name) or {}
default_profile = self._profiles.get("default") or {}
if key in specific_profile and specific_profile.get(key) is not None:
return specific_profile.get(key)
if key in default_profile and default_profile.get(key) is not None:
return default_profile.get(key)
if env_var:
raw = os.getenv(env_var)
if raw is not None and str(raw).strip() != "":
raw = str(raw).strip()
try:
if isinstance(fallback, bool):
return raw.lower() in {"1", "true", "yes", "on"}
if isinstance(fallback, int) and not isinstance(fallback, bool):
return int(raw)
if isinstance(fallback, float):
return float(raw)
except (TypeError, ValueError):
logger.warning(
"Invalid %s=%r for profile=%s key=%s; using legacy fallback=%r",
env_var, raw, selected_name, key, fallback,
)
return fallback
return raw
# Also honor a value explicitly supplied when Settings was instantiated
# programmatically, without treating the class default as configuration.
field_name = env_var or ""
fields_set = getattr(self.settings, "model_fields_set", set()) or set()
if field_name and field_name in fields_set:
value = getattr(self.settings, field_name, None)
if value is not None:
return value
return fallback
def normalize_profile_name(self, profile_name: str | None) -> str:
return _canonical_profile_name(profile_name)

View File

@@ -370,6 +370,14 @@ class OCICompatibleOpenAIProvider(LLMProvider):
profile_name = kwargs.pop("profile_name", None)
component_name = kwargs.pop("component_name", None) or kwargs.pop("component", None) or profile_name or "default"
generation_name = kwargs.pop("generation_name", None) or f"llm.{component_name}"
fallback_max_tokens = kwargs.pop("fallback_max_tokens", None)
if fallback_max_tokens is not None:
kwargs["max_tokens"] = self.profile_resolver.resolve_configured_value(
profile_name,
"max_tokens",
env_var="LLM_MAX_TOKENS",
fallback=fallback_max_tokens,
)
generation_name, generation_mapping_meta = _normalize_generation_name(self.telemetry, generation_name)
effective = self.profile_resolver.resolve(profile_name, **kwargs)
provider = str(effective.get("provider") or self.provider_name)

View File

@@ -167,7 +167,7 @@ class ConversationSummaryMemory:
summary = await self.llm.ainvoke([
{"role": "system", "content": "Você resume memória conversacional para agentes corporativos."},
{"role": "user", "content": prompt},
], max_tokens=max(256, max_summary_chars // 4), temperature=0.1, profile_name="summary_memory", component_name="summary_memory", generation_name="llm.summary_memory")
], fallback_max_tokens=max(256, max_summary_chars // 4), temperature=0.1, profile_name="summary_memory", component_name="summary_memory", generation_name="llm.summary_memory")
summary = (summary or "").strip()
if not summary:
return self._deterministic_summary(previous_summary=previous_summary, messages=messages, max_chars=max_summary_chars)

View File

@@ -98,7 +98,7 @@ class RagService:
{'role': 'user', 'content': prompt},
],
temperature=0,
max_tokens=300,
fallback_max_tokens=300,
profile_name=profile_name,
component_name=profile_name,
generation_name=f"llm.{profile_name}",
@@ -126,7 +126,7 @@ class RagService:
{'role': 'user', 'content': prompt},
],
temperature=0,
max_tokens=max(512, max_chars // 3),
fallback_max_tokens=max(512, max_chars // 3),
profile_name=profile_name,
component_name=profile_name,
generation_name=f"llm.{profile_name}",

View File

@@ -1045,7 +1045,7 @@ class EnterpriseRouter:
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
],
temperature=0.0,
max_tokens=256,
fallback_max_tokens=256,
profile_name="router",
component_name="router.transaction_parameter_relevance",
generation_name="llm.transaction_parameter_relevance",
@@ -1328,7 +1328,7 @@ class EnterpriseRouter:
{"role": "user", "content": json.dumps(user, ensure_ascii=False)},
],
temperature=0.0,
max_tokens=512,
fallback_max_tokens=512,
profile_name="router",
component_name="router",
generation_name="llm.transaction_intent_shift",
@@ -1674,7 +1674,7 @@ class EnterpriseRouter:
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
],
temperature=0.0,
max_tokens=768,
fallback_max_tokens=768,
profile_name="router",
component_name="router.multi_intent",
generation_name="llm.router.multi_intent",
@@ -1730,7 +1730,7 @@ class EnterpriseRouter:
{"role": "user", "content": json.dumps(user, ensure_ascii=False)},
],
temperature=0.0,
max_tokens=512,
fallback_max_tokens=512,
profile_name="router",
component_name="router",
generation_name="llm.router",

View File

@@ -849,7 +849,7 @@ class AgentRuntimeMixin:
component_name="mcp_parameter_extraction",
generation_name="llm.mcp_parameter_extraction",
temperature=0.0,
max_tokens=80,
fallback_max_tokens=80,
)
raw = self._llm_response_text(response).strip()
payload = parse_json_object(raw)

View File

@@ -15,11 +15,17 @@ profiles:
temperature: 0
max_tokens: 80
timeout_seconds: 5
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
guardrail:
provider: oci_openai
model: openai.gpt-4.1

View File

@@ -15,11 +15,17 @@ profiles:
temperature: 0
max_tokens: 80
timeout_seconds: 5
processing_interruption_classifier:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 8
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
max_tokens: 768
guardrail:
provider: oci_openai
model: openai.gpt-4.1

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
from types import SimpleNamespace
import yaml
ROOT = Path(__file__).resolve().parents[1]
LIB = ROOT / "libs" / "agent_framework" / "src"
if str(LIB) not in sys.path:
sys.path.insert(0, str(LIB))
from agent_framework.llm.profile_resolver import LLMProfileResolver
def _settings(path: Path):
return SimpleNamespace(
LLM_PROFILES_PATH=str(path),
LLM_PROVIDER="oci_openai",
OCI_GENAI_MODEL="openai.gpt-4.1",
LLM_TEMPERATURE=0.2,
LLM_MAX_TOKENS=2048,
LLM_TIMEOUT_SECONDS=120,
OCI_GENAI_BASE_URL="",
OCI_GENAI_API_KEY=None,
OCI_GENAI_PROJECT_OCID=None,
OCI_AUTH_MODE="config_file",
OCI_GENAI_ENDPOINT=None,
OCI_REGION="",
model_fields_set=set(),
)
def test_profile_max_tokens_wins_over_env_and_legacy_fallback(tmp_path, monkeypatch):
profile = tmp_path / "llm_profiles.yaml"
profile.write_text(
"profiles:\n"
" default:\n"
" provider: oci_openai\n"
" router:\n"
" max_tokens: 768\n",
encoding="utf-8",
)
monkeypatch.setenv("LLM_MAX_TOKENS", "999")
resolver = LLMProfileResolver.from_settings(_settings(profile))
assert resolver.resolve_configured_value(
"router", "max_tokens", env_var="LLM_MAX_TOKENS", fallback=512
) == 768
def test_env_max_tokens_wins_when_profiles_file_is_absent(tmp_path, monkeypatch):
missing = tmp_path / "missing.yaml"
monkeypatch.setenv("LLM_MAX_TOKENS", "901")
resolver = LLMProfileResolver.from_settings(_settings(missing))
assert resolver.resolve_configured_value(
"router", "max_tokens", env_var="LLM_MAX_TOKENS", fallback=512
) == 901
def test_legacy_fallback_is_kept_when_yaml_and_env_are_absent(tmp_path, monkeypatch):
missing = tmp_path / "missing.yaml"
monkeypatch.delenv("LLM_MAX_TOKENS", raising=False)
resolver = LLMProfileResolver.from_settings(_settings(missing))
assert resolver.resolve_configured_value(
"router", "max_tokens", env_var="LLM_MAX_TOKENS", fallback=512
) == 512
def test_canonical_profiles_keep_existing_budgets_and_router_uses_largest_value():
profile_path = ROOT / "libs" / "agent_framework" / "config" / "llm_profiles.yaml"
profiles = yaml.safe_load(profile_path.read_text(encoding="utf-8"))["profiles"]
assert profiles["router"]["max_tokens"] == 768
assert profiles["processing_interruption_classifier"]["max_tokens"] == 8
assert profiles["mcp_parameter_extraction"]["max_tokens"] == 80
assert profiles["guardrail"]["max_tokens"] == 600
assert profiles["rag_rewriter"]["max_tokens"] == 300
assert profiles["rag_compressor"]["max_tokens"] == 1200
assert profiles["summary_memory"]["max_tokens"] == 1200
def test_inference_points_use_fallback_max_tokens_instead_of_direct_max_tokens():
checks = {
"libs/agent_framework/src/agent_framework/routing/enterprise_router.py": [256, 512, 768],
"libs/agent_framework/src/agent_framework/runtime/agent_runtime.py": [80],
"libs/agent_framework/src/agent_framework/channels/interruption.py": [8],
"libs/agent_framework/src/agent_framework/guardrails/llm_rails.py": [600],
"libs/agent_framework/src/agent_framework/rag/rag_service.py": [300],
}
for relative, values in checks.items():
text = (ROOT / relative).read_text(encoding="utf-8")
for value in values:
assert f"fallback_max_tokens={value}" in text
assert f"max_tokens={value}," not in text.replace(f"fallback_max_tokens={value},", "")