diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py
index 68ca96e57e..aa1224eea2 100644
--- a/backend/agents/create_agent_info.py
+++ b/backend/agents/create_agent_info.py
@@ -8,7 +8,7 @@
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
-from urllib.parse import urljoin
+from urllib.parse import urljoin, urlparse
from nexent.core.utils.observer import MessageObserver
from nexent.core.agents.agent_model import AgentRunInfo, ModelConfig, AgentConfig, ToolConfig, ExternalA2AAgentConfig, AgentHistory, AgentVerificationConfig
@@ -18,13 +18,22 @@
resolve_policy,
)
from nexent.core.models.prompt_cache import resolve_prompt_cache_profile
+try:
+ from nexent.core.models.provider_usage import resolve_provider_usage_profile
+except ImportError: # Rolling-upgrade and lightweight test compatibility.
+ from nexent.core.models.prompt_cache import resolve_provider_usage_profile
from nexent.core.models.capacity_resolver import (
ModelCapacitySnapshot,
ProviderCapabilityUnknown,
ResolverError,
resolve_capacity,
)
+from nexent.core.models.feature_capability import (
+ normalize_feature_profile,
+ resolve_feature_capabilities,
+)
from nexent.core.models.capacity_budget import (
+ BudgetResolverError,
RequestBudgetOverrides,
SafeInputBudgetCalculator,
UncertaintyReserveBasisUnknown,
@@ -34,6 +43,11 @@
from nexent.core.agents.nexent_agent import get_local_python_authorized_imports
from consts.capability_profiles import CATALOG as CAPABILITY_CATALOG
+from consts.model_feature_capabilities import (
+ CATALOG_REVISION as FEATURE_CATALOG_REVISION,
+ EXACT_CATALOG as FEATURE_EXACT_CATALOG,
+ FAMILY_RULES as FEATURE_FAMILY_RULES,
+)
from services.file_management_service import validate_urls_access
from management.services.model.resolver import get_rerank_model
@@ -81,7 +95,7 @@
NEXENT_SANDBOX_WORKSPACE_VOLUME,
)
from consts.model import ToolParamsRequest
-from consts.exceptions import ValidationError
+from consts.exceptions import ModelCapacityConfigError, ValidationError
logger = logging.getLogger("create_agent_info")
logger.setLevel(logging.INFO)
@@ -235,6 +249,58 @@ def _operator_overrides_from_model_info(model_info: Optional[dict]) -> dict:
return overrides
+def _effective_feature_factory(model_info: Optional[dict]) -> str:
+ """Resolve a known provider only when a generic factory URL proves it."""
+ record = model_info if isinstance(model_info, dict) else {}
+ factory = str(record.get("model_factory") or "").strip().lower()
+ normalized_factory = re.sub(r"[^a-z0-9]+", "", factory)
+ if normalized_factory not in {
+ "openaiapicompatible",
+ "openaicompatible",
+ "openaiapi",
+ }:
+ return factory
+
+ raw_url = str(record.get("base_url") or "").strip()
+ parsed = urlparse(raw_url if "://" in raw_url else f"https://{raw_url}")
+ host = (parsed.hostname or "").lower().rstrip(".")
+ provider_hosts = (
+ ("dashscope", ("dashscope.aliyuncs.com",)),
+ ("silicon", ("api.siliconflow.cn", "siliconflow.cn")),
+ ("deepseek", ("api.deepseek.com",)),
+ ("openai", ("api.openai.com",)),
+ ("tokenpony", ("api.tokenpony.cn",)),
+ )
+ for provider, hosts in provider_hosts:
+ if host in hosts:
+ return provider
+ return factory
+
+
+def _resolve_model_feature_capabilities(model_info: Optional[dict]) -> dict:
+ """Use persisted sanitized metadata or a factory-scoped catalog fallback."""
+ record = model_info if isinstance(model_info, dict) else {}
+ persisted = normalize_feature_profile(record.get("feature_capability_metadata"))
+ if persisted and any(
+ persisted[branch]["supported"] is not None
+ for branch in ("reasoning", "prompt_cache")
+ ):
+ return persisted
+ model_name = str(record.get("model_name") or "")
+ model_repo = str(record.get("model_repo") or "")
+ full_model_name = (
+ model_name if "/" in model_name or not model_repo
+ else f"{model_repo}/{model_name}"
+ )
+ return resolve_feature_capabilities(
+ _effective_feature_factory(record),
+ full_model_name,
+ exact_catalog=FEATURE_EXACT_CATALOG,
+ family_rules=FEATURE_FAMILY_RULES,
+ catalog_revision=FEATURE_CATALOG_REVISION,
+ )
+
+
def _dominant_capacity_source(field_sources: dict) -> Optional[str]:
values = [value for value in field_sources.values() if value]
if not values:
@@ -311,6 +377,28 @@ def _resolve_safe_input_budget(
exc,
)
return None
+ except BudgetResolverError as exc:
+ reason_by_type = {
+ "InvalidReservePolicy": "invalid_reserve_policy",
+ "RequestedOutputExceedsCapacity": "requested_output_exceeds_model",
+ "ReserveExceedsCapacity": "reserve_exceeds_capacity",
+ "NoSafeInputCapacity": "no_safe_input_capacity",
+ "SafeInputBudgetFingerprintMismatch": "budget_fingerprint_mismatch",
+ "CallerMaxTokensOverrideForbidden": "caller_output_override_forbidden",
+ "SafeInputBudgetCapacityMismatch": "capacity_snapshot_mismatch",
+ }
+ reason = reason_by_type.get(type(exc).__name__, "budget_resolution_failed")
+ logger.warning(
+ "W2 safe input budget rejected: tenant_id=%s model=%s reason=%s",
+ tenant_id,
+ capacity_snapshot.model_name,
+ reason,
+ )
+ raise ModelCapacityConfigError(
+ f"capacity_config_invalid.{reason}",
+ "The selected model capacity cannot produce a safe Agent input budget. "
+ "Review the model context, input, output, and reserve settings.",
+ ) from exc
logger.debug(
"W2 safe input budget resolved: tenant_id=%s model=%s requested_output_tokens=%s "
"soft_input_budget_tokens=%s hard_input_budget_tokens=%s fingerprint=%s warnings=%s",
@@ -341,6 +429,12 @@ def _resolve_input_budget(
provider_raw = model_info.get("model_factory")
provider = provider_raw.lower().strip() if isinstance(provider_raw, str) else ""
model_id = model_info.get("model_name") or ""
+ persisted_profile_version = model_info.get("capability_profile_version")
+ if persisted_profile_version:
+ for (catalog_provider, catalog_model), profile in CAPABILITY_CATALOG.items():
+ if profile.capability_profile_version == persisted_profile_version:
+ provider, model_id = catalog_provider, catalog_model
+ break
provider_missing_detail = None
if not provider:
provider_missing_detail = (
@@ -873,6 +967,8 @@ async def create_model_config_list(tenant_id):
model_list = []
extra_body = {"logprobs": True} if LLM_INCLUDE_LOGPROBS else None
for record in records:
+ effective_feature_factory = _effective_feature_factory(record)
+ feature_capabilities = _resolve_model_feature_capabilities(record)
model_list.append(
ModelConfig(cite_name=record["display_name"],
api_key=record.get("api_key", ""),
@@ -886,7 +982,12 @@ async def create_model_config_list(tenant_id):
timeout_seconds=record.get("timeout_seconds"),
concurrency_limit=record.get("concurrency_limit"),
prompt_cache=resolve_prompt_cache_profile(
- record.get("model_factory")),
+ effective_feature_factory, feature_capabilities),
+ feature_capabilities=feature_capabilities,
+ provider_usage_profile=resolve_provider_usage_profile(
+ effective_feature_factory,
+ record.get("capability_profile_version"),
+ ),
# W1 step 6: pass capacity columns through so SDK can
# honor operator-configured values end to end.
max_output_tokens=record.get("max_output_tokens"),
@@ -897,26 +998,21 @@ async def create_model_config_list(tenant_id):
tokenizer_family=record.get("tokenizer_family"),
capacity_source=record.get("capacity_source"),
capability_profile_version=record.get("capability_profile_version"),
- extra_body=extra_body))
+ extra_body=extra_body,
+ canonical_model_id=record.get("canonical_model_id"),
+ model_identity_metadata=record.get("model_identity_metadata"),
+ tokenizer_match_metadata=record.get("tokenizer_match_metadata"),
+ token_count_probe_metadata=record.get("token_count_probe_metadata")))
# fit for old version, main_model and sub_model use default model
main_model_config = tenant_config_manager.get_model_config(
key=MODEL_CONFIG_MAPPING["llm"], tenant_id=tenant_id)
+ main_effective_feature_factory = _effective_feature_factory(main_model_config)
+ main_feature_capabilities = _resolve_model_feature_capabilities(main_model_config)
main_prompt_cache = resolve_prompt_cache_profile(
- main_model_config.get("model_factory"))
- model_list.append(
- ModelConfig(cite_name="main_model",
- api_key=main_model_config.get("api_key", ""),
- model_name=get_model_name_from_config(main_model_config) if main_model_config.get(
- "model_name") else "",
- url=main_model_config.get("base_url", ""),
- ssl_verify=main_model_config.get("ssl_verify", True),
- model_factory=main_model_config.get("model_factory"),
- timeout_seconds=main_model_config.get("timeout_seconds"),
- concurrency_limit=main_model_config.get("concurrency_limit"),
- prompt_cache=main_prompt_cache,
- extra_body=extra_body))
- model_list.append(
- ModelConfig(cite_name="sub_model",
+ main_effective_feature_factory, main_feature_capabilities)
+ for cite_name in ("main_model", "sub_model"):
+ model_list.append(
+ ModelConfig(cite_name=cite_name,
api_key=main_model_config.get("api_key", ""),
model_name=get_model_name_from_config(main_model_config) if main_model_config.get(
"model_name") else "",
@@ -926,7 +1022,24 @@ async def create_model_config_list(tenant_id):
timeout_seconds=main_model_config.get("timeout_seconds"),
concurrency_limit=main_model_config.get("concurrency_limit"),
prompt_cache=main_prompt_cache,
- extra_body=extra_body))
+ extra_body=extra_body,
+ feature_capabilities=main_feature_capabilities,
+ provider_usage_profile=resolve_provider_usage_profile(
+ main_effective_feature_factory,
+ main_model_config.get("capability_profile_version"),
+ ),
+ max_output_tokens=main_model_config.get("max_output_tokens"),
+ max_tokens=main_model_config.get("max_tokens"),
+ context_window_tokens=main_model_config.get("context_window_tokens"),
+ max_input_tokens=main_model_config.get("max_input_tokens"),
+ default_output_reserve_tokens=main_model_config.get("default_output_reserve_tokens"),
+ tokenizer_family=main_model_config.get("tokenizer_family"),
+ capacity_source=main_model_config.get("capacity_source"),
+ capability_profile_version=main_model_config.get("capability_profile_version"),
+ canonical_model_id=main_model_config.get("canonical_model_id"),
+ model_identity_metadata=main_model_config.get("model_identity_metadata"),
+ tokenizer_match_metadata=main_model_config.get("tokenizer_match_metadata"),
+ token_count_probe_metadata=main_model_config.get("token_count_probe_metadata")))
return model_list
@@ -1275,10 +1388,6 @@ async def create_agent_config(
include_empty_message=not bool(runtime_knowledge_context),
)
- # This compatibility flag controls compression only. ContextManager remains
- # the single context assembly path when compression is disabled.
- enable_context_manager = agent_info.get("enable_context_manager", False)
-
# Get the skills included in ContextManager items.
skills = _get_skills_for_template(agent_id, tenant_id, version_no)
@@ -1330,12 +1439,14 @@ async def create_agent_config(
capacity_snapshot = None
resolved_capacity_snapshot = None
- requested_output_tokens = agent_info.get("requested_output_tokens")
+ # Legacy model/Agent/request output-reserve overrides no longer influence
+ # runtime. W1 derives one automatic protection value from model capacity.
+ requested_output_tokens = None
safe_input_budget_snapshot = _resolve_safe_input_budget(
capacity_snapshot=resolved_capacity_snapshot,
tenant_id=tenant_id,
- agent_requested_output_tokens=requested_output_tokens,
- request_requested_output_tokens=request_requested_output_tokens,
+ agent_requested_output_tokens=None,
+ request_requested_output_tokens=None,
)
if safe_input_budget_snapshot is not None:
soft_input_budget_tokens = safe_input_budget_snapshot["soft_input_budget_tokens"]
@@ -1392,13 +1503,10 @@ async def create_agent_config(
f"skills_count={len(skills)}, "
f"items={[f'{item.id}(type={item.type.value},priority={item.priority})' for item in context_items]}"
)
+ # Automatic compaction is the single production policy. Persisted legacy
+ # tenant/Agent/request switches are ignored during the compatibility window.
policy_layers = PolicyLayers.model_validate({
- "platform": {
- "processing_mode": "adaptive_compact" if enable_context_manager else "passthrough"
- },
- "tenant": tenant_config_manager.get_context_policy(tenant_id),
- "agent": agent_info.get("context_policy"),
- "request": request_context_policy,
+ "platform": {"processing_mode": "adaptive_compact"},
})
effective_context_policy = resolve_policy(policy_layers)
effective_processing_mode = getattr(
@@ -2151,10 +2259,8 @@ async def create_agent_run_info(
})
if override_model_id is not None:
create_config_kwargs["override_model_id"] = override_model_id
- if requested_output_tokens is not None:
- create_config_kwargs["request_requested_output_tokens"] = requested_output_tokens
- if context_policy is not None:
- create_config_kwargs["request_context_policy"] = context_policy
+ # Legacy per-run output/context overrides are accepted at the API boundary
+ # for rolling compatibility, but runtime policy is now automatic.
agent_config = await create_agent_config(**create_config_kwargs, tool_params=tool_params)
diff --git a/backend/apps/monitoring_app.py b/backend/apps/monitoring_app.py
index 4313567540..43324cf112 100644
--- a/backend/apps/monitoring_app.py
+++ b/backend/apps/monitoring_app.py
@@ -114,6 +114,68 @@ def _query_model_metrics_from_db(
return []
+def _query_context_budget_metrics_from_db(
+ time_range: str, tenant_id: str | None = None
+) -> list[dict[str, Any]]:
+ """Aggregate content-free P3 evidence by Provider/model/profile version."""
+ time_filter = _compute_time_range_filter(time_range)
+ tenant_filter = "AND m.tenant_id = :tenant_id" if tenant_id else ""
+ params = {"tenant_id": tenant_id} if tenant_id else {}
+ query_sql = f"""
+ SELECT
+ COALESCE(m.context_budget_evidence->>'provider_protocol', 'unknown') AS provider_protocol,
+ m.model_name,
+ COALESCE(m.capability_profile_version, 'unknown') AS capability_profile_version,
+ COUNT(*) FILTER (WHERE m.context_budget_evidence IS NOT NULL) AS request_count,
+ COUNT(*) FILTER (WHERE COALESCE((m.context_budget_evidence->>'provider_overflow')::boolean, FALSE)) AS overflow_count,
+ COUNT(*) FILTER (WHERE COALESCE((m.context_budget_evidence->>'compression_attempted')::boolean, FALSE)) AS compacted_count,
+ ROUND(AVG(CASE WHEN COALESCE((m.context_budget_evidence->>'compression_attempted')::boolean, FALSE)
+ AND (m.context_budget_evidence->>'context_raw_tokens')::numeric > 0
+ THEN 1 - (m.context_budget_evidence->>'context_final_tokens')::numeric
+ / (m.context_budget_evidence->>'context_raw_tokens')::numeric END), 4) AS avg_compression_ratio,
+ COUNT(*) FILTER (WHERE (m.context_budget_evidence->>'provider_prompt_usage_tokens')::numeric > 0) AS estimate_sample_count,
+ ROUND(AVG(CASE WHEN (m.context_budget_evidence->>'provider_prompt_usage_tokens')::numeric > 0
+ THEN ABS((m.context_budget_evidence->>'raw_estimate_tokens')::numeric
+ - (m.context_budget_evidence->>'provider_prompt_usage_tokens')::numeric)
+ / (m.context_budget_evidence->>'provider_prompt_usage_tokens')::numeric END), 4) AS mean_absolute_estimate_error,
+ COUNT(*) FILTER (WHERE COALESCE((m.context_budget_evidence->>'recovery_attempted')::boolean, FALSE)) AS recovery_attempt_count,
+ COUNT(*) FILTER (WHERE COALESCE((m.context_budget_evidence->>'recovery_succeeded')::boolean, FALSE)) AS recovery_success_count
+ FROM nexent.model_monitoring_record_t m
+ WHERE {time_filter} {tenant_filter} AND m.delete_flag = 'N'
+ AND m.context_budget_evidence IS NOT NULL
+ GROUP BY provider_protocol, m.model_name, capability_profile_version
+ ORDER BY request_count DESC
+ """
+ try:
+ with get_monitoring_db_session() as session:
+ rows = session.execute(text(query_sql), params).fetchall()
+ output = []
+ for row in rows:
+ requests = int(row.request_count or 0)
+ attempts = int(row.recovery_attempt_count or 0)
+ compacted = int(row.compacted_count or 0)
+ output.append({
+ "provider_protocol": row.provider_protocol,
+ "model_name": row.model_name,
+ "capability_profile_version": row.capability_profile_version,
+ "request_count": requests,
+ "overflow_count": int(row.overflow_count or 0),
+ "overflow_rate": (int(row.overflow_count or 0) / requests) if requests else None,
+ "compacted_count": compacted,
+ "compaction_incidence": (compacted / requests) if requests else None,
+ "avg_compression_ratio": float(row.avg_compression_ratio) if row.avg_compression_ratio is not None else None,
+ "estimate_sample_count": int(row.estimate_sample_count or 0),
+ "mean_absolute_estimate_error": float(row.mean_absolute_estimate_error) if row.mean_absolute_estimate_error is not None else None,
+ "recovery_attempt_count": attempts,
+ "recovery_success_count": int(row.recovery_success_count or 0),
+ "recovery_success_rate": (int(row.recovery_success_count or 0) / attempts) if attempts else None,
+ })
+ return output
+ except Exception as exc:
+ logger.error("Failed to query context budget metrics: %s", exc)
+ return []
+
+
@router.get("/models", response_model=ConversationResponse)
async def list_models_endpoint(
time_range: Annotated[str, Query(
@@ -151,3 +213,16 @@ async def get_monitoring_status_endpoint():
message="success",
data=get_monitoring_status(),
)
+
+
+@router.get("/context-budget", response_model=ConversationResponse)
+async def get_context_budget_metrics_endpoint(
+ time_range: Annotated[str, Query(description="Time range: 24h, 7d, 30d")] = "24h",
+ authorization: Annotated[str | None, Header()] = None,
+):
+ _, tenant_id = get_current_user_id(authorization)
+ return ConversationResponse(
+ code=0,
+ message="success",
+ data=_query_context_budget_metrics_from_db(time_range, tenant_id),
+ )
diff --git a/backend/database/conversation_db.py b/backend/database/conversation_db.py
index e9595f7b06..a989247d1f 100644
--- a/backend/database/conversation_db.py
+++ b/backend/database/conversation_db.py
@@ -82,7 +82,11 @@ def _parse_history_summary_content(content: Any) -> Optional[Dict[str, Any]]:
"""Return a valid summary payload, or ``None`` for malformed/stale units."""
try:
payload = json.loads(content) if isinstance(content, str) else content
- if not isinstance(payload, dict) or not isinstance(payload.get("summary"), dict):
+ if not isinstance(payload, dict):
+ return None
+ summary = payload.get("summary")
+ if not isinstance(summary, (dict, str)) or (
+ isinstance(summary, str) and not summary.strip()):
return None
boundary = payload.get("covered_through_message_id")
if isinstance(boundary, bool) or int(boundary) <= 0:
@@ -1940,14 +1944,16 @@ def update_message_minio_files(message_id: int, skill_file_uploads: List[Dict[st
def save_history_summary(
conversation_id: int, user_id: str, tenant_id: str,
- summary: Dict[str, Any], covered_through_message_id: int,
+ summary: Dict[str, Any] | str, covered_through_message_id: int,
previous_summary_unit_id: Optional[int] = None,
trigger: Optional[str] = None,
) -> int:
"""Persist a validated checkpoint on its last covered assistant message."""
- if not user_id or not tenant_id or not isinstance(summary, dict):
+ if (not user_id or not tenant_id
+ or not isinstance(summary, (dict, str))
+ or (isinstance(summary, str) and not summary.strip())):
raise HistorySummaryPersistenceError(
- "user_id, tenant_id and an object summary are required")
+ "user_id, tenant_id and a summary are required")
conversation_id = int(conversation_id)
covered_through_message_id = int(covered_through_message_id)
user_tenant = _get_user_tenant(user_id)
diff --git a/backend/management/services/agent/management.py b/backend/management/services/agent/management.py
index cd8ad5ecff..8e16a72f11 100644
--- a/backend/management/services/agent/management.py
+++ b/backend/management/services/agent/management.py
@@ -672,12 +672,10 @@ async def import_agent_by_agent_id(
"prompt_template_id": import_agent_info.prompt_template_id or SYSTEM_PROMPT_TEMPLATE_ID,
"prompt_template_name": import_agent_info.prompt_template_name or SYSTEM_PROMPT_TEMPLATE_NAME,
"max_steps": import_agent_info.max_steps,
- "requested_output_tokens": import_agent_info.requested_output_tokens,
"is_main_agent": getattr(import_agent_info, "is_main_agent", True),
"provide_run_summary": import_agent_info.provide_run_summary,
"allow_chat_metadata": import_agent_info.allow_chat_metadata,
"verification_config": getattr(import_agent_info, "verification_config", None),
- "context_policy": getattr(import_agent_info, "context_policy", None),
"duty_prompt": import_agent_info.duty_prompt,
"constraint_prompt": import_agent_info.constraint_prompt,
"few_shots_prompt": import_agent_info.few_shots_prompt,
diff --git a/backend/management/services/agent/run.py b/backend/management/services/agent/run.py
index 37590315fd..291783040e 100644
--- a/backend/management/services/agent/run.py
+++ b/backend/management/services/agent/run.py
@@ -804,10 +804,8 @@ async def prepare_agent_run(
"is_debug": agent_request.is_debug,
"override_version_no": agent_request.version_no,
"override_model_id": agent_request.model_id,
- "requested_output_tokens": agent_request.requested_output_tokens,
"tool_params": agent_request.tool_params,
"conversation_id": agent_request.conversation_id,
- "context_policy": agent_request.context_policy,
"enable_planning": agent_request.enable_plan,
}
runtime_knowledge_context = getattr(agent_request, "_runtime_knowledge_context", None)
@@ -1756,4 +1754,3 @@ def stop_agent_tasks(conversation_id: int | str, user_id: str):
def is_agent_running(conversation_id: int, user_id: str) -> bool:
return agent_run_manager.get_agent_run_info(conversation_id, user_id) is not None
-
diff --git a/backend/management/services/agent/service.py b/backend/management/services/agent/service.py
index ad1ea983e4..c5449d3e17 100644
--- a/backend/management/services/agent/service.py
+++ b/backend/management/services/agent/service.py
@@ -582,12 +582,11 @@ def _validate_requested_output_tokens_for_agent(
request: AgentInfoRequest,
tenant_id: str,
) -> None:
+ """Deprecated compatibility validator; runtime no longer calls this path."""
requested_output_tokens = request.requested_output_tokens
if requested_output_tokens is None:
return
- # Validate against every configured model — the user can switch models at
- # chat time, so requested_output_tokens must not exceed any model's limit.
model_ids = list(request.model_ids or [])
if not model_ids and request.agent_id is not None:
try:
@@ -598,14 +597,7 @@ def _validate_requested_output_tokens_for_agent(
)
model_ids = list(existing_agent.get("model_ids") or [])
except Exception as exc:
- logger.warning(
- "Could not resolve existing agent models for requested_output_tokens validation: %s",
- exc,
- )
-
- if not model_ids:
- return
-
+ logger.warning("Could not resolve existing agent models: %s", exc)
for model_id in model_ids:
model_info = get_model_by_model_id(model_id, tenant_id=tenant_id)
max_output_tokens = model_info.get("max_output_tokens") if model_info else None
@@ -615,10 +607,8 @@ def _validate_requested_output_tokens_for_agent(
)
raise AppException(
ErrorCode.COMMON_PARAMETER_INVALID,
- (
- f"requested_output_tokens ({requested_output_tokens}) cannot exceed "
- f"max_output_tokens ({max_output_tokens}) of model '{model_display}'"
- ),
+ f"requested_output_tokens ({requested_output_tokens}) cannot exceed "
+ f"max_output_tokens ({max_output_tokens}) of model '{model_display}'",
)
@@ -628,7 +618,11 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str =
if request.example_questions is not None and len(request.example_questions) > 6:
raise AppException(ErrorCode.COMMON_PARAMETER_INVALID, "example_questions cannot exceed 6 items")
- _validate_requested_output_tokens_for_agent(request, tenant_id)
+ # These fields remain accepted for rolling-client compatibility, but the
+ # automatic context policy is the only policy persisted for new updates.
+ request.requested_output_tokens = None
+ request.context_policy = None
+ request.enable_context_manager = True
prompt_template_id, prompt_template_name = get_prompt_template_summary(
template_id=request.prompt_template_id,
@@ -654,13 +648,11 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str =
"prompt_template_id": prompt_template_id,
"prompt_template_name": prompt_template_name,
"max_steps": request.max_steps,
- "requested_output_tokens": request.requested_output_tokens,
"is_main_agent": request.is_main_agent if request.is_main_agent is not None else True,
"provide_run_summary": request.provide_run_summary,
"allow_chat_metadata": request.allow_chat_metadata if request.allow_chat_metadata is not None else False,
"is_a2a": request.is_a2a if request.is_a2a is not None else False,
"verification_config": request.verification_config,
- "context_policy": request.context_policy,
"duty_prompt": request.duty_prompt,
"constraint_prompt": request.constraint_prompt,
"few_shots_prompt": request.few_shots_prompt,
diff --git a/deploy/sql/migrations/v2.6.0_0903_context_usage_observability.sql b/deploy/sql/migrations/v2.6.0_0903_context_usage_observability.sql
new file mode 100644
index 0000000000..747b3c0151
--- /dev/null
+++ b/deploy/sql/migrations/v2.6.0_0903_context_usage_observability.sql
@@ -0,0 +1,11 @@
+-- Persist content-free request-budget, compaction, overflow, and usage evidence.
+SET search_path TO nexent;
+BEGIN;
+
+ALTER TABLE nexent.model_monitoring_record_t
+ ADD COLUMN IF NOT EXISTS context_budget_evidence JSONB DEFAULT NULL;
+
+COMMENT ON COLUMN nexent.model_monitoring_record_t.context_budget_evidence IS
+ 'Content-free request-budget, compaction, overflow, recovery, and usage evidence.';
+
+COMMIT;
diff --git a/frontend/app/[locale]/agents/components/agent-config-actions.tsx b/frontend/app/[locale]/agents/components/agent-config-actions.tsx
index fc7eab0907..ec4369f68d 100644
--- a/frontend/app/[locale]/agents/components/agent-config-actions.tsx
+++ b/frontend/app/[locale]/agents/components/agent-config-actions.tsx
@@ -131,7 +131,6 @@ export default function AgentConfigActions() {
author: detail.author,
model_ids: modelIdsForCopy,
max_steps: detail.max_step,
- requested_output_tokens: detail.requested_output_tokens ?? null,
is_main_agent: detail.is_main_agent ?? true,
provide_run_summary: detail.provide_run_summary,
enabled: detail.enabled,
diff --git a/frontend/app/[locale]/agents/components/agent-run-policy.tsx b/frontend/app/[locale]/agents/components/agent-run-policy.tsx
index 6999e45b4f..d233858e20 100644
--- a/frontend/app/[locale]/agents/components/agent-run-policy.tsx
+++ b/frontend/app/[locale]/agents/components/agent-run-policy.tsx
@@ -15,7 +15,7 @@ export default function AgentRunPolicy() {
{/* Max Steps */}
-
+
-
- {/* Output Reserve */}
-
-
-
- updateAgent({ requested_output_tokens: val ?? 4096 })
- }
- />
-
- tokens
-
-
-
{/* Self Validation */}
diff --git a/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx b/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx
index 97bb24a737..df29509a6f 100644
--- a/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx
+++ b/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx
@@ -1,7 +1,12 @@
// Tool function for processing chat streaming response
import { chatConfig } from "@/const/chatConfig";
-import { ChatMessageType, AgentStep } from "@/types/chat";
+import {
+ ChatMessageType,
+ AgentStep,
+ ContextBudgetMetrics,
+ TokenMetrics,
+} from "@/types/chat";
import log from "@/lib/logger";
import { MESSAGE_ROLES } from "@/const/chatConfig";
@@ -97,8 +102,15 @@ type ReconstructionState = {
finalAnswer: string;
steps: AgentStep[];
stepCounter: number;
+ metricsByStep: Map
;
+ budgetEventsByStep: Map;
};
+const isContextBudgetTimelineEvent = (budget: ContextBudgetMetrics): boolean =>
+ Boolean(budget?.compression?.attempted) ||
+ Number(budget?.retry_ordinal || 0) > 0 ||
+ !["not_needed", "not_attempted"].includes(budget?.recovery_state);
+
// Helper to create a new step
const createNewStep = (
stepCounter: number,
@@ -287,7 +299,6 @@ const processThinkingCodeUnit = (
const isSkippedUnitType = (unitType: string): boolean => {
const skippedTypes = [
"search_content_placeholder",
- "token_count",
"parse",
"execution_logs",
"agent_new_run",
@@ -319,6 +330,8 @@ export function reconstructFromStreamingMessage(
finalAnswer: streamingMessage.message_content || "",
steps: [],
stepCounter: 0,
+ metricsByStep: new Map(),
+ budgetEventsByStep: new Map(),
};
// Sort units by index (should already be sorted)
@@ -353,6 +366,60 @@ export function reconstructFromStreamingMessage(
state.finalAnswer = unit.unit_content;
break;
+ case "token_count":
+ try {
+ const metrics = JSON.parse(unit.unit_content) as TokenMetrics;
+ const stepId = `step-${metrics.step_number}`;
+ const existing = state.metricsByStep.get(stepId);
+ state.metricsByStep.set(stepId, {
+ ...(existing || {}),
+ ...metrics,
+ context_budget: existing?.context_budget,
+ });
+ } catch {
+ /* Ignore malformed optional metrics from older runtimes. */
+ }
+ break;
+
+ case "context_budget":
+ try {
+ const budget = JSON.parse(unit.unit_content) as ContextBudgetMetrics;
+ const stepId = `step-${budget.step_number}`;
+ const existing = state.metricsByStep.get(stepId);
+ state.metricsByStep.set(
+ stepId,
+ existing
+ ? { ...existing, context_budget: budget }
+ : {
+ step_number: budget.step_number,
+ duration: 0,
+ step_input_tokens: null,
+ step_output_tokens: null,
+ total_output_tokens: 0,
+ estimated_context_tokens: budget.final_tokens,
+ token_threshold: budget.soft_budget,
+ hard_input_budget_tokens: budget.hard_budget,
+ context_processing_mode: null,
+ output_finish_reason: null,
+ context_budget: budget,
+ }
+ );
+ if (isContextBudgetTimelineEvent(budget)) {
+ const events = state.budgetEventsByStep.get(stepId) || [];
+ events.push({
+ id: `context-budget-${unit.unit_index}`,
+ type: chatConfig.messageTypes.CONTEXT_BUDGET,
+ content: unit.unit_content,
+ expanded: true,
+ timestamp: Date.now(),
+ });
+ state.budgetEventsByStep.set(stepId, events);
+ }
+ } catch {
+ /* Ignore malformed optional events for forward compatibility. */
+ }
+ break;
+
default: {
if (isSkippedUnitType(unit.unit_type)) {
break;
@@ -377,6 +444,16 @@ export function reconstructFromStreamingMessage(
// Don't forget to save the last currentStep if it has contents
finalizeCurrentStep(state);
+ state.steps = state.steps.map((step) => {
+ const metrics = state.metricsByStep.get(step.id);
+ const budgetEvents = state.budgetEventsByStep.get(step.id) || [];
+ return {
+ ...step,
+ ...(metrics ? { metrics } : {}),
+ contents: [...budgetEvents, ...step.contents],
+ };
+ });
+
return {
currentStep: state.steps[state.steps.length - 1] || null,
lastContentType: state.lastContentType,
@@ -535,6 +612,7 @@ export const handleStreamResponse = async (
resumeConfig &&
(messageType === chatConfig.messageTypes.STEP_COUNT ||
messageType === chatConfig.messageTypes.TOKEN_COUNT ||
+ messageType === chatConfig.messageTypes.CONTEXT_BUDGET ||
messageType ===
chatConfig.messageTypes.SEARCH_CONTENT_PLACEHOLDER ||
messageType === chatConfig.messageTypes.PARSE ||
@@ -599,16 +677,76 @@ export const handleStreamResponse = async (
// If currentStep matches the metrics step number, set directly
if (currentStep && currentStep.id === metricsStepId) {
- currentStep.metrics = metricsData;
+ currentStep.metrics = {
+ ...(currentStep.metrics || {}),
+ ...metricsData,
+ context_budget:
+ currentStep.metrics?.context_budget ||
+ metricsData.context_budget,
+ };
} else {
// currentStep was already reset to a new step, store metrics for later application
- pendingMetrics.set(metricsStepId, metricsData);
+ const existing = pendingMetrics.get(metricsStepId) || {};
+ pendingMetrics.set(metricsStepId, {
+ ...existing,
+ ...metricsData,
+ context_budget:
+ existing.context_budget || metricsData.context_budget,
+ });
}
} catch {
// Failed to parse metrics
}
break;
+ case chatConfig.messageTypes.CONTEXT_BUDGET:
+ try {
+ const budgetData = JSON.parse(messageContent);
+ const metricsStepId = `step-${budgetData.step_number}`;
+ if (currentStep && currentStep.id === metricsStepId) {
+ currentStep.metrics = {
+ ...(currentStep.metrics || {
+ step_number: budgetData.step_number,
+ duration: 0,
+ step_input_tokens: null,
+ step_output_tokens: null,
+ total_output_tokens: 0,
+ estimated_context_tokens: budgetData.final_tokens,
+ token_threshold: budgetData.soft_budget,
+ hard_input_budget_tokens: budgetData.hard_budget,
+ context_processing_mode: null,
+ output_finish_reason: null,
+ }),
+ context_budget: budgetData,
+ };
+ if (
+ isContextBudgetTimelineEvent(budgetData) &&
+ !currentStep.contents.some(
+ (content) =>
+ content.id ===
+ `context-budget-${budgetData.step_number}-${budgetData.retry_ordinal}`
+ )
+ ) {
+ currentStep.contents.push({
+ id: `context-budget-${budgetData.step_number}-${budgetData.retry_ordinal}`,
+ type: chatConfig.messageTypes.CONTEXT_BUDGET,
+ content: messageContent,
+ expanded: true,
+ timestamp: Date.now(),
+ });
+ }
+ } else {
+ const existing = pendingMetrics.get(metricsStepId);
+ pendingMetrics.set(metricsStepId, {
+ ...(existing || {}),
+ context_budget: budgetData,
+ });
+ }
+ } catch {
+ /* optional forward-compatible event */
+ }
+ break;
+
case chatConfig.messageTypes.MODEL_OUTPUT:
case chatConfig.messageTypes.MODEL_OUTPUT_THINKING:
case chatConfig.messageTypes.MODEL_OUTPUT_DEEP_THINKING:
diff --git a/frontend/app/[locale]/chat/streaming/taskWindow.tsx b/frontend/app/[locale]/chat/streaming/taskWindow.tsx
index a53c35f976..015c8da21d 100644
--- a/frontend/app/[locale]/chat/streaming/taskWindow.tsx
+++ b/frontend/app/[locale]/chat/streaming/taskWindow.tsx
@@ -419,6 +419,64 @@ type KnowledgeSiteInfo = {
// Define the handlers for different types of messages to improve extensibility
const messageHandlers: MessageHandler[] = [
+ {
+ canHandle: (message) =>
+ message.type === chatConfig.messageTypes.CONTEXT_BUDGET,
+ render: (message, t) => {
+ try {
+ const budget = JSON.parse(message.content || "{}");
+ const raw = Number(budget.raw_tokens || 0);
+ const final = Number(budget.final_tokens || 0);
+ const saved = Number(budget.compression?.saved_tokens || 0);
+ const ratio = Number(budget.compression?.ratio || 0);
+ const reasons = Array.isArray(budget.compression?.reasons)
+ ? budget.compression.reasons.filter(
+ (reason: unknown): reason is string => typeof reason === "string"
+ )
+ : [];
+ return (
+
+
+
+ {t("taskWindow.contextBudget.optimized")}
+
+
+ {t("taskWindow.contextBudget.savings", {
+ raw: raw.toLocaleString(),
+ final: final.toLocaleString(),
+ saved: saved.toLocaleString(),
+ percent: Math.round(ratio * 100),
+ })}
+
+ {reasons.length > 0 && (
+
+ {t("taskWindow.contextBudget.reason", {
+ reason: reasons
+ .map((reason: string) =>
+ t(`taskWindow.contextBudget.reasons.${reason}`, reason)
+ )
+ .join(", "),
+ })}
+
+ )}
+ {budget.recovery?.auto_continued && (
+
+ {t("taskWindow.contextBudget.autoContinued")}
+
+ )}
+ {budget.recovery?.partial_preserved &&
+ budget.recovery?.terminal_reason && (
+
+ {t("chat.tokenUsage.recoveryExhausted")}
+
+ )}
+
+ );
+ } catch {
+ return null;
+ }
+ },
+ },
{
canHandle: (message) =>
message.type === chatConfig.messageTypes.HISTORY_SUMMARY,
diff --git a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx
index 14f9ca8d86..3386e15845 100644
--- a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx
+++ b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx
@@ -45,11 +45,17 @@ import {
skillFileUploadsRegistry,
remoteChatModelAdapter,
parseStepTokenCount,
+ parseContextBudget,
+ parseProviderCallUsage,
+ parseTurnUsage,
parsePlan,
parsePlanStepUpdate,
planRegistry,
type PlanData,
type SearchSource,
+ type ContextBudgetEvent,
+ type ProviderCallUsageV2,
+ type TurnUsageV2,
type StepTokenCount,
} from "./remote-chat-model-adapter";
@@ -370,6 +376,9 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter {
// the same data into the global registry, but historical restores have
// no streaming run to read from.
const stepTokenCounts: StepTokenCount[] = [];
+ const pendingContextBudgets = new Map();
+ const providerCallUsages: ProviderCallUsageV2[] = [];
+ let turnUsage: TurnUsageV2 | null = null;
// Populate conversationSourcesRegistry for historical assistant messages
// and build the matching `source` parts that drive the
@@ -570,7 +579,36 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter {
// `SingleTurnTokenUsage` via message metadata.
if (part.type === "token_count") {
const parsed = parseStepTokenCount(part.content);
- if (parsed) stepTokenCounts.push(parsed);
+ if (parsed) {
+ const pendingBudget = pendingContextBudgets.get(
+ parsed.stepNumber
+ );
+ if (pendingBudget) {
+ parsed.contextBudget = pendingBudget;
+ pendingContextBudgets.delete(parsed.stepNumber);
+ }
+ stepTokenCounts.push(parsed);
+ }
+ continue;
+ }
+ if (part.type === "context_budget") {
+ const budget = parseContextBudget(part.content);
+ if (budget) {
+ const step = [...stepTokenCounts]
+ .reverse()
+ .find((item) => item.stepNumber === budget.step_number);
+ if (step) step.contextBudget = budget;
+ else pendingContextBudgets.set(budget.step_number, budget);
+ }
+ continue;
+ }
+ if (part.type === "llm_usage") {
+ const usage = parseProviderCallUsage(part.content);
+ if (usage) providerCallUsages.push(usage);
+ continue;
+ }
+ if (part.type === "turn_usage") {
+ turnUsage = parseTurnUsage(part.content);
continue;
}
@@ -995,6 +1033,8 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter {
custom: {
...(stepTokenCounts.length > 0 ? { stepTokenCounts } : {}),
...(createdAt ? { databaseCreateTime: createdAt.getTime() } : {}),
+ ...(providerCallUsages.length > 0 ? { providerCallUsages } : {}),
+ ...(turnUsage ? { turnUsage } : {}),
},
};
diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts
index 5c984185e7..bada13d165 100644
--- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts
+++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts
@@ -363,6 +363,127 @@ export interface StepTokenCount {
estimatedContextTokens: number;
tokenThreshold: number | null;
contextWindowTokens: number | null;
+ outputFinishReason: string | null;
+ contextBudget?: ContextBudgetEvent;
+}
+
+export interface ContextBudgetEvent {
+ schema_version: 1;
+ step_number: number;
+ raw_tokens: number;
+ final_tokens: number;
+ soft_budget: number;
+ hard_budget: number;
+ hard_count: number;
+ components: Record;
+ count_source: string;
+ compression: { attempted: boolean; saved_tokens: number; ratio: number };
+ recovery_state: string;
+ recovery?: {
+ archive_active?: boolean;
+ archived_item_count?: number;
+ retained_item_count?: number;
+ recalled_tokens?: number;
+ partial_preserved?: boolean;
+ auto_continued?: boolean;
+ provisional_capacity?: boolean;
+ terminal_reason?: string;
+ };
+}
+
+export interface ProviderCallUsageV2 {
+ schema_version: 2 | 3;
+ call_id: string;
+ turn_id: string | null;
+ step_number: number | null;
+ purpose: string;
+ attempt: number;
+ provider: string;
+ model: string;
+ capability_profile_version: string | null;
+ source: "provider" | "estimated" | "missing" | string;
+ status: "completed" | "partial" | "failed" | "cancelled";
+ usage: {
+ input_tokens: number | null;
+ output_tokens: number | null;
+ total_tokens: number | null;
+ fresh_input_tokens: number | null;
+ cache_read_tokens: number | null;
+ cache_write_tokens: number | null;
+ reasoning_tokens: number | null;
+ visible_output_tokens: number | null;
+ total_source: string;
+ };
+ quality: { degraded: boolean; reasons: string[] };
+ finish_reason: string | null;
+ duration_ms: number | null;
+ time_to_first_token_ms: number | null;
+ provider_metadata: Record;
+ context_composition: {
+ source: string;
+ denominator_tokens: number;
+ estimator_version: string;
+ segments: Record;
+ adjustment_ratio: number;
+ high_adjustment: boolean;
+ } | null;
+}
+
+export interface TurnUsageV2 {
+ schema_version: 2 | 3;
+ turn_id: string;
+ call_count: number;
+ known_usage_call_count: number;
+ known_field_call_counts: Record;
+ usage: Record;
+ latest_context: {
+ call_id: string;
+ input_tokens: number;
+ limit_tokens: number | null;
+ } | null;
+ peak_context: {
+ call_id: string;
+ input_tokens: number;
+ limit_tokens: number | null;
+ } | null;
+ data_quality: string;
+ call_ids: string[];
+}
+
+export function parseProviderCallUsage(
+ content: string
+): ProviderCallUsageV2 | null {
+ try {
+ const data = JSON.parse(content);
+ return [2, 3].includes(data?.schema_version) &&
+ typeof data.call_id === "string"
+ ? (data as ProviderCallUsageV2)
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+export function parseTurnUsage(content: string): TurnUsageV2 | null {
+ try {
+ const data = JSON.parse(content);
+ return [2, 3].includes(data?.schema_version) && Array.isArray(data.call_ids)
+ ? (data as TurnUsageV2)
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+export function parseContextBudget(content: string): ContextBudgetEvent | null {
+ try {
+ const data = JSON.parse(content);
+ return data?.schema_version === 1 && typeof data.step_number === "number"
+ ? (data as ContextBudgetEvent)
+ : null;
+ } catch {
+ return null;
+ }
}
/**
@@ -406,6 +527,7 @@ export function parseStepTokenCount(content: string): StepTokenCount | null {
estimated_context_tokens?: number;
token_threshold?: number | null;
context_window_tokens?: number | null;
+ output_finish_reason?: string | null;
};
return {
stepNumber: data.step_number ?? 0,
@@ -416,6 +538,7 @@ export function parseStepTokenCount(content: string): StepTokenCount | null {
estimatedContextTokens: data.estimated_context_tokens ?? 0,
tokenThreshold: data.token_threshold ?? null,
contextWindowTokens: data.context_window_tokens ?? null,
+ outputFinishReason: data.output_finish_reason ?? null,
};
} catch {
return null;
@@ -1957,15 +2080,26 @@ export const remoteChatModelAdapter: ChatModelAdapter = {
// Generate a stable message ID for this stream so MarkdownText can look up sources
const messageId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ const providerCallUsages: ProviderCallUsageV2[] = [];
+ let turnUsage: TurnUsageV2 | null = null;
const buildStreamResult = (content: any[]): ChatModelRunResult => ({
content: collapseSubAgentParts(content),
- metadata: nl2a ? { custom: { nl2a } } : undefined,
+ metadata: {
+ custom: {
+ ...(nl2a ? { nl2a } : {}),
+ ...(providerCallUsages.length > 0
+ ? { providerCallUsages: [...providerCallUsages] }
+ : {}),
+ ...(turnUsage ? { turnUsage } : {}),
+ },
+ },
});
const streamStartTime = Date.now();
let firstTokenTime: number | undefined;
let toolCallCount = 0;
let storedTiming: ReturnType | null = null;
+ const pendingContextBudgets = new Map();
try {
while (true) {
@@ -2018,8 +2152,47 @@ export const remoteChatModelAdapter: ChatModelAdapter = {
// Handle token_count - store timing for final yield
if (chunk.type === "token_count") {
storedTiming = buildTimingFromTokenCount(chunk.content);
+ const parsedStep = parseStepTokenCount(chunk.content);
+ if (parsedStep) {
+ const step = [...stepTokenCounts]
+ .reverse()
+ .find((item) => item.stepNumber === parsedStep.stepNumber);
+ const pendingBudget = pendingContextBudgets.get(
+ parsedStep.stepNumber
+ );
+ if (step && pendingBudget) {
+ step.contextBudget = pendingBudget;
+ pendingContextBudgets.delete(parsedStep.stepNumber);
+ }
+ }
continue; // Don't yield for internal data chunks
}
+ if (chunk.type === "context_budget") {
+ const budget = parseContextBudget(chunk.content);
+ if (budget) {
+ const step = [...stepTokenCounts]
+ .reverse()
+ .find((item) => item.stepNumber === budget.step_number);
+ if (step) step.contextBudget = budget;
+ else pendingContextBudgets.set(budget.step_number, budget);
+ }
+ continue;
+ }
+ if (chunk.type === "llm_usage") {
+ const usage = parseProviderCallUsage(chunk.content);
+ if (usage) {
+ const index = providerCallUsages.findIndex(
+ (item) => item.call_id === usage.call_id
+ );
+ if (index >= 0) providerCallUsages[index] = usage;
+ else providerCallUsages.push(usage);
+ }
+ continue;
+ }
+ if (chunk.type === "turn_usage") {
+ turnUsage = parseTurnUsage(chunk.content);
+ continue;
+ }
if (chunk.type === "plan") {
const plan = parsePlan(chunk.content);
diff --git a/frontend/app/[locale]/newchat/ui/token-usage.tsx b/frontend/app/[locale]/newchat/ui/token-usage.tsx
index 3da6d9e08b..a9b6d712fd 100644
--- a/frontend/app/[locale]/newchat/ui/token-usage.tsx
+++ b/frontend/app/[locale]/newchat/ui/token-usage.tsx
@@ -4,9 +4,16 @@ import { useState, type FC } from "react";
import { useTranslation } from "react-i18next";
import { useAuiState, useMessageTiming } from "@assistant-ui/react";
import { Zap } from "lucide-react";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
import {
stepTokenCounts,
+ type ProviderCallUsageV2,
type StepTokenCount,
+ type TurnUsageV2,
} from "../adapter/remote-chat-model-adapter";
interface TokenUsageProps {
@@ -28,86 +35,97 @@ export const TokenUsage: FC = ({ className }) => {
const usagePercent = Math.round((tokenCount / 128000) * 100);
return (
-
-
+
+
+
+
- {/* Expanded details popover */}
- {expanded && (
-
-
-
{t("chat.tokenUsage.details")}
-
+
+
);
};
@@ -121,6 +139,67 @@ interface SingleTurnTokenUsageProps {
className?: string;
}
+const CONTEXT_COMPONENTS = [
+ ["message_text", "chat.tokenUsage.components.messageText", "bg-blue-500"],
+ [
+ "message_framing",
+ "chat.tokenUsage.components.messageFraming",
+ "bg-cyan-500",
+ ],
+ ["tools", "chat.tokenUsage.components.tools", "bg-violet-500"],
+ ["media", "chat.tokenUsage.components.media", "bg-pink-500"],
+ ["reasoning", "chat.tokenUsage.components.reasoning", "bg-amber-500"],
+ [
+ "other_semantic",
+ "chat.tokenUsage.components.otherSemantic",
+ "bg-emerald-500",
+ ],
+] as const;
+
+const V2_CONTEXT_COMPONENTS = [
+ [
+ "system_instructions",
+ "chat.tokenUsage.components.systemInstructions",
+ "bg-blue-500",
+ ],
+ ["user_history", "chat.tokenUsage.components.userHistory", "bg-cyan-500"],
+ [
+ "assistant_history",
+ "chat.tokenUsage.components.assistantHistory",
+ "bg-sky-500",
+ ],
+ [
+ "current_request",
+ "chat.tokenUsage.components.currentRequest",
+ "bg-indigo-500",
+ ],
+ [
+ "retrieved_context",
+ "chat.tokenUsage.components.retrievedContext",
+ "bg-emerald-500",
+ ],
+ [
+ "tool_definitions",
+ "chat.tokenUsage.components.toolDefinitions",
+ "bg-violet-500",
+ ],
+ [
+ "tool_calls_results",
+ "chat.tokenUsage.components.toolResults",
+ "bg-fuchsia-500",
+ ],
+ [
+ "attachments_media",
+ "chat.tokenUsage.components.attachmentsMedia",
+ "bg-pink-500",
+ ],
+] as const;
+
+const countSourceKey = (value: string) =>
+ `chat.tokenUsage.countSources.${value}`;
+const recoveryStateKey = (value: string) =>
+ `chat.tokenUsage.recoveryStates.${value}`;
+
/**
* Displays per-step token consumption with a stacked progress bar.
* Each step shows input tokens (blue) + output tokens (amber) relative to the token threshold.
@@ -132,56 +211,178 @@ interface SingleTurnTokenUsageProps {
* - Fall back to the global `stepTokenCounts` registry written during live
* streaming runs.
*/
-export const SingleTurnTokenUsage: FC
= ({ className }) => {
+export const SingleTurnTokenUsage: FC = ({
+ className,
+}) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const messageSteps = useAuiState((s) => {
const custom = s.message.metadata?.custom as
- | { stepTokenCounts?: StepTokenCount[] }
+ | {
+ stepTokenCounts?: StepTokenCount[];
+ providerCallUsages?: ProviderCallUsageV2[];
+ turnUsage?: TurnUsageV2;
+ }
| undefined;
return custom?.stepTokenCounts;
});
+ const providerCallUsages = useAuiState((s) => {
+ const custom = s.message.metadata?.custom as
+ | {
+ providerCallUsages?: ProviderCallUsageV2[];
+ }
+ | undefined;
+ return custom?.providerCallUsages;
+ });
+ const turnUsage = useAuiState((s) => {
+ const custom = s.message.metadata?.custom as
+ | {
+ turnUsage?: TurnUsageV2;
+ }
+ | undefined;
+ return custom?.turnUsage;
+ });
// Message-level metadata wins when present; otherwise use the live stream
// registry. The two sources are never populated simultaneously — historical
// conversations take the metadata path, live streaming takes the registry.
const steps: readonly StepTokenCount[] = messageSteps ?? stepTokenCounts;
- if (steps.length === 0) return null;
+ if (steps.length === 0 && !turnUsage) return null;
const latestStep = steps[steps.length - 1];
- const contextWindowTokens = latestStep.contextWindowTokens;
- const tokenThreshold = latestStep.tokenThreshold;
+ const recoveryStep = [...steps].reverse().find((step) => {
+ const state = step.contextBudget?.recovery_state;
+ return Boolean(state && !["not_needed", "not_attempted"].includes(state));
+ });
+ // Recovery commonly happens on the action step before a second step emits
+ // the final answer. Preserve that recovery evidence instead of hiding it
+ // behind the last step's ordinary, non-recovery budget snapshot.
+ const budget = recoveryStep?.contextBudget ?? latestStep?.contextBudget;
+ const contextWindowTokens = latestStep?.contextWindowTokens ?? null;
+ const tokenThreshold = latestStep?.tokenThreshold ?? null;
const maxTokens = contextWindowTokens ?? tokenThreshold;
- if (maxTokens === null) return null;
-
const stepCount = steps.length;
-
- // Calculate total tokens used (sum of step_input_tokens + step_output_tokens for all steps)
- const totalTokensUsed = steps.reduce(
- (sum, step) => sum + step.stepInputTokens + step.stepOutputTokens,
+ const finalProviderCall = [...(providerCallUsages ?? [])]
+ .reverse()
+ .find((call) => call.status === "completed" && call.source === "provider");
+ const observedTurnUsage =
+ turnUsage?.schema_version === 3 || turnUsage?.data_quality === "provider"
+ ? turnUsage
+ : null;
+ const peakContext = observedTurnUsage?.peak_context;
+ const finalInputTokens =
+ peakContext?.input_tokens ??
+ budget?.final_tokens ??
+ latestStep?.stepInputTokens;
+ if (finalInputTokens === undefined || finalInputTokens === null) return null;
+ const effectiveLimit =
+ peakContext?.limit_tokens ?? budget?.hard_budget ?? maxTokens;
+ const outputTokens = finalProviderCall?.usage.output_tokens;
+ const exactUsagePercent = effectiveLimit
+ ? (finalInputTokens / effectiveLimit) * 100
+ : null;
+ const usagePercentLabel =
+ exactUsagePercent === null
+ ? null
+ : exactUsagePercent > 0 && exactUsagePercent < 1
+ ? "<1%"
+ : `${Math.round(exactUsagePercent)}%`;
+ const contextBarDenominator = Math.max(1, effectiveLimit ?? finalInputTokens);
+ const unusedContextTokens = effectiveLimit
+ ? Math.max(0, effectiveLimit - finalInputTokens)
+ : null;
+ const peakCall = providerCallUsages?.find(
+ (call) => call.call_id === peakContext?.call_id
+ );
+ const v2Composition = peakCall?.context_composition;
+ const knownComponents = budget
+ ? CONTEXT_COMPONENTS.map(([key, label, color]) => ({
+ key,
+ label,
+ color,
+ tokens: Math.max(0, budget.components[key] ?? 0),
+ })).filter((item) => item.tokens > 0)
+ : [];
+ const knownComponentTotal = knownComponents.reduce(
+ (sum, item) => sum + item.tokens,
0
);
+ const unclassifiedTokens = Math.max(
+ 0,
+ finalInputTokens - knownComponentTotal
+ );
+ const composition = v2Composition
+ ? V2_CONTEXT_COMPONENTS.map(([key, label, color]) => ({
+ key,
+ label,
+ color,
+ tokens: Math.max(0, v2Composition.segments[key] ?? 0),
+ })).filter((item) => item.tokens > 0)
+ : budget
+ ? [
+ ...knownComponents,
+ ...(unclassifiedTokens > 0
+ ? [
+ {
+ key: "unclassified",
+ label: "chat.tokenUsage.components.unclassified",
+ color: "bg-slate-400",
+ tokens: unclassifiedTokens,
+ },
+ ]
+ : []),
+ ]
+ : [];
+ const estimatedCompositionTotal = Math.max(
+ 1,
+ composition.reduce((sum, item) => sum + item.tokens, 0)
+ );
- const usagePercent = Math.round((totalTokensUsed / maxTokens) * 100);
+ const recoveryExhausted =
+ budget?.recovery_state === "exhausted" ||
+ Boolean(budget?.recovery?.terminal_reason);
return (
-
-
setExpanded(!expanded)}
- className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted ${className ?? ""}`}
- >
-
- {usagePercent}%
- {t("chat.tokenUsage.turn")}
-
+
+ {budget?.recovery?.auto_continued && (
+
+ {t("taskWindow.contextBudget.autoContinued")}
+
+ )}
+ {recoveryExhausted && budget?.recovery?.partial_preserved && (
+
+ {t("chat.tokenUsage.recoveryExhausted")}
+
+ )}
+
+
+
+
+
+ {usagePercentLabel === null
+ ? `${finalInputTokens.toLocaleString()} input`
+ : usagePercentLabel}
+
+
+ {t("chat.tokenUsage.turn")}
+
+
+
- {/* Expanded details popover */}
- {expanded && (
-
+
{t("chat.tokenUsage.turnDetails")}
@@ -208,85 +409,217 @@ export const SingleTurnTokenUsage: FC = ({ className
- {/* Stacked progress bar */}
-
-
-
{t("chat.tokenUsage.context")}
+ {/* Row 1: request capacity and semantic distribution */}
+
+
- {totalTokensUsed.toLocaleString()} / {maxTokens.toLocaleString()}
+ {t("chat.tokenUsage.observedPeakRequest")}
+
+
+
+ {effectiveLimit
+ ? `${finalInputTokens.toLocaleString()} / ${effectiveLimit.toLocaleString()}`
+ : `${finalInputTokens.toLocaleString()} input`}
+
+ {exactUsagePercent !== null && (
+
+ {t("chat.tokenUsage.contextUsed", {
+ percent:
+ exactUsagePercent > 0 && exactUsagePercent < 0.1
+ ? exactUsagePercent.toFixed(2)
+ : exactUsagePercent.toFixed(1),
+ })}
+
+ )}
+ {unusedContextTokens !== null && (
+
+ {t("chat.tokenUsage.contextUnused", {
+ count: unusedContextTokens.toLocaleString(),
+ })}
+
+ )}
+
+
+
+
+
+ {t("chat.tokenUsage.estimatedComposition")}
+
+ {budget && (
+
+ {t("chat.tokenUsage.requestBudgetCountMethod")}:{" "}
+ {t(countSourceKey(budget.count_source), {
+ defaultValue: budget.count_source,
+ })}
+
+ )}
+
+
+ {composition.map((item) => (
+
+ ))}
+
+
+ {composition.map((item) => (
+
+
+ {t(item.label)} ·{" "}
+ {Math.round((item.tokens / estimatedCompositionTotal) * 100)}%
+
+ ))}
+
+ {t("chat.tokenUsage.steps", { count: stepCount })}
-
- {steps.map((step, index) => {
- const stepTotal = step.stepInputTokens + step.stepOutputTokens;
- const stepPercent = (stepTotal / maxTokens) * 100;
- const inputPercent = (step.stepInputTokens / maxTokens) * 100;
- const outputPercent = (step.stepOutputTokens / maxTokens) * 100;
+
- return (
-
0 ? steps.slice(0, index).reduce((sum, s) => sum + ((s.stepInputTokens + s.stepOutputTokens) / maxTokens) * 100, 0) : 0))}%`,
- }}
- title={t("chat.tokenUsage.stepSummary", { step: step.stepNumber, input: step.stepInputTokens, output: step.stepOutputTokens })}
- >
- {/* Input portion (blue) */}
-
- {/* Output portion (amber) */}
+ {/* Row 2: compact multi-column details */}
+
+
+
+ {t("chat.tokenUsage.modelCallCost")}
+
+ {finalProviderCall && (
+
+ {(
+ [
+ ["input_tokens", "chat.tokenUsage.input"],
+ ["cache_read_tokens", "chat.tokenUsage.cacheRead"],
+ ["cache_write_tokens", "chat.tokenUsage.cacheWrite"],
+ ["output_tokens", "chat.tokenUsage.output"],
+ ["reasoning_tokens", "chat.tokenUsage.reasoning"],
+ ["total_tokens", "chat.tokenUsage.total"],
+ ] as const
+ ).map(([key, label]) => {
+ const value = finalProviderCall.usage[key];
+ return (
+
+ {t(label)}
+
+ {value === null || value === undefined
+ ? "—"
+ : value.toLocaleString()}
+
+
+ );
+ })}
+
+ )}
+ {!finalProviderCall && (
+
+ {t("chat.tokenUsage.apiUsageUnavailable")}
+
+ )}
+
+
+
+
+ {t("chat.tokenUsage.composition")}
+
+ {composition.length > 0 && (
+
+ {composition.map((item) => (
- {/* Step number label on hover */}
-
-
- {step.stepNumber}
+ key={item.key}
+ className="flex justify-between text-muted-foreground"
+ >
+
+
+ {t(item.label)}
+
+
+ {Math.round(
+ (item.tokens / estimatedCompositionTotal) * 100
+ )}
+ %
-
- );
- })}
-
-
+ ))}
+
+ )}
+ {composition.length === 0 && (
+
+ {t("chat.tokenUsage.breakdownUnavailable")}
+
+ )}
+
- {/* Legend */}
-
-
-
-
- {t("chat.tokenUsage.input")}
-
-
-
-
{t("chat.tokenUsage.output")}
+
+
+ {t("chat.tokenUsage.responseOutput")}
+
+
+
+ {t("chat.tokenUsage.generated")}
+ {outputTokens?.toLocaleString() ?? "—"}
+
+ {latestStep?.outputFinishReason && (
+
+ {t("chat.tokenUsage.finishReason")}
+ {latestStep.outputFinishReason}
+
+ )}
+ {budget?.compression.attempted && (
+
+ {t("chat.tokenUsage.compactionSaved")}
+
+ {budget.compression.saved_tokens.toLocaleString()} (
+ {Math.round(budget.compression.ratio * 100)}%)
+
+
+ )}
+ {budget && budget.recovery_state !== "not_needed" && (
+
+ {t("chat.tokenUsage.recovery")}
+
+ {t(recoveryStateKey(budget.recovery_state), {
+ defaultValue: budget.recovery_state,
+ })}
+
+
+ )}
+ {budget?.recovery?.archive_active && (
+
+ {t("chat.tokenUsage.archive")}
+
+ {budget.recovery.archived_item_count ?? 0} /{" "}
+ {budget.recovery.retained_item_count ?? 0}
+
+
+ )}
+ {(budget?.recovery?.recalled_tokens ?? 0) > 0 && (
+
+ {t("chat.tokenUsage.recalledTokens")}
+
+ {budget?.recovery?.recalled_tokens?.toLocaleString()}
+
+
+ )}
-
-
- {t("chat.tokenUsage.steps", { count: stepCount })}
-
+
-
- {/* Step details */}
-
-
-
- {t("chat.tokenUsage.total")}
-
-
- {totalTokensUsed.toLocaleString()} / {maxTokens.toLocaleString()}
-
-
-
-
- )}
+
+
);
};
diff --git a/frontend/components/common/tokenUsageIndicator.tsx b/frontend/components/common/tokenUsageIndicator.tsx
index 262fc09808..3a7d562036 100644
--- a/frontend/components/common/tokenUsageIndicator.tsx
+++ b/frontend/components/common/tokenUsageIndicator.tsx
@@ -28,6 +28,7 @@ export function TokenUsageIndicator({
const processingMode = latestMetrics?.context_processing_mode ?? null;
const outputFinishReason = latestMetrics?.output_finish_reason ?? null;
const total_output_tokens = latestMetrics?.total_output_tokens ?? 0;
+ const budget = latestMetrics?.context_budget;
// Prefer provider-reported input usage; fall back to the pre-call estimate.
const contextTokens =
@@ -85,6 +86,42 @@ export function TokenUsageIndicator({
{processingMode}
)}
+ {budget && (
+ <>
+
+ Final request breakdown
+
+ {Object.entries(budget.components)
+ .filter(([, value]) => value > 0)
+ .map(([name, value]) => (
+
+
+ {name.replaceAll("_", " ")}
+
+ {formatNumber(value)}
+
+ ))}
+
+ Count source
+ {budget.count_source}
+
+ {budget.compression.attempted && (
+
+ Compaction saved
+
+ {formatNumber(budget.compression.saved_tokens)} (
+ {Math.round(budget.compression.ratio * 100)}%)
+
+
+ )}
+ {budget.recovery_state !== "not_needed" && (
+
+ Recovery
+ {budget.recovery_state}
+
+ )}
+ >
+ )}
{isDefaultThreshold && (
* estimated limit
)}
diff --git a/frontend/const/chatConfig.ts b/frontend/const/chatConfig.ts
index a08b00aeaf..edb9caba05 100644
--- a/frontend/const/chatConfig.ts
+++ b/frontend/const/chatConfig.ts
@@ -1,4 +1,4 @@
- import { resourcesCustom } from "@/app/i18n";
+import { resourcesCustom } from "@/app/i18n";
// Chat related configuration
export const chatConfig = {
@@ -33,7 +33,11 @@ export const chatConfig = {
// File limit configuration
maxFileCount: 50,
- maxFileSize: (Number((resourcesCustom?.zh?.custom as any)?.['FILE_UPLOAD_SIZE_LIMIT']) || 10) * 1024 * 1024, // Maximum 10MB - 100MB per file
+ maxFileSize:
+ (Number((resourcesCustom?.zh?.custom as any)?.["FILE_UPLOAD_SIZE_LIMIT"]) ||
+ 10) *
+ 1024 *
+ 1024, // Maximum 10MB - 100MB per file
// Supported image file extensions
imageExtensions: ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"],
@@ -149,6 +153,7 @@ export const chatConfig = {
PREPROCESS: "preprocess" as const,
FILES: "files" as const,
HISTORY_SUMMARY: "history_summary" as const,
+ CONTEXT_BUDGET: "context_budget" as const,
},
// Content type constants for last content type tracking
@@ -183,8 +188,7 @@ export const chatConfig = {
// Type definitions for better type safety
export type Opinion =
- | (typeof chatConfig.opinion)[keyof typeof chatConfig.opinion]
- | null;
+ (typeof chatConfig.opinion)[keyof typeof chatConfig.opinion] | null;
export type MessageType =
(typeof chatConfig.messageTypes)[keyof typeof chatConfig.messageTypes];
export type ContentType =
diff --git a/frontend/lib/chatMessageExtractor.ts b/frontend/lib/chatMessageExtractor.ts
index 3f98409acf..be9aeecd7a 100644
--- a/frontend/lib/chatMessageExtractor.ts
+++ b/frontend/lib/chatMessageExtractor.ts
@@ -302,7 +302,13 @@ export function extractAssistantMsgFromResponse(
const currentStep = steps[steps.length - 1];
if (currentStep) {
try {
- currentStep.metrics = JSON.parse(msg.content);
+ const metrics = JSON.parse(msg.content);
+ currentStep.metrics = {
+ ...(currentStep.metrics || {}),
+ ...metrics,
+ context_budget:
+ currentStep.metrics?.context_budget || metrics.context_budget,
+ };
} catch {
currentStep.metrics = null;
}
@@ -310,6 +316,48 @@ export function extractAssistantMsgFromResponse(
break;
}
+ case chatConfig.messageTypes.CONTEXT_BUDGET: {
+ const currentStep = steps[steps.length - 1];
+ if (currentStep) {
+ try {
+ const contextBudget = JSON.parse(msg.content);
+ currentStep.metrics = {
+ ...(currentStep.metrics || {
+ step_number: contextBudget.step_number,
+ duration: 0,
+ step_input_tokens: null,
+ step_output_tokens: null,
+ total_output_tokens: 0,
+ estimated_context_tokens: contextBudget.final_tokens,
+ token_threshold: contextBudget.soft_budget,
+ hard_input_budget_tokens: contextBudget.hard_budget,
+ context_processing_mode: null,
+ output_finish_reason: null,
+ }),
+ context_budget: contextBudget,
+ };
+ if (
+ contextBudget?.compression?.attempted ||
+ Number(contextBudget?.retry_ordinal || 0) > 0 ||
+ !["not_needed", "not_attempted"].includes(
+ contextBudget?.recovery_state
+ )
+ ) {
+ currentStep.contents.push({
+ id: `context-budget-${dialog_msg.message_id}-${currentStep.contents.length}`,
+ type: chatConfig.messageTypes.CONTEXT_BUDGET,
+ content: msg.content,
+ expanded: true,
+ timestamp: Date.now(),
+ });
+ }
+ } catch {
+ /* forward-compatible: ignore malformed optional event */
+ }
+ }
+ break;
+ }
+
case chatConfig.messageTypes.HISTORY_SUMMARY: {
const currentStep = getOrCreateCurrentStep(steps, "History Summary");
currentStep.contents.push({
diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json
index f90375ae78..9d54d20898 100644
--- a/frontend/public/locales/en/common.json
+++ b/frontend/public/locales/en/common.json
@@ -284,7 +284,7 @@
"chat.tokenUsage.used": "used",
"chat.tokenUsage.turn": "this turn",
"chat.tokenUsage.details": "Token usage details",
- "chat.tokenUsage.turnDetails": "Turn token usage details",
+ "chat.tokenUsage.turnDetails": "Current Agent run usage details",
"chat.tokenUsage.close": "Close",
"chat.tokenUsage.context": "Context usage",
"chat.tokenUsage.input": "Input",
@@ -293,6 +293,39 @@
"chat.tokenUsage.total": "Total",
"chat.tokenUsage.steps": "{{count}} steps",
"chat.tokenUsage.stepSummary": "Step {{step}}: {{input}} in + {{output}} out",
+ "chat.tokenUsage.composition": "Context composition",
+ "chat.tokenUsage.responseOutput": "Response output",
+ "chat.tokenUsage.generated": "Generated tokens",
+ "chat.tokenUsage.finishReason": "Finished by",
+ "chat.tokenUsage.breakdownUnavailable": "A component breakdown is unavailable for this response.",
+ "chat.tokenUsage.components.messageText": "System & conversation",
+ "chat.tokenUsage.components.messageFraming": "Message framing",
+ "chat.tokenUsage.components.tools": "Tools & tool calls",
+ "chat.tokenUsage.components.media": "Images & media",
+ "chat.tokenUsage.components.reasoning": "Reasoning controls",
+ "chat.tokenUsage.components.otherSemantic": "Other context",
+ "chat.tokenUsage.components.unclassified": "Unclassified",
+ "chat.tokenUsage.components.systemInstructions": "System instructions",
+ "chat.tokenUsage.components.userHistory": "User history",
+ "chat.tokenUsage.components.assistantHistory": "Assistant history",
+ "chat.tokenUsage.components.currentRequest": "Current request",
+ "chat.tokenUsage.components.retrievedContext": "Retrieved context",
+ "chat.tokenUsage.components.toolDefinitions": "Tool definitions",
+ "chat.tokenUsage.components.toolResults": "Tool calls & results",
+ "chat.tokenUsage.components.attachmentsMedia": "Attachments & media",
+ "chat.tokenUsage.modelCallCost": "Model call cost",
+ "chat.tokenUsage.freshInput": "Fresh input",
+ "chat.tokenUsage.cacheRead": "Cache read",
+ "chat.tokenUsage.cacheWrite": "Cache write",
+ "chat.tokenUsage.visibleOutput": "Visible output",
+ "chat.tokenUsage.reasoning": "Reasoning",
+ "chat.tokenUsage.source": "Source",
+ "chat.tokenUsage.calls": "Calls",
+ "chat.tokenUsage.highAdjustment": "High estimation adjustment",
+ "chat.tokenUsage.highAdjustmentExplanation": "The local component estimate differs from the provider's exact input total by more than 10%. Components were reconciled to that exact total; this is not extra usage or billing.",
+ "chat.tokenUsage.contextUsed": "{{percent}}% used",
+ "chat.tokenUsage.contextUnused": "{{count}} unused",
+ "chat.tokenUsage.unusedContext": "Unused capacity",
"chat.messageTiming.generatedIn": "Generated in {{time}}",
"chat.messageTiming.firstToken": "TTFT: {{time}}",
"chat.messageTiming.total": "Total: {{time}}",
@@ -1239,7 +1272,7 @@
"model.dialog.capacity.defaultOutputReserveTokens": "Output Reserve",
"model.dialog.capacity.defaultOutputReserveTokens.tooltip": "Default output allowance reserved before constructing request input.",
"model.dialog.capacity.error.positiveInteger": "Capacity numeric fields must be positive integers or empty.",
- "model.dialog.capacity.error.outputExceedsWindow": "Max output tokens cannot exceed the context window.",
+ "model.dialog.capacity.error.outputExceedsWindow": "Max output tokens must be less than the context window.",
"model.dialog.capacity.error.inputExceedsWindow": "Max input tokens cannot exceed the context window (any excess is silently clipped, so please adjust the value directly).",
"model.dialog.capacity.error.reserveExceedsOutput": "Output reserve cannot exceed max output tokens.",
"model.dialog.capacity.error.requiredMissing": "Context window and max input tokens are required.",
@@ -1441,6 +1474,82 @@
"modelConfig.capacityCoverage.warning": "{{bareCount}} of {{total}} LLM/VLM models are missing capacity — output token cap is not enforced.",
"modelConfig.capacityCoverage.description": "{{suggestionCount}} have an approved capacity suggestion ready to apply. Click Manage, then click the warning icon on each affected row to repair.",
"modelConfig.capacityCoverage.manage": "Manage",
+ "modelConfig.capacityHealth.title": "Model capacity health",
+ "modelConfig.capacityHealth.summary": "{{healthy}} of {{total}} models healthy · catalog {{revision}}",
+ "modelConfig.capacityHealth.catalogLifecycle": "Profiles: {{current}} current · {{reviewDue}} review due · {{expired}} expired",
+ "modelConfig.capacityHealth.catalogCandidate": "Staged {{revision}} (+{{added}} / ~{{changed}} / -{{removed}})",
+ "taskWindow.contextBudget.optimized": "Context optimized",
+ "taskWindow.contextBudget.savings": "{{raw}} → {{final}} tokens · saved {{saved}} ({{percent}}%)",
+ "taskWindow.contextBudget.reason": "Reason: {{reason}}",
+ "taskWindow.contextBudget.reasons.history_summary": "history summarized",
+ "taskWindow.contextBudget.reasons.history_incremental": "new history summarized",
+ "taskWindow.contextBudget.reasons.long_term_memory_selection": "long-term memory selected",
+ "taskWindow.contextBudget.reasons.representation_compaction": "compact representation selected",
+ "taskWindow.contextBudget.recovery": "Recovery: {{state}}",
+ "taskWindow.contextBudget.archiveActive": "Older context archived · recall available",
+ "taskWindow.contextBudget.autoContinued": "Response automatically continued",
+ "taskWindow.contextBudget.provisionalCapacity": "Using estimated 32K capacity",
+ "modelConfig.capacityHealth.review": "Review {{count}} issues",
+ "modelConfig.capacityHealth.model": "Model",
+ "modelConfig.capacityHealth.status": "Health",
+ "modelConfig.capacityHealth.reason": "Reason",
+ "modelConfig.capacityHealth.action": "Action",
+ "modelConfig.capacityHealth.reviewFix": "Review and fix",
+ "modelConfig.capacityHealth.profile": "Profile",
+ "modelConfig.capacityHealth.verifiedAt": "Evidence verified",
+ "modelConfig.capacityHealth.field": "Field",
+ "modelConfig.capacityHealth.current": "Current",
+ "modelConfig.capacityHealth.proposed": "Proposed",
+ "modelConfig.capacityHealth.protection": "Protection",
+ "modelConfig.capacityHealth.manualProtected": "Manual value protected",
+ "modelConfig.capacityHealth.applicable": "Will adopt",
+ "modelConfig.capacityHealth.noChange": "No change",
+ "modelConfig.capacityHealth.applyReviewed": "Apply reviewed changes",
+ "modelConfig.capacityHealth.applied": "Capacity profile applied",
+ "modelConfig.capacityHealth.previewFailed": "Could not load a safe adoption preview",
+ "modelConfig.capacityHealth.applyFailed": "Capacity profile was not applied",
+ "modelConfig.capacityHealth.statuses.healthy": "Healthy",
+ "modelConfig.capacityHealth.statuses.review_due": "Review due",
+ "modelConfig.capacityHealth.statuses.expired": "Evidence expired",
+ "modelConfig.capacityHealth.statuses.estimated": "Estimated",
+ "modelConfig.capacityHealth.statuses.unconfigured": "Unconfigured",
+ "modelConfig.capacityHealth.statuses.invalid": "Invalid",
+ "modelConfig.capacityHealth.statuses.probe_degraded": "Count probe degraded",
+ "modelConfig.capacityHealth.reasons.capacity_verified": "Capacity verified",
+ "modelConfig.capacityHealth.reasons.required_capacity_missing": "Required capacity is missing",
+ "modelConfig.capacityHealth.reasons.catalog_evidence_expired": "Catalog evidence expired",
+ "modelConfig.capacityHealth.reasons.catalog_evidence_review_due": "Catalog evidence needs review",
+ "modelConfig.capacityHealth.reasons.token_count_probe_degraded": "Provider count probe needs attention",
+ "modelConfig.capacityHealth.reasons.capacity_or_counting_estimated": "Capacity or token counting is estimated",
+ "modelConfig.capacityHealth.reasons.context_window_invalid": "Context window is invalid",
+ "modelConfig.capacityHealth.reasons.max_output_invalid": "Maximum output is invalid",
+ "modelConfig.capacityHealth.reasons.max_input_invalid": "Maximum input is invalid",
+ "modelConfig.capacityHealth.reasons.output_not_below_context": "Maximum output must be below the context window",
+ "chat.tokenUsage.finalRequest": "Final request",
+ "chat.tokenUsage.countSource": "Request budget count method",
+ "chat.tokenUsage.compactionSaved": "Compaction saved",
+ "chat.tokenUsage.recovery": "Overflow recovery",
+ "chat.tokenUsage.archive": "Archived / retained items",
+ "chat.tokenUsage.recalledTokens": "Recalled tokens",
+ "chat.tokenUsage.contextRecovered": "Context recovered · compressed and retried",
+ "chat.tokenUsage.recoveryExhausted": "Partial response preserved · recovery exhausted",
+ "chat.tokenUsage.observedPeakRequest": "Peak request context (API observed)",
+ "chat.tokenUsage.estimatedComposition": "Estimated context composition",
+ "chat.tokenUsage.observedInput": "API observed input",
+ "chat.tokenUsage.apiUsageUnavailable": "Token usage was not returned by the API",
+ "chat.tokenUsage.requestBudgetCountMethod": "Request budget count method",
+ "chat.tokenUsage.countSources.provider": "API preflight count",
+ "chat.tokenUsage.countSources.tokenizer": "Local tokenizer count",
+ "chat.tokenUsage.countSources.estimated": "Local full-request estimate",
+ "chat.tokenUsage.countSources.provider_anchor_delta": "API history anchor + current delta estimate",
+ "chat.tokenUsage.recoveryStates.not_attempted": "Not attempted",
+ "chat.tokenUsage.recoveryStates.not_needed": "Not needed",
+ "chat.tokenUsage.recoveryStates.retrying": "Retrying",
+ "chat.tokenUsage.recoveryStates.recovered": "Recovered",
+ "chat.tokenUsage.recoveryStates.retry_exhausted": "Retries exhausted",
+ "chat.tokenUsage.recoveryStates.retry_unsafe": "Unsafe to retry",
+ "chat.tokenUsage.recoveryStates.retry_unsafe_after_response": "Unsafe to retry after response started",
+ "chat.tokenUsage.recoveryStates.exhausted": "Recovery exhausted",
"modelConfig.button.editCustomModel": "Edit or Delete Model",
"modelConfig.button.checkConnectivity": "Check Model Connectivity",
"modelConfig.button.sync": "Sync",
diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json
index b6dd3b28d6..b050d09d97 100644
--- a/frontend/public/locales/zh/common.json
+++ b/frontend/public/locales/zh/common.json
@@ -284,7 +284,7 @@
"chat.tokenUsage.used": "已使用",
"chat.tokenUsage.turn": "本轮",
"chat.tokenUsage.details": "Token 使用详情",
- "chat.tokenUsage.turnDetails": "单轮 Token 使用详情",
+ "chat.tokenUsage.turnDetails": "本轮 Agent 运行使用详情",
"chat.tokenUsage.close": "关闭",
"chat.tokenUsage.context": "上下文使用",
"chat.tokenUsage.input": "输入",
@@ -293,6 +293,39 @@
"chat.tokenUsage.total": "总计",
"chat.tokenUsage.steps": "{{count}} 步",
"chat.tokenUsage.stepSummary": "第 {{step}} 步:输入 {{input}},输出 {{output}}",
+ "chat.tokenUsage.composition": "上下文构成",
+ "chat.tokenUsage.responseOutput": "响应输出",
+ "chat.tokenUsage.generated": "生成 Token",
+ "chat.tokenUsage.finishReason": "结束原因",
+ "chat.tokenUsage.breakdownUnavailable": "此响应没有可用的上下文构成明细。",
+ "chat.tokenUsage.components.messageText": "系统指令与会话",
+ "chat.tokenUsage.components.messageFraming": "消息结构",
+ "chat.tokenUsage.components.tools": "工具与工具调用",
+ "chat.tokenUsage.components.media": "图片与媒体",
+ "chat.tokenUsage.components.reasoning": "推理控制",
+ "chat.tokenUsage.components.otherSemantic": "其他上下文",
+ "chat.tokenUsage.components.unclassified": "未分类",
+ "chat.tokenUsage.components.systemInstructions": "系统指令",
+ "chat.tokenUsage.components.userHistory": "用户历史",
+ "chat.tokenUsage.components.assistantHistory": "助手历史",
+ "chat.tokenUsage.components.currentRequest": "当前请求",
+ "chat.tokenUsage.components.retrievedContext": "检索上下文",
+ "chat.tokenUsage.components.toolDefinitions": "工具定义",
+ "chat.tokenUsage.components.toolResults": "工具调用与结果",
+ "chat.tokenUsage.components.attachmentsMedia": "附件与媒体",
+ "chat.tokenUsage.modelCallCost": "模型调用开销",
+ "chat.tokenUsage.freshInput": "新输入",
+ "chat.tokenUsage.cacheRead": "缓存读取",
+ "chat.tokenUsage.cacheWrite": "缓存写入",
+ "chat.tokenUsage.visibleOutput": "可见输出",
+ "chat.tokenUsage.reasoning": "推理",
+ "chat.tokenUsage.source": "来源",
+ "chat.tokenUsage.calls": "调用次数",
+ "chat.tokenUsage.highAdjustment": "估算调整较大",
+ "chat.tokenUsage.highAdjustmentExplanation": "上下文分类的本地估算与供应商返回的精确输入量差异超过 10%;分类已按精确总量校准,不代表额外用量或计费。",
+ "chat.tokenUsage.contextUsed": "已用 {{percent}}%",
+ "chat.tokenUsage.contextUnused": "未使用 {{count}}",
+ "chat.tokenUsage.unusedContext": "未使用空间",
"chat.messageTiming.generatedIn": "生成耗时 {{time}}",
"chat.messageTiming.firstToken": "首个 Token:{{time}}",
"chat.messageTiming.total": "总计:{{time}}",
@@ -1225,7 +1258,7 @@
"model.dialog.capacity.defaultOutputReserveTokens": "输出预留Token数",
"model.dialog.capacity.defaultOutputReserveTokens.tooltip": "构造请求输入前默认预留的输出额度。",
"model.dialog.capacity.error.positiveInteger": "容量数字字段必须为空或正整数。",
- "model.dialog.capacity.error.outputExceedsWindow": "最大输出Token数不能超过上下文窗口。",
+ "model.dialog.capacity.error.outputExceedsWindow": "最大输出Token数必须小于上下文窗口。",
"model.dialog.capacity.error.inputExceedsWindow": "最大输入Token数不能超过上下文窗口(超出部分会被自动忽略,请直接调整数值)。",
"model.dialog.capacity.error.reserveExceedsOutput": "输出预留Token数不能超过最大输出Token数。",
"model.dialog.capacity.error.requiredMissing": "上下文窗口和最大输入Token数为必填项。",
@@ -1427,6 +1460,82 @@
"modelConfig.capacityCoverage.warning": "{{total}} 个 LLM/VLM 模型中有 {{bareCount}} 个未配置容量,输出 token 限额未启用。",
"modelConfig.capacityCoverage.description": "其中 {{suggestionCount}} 个有已审核容量建议可一键应用。点击\"管理\"打开列表,逐行点击警告图标即可修复。",
"modelConfig.capacityCoverage.manage": "管理",
+ "modelConfig.capacityHealth.title": "模型容量健康度",
+ "modelConfig.capacityHealth.summary": "{{total}} 个模型中 {{healthy}} 个健康 · 目录 {{revision}}",
+ "modelConfig.capacityHealth.catalogLifecycle": "配置档案:{{current}} 个当前有效 · {{reviewDue}} 个待复核 · {{expired}} 个已过期",
+ "modelConfig.capacityHealth.catalogCandidate": "已暂存 {{revision}}(+{{added}} / ~{{changed}} / -{{removed}})",
+ "taskWindow.contextBudget.optimized": "上下文已优化",
+ "taskWindow.contextBudget.savings": "{{raw}} → {{final}} 个令牌 · 节省 {{saved}}({{percent}}%)",
+ "taskWindow.contextBudget.reason": "原因:{{reason}}",
+ "taskWindow.contextBudget.reasons.history_summary": "已汇总历史会话",
+ "taskWindow.contextBudget.reasons.history_incremental": "已增量汇总新历史",
+ "taskWindow.contextBudget.reasons.long_term_memory_selection": "已筛选长期记忆",
+ "taskWindow.contextBudget.reasons.representation_compaction": "已选择紧凑表示",
+ "taskWindow.contextBudget.recovery": "恢复状态:{{state}}",
+ "taskWindow.contextBudget.archiveActive": "较早上下文已归档 · 可按需召回",
+ "taskWindow.contextBudget.autoContinued": "响应已自动续写",
+ "taskWindow.contextBudget.provisionalCapacity": "正在使用估算的 32K 容量",
+ "modelConfig.capacityHealth.review": "检查 {{count}} 个问题",
+ "modelConfig.capacityHealth.model": "模型",
+ "modelConfig.capacityHealth.status": "健康状态",
+ "modelConfig.capacityHealth.reason": "原因",
+ "modelConfig.capacityHealth.action": "操作",
+ "modelConfig.capacityHealth.reviewFix": "检查并修复",
+ "modelConfig.capacityHealth.profile": "能力配置版本",
+ "modelConfig.capacityHealth.verifiedAt": "证据验证时间",
+ "modelConfig.capacityHealth.field": "字段",
+ "modelConfig.capacityHealth.current": "当前值",
+ "modelConfig.capacityHealth.proposed": "建议值",
+ "modelConfig.capacityHealth.protection": "保护状态",
+ "modelConfig.capacityHealth.manualProtected": "人工值受保护",
+ "modelConfig.capacityHealth.applicable": "将采用",
+ "modelConfig.capacityHealth.noChange": "无需变更",
+ "modelConfig.capacityHealth.applyReviewed": "应用已检查的更改",
+ "modelConfig.capacityHealth.applied": "已采用容量配置",
+ "modelConfig.capacityHealth.previewFailed": "无法加载安全的采用预览",
+ "modelConfig.capacityHealth.applyFailed": "未采用容量配置",
+ "modelConfig.capacityHealth.statuses.healthy": "健康",
+ "modelConfig.capacityHealth.statuses.review_due": "需要复核",
+ "modelConfig.capacityHealth.statuses.expired": "证据已过期",
+ "modelConfig.capacityHealth.statuses.estimated": "估算",
+ "modelConfig.capacityHealth.statuses.unconfigured": "未配置",
+ "modelConfig.capacityHealth.statuses.invalid": "配置无效",
+ "modelConfig.capacityHealth.statuses.probe_degraded": "计数探测异常",
+ "modelConfig.capacityHealth.reasons.capacity_verified": "容量已验证",
+ "modelConfig.capacityHealth.reasons.required_capacity_missing": "缺少必要容量字段",
+ "modelConfig.capacityHealth.reasons.catalog_evidence_expired": "目录证据已过期",
+ "modelConfig.capacityHealth.reasons.catalog_evidence_review_due": "目录证据需要复核",
+ "modelConfig.capacityHealth.reasons.token_count_probe_degraded": "Provider 计数探测需要处理",
+ "modelConfig.capacityHealth.reasons.capacity_or_counting_estimated": "容量或 Token 计数为估算值",
+ "modelConfig.capacityHealth.reasons.context_window_invalid": "上下文窗口无效",
+ "modelConfig.capacityHealth.reasons.max_output_invalid": "最大输出无效",
+ "modelConfig.capacityHealth.reasons.max_input_invalid": "最大输入无效",
+ "modelConfig.capacityHealth.reasons.output_not_below_context": "最大输出必须小于上下文窗口",
+ "chat.tokenUsage.finalRequest": "最终请求",
+ "chat.tokenUsage.countSource": "请求预算计数方式",
+ "chat.tokenUsage.compactionSaved": "压缩节省",
+ "chat.tokenUsage.recovery": "溢出恢复",
+ "chat.tokenUsage.archive": "已归档 / 保留项",
+ "chat.tokenUsage.recalledTokens": "召回令牌",
+ "chat.tokenUsage.contextRecovered": "上下文已恢复 · 压缩并重试",
+ "chat.tokenUsage.recoveryExhausted": "已保留部分响应 · 恢复已用尽",
+ "chat.tokenUsage.observedPeakRequest": "峰值请求上下文(API 实测)",
+ "chat.tokenUsage.estimatedComposition": "上下文组成占比(估算)",
+ "chat.tokenUsage.observedInput": "API 实测输入",
+ "chat.tokenUsage.apiUsageUnavailable": "API 未返回 Token 用量",
+ "chat.tokenUsage.requestBudgetCountMethod": "请求预算计数方式",
+ "chat.tokenUsage.countSources.provider": "API 预检计数",
+ "chat.tokenUsage.countSources.tokenizer": "本地 Tokenizer 计数",
+ "chat.tokenUsage.countSources.estimated": "本地完整请求估算",
+ "chat.tokenUsage.countSources.provider_anchor_delta": "API 历史锚点 + 本轮增量估算",
+ "chat.tokenUsage.recoveryStates.not_attempted": "未尝试",
+ "chat.tokenUsage.recoveryStates.not_needed": "无需恢复",
+ "chat.tokenUsage.recoveryStates.retrying": "正在重试",
+ "chat.tokenUsage.recoveryStates.recovered": "已恢复",
+ "chat.tokenUsage.recoveryStates.retry_exhausted": "重试已用尽",
+ "chat.tokenUsage.recoveryStates.retry_unsafe": "无法安全重试",
+ "chat.tokenUsage.recoveryStates.retry_unsafe_after_response": "响应开始后无法安全重试",
+ "chat.tokenUsage.recoveryStates.exhausted": "恢复已用尽",
"modelConfig.button.editCustomModel": "修改或删除模型",
"modelConfig.button.checkConnectivity": "检查模型连通性",
"modelConfig.button.sync": "同步",
diff --git a/frontend/types/chat.ts b/frontend/types/chat.ts
index acc9aeb907..0d8761233a 100644
--- a/frontend/types/chat.ts
+++ b/frontend/types/chat.ts
@@ -16,6 +16,57 @@ export interface TokenMetrics {
hard_input_budget_tokens: number | null;
context_processing_mode: "adaptive_compact" | "passthrough" | null;
output_finish_reason: string | null;
+ context_budget?: ContextBudgetMetrics;
+}
+
+export interface ContextBudgetMetrics {
+ schema_version: 1;
+ purpose: string;
+ step_number: number;
+ raw_tokens: number;
+ final_tokens: number;
+ soft_budget: number;
+ hard_budget: number;
+ hard_count: number;
+ components: {
+ message_text: number;
+ message_framing: number;
+ tools: number;
+ media: number;
+ reasoning: number;
+ other_semantic: number;
+ };
+ count_source: string;
+ compression: {
+ attempted: boolean;
+ saved_tokens: number;
+ ratio: number;
+ fallback_compaction: boolean;
+ reasons: Array<
+ | "history_summary"
+ | "history_incremental"
+ | "long_term_memory_selection"
+ | "representation_compaction"
+ >;
+ };
+ recovery_state: string;
+ recovery?: {
+ trigger?: string;
+ phase?: "compression" | "archive";
+ attempt?: number;
+ maximum_attempts?: number;
+ compression_target?: number;
+ provisional_capacity?: boolean;
+ archive_active?: boolean;
+ archived_item_count?: number;
+ retained_item_count?: number;
+ recall_invocation_count?: number;
+ recalled_tokens?: number;
+ partial_preserved?: boolean;
+ auto_continued?: boolean;
+ terminal_reason?: string;
+ };
+ retry_ordinal: number;
}
// Step related types
@@ -45,6 +96,7 @@ export interface StepContent {
| typeof chatConfig.messageTypes.PREPROCESS
| typeof chatConfig.messageTypes.VERIFICATION
| typeof chatConfig.messageTypes.HISTORY_SUMMARY
+ | typeof chatConfig.messageTypes.CONTEXT_BUDGET
| typeof chatConfig.messageTypes.MAX_STEPS_REACHED;
content: string;
expanded: boolean;
diff --git a/sdk/nexent/core/agents/agent_model.py b/sdk/nexent/core/agents/agent_model.py
index bee5e05ce5..65e0d7684e 100644
--- a/sdk/nexent/core/agents/agent_model.py
+++ b/sdk/nexent/core/agents/agent_model.py
@@ -82,6 +82,22 @@ class ModelConfig(BaseModel):
description="Version of the approved provider/model capability profile selected by the resolver, e.g. 'openai/gpt-4o@1'.",
default=None,
)
+ canonical_model_id: Optional[str] = Field(
+ description="P1 canonical model identity used to isolate P2 request calibration.",
+ default=None,
+ )
+ model_identity_metadata: Optional[Dict[str, Any]] = Field(
+ description="Sanitized P1 model-identity and matcher evidence.",
+ default=None,
+ )
+ tokenizer_match_metadata: Optional[Dict[str, Any]] = Field(
+ description="Sanitized P1 tokenizer match and conformance evidence.",
+ default=None,
+ )
+ token_count_probe_metadata: Optional[Dict[str, Any]] = Field(
+ description="Sanitized P1 Provider full-request count capability evidence.",
+ default=None,
+ )
timeout_seconds: Optional[float] = Field(
description="Request timeout in seconds. If None, uses provider default.",
default=None
@@ -98,6 +114,21 @@ class ModelConfig(BaseModel):
),
default=None,
)
+ feature_capabilities: Optional[Dict[str, Any]] = Field(
+ description="Resolved model-level reasoning and prompt-cache capabilities.",
+ default=None,
+ )
+ feature_preferences: Optional[Dict[str, Any]] = Field(
+ description=(
+ "Optional user behavior overrides applied within confirmed model capabilities. "
+ "Reserved for the future preference API/UI; absent means Nexent defaults."
+ ),
+ default=None,
+ )
+ provider_usage_profile: Optional[Dict[str, Any]] = Field(
+ description="P7 provider usage field paths and verified semantic relationships.",
+ default=None,
+ )
@model_validator(mode="after")
def _backfill_max_output_from_legacy_max_tokens(self) -> "ModelConfig":
diff --git a/sdk/nexent/core/agents/context/archive.py b/sdk/nexent/core/agents/context/archive.py
new file mode 100755
index 0000000000..ae7215be42
--- /dev/null
+++ b/sdk/nexent/core/agents/context/archive.py
@@ -0,0 +1,147 @@
+"""Run-local searchable history archive used by emergency context recovery."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import math
+import re
+from dataclasses import dataclass
+from typing import Any, Iterable
+
+from smolagents import Tool
+
+
+ARCHIVE_KINDS = frozenset({"chat_turn", "tool_call", "observation", "error", "result"})
+_SECRET_KEY = re.compile(r"(?:api[_-]?key|authorization|cookie|password|secret|token)$", re.IGNORECASE)
+_WORD = re.compile(r"[\w]+", re.UNICODE)
+
+
+def _redact(value: Any, *, key: str = "") -> Any:
+ if _SECRET_KEY.search(key):
+ return "[REDACTED]"
+ if isinstance(value, dict):
+ return {
+ str(name): _redact(item, key=str(name))
+ for name, item in value.items()
+ if str(name).lower() not in {"reasoning", "thoughts", "chain_of_thought"}
+ }
+ if isinstance(value, (list, tuple)):
+ return [_redact(item) for item in value]
+ if isinstance(value, (bytes, bytearray, memoryview)):
+ return "[BINARY OMITTED]"
+ return value
+
+
+def _text(value: Any) -> str:
+ return json.dumps(_redact(value), ensure_ascii=False, sort_keys=True, default=str)
+
+
+def _features(value: str) -> set[str]:
+ normalized = value.casefold()
+ words = set(_WORD.findall(normalized))
+ compact = re.sub(r"\s+", "", normalized)
+ grams = {compact[i:i + size] for size in (2, 3) for i in range(max(0, len(compact) - size + 1))}
+ return words | grams
+
+
+@dataclass(frozen=True)
+class ArchiveRecord:
+ archive_id: str
+ kind: str
+ source_id: str
+ content: str
+ ordinal: int
+
+
+class RunHistoryArchive:
+ """An isolated in-memory index whose lifetime is one Agent run."""
+
+ def __init__(self, *, run_id: str, hard_input_budget: int, chars_per_token: float = 1.5):
+ self.run_id = run_id
+ self.hard_input_budget = max(1, int(hard_input_budget))
+ self.chars_per_token = max(0.1, float(chars_per_token))
+ self._records: list[ArchiveRecord] = []
+ self.recall_invocations = 0
+ self.recalled_tokens = 0
+
+ @property
+ def records(self) -> tuple[ArchiveRecord, ...]:
+ return tuple(self._records)
+
+ def add(self, *, kind: str, source_id: str, content: Any) -> ArchiveRecord | None:
+ if kind not in ARCHIVE_KINDS:
+ raise ValueError(f"unsupported archive kind: {kind}")
+ rendered = _text(content)
+ if not rendered or rendered in {'""', "null", "[]", "{}"}:
+ return None
+ ordinal = len(self._records)
+ digest = hashlib.sha256(f"{self.run_id}\0{kind}\0{source_id}\0{ordinal}".encode()).hexdigest()[:16]
+ record = ArchiveRecord(f"archive:{digest}", kind, str(source_id), rendered, ordinal)
+ self._records.append(record)
+ return record
+
+ def search(self, query: str, top_k: int = 5, kinds: Iterable[str] | None = None) -> dict[str, Any]:
+ self.recall_invocations += 1
+ limit = min(5, max(1, int(top_k)))
+ selected_kinds = set(kinds or ARCHIVE_KINDS)
+ invalid = selected_kinds - ARCHIVE_KINDS
+ if invalid:
+ raise ValueError(f"unsupported archive kinds: {', '.join(sorted(invalid))}")
+ query_features = _features(str(query))
+ ranked = []
+ for record in self._records:
+ if record.kind not in selected_kinds:
+ continue
+ record_features = _features(record.content)
+ overlap = len(query_features & record_features)
+ if not overlap:
+ continue
+ score = overlap / math.sqrt(max(1, len(query_features) * len(record_features)))
+ ranked.append((score, record.ordinal, record))
+ ranked.sort(key=lambda row: (-row[0], -row[1], row[2].archive_id))
+
+ token_cap = max(1, int(self.hard_input_budget * 0.2))
+ char_cap = max(1, int(token_cap * self.chars_per_token))
+ used_chars = 0
+ results = []
+ for score, _, record in ranked[:limit]:
+ remaining = char_cap - used_chars
+ if remaining <= 0:
+ break
+ content = record.content[:remaining]
+ used_chars += len(content)
+ results.append({
+ "archive_id": record.archive_id,
+ "kind": record.kind,
+ "source_id": record.source_id,
+ "score": round(score, 6),
+ "content": content,
+ })
+ recalled = min(token_cap, math.ceil(used_chars / self.chars_per_token))
+ self.recalled_tokens += recalled
+ return {"results": results, "recalled_tokens": recalled, "token_cap": token_cap}
+
+
+class SearchArchivedHistoryTool(Tool):
+ name = "search_archived_history"
+ description = (
+ "Search older run history that was removed from the active prompt during context recovery. "
+ "Use only when missing prior user, answer, tool, observation, error, or result detail is relevant."
+ )
+ inputs = {
+ "query": {"type": "string", "description": "Natural-language or keyword search."},
+ "top_k": {"type": "integer", "description": "Number of results, clamped to 1-5.", "nullable": True},
+ "kinds": {
+ "type": "array", "description": "Optional archive kind filter.",
+ "items": {"type": "string"}, "nullable": True,
+ },
+ }
+ output_type = "object"
+
+ def __init__(self, archive: RunHistoryArchive):
+ self.archive = archive
+ super().__init__()
+
+ def forward(self, query: str, top_k: int = 5, kinds: list[str] | None = None) -> dict[str, Any]:
+ return self.archive.search(query=query, top_k=top_k, kinds=kinds)
diff --git a/sdk/nexent/core/agents/context/llm_summary.py b/sdk/nexent/core/agents/context/llm_summary.py
index 432388d132..9e6805a906 100644
--- a/sdk/nexent/core/agents/context/llm_summary.py
+++ b/sdk/nexent/core/agents/context/llm_summary.py
@@ -114,7 +114,15 @@ def _do_generate_summary(
ChatMessage(role=MessageRole.USER,
content=[{"type": "text", "text": user_prompt}]),
]
- response = model(messages, stop_sequences=[])
+ usage_kwargs = (
+ {
+ "usage_purpose": "history_summary",
+ "usage_turn_id": getattr(model, "default_usage_turn_id", None),
+ }
+ if hasattr(model, "provider_call_usages")
+ else {}
+ )
+ response = model(messages, stop_sequences=[], **usage_kwargs)
raw_output = response.content
if isinstance(raw_output, list):
diff --git a/sdk/nexent/core/agents/context/long_term_memory_selector.py b/sdk/nexent/core/agents/context/long_term_memory_selector.py
index 54fdde00a8..1a8e51f6d3 100644
--- a/sdk/nexent/core/agents/context/long_term_memory_selector.py
+++ b/sdk/nexent/core/agents/context/long_term_memory_selector.py
@@ -117,8 +117,19 @@ def select_long_term_memory(
"rules": ["Return JSON only", "Select IDs only", "Keep each ID at most once"],
}, ensure_ascii=False)
try:
- response = model([ChatMessage(role=MessageRole.USER, content=[{"type": "text", "text": prompt}])],
- stop_sequences=[])
+ usage_kwargs = (
+ {
+ "usage_purpose": "history_summary",
+ "usage_turn_id": getattr(model, "default_usage_turn_id", None),
+ }
+ if hasattr(model, "provider_call_usages")
+ else {}
+ )
+ response = model(
+ [ChatMessage(role=MessageRole.USER, content=[{"type": "text", "text": prompt}])],
+ stop_sequences=[],
+ **usage_kwargs,
+ )
output = response.content
if isinstance(output, list):
output = "".join(block.get("text", "") for block in output if isinstance(block, dict))
diff --git a/sdk/nexent/core/agents/context/manager.py b/sdk/nexent/core/agents/context/manager.py
index 4714ba81d3..be774c570c 100644
--- a/sdk/nexent/core/agents/context/manager.py
+++ b/sdk/nexent/core/agents/context/manager.py
@@ -7,6 +7,7 @@
import logging
import math
import threading
+import uuid
from copy import deepcopy
from dataclasses import asdict, is_dataclass
from enum import Enum
@@ -16,8 +17,10 @@
from smolagents.models import ChatMessage, MessageRole
from ...context_runtime.contracts import ContextEvidence, FinalContext
+from ...models.final_request_budget import build_final_request_shape
from ..summary_cache import CompressionCallRecord
from .budget import extract_message_text, message_role
+from .archive import RunHistoryArchive, SearchArchivedHistoryTool
from .config import ContextManagerConfig
from .history_compression import HistoryCompressor, HistorySummaryCandidate
from .llm_summary import LLMSummary
@@ -58,6 +61,8 @@ def __init__(self, config: Optional[ContextManagerConfig] = None, max_steps: int
self._previous_stable_items: dict[str, str] = {}
self._pending_history_summary_event: dict[str, Any] | None = None
self._memory_compact_cache: dict[tuple[Any, ...], list[ContextItem]] = {}
+ self._archive: RunHistoryArchive | None = None
+ self._archive_tool: SearchArchivedHistoryTool | None = None
def _soft_input_budget_tokens(self) -> int:
return self.config.soft_input_budget_tokens or self.config.token_threshold
@@ -81,6 +86,8 @@ def prepare_run_context(
items: Optional[Sequence[Any]] = None,
) -> ManagedRunContext:
self._history_candidate = None
+ self._archive = None
+ self._archive_tool = None
self._current_item_cache.clear()
source = self._item_source(items)
if fallback_system_prompt and not any(item.type == ContextItemType.SYSTEM for item in source):
@@ -119,7 +126,10 @@ def assemble_final_context(
task: str | None = None,
final_answer_templates: Optional[Dict[str, Any]] = None,
run_context: ManagedRunContext | None = None,
+ target_input_budget_tokens: int | None = None,
+ emergency_archive: bool = False,
) -> FinalContext:
+ self._active_request_meter = getattr(model, "_final_request_meter", None)
run_context = run_context or self.prepare_run_context(memory, "")
policy = resolve_policy(self.config.policy_layers)
persisted_items = list(run_context.items)
@@ -141,8 +151,28 @@ def assemble_final_context(
task=task,
final_answer_templates=final_answer_templates,
)
- canonical_tools = self._canonical_tools(tools or ())
+ archived_item_count = 0
+ if emergency_archive:
+ self.activate_emergency_archive(hard_budget=target_input_budget_tokens or self._hard_input_budget_tokens())
+ runtime_tools = list(tools or ())
+ if emergency_archive and self._archive_tool is not None and all(
+ getattr(tool, "name", None) != self._archive_tool.name for tool in runtime_tools
+ ):
+ runtime_tools.append(self._archive_tool)
+ canonical_tools = self._canonical_tools(runtime_tools)
+ if emergency_archive:
+ items, archived_item_count = self._select_emergency_working_set(
+ items, purpose_stable, purpose_dynamic, canonical_tools,
+ target_tokens=target_input_budget_tokens or self._hard_input_budget_tokens(),
+ )
raw_tokens = self._estimate_items(items, purpose_stable, purpose_dynamic, canonical_tools)
+ soft_budget = self._soft_input_budget_tokens()
+ hard_budget = self._hard_input_budget_tokens()
+ if target_input_budget_tokens is not None:
+ if target_input_budget_tokens <= 0:
+ raise ValueError("target_input_budget_tokens must be positive")
+ soft_budget = min(soft_budget, target_input_budget_tokens)
+ hard_budget = min(hard_budget, target_input_budget_tokens)
final_items = list(items)
history_triggered = False
new_coverage = None
@@ -151,7 +181,7 @@ def assemble_final_context(
if (
policy.processing_mode == ContextProcessingMode.ADAPTIVE_COMPACT
- and raw_tokens > self._soft_input_budget_tokens()
+ and raw_tokens > soft_budget
):
summary = next((item for item in final_items if item.type == ContextItemType.HISTORY_SUMMARY), None)
turns = [item for item in final_items if item.type == ContextItemType.CONVERSATION_TURN]
@@ -187,6 +217,7 @@ def assemble_final_context(
purpose_dynamic,
canonical_tools,
model=model,
+ target_tokens=soft_budget,
)
final_items.sort(key=lambda item: item.layout_key)
@@ -195,13 +226,28 @@ def assemble_final_context(
stable = [message for message in rendered if message_role(message) in {"system", "developer"}]
dynamic = [message for message in rendered if message_role(message) not in {"system", "developer"}]
messages = [*stable, *purpose_stable, *dynamic, *purpose_dynamic]
- final_tokens = self._message_tokens(messages) + self._tools_tokens(canonical_tools)
+ final_tokens = self._request_tokens(messages, canonical_tools)
self._last_uncompressed_token_count = raw_tokens
self._last_compressed_token_count = final_tokens
- hard = self._hard_input_budget_tokens()
+ hard = hard_budget
over_hard = final_tokens > hard
compact_exhausted = over_hard
+ budget_failure_reason = None
if over_hard:
+ budget_failure_reason = self._budget_failure_reason(
+ final_items,
+ purpose_stable,
+ purpose_dynamic,
+ canonical_tools,
+ hard_budget=hard,
+ compression_attempted=(
+ bool(self._step_local_log)
+ or any(
+ str(item.metadata.get("representation", "raw")) != "raw"
+ for item in final_items
+ )
+ ),
+ )
logger.warning("Context remains over hard budget after safe compact: %s > %s", final_tokens, hard)
representations = tuple((item.id, str(item.metadata.get("representation", "raw"))) for item in final_items)
@@ -214,16 +260,25 @@ def assemble_final_context(
)
self._previous_stable_fingerprint = stable_fp
selected_ids = tuple(item.id for item in final_items)
+ from .composition import estimate_context_segments
+
+ composition_estimate = estimate_context_segments(
+ final_items,
+ purpose_messages=[*purpose_stable, *purpose_dynamic],
+ tools=canonical_tools,
+ )
message_roles = tuple(message_role(message) for message in messages)
system_messages = [message for message in messages if message_role(message) in {"system", "developer"}]
history_messages = [message for message in messages if message_role(message) not in {"system", "developer"}]
return FinalContext(
messages=messages,
tools=canonical_tools,
+ runtime_tools=tuple(runtime_tools),
evidence=ContextEvidence(
purpose=purpose,
selected_item_ids=selected_ids,
selected_item_types=tuple(item.type.value for item in final_items),
+ context_composition_estimate=tuple(composition_estimate.items()),
stable_message_count=len(stable) + len(purpose_stable),
dynamic_message_count=len(dynamic) + len(purpose_dynamic),
compression_records=tuple(self._step_local_log),
@@ -233,7 +288,7 @@ def assemble_final_context(
if run_context.selection_decision
else None,
processing_mode=policy.processing_mode.value,
- soft_budget=self._soft_input_budget_tokens(),
+ soft_budget=soft_budget,
hard_budget=hard,
raw_token_estimate=raw_tokens,
final_token_estimate=final_tokens,
@@ -255,6 +310,7 @@ def assemble_final_context(
representation_cache_misses=misses,
compact_exhausted=compact_exhausted,
over_hard_budget=over_hard,
+ budget_failure_reason=budget_failure_reason,
messages_fingerprint=self._fingerprint(messages),
tools_fingerprint=self._fingerprint(canonical_tools),
system_messages_fingerprint=self._fingerprint(system_messages),
@@ -267,18 +323,128 @@ def assemble_final_context(
history_message_roles=tuple(message_role(message) for message in history_messages),
compression_attempted=bool(self._step_local_log),
fallback_compaction_used=any(representation != "raw" for _, representation in representations),
+ archive_active=emergency_archive,
+ archived_item_count=archived_item_count,
+ retained_item_count=len(final_items),
+ recall_invocation_count=self._archive.recall_invocations if self._archive else 0,
+ recalled_tokens=self._archive.recalled_tokens if self._archive else 0,
),
)
+ @property
+ def archive_tool(self) -> SearchArchivedHistoryTool | None:
+ return self._archive_tool
+
+ def activate_emergency_archive(self, *, hard_budget: int) -> SearchArchivedHistoryTool:
+ if self._archive is None:
+ self._archive = RunHistoryArchive(
+ run_id=uuid.uuid4().hex,
+ hard_input_budget=hard_budget,
+ chars_per_token=self.config.chars_per_token,
+ )
+ self._archive_tool = SearchArchivedHistoryTool(self._archive)
+ return self._archive_tool
+
+ def _select_emergency_working_set(self, items, stable, dynamic, tools, *, target_tokens: int):
+ turns = [item for item in items if item.type == ContextItemType.CONVERSATION_TURN]
+ actions = [item for item in items if item.type == ContextItemType.CURRENT_ACTION]
+ considered = set(item.id for item in [*turns[-3:], *actions[-4:]])
+ protected = set(item.id for item in [*turns[-1:], *actions[-1:]])
+ retained = [
+ item for item in items
+ if item.type not in {ContextItemType.CONVERSATION_TURN, ContextItemType.CURRENT_ACTION}
+ or item.id in considered
+ ]
+ optional = [item for item in retained if item.id in considered and item.id not in protected]
+ for item in optional:
+ if self._estimate_items(retained, stable, dynamic, tools) <= target_tokens:
+ break
+ retained.remove(item)
+ retained_ids = {item.id for item in retained}
+ omitted = [item for item in items if item.id not in retained_ids]
+ for item in omitted:
+ self._archive_item(item)
+ if omitted:
+ manifest = ContextItem.from_input(ContextItemInput(
+ id="system:archive_manifest",
+ type=ContextItemType.SYSTEM,
+ content={"text": (
+ f"Older history was archived ({len(omitted)} items). It may contain prior user turns, "
+ "final answers, tool calls, observations, errors, and results. Call search_archived_history "
+ "only when missing detail is relevant; do not guess archived content."
+ )},
+ metadata={"layout_order": 9_999, "run_local": True},
+ ))
+ retained.append(manifest)
+ return sorted(retained, key=lambda item: item.layout_key), len(omitted)
+
+ def _archive_item(self, item: ContextItem) -> None:
+ if self._archive is None:
+ return
+ if item.type == ContextItemType.CONVERSATION_TURN:
+ self._archive.add(kind="chat_turn", source_id=item.id, content=item.content)
+ return
+ content = item.content
+ for field, kind in (
+ ("tool_calls", "tool_call"), ("observations", "observation"),
+ ("error", "error"), ("result", "result"),
+ ):
+ self._archive.add(kind=kind, source_id=item.id, content=content.get(field))
+
+ def _budget_failure_reason(
+ self,
+ items,
+ purpose_stable,
+ purpose_dynamic,
+ tools,
+ *,
+ hard_budget: int,
+ compression_attempted: bool,
+ ) -> str:
+ if any(
+ self._estimate_items([item], [], [], []) > hard_budget
+ for item in items
+ ):
+ return "single_context_item_oversize"
+ fixed_types = {
+ ContextItemType.SYSTEM,
+ ContextItemType.TOOL,
+ ContextItemType.SKILL,
+ ContextItemType.MANAGED_AGENT,
+ ContextItemType.EXTERNAL_AGENT,
+ }
+ fixed_items = [item for item in items if item.type in fixed_types]
+ if (
+ self._estimate_items(
+ fixed_items, purpose_stable, purpose_dynamic, tools
+ )
+ > hard_budget
+ ):
+ return "fixed_context_over_budget"
+ if compression_attempted:
+ return "compaction_no_reduction"
+ return "final_request_over_hard_budget"
+
def consume_history_summary_event(self) -> dict[str, Any] | None:
"""Return a newly-created summary checkpoint once for stream display."""
event = self._pending_history_summary_event
self._pending_history_summary_event = None
return deepcopy(event) if event is not None else None
- def _compact_to_soft_budget(self, items, purpose_stable, purpose_dynamic, tools, *, model):
+ def _compact_to_soft_budget(
+ self,
+ items,
+ purpose_stable,
+ purpose_dynamic,
+ tools,
+ *,
+ model,
+ target_tokens: Optional[int] = None,
+ ):
+ if target_tokens is None:
+ target_tokens = self.config.token_threshold
result = list(items)
- if self._estimate_items(result, purpose_stable, purpose_dynamic, tools) <= self._soft_input_budget_tokens():
+ if self._estimate_items(result, purpose_stable, purpose_dynamic, tools) <= target_tokens:
return result
keep_recent = max(0, self.config.keep_recent_steps)
actions = [item for item in result if item.type == ContextItemType.CURRENT_ACTION]
@@ -309,19 +475,21 @@ def _compact_to_soft_budget(self, items, purpose_stable, purpose_dynamic, tools,
result[index] = compact
if (
self._estimate_items(result, purpose_stable, purpose_dynamic, tools)
- <= self._soft_input_budget_tokens()
+ <= target_tokens
):
return result
if self.config.enable_long_term_memory_selection and long_term_items:
- return self._select_long_term_memories(result, long_term_items, model=model)
+ return self._select_long_term_memories(
+ result, long_term_items, model=model, target_tokens=target_tokens
+ )
return result
- def _select_long_term_memories(self, result, memory_items, *, model):
+ def _select_long_term_memories(self, result, memory_items, *, model, target_tokens: int):
task_item = next((item for item in result if item.type == ContextItemType.CURRENT_TASK), None)
task = json.dumps(task_item.content, ensure_ascii=False, default=str) if task_item else ""
model_id = str(getattr(model, "model_id", None) or getattr(model, "model_name", None)
or model.__class__.__name__)
- target_tokens = max(64, self._soft_input_budget_tokens() // 4)
+ target_tokens = max(64, target_tokens // 4)
versions = tuple(sorted(str(item.metadata.get("version_id") or item.id) for item in memory_items))
cache_key = (*versions, task, target_tokens, model_id)
cached = self._memory_compact_cache.get(cache_key)
@@ -474,10 +642,22 @@ def _purpose_messages(self, *, purpose, task, final_answer_templates):
)
def _estimate_items(self, items, stable, dynamic, tools):
- return self._message_tokens([*self.build_context_messages(items), *stable, *dynamic]) + self._tools_tokens(
- tools
+ return self._request_tokens(
+ [*self.build_context_messages(items), *stable, *dynamic], tools
)
+ def _request_tokens(self, messages, tools):
+ meter = getattr(self, "_active_request_meter", None)
+ if meter is not None:
+ anchored = meter.estimate_context_candidate(messages, tools)
+ if isinstance(anchored, int) and not isinstance(anchored, bool):
+ return max(0, anchored)
+ structural = build_final_request_shape(
+ {"messages": list(messages), "tools": list(tools)}
+ ).components.raw_total
+ configured = self._message_tokens(messages) + self._tools_tokens(tools)
+ return max(structural, configured)
+
def _message_tokens(self, messages):
return max(
0, int(sum(len(extract_message_text(message)) for message in messages) / self.config.chars_per_token)
diff --git a/sdk/nexent/core/agents/context/runtime.py b/sdk/nexent/core/agents/context/runtime.py
index 44bacc8168..fcea1dde70 100644
--- a/sdk/nexent/core/agents/context/runtime.py
+++ b/sdk/nexent/core/agents/context/runtime.py
@@ -68,6 +68,8 @@ def prepare_step(
memory: AgentMemory,
current_run_start_idx: int,
tools: Sequence[ModelTool] | None = None,
+ target_input_budget_tokens: int | None = None,
+ emergency_archive: bool = False,
) -> FinalContext:
final_context = self.context_manager.assemble_final_context(
model=model,
@@ -76,6 +78,8 @@ def prepare_step(
tools=tools,
purpose="step",
run_context=self._ensure_run_context(memory),
+ target_input_budget_tokens=target_input_budget_tokens,
+ emergency_archive=emergency_archive,
)
self._evidence.record_call(final_context.evidence)
return final_context
@@ -89,6 +93,8 @@ def prepare_final_answer(
task: str,
final_answer_templates: Mapping[str, Mapping[str, str]],
tools: Sequence[ModelTool] | None = None,
+ target_input_budget_tokens: int | None = None,
+ emergency_archive: bool = False,
) -> FinalContext:
final_context = self.context_manager.assemble_final_context(
model=model,
@@ -99,6 +105,8 @@ def prepare_final_answer(
task=task,
final_answer_templates=final_answer_templates,
run_context=self._ensure_run_context(memory),
+ target_input_budget_tokens=target_input_budget_tokens,
+ emergency_archive=emergency_archive,
)
self._evidence.record_call(final_context.evidence)
return final_context
diff --git a/sdk/nexent/core/agents/core_agent.py b/sdk/nexent/core/agents/core_agent.py
index 6a4686c3b1..62e59eadd4 100644
--- a/sdk/nexent/core/agents/core_agent.py
+++ b/sdk/nexent/core/agents/core_agent.py
@@ -7,6 +7,7 @@
import uuid
import threading
from copy import deepcopy
+from dataclasses import replace
from datetime import datetime
from textwrap import dedent
from typing import Any, Optional, List, Dict
@@ -262,8 +263,6 @@ def _looks_like_incomplete_action_output(
"""
if not isinstance(text, str) or not text.strip():
return False
- if finish_reason == "length":
- return True
if _looks_like_invalid_action_output(text):
return True
if _EXPLICIT_FINAL_ANSWER_RE.search(text):
@@ -613,6 +612,67 @@ def _context_tools(self) -> List[Any]:
tools.extend(list(iterable or ()))
return tools
+ def _activate_emergency_archive_tool(self, *, hard_budget: int) -> Any:
+ """Expose the run-local recall tool and authorize it in the live executor.
+
+ ``run()`` sends an initial tool snapshot to the Python executor before
+ the first model step. Emergency archive mode can activate later, while
+ rebuilding an overflowing request, so updating ``self.tools`` alone
+ would leave the executor unable to call the newly visible tool.
+ """
+ archive_tool = self.context_runtime.context_manager.activate_emergency_archive(
+ hard_budget=hard_budget
+ )
+ if not isinstance(self.tools, dict):
+ raise TypeError("Agent tool registry must be a dictionary")
+ self.tools.setdefault(archive_tool.name, archive_tool)
+ self._guardrail_wrap_tools()
+ self._wrap_visible_tool_events()
+ executor = getattr(self, "python_executor", None)
+ if executor is not None:
+ executor.send_tools({**self.tools, **self.managed_agents})
+ return archive_tool
+
+ def _emit_archive_recall_budget_update(self) -> None:
+ """Persist post-execution recall counters for the active recovery step."""
+ manager = getattr(self.context_runtime, "context_manager", None)
+ archive_tool = getattr(manager, "archive_tool", None)
+ archive = getattr(archive_tool, "archive", None)
+ evidence = getattr(self.model, "last_context_evidence", None)
+ preflight = getattr(self.model, "last_final_request_preflight", None)
+ if archive is None or evidence is None or preflight is None:
+ return
+ invocations = int(getattr(archive, "recall_invocations", 0) or 0)
+ if invocations <= int(getattr(evidence, "recall_invocation_count", 0) or 0):
+ return
+ updated = replace(
+ evidence,
+ recall_invocation_count=invocations,
+ recalled_tokens=int(getattr(archive, "recalled_tokens", 0) or 0),
+ )
+ self.model.last_context_evidence = updated
+ from .context_budget_event import build_context_budget_event
+
+ event = build_context_budget_event(
+ preflight,
+ updated,
+ step_number=self.step_number,
+ recovery_state="recovered",
+ recovery={
+ "trigger": "provider_overflow",
+ "phase": "archive",
+ "attempt": int(getattr(preflight, "retry_ordinal", 2) or 2),
+ "maximum_attempts": 2,
+ "compression_target": int(getattr(preflight, "hard_budget", 0) or 0),
+ "provisional_capacity": bool(getattr(self.model, "_using_provisional_capacity", False)),
+ },
+ )
+ self.observer.add_message(
+ self.agent_name,
+ ProcessType.CONTEXT_BUDGET,
+ json.dumps(event, ensure_ascii=False),
+ )
+
def _guardrail_wrap_tools(self) -> None:
"""Wrap each tool's forward() to screen resolved args before execution (checkpoint ③).
@@ -783,10 +843,18 @@ def _ensure_context_within_hard_budget(final_context: Any) -> None:
"""Stop before the provider call when safe compaction cannot fit input."""
evidence = final_context.evidence
if evidence.over_hard_budget is True:
- raise ValueError(
- "Context input remains over the model hard budget after compaction: "
+ reason = getattr(
+ evidence, "budget_failure_reason", None
+ ) or "final_request_over_hard_budget"
+ error = ValueError(
+ f"{reason}: Context input remains over the model hard budget after compaction: "
f"{evidence.final_token_estimate} > {evidence.hard_budget} tokens"
)
+ error.context_rebuild_over_budget = True
+ error.failure_reason = reason
+ error.actual = evidence.final_token_estimate
+ error.hard_budget = evidence.hard_budget
+ raise error
def _emit_history_summary_event(self) -> None:
payload = self.context_runtime.consume_history_summary_event()
@@ -815,6 +883,8 @@ def _step_stream(self, memory_step: ActionStep) -> Generator[Any]:
self._emit_history_summary_event()
self._ensure_context_within_hard_budget(final_context)
input_messages = final_context.messages
+ self.model.last_context_evidence = final_context.evidence
+ self.model.context_budget_step_number = self.step_number
chars_per_token = self.context_runtime.chars_per_token
# Baseline for the per-step compression ratio. ``final_context.messages``
# is already the compressed payload, so use the ContextManager's raw
@@ -858,8 +928,36 @@ def _step_stream(self, memory_step: ActionStep) -> Generator[Any]:
self._append_verification_feedback(memory_step, decision.verification_result)
try:
- chat_message: ChatMessage = self.model(input_messages,
- stop_sequences=stop_sequences, **additional_args)
+ model_call_args = dict(additional_args)
+ rebuild_allowed = guardrail_engine is None or decision.effective_action == "pass"
+ if (
+ rebuild_allowed
+ ):
+ def rebuild_context(target_tokens: int, *, emergency_archive: bool = False):
+ if emergency_archive:
+ self._activate_emergency_archive_tool(hard_budget=target_tokens)
+ rebuilt = self.context_runtime.prepare_step(
+ model=self.model,
+ memory=self.memory,
+ current_run_start_idx=self._history_step_count,
+ tools=self._context_tools(),
+ target_input_budget_tokens=target_tokens,
+ emergency_archive=emergency_archive,
+ )
+ get_monitoring_manager().record_final_context_evidence(
+ rebuilt.evidence, step_number=self.step_number
+ )
+ self._ensure_context_within_hard_budget(rebuilt)
+ return rebuilt
+
+ model_call_args["context_rebuild"] = rebuild_context
+ chat_message: ChatMessage = self.model(
+ input_messages,
+ stop_sequences=stop_sequences,
+ usage_purpose="main_agent",
+ usage_turn_id=getattr(self, "_usage_turn_id", None),
+ **model_call_args,
+ )
memory_step.model_output_message = chat_message
model_output = chat_message.content
memory_step.token_usage = chat_message.token_usage
@@ -1014,6 +1112,7 @@ def _step_stream(self, memory_step: ActionStep) -> Generator[Any]:
"Last output from code snippet:\n" + truncated_output,
)
memory_step.observations = observation
+ self._emit_archive_recall_budget_update()
verification_controller = getattr(self, "verification_controller", None)
if verification_controller:
@@ -1555,6 +1654,8 @@ def _handle_max_steps_reached(self, task: str) -> Any:
self._emit_history_summary_event()
self._ensure_context_within_hard_budget(final_context)
messages = final_context.messages
+ self.model.last_context_evidence = final_context.evidence
+ self.model.context_budget_step_number = self.step_number
# Create the final memory step with error
final_memory_step = ActionStep(
@@ -1573,7 +1674,34 @@ def _handle_max_steps_reached(self, task: str) -> Any:
# Use streaming call (model.__call__) to generate final answer
# This will trigger observer.add_model_new_token() and
# observer.add_model_reasoning_content() in OpenAIModel
- chat_message: ChatMessage = self.model(messages)
+ model_call_args = {}
+ if self.context_runtime.context_manager is not None:
+ def rebuild_final_context(target_tokens: int, *, emergency_archive: bool = False):
+ if emergency_archive:
+ self._activate_emergency_archive_tool(hard_budget=target_tokens)
+ rebuilt = self.context_runtime.prepare_final_answer(
+ model=self.model,
+ memory=self.memory,
+ current_run_start_idx=self._history_step_count,
+ tools=self._context_tools(),
+ task=task,
+ final_answer_templates=self.prompt_templates,
+ target_input_budget_tokens=target_tokens,
+ emergency_archive=emergency_archive,
+ )
+ get_monitoring_manager().record_final_context_evidence(
+ rebuilt.evidence, step_number=self.step_number
+ )
+ self._ensure_context_within_hard_budget(rebuilt)
+ return rebuilt
+
+ model_call_args["context_rebuild"] = rebuild_final_context
+ chat_message: ChatMessage = self.model(
+ messages,
+ usage_purpose="final_answer",
+ usage_turn_id=getattr(self, "_usage_turn_id", None),
+ **model_call_args,
+ )
# Update role and content from the completed message
role = chat_message.role
diff --git a/sdk/nexent/core/agents/nexent_agent.py b/sdk/nexent/core/agents/nexent_agent.py
index 1415c67699..25e1e51294 100644
--- a/sdk/nexent/core/agents/nexent_agent.py
+++ b/sdk/nexent/core/agents/nexent_agent.py
@@ -15,6 +15,7 @@
from pathlib import Path
from threading import Event
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence
+from uuid import uuid4
from smolagents import ActionStep, AgentText, TaskStep, Timing
from smolagents.tools import Tool
@@ -297,6 +298,20 @@ def create_model(self, model_cite_name: str):
max_output_tokens=model_config.max_output_tokens,
timeout_seconds=model_config.timeout_seconds,
prompt_cache=model_config.prompt_cache,
+ **{
+ key: value
+ for key, value in {
+ "provider_usage_profile": model_config.provider_usage_profile,
+ "feature_capabilities": model_config.feature_capabilities,
+ "feature_preferences": model_config.feature_preferences,
+ "canonical_model_id": model_config.canonical_model_id,
+ "model_identity_metadata": model_config.model_identity_metadata,
+ "tokenizer_match_metadata": model_config.tokenizer_match_metadata,
+ "token_count_probe_metadata": model_config.token_count_probe_metadata,
+ "tokenizer_family": model_config.tokenizer_family,
+ }.items()
+ if value is not None
+ },
)
model.stop_event = self.stop_event
return model
@@ -1018,6 +1033,13 @@ def agent_run_with_observer(
observer = self.agent.observer
total_output_tokens = 0
final_answer_for_trace = None
+ turn_id = str(uuid4())
+ self.agent._usage_turn_id = turn_id
+ active_model = getattr(self.agent, "model", None)
+ if active_model is not None:
+ active_model.default_usage_turn_id = turn_id
+ turn_call_records = []
+ emitted_call_ids: set[str] = set()
with monitoring_manager.start_agent_run(metadata):
with monitoring_manager.trace_agent_step(
"agent.run.loop",
@@ -1105,6 +1127,14 @@ def agent_run_with_observer(
"uncompressed_est_tokens": last_metric.get("uncompressed_mem_est_input", 0),
})
active_model = getattr(self.agent, "model", None)
+ step_call_records = list(
+ getattr(active_model, "turn_provider_call_usages", ()) or ()
+ )
+ for call_record in step_call_records:
+ if call_record.call_id in emitted_call_ids:
+ continue
+ emitted_call_ids.add(call_record.call_id)
+ turn_call_records.append(call_record)
cache_usage = getattr(active_model, "last_prompt_cache_usage", None)
cache_advice = getattr(active_model, "last_provider_cache_advice", None)
if cache_usage is not None:
@@ -1151,6 +1181,25 @@ def agent_run_with_observer(
getattr(observer, "lang", "en"),
)
final_answer_for_trace = final_answer_str
+ context_limit = getattr(
+ getattr(self.agent, "context_runtime", None),
+ "context_window_tokens",
+ None,
+ )
+ from ..models.usage_aggregation import aggregate_turn_usage
+
+ turn_summary = aggregate_turn_usage(
+ turn_call_records,
+ context_limit_tokens=context_limit,
+ )
+ turn_summary["turn_id"] = turn_id
+ turn_usage_type = getattr(ProcessType, "TURN_USAGE", None)
+ if turn_usage_type is not None:
+ observer.add_message(
+ "",
+ turn_usage_type,
+ json.dumps(turn_summary, ensure_ascii=False),
+ )
monitoring_manager.set_openinference_output(final_answer_str)
observer.add_message(self.agent.agent_name,
ProcessType.FINAL_ANSWER, final_answer_str)
diff --git a/sdk/nexent/core/context_runtime/contracts.py b/sdk/nexent/core/context_runtime/contracts.py
index ba3b86d322..50f785e727 100644
--- a/sdk/nexent/core/context_runtime/contracts.py
+++ b/sdk/nexent/core/context_runtime/contracts.py
@@ -56,6 +56,7 @@ class ContextEvidence:
representation_cache_misses: int = 0
compact_exhausted: bool = False
over_hard_budget: bool = False
+ budget_failure_reason: str | None = None
model_call_count: int = 0
loop_status: str | None = None
messages_fingerprint: str | None = None
@@ -67,6 +68,12 @@ class ContextEvidence:
history_message_roles: tuple[str, ...] = ()
compression_attempted: bool = False
fallback_compaction_used: bool = False
+ archive_active: bool = False
+ archived_item_count: int = 0
+ retained_item_count: int = 0
+ recall_invocation_count: int = 0
+ recalled_tokens: int = 0
+ context_composition_estimate: tuple[tuple[str, int], ...] = ()
@dataclass(frozen=True)
@@ -76,6 +83,7 @@ class FinalContext:
messages: list[ModelMessage]
tools: list[dict[str, object]] = field(default_factory=list)
evidence: ContextEvidence = field(default_factory=ContextEvidence)
+ runtime_tools: tuple[ModelTool, ...] = ()
class ContextRuntime(Protocol):
@@ -96,6 +104,8 @@ def prepare_step(
memory: AgentMemory,
current_run_start_idx: int,
tools: Sequence[ModelTool] | None = None,
+ target_input_budget_tokens: int | None = None,
+ emergency_archive: bool = False,
) -> FinalContext:
"""Return all model messages for the current step."""
@@ -108,6 +118,8 @@ def prepare_final_answer(
task: str,
final_answer_templates: Mapping[str, Mapping[str, str]],
tools: Sequence[ModelTool] | None = None,
+ target_input_budget_tokens: int | None = None,
+ emergency_archive: bool = False,
) -> FinalContext:
"""Return all model messages for final-answer generation."""
@@ -168,6 +180,7 @@ def prepare_step(
memory: AgentMemory,
current_run_start_idx: int,
tools: Sequence[ModelTool] | None = None,
+ target_input_budget_tokens: int | None = None,
) -> FinalContext:
raise RuntimeError(_UNCONFIGURED_RUNTIME_ERROR)
@@ -180,6 +193,7 @@ def prepare_final_answer(
task: str,
final_answer_templates: Mapping[str, Mapping[str, str]],
tools: Sequence[ModelTool] | None = None,
+ target_input_budget_tokens: int | None = None,
) -> FinalContext:
raise RuntimeError(_UNCONFIGURED_RUNTIME_ERROR)
diff --git a/sdk/nexent/core/models/usage_aggregation.py b/sdk/nexent/core/models/usage_aggregation.py
new file mode 100644
index 0000000000..feb325b8c2
--- /dev/null
+++ b/sdk/nexent/core/models/usage_aggregation.py
@@ -0,0 +1,95 @@
+"""Deterministic aggregation of immutable physical provider-call usage records."""
+
+from __future__ import annotations
+
+from typing import Any, Iterable, Optional
+
+from .provider_usage import ProviderCallUsage
+
+
+_SUM_FIELDS = (
+ "input_tokens",
+ "output_tokens",
+ "total_tokens",
+ "fresh_input_tokens",
+ "cache_read_tokens",
+ "cache_write_tokens",
+ "reasoning_tokens",
+ "visible_output_tokens",
+)
+_CONTEXT_PURPOSES = frozenset({"main_agent", "final_answer"})
+
+
+def aggregate_turn_usage(
+ records: Iterable[ProviderCallUsage],
+ *,
+ context_limit_tokens: Optional[int] = None,
+) -> dict[str, Any]:
+ """Aggregate once by call ID; incomplete field coverage stays null."""
+ unique: dict[str, ProviderCallUsage] = {}
+ for record in records:
+ existing = unique.get(record.call_id)
+ if existing is None or _status_rank(record.status) >= _status_rank(existing.status):
+ unique[record.call_id] = record
+ calls = list(unique.values())
+
+ sums: dict[str, Optional[int]] = {}
+ known_counts: dict[str, int] = {}
+ for field_name in _SUM_FIELDS:
+ values = [getattr(record.usage, field_name) for record in calls]
+ known = [value for value in values if value is not None]
+ known_counts[field_name] = len(known)
+ sums[field_name] = sum(known) if len(known) == len(values) and values else None
+
+ context_calls = [
+ record
+ for record in calls
+ if record.purpose in _CONTEXT_PURPOSES
+ and record.source == "provider"
+ and record.status in {"completed", "partial"}
+ and record.usage.input_tokens is not None
+ ]
+ latest = context_calls[-1] if context_calls else None
+ peak = max(context_calls, key=lambda record: record.usage.input_tokens or 0) if context_calls else None
+ sources = {record.source for record in calls}
+ if not calls:
+ data_quality = "missing"
+ elif sources == {"provider"}:
+ data_quality = "provider"
+ elif "provider" in sources:
+ data_quality = "mixed"
+ elif sources == {"estimated"}:
+ data_quality = "estimated"
+ else:
+ data_quality = "degraded"
+
+ return {
+ "schema_version": 3,
+ "call_count": len(calls),
+ "known_usage_call_count": sum(
+ record.usage.input_tokens is not None or record.usage.output_tokens is not None
+ for record in calls
+ ),
+ "known_field_call_counts": known_counts,
+ "usage": sums,
+ "latest_context": _context_snapshot(latest, context_limit_tokens),
+ "peak_context": _context_snapshot(peak, context_limit_tokens),
+ "data_quality": data_quality,
+ "call_ids": [record.call_id for record in calls],
+ }
+
+
+def _context_snapshot(
+ record: Optional[ProviderCallUsage], context_limit_tokens: Optional[int]
+) -> Optional[dict[str, Any]]:
+ if record is None:
+ return None
+ return {
+ "call_id": record.call_id,
+ "input_tokens": record.usage.input_tokens,
+ "limit_tokens": context_limit_tokens,
+ }
+
+
+def _status_rank(status: str) -> int:
+ return {"failed": 0, "cancelled": 1, "partial": 2, "completed": 3}.get(status, -1)
diff --git a/sdk/nexent/core/utils/observer.py b/sdk/nexent/core/utils/observer.py
index 3cb55b8b2e..f126c2db16 100644
--- a/sdk/nexent/core/utils/observer.py
+++ b/sdk/nexent/core/utils/observer.py
@@ -41,6 +41,9 @@ class ProcessType(Enum):
OTHER = "other" # temporary other fields
TOKEN_COUNT = "token_count" # record the number of tokens used in each step
HISTORY_SUMMARY = "history_summary" # newly-created context compression checkpoint
+ CONTEXT_BUDGET = "context_budget" # content-free P3 final request budget snapshot
+ LLM_USAGE = "llm_usage" # P7 normalized usage for one physical provider call
+ TURN_USAGE = "turn_usage" # P7 deterministic user-turn usage summary
SEARCH_CONTENT = "search_content" # search content in tool
PICTURE_WEB = "picture_web" # record the image after联网搜索
@@ -230,6 +233,9 @@ def _init_message_transformers(self):
ProcessType.SEARCH_CONTENT: default_transformer,
ProcessType.TOKEN_COUNT: TokenCountTransformer(),
ProcessType.HISTORY_SUMMARY: default_transformer,
+ ProcessType.CONTEXT_BUDGET: default_transformer,
+ ProcessType.LLM_USAGE: default_transformer,
+ ProcessType.TURN_USAGE: default_transformer,
ProcessType.PICTURE_WEB: default_transformer,
ProcessType.AGENT_FINISH: default_transformer,
ProcessType.CARD: default_transformer,
diff --git a/test/backend/agents/test_create_agent_info.py b/test/backend/agents/test_create_agent_info.py
index 171da8341b..58573af687 100644
--- a/test/backend/agents/test_create_agent_info.py
+++ b/test/backend/agents/test_create_agent_info.py
@@ -45,6 +45,13 @@ class ToolExecutionException(Exception):
pass
+class MockModelCapacityConfigError(ValidationError):
+ def __init__(self, reason_code, message, *, field=None):
+ self.reason_code = reason_code
+ self.field = field
+ super().__init__(f"{reason_code}: {message}")
+
+
consts_model_module = types.ModuleType("consts.model")
consts_model_module.HistoryItem = HistoryItem
@@ -67,10 +74,17 @@ class MockToolParamsRequest(BaseModel):
"consts.capability_profiles"
)
sys.modules["consts.capability_profiles"].CATALOG = {}
+sys.modules["consts.model_feature_capabilities"] = types.ModuleType(
+ "consts.model_feature_capabilities"
+)
+sys.modules["consts.model_feature_capabilities"].CATALOG_REVISION = "test"
+sys.modules["consts.model_feature_capabilities"].EXACT_CATALOG = {}
+sys.modules["consts.model_feature_capabilities"].FAMILY_RULES = ()
# Mock consts.exceptions module with ValidationError
consts_exceptions_module = types.ModuleType("consts.exceptions")
consts_exceptions_module.ValidationError = ValidationError
+consts_exceptions_module.ModelCapacityConfigError = MockModelCapacityConfigError
consts_exceptions_module.MCPConnectionError = MCPConnectionError
consts_exceptions_module.NotFoundException = NotFoundException
consts_exceptions_module.ToolExecutionException = ToolExecutionException
@@ -170,6 +184,16 @@ def model_validate(cls, value):
return value or {}
sys.modules['nexent.core.utils.observer'] = MagicMock(MessageObserver=mock_message_observer)
+sys.modules['nexent.core.models.feature_capability'] = _create_stub_module(
+ "nexent.core.models.feature_capability",
+ normalize_feature_profile=lambda value: value if isinstance(value, dict) else None,
+ resolve_feature_capabilities=lambda *args, **kwargs: {
+ "schema_version": 1,
+ "reasoning": {"supported": None, "mode": "unknown", "request_style": "unknown", "efforts": []},
+ "prompt_cache": {"supported": None, "mode": "unknown", "metrics_available": None},
+ "source": "unknown",
+ },
+)
sys.modules['nexent.core.agents.agent_model'] = _create_stub_module(
"nexent.core.agents.agent_model",
AgentHistory=AgentHistory,
@@ -205,10 +229,14 @@ def model_validate(cls, value):
)
sys.modules['nexent.core.models.prompt_cache'] = _create_stub_module(
"nexent.core.models.prompt_cache",
- resolve_prompt_cache_profile=lambda provider: (
+ resolve_prompt_cache_profile=lambda provider, explicit_profile=None: (
{"mode": "openai_automatic", "enabled": True}
if (provider or "").lower() == "openai" else None
),
+ resolve_provider_usage_profile=lambda provider, version=None: {
+ "capability_profile_version": version,
+ "reasoning_usage_semantics": "unavailable",
+ },
)
sys.modules['smolagents.agents'] = MagicMock()
sys.modules['smolagents.utils'] = MagicMock()
@@ -384,7 +412,11 @@ def calculate_safe_input_budget(
)
-class MockUncertaintyReserveBasisUnknown(Exception):
+class MockBudgetResolverError(Exception):
+ """Mock W2 base exception."""
+
+
+class MockUncertaintyReserveBasisUnknown(MockBudgetResolverError):
"""Mock W2 exception raised when context_window_tokens is missing."""
@@ -397,6 +429,7 @@ class MockUncertaintyReserveBasisUnknown(Exception):
)
sys.modules['nexent.core.models.capacity_budget'] = _create_stub_module(
"nexent.core.models.capacity_budget",
+ BudgetResolverError=MockBudgetResolverError,
RequestBudgetOverrides=MockRequestBudgetOverrides,
SafeInputBudgetCalculator=MockSafeInputBudgetCalculator,
UncertaintyReserveBasisUnknown=MockUncertaintyReserveBasisUnknown,
@@ -516,6 +549,7 @@ class MockUncertaintyReserveBasisUnknown(Exception):
_build_security_headers,
_resolve_scheme_field,
_build_auth_header_for_scheme,
+ _effective_feature_factory,
_get_external_a2a_agents,
_build_internal_s3_url,
_format_minio_files_for_content,
@@ -587,6 +621,34 @@ def test_resolve_input_budget_returns_monitoring_dict_then_resolver_snapshot(sel
assert isinstance(resolved_capacity_snapshot, MockModelCapacitySnapshot)
assert safe_budget_snapshot["model_name"] == resolved_capacity_snapshot.model_name
+ def test_persisted_profile_identity_overrides_compatibility_factory(self):
+ profile = types.SimpleNamespace(
+ capability_profile_version="dashscope/qwen3.7-plus@1"
+ )
+ snapshot = MockModelCapacitySnapshot(
+ model_name="qwen3.7-plus",
+ capability_profile_version="dashscope/qwen3.7-plus@1",
+ )
+ with patch.object(
+ create_agent_info_module,
+ "CAPABILITY_CATALOG",
+ {("dashscope", "qwen3.7-plus"): profile},
+ ), patch.object(
+ create_agent_info_module,
+ "resolve_capacity",
+ return_value=snapshot,
+ ) as resolver:
+ _resolve_input_budget(
+ {
+ "model_factory": "OpenAI-API-Compatible",
+ "model_name": "qwen3.7-plus",
+ "capability_profile_version": "dashscope/qwen3.7-plus@1",
+ }
+ )
+
+ assert resolver.call_args.kwargs["provider"] == "dashscope"
+ assert resolver.call_args.kwargs["model_id"] == "qwen3.7-plus"
+
class TestGetSkillsForTemplate:
"""Tests for the _get_skills_for_template function"""
@@ -2259,7 +2321,7 @@ async def test_create_agent_config_disabled_compression_still_builds_components(
assert "system_prompt" not in mocks["prepare_templates"].call_args.kwargs
assert mocks["agent_config"].call_args.kwargs["context_items"] is components
config = mocks["agent_config"].call_args.kwargs["context_manager_config"]
- assert config.policy_layers["platform"]["processing_mode"] == "passthrough"
+ assert config.policy_layers["platform"]["processing_mode"] == "adaptive_compact"
@pytest.mark.asyncio
async def test_create_agent_config_basic(self):
@@ -3730,6 +3792,25 @@ async def test_create_agent_config_includes_parallel_executor(self):
assert last_tool.source == "local"
+def test_p8_effective_feature_factory_requires_generic_factory_and_exact_known_host():
+ assert _effective_feature_factory({
+ "model_factory": "OpenAI-API-Compatible",
+ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
+ }) == "dashscope"
+ assert _effective_feature_factory({
+ "model_factory": "OpenAI-API-Compatible",
+ "base_url": "https://api.openai.com/v1",
+ }) == "openai"
+ assert _effective_feature_factory({
+ "model_factory": "OpenAI-API-Compatible",
+ "base_url": "https://dashscope.aliyuncs.com.evil.example/v1",
+ }) == "openai-api-compatible"
+ assert _effective_feature_factory({
+ "model_factory": "modelengine",
+ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
+ }) == "modelengine"
+
+
class TestCreateModelConfigList:
"""Tests for the create_model_config_list function"""
@@ -3766,7 +3847,14 @@ async def test_create_model_config_list(self):
mock_manager.get_model_config.return_value = {
"api_key": "main_key",
"model_name": "main_model",
- "base_url": "http://main.url"
+ "base_url": "http://main.url",
+ "context_window_tokens": 1_000_000,
+ "max_input_tokens": 991_808,
+ "default_output_reserve_tokens": 8_192,
+ "canonical_model_id": "qwen:qwen3.7-plus",
+ "tokenizer_family": "qwen",
+ "tokenizer_match_metadata": {"auto_applicable": True},
+ "token_count_probe_metadata": {"status": "supported"},
}
# Mock utility functions
@@ -3809,12 +3897,18 @@ async def test_create_model_config_list(self):
assert calls[2][1]['api_key'] == "main_key"
assert calls[2][1]['model_name'] == "main_model_name"
assert calls[2][1]['url'] == "http://main.url"
+ assert calls[2][1]['context_window_tokens'] == 1_000_000
+ assert calls[2][1]['canonical_model_id'] == "qwen:qwen3.7-plus"
+ assert calls[2][1]['token_count_probe_metadata'] == {"status": "supported"}
# Fourth call: sub_model
assert calls[3][1]['cite_name'] == "sub_model"
assert calls[3][1]['api_key'] == "main_key"
assert calls[3][1]['model_name'] == "main_model_name"
assert calls[3][1]['url'] == "http://main.url"
+ assert calls[3][1]['context_window_tokens'] == 1_000_000
+ assert calls[3][1]['canonical_model_id'] == "qwen:qwen3.7-plus"
+ assert calls[3][1]['token_count_probe_metadata'] == {"status": "supported"}
@pytest.mark.asyncio
async def test_create_model_config_list_empty_database(self):
@@ -5145,6 +5239,43 @@ def test_resolve_safe_input_budget_returns_none_for_uncertain_basis(self):
assert result is None
+ @pytest.mark.parametrize(
+ ("exception_name", "reason"),
+ [
+ ("InvalidReservePolicy", "invalid_reserve_policy"),
+ ("RequestedOutputExceedsCapacity", "requested_output_exceeds_model"),
+ ("ReserveExceedsCapacity", "reserve_exceeds_capacity"),
+ ("NoSafeInputCapacity", "no_safe_input_capacity"),
+ ("SafeInputBudgetFingerprintMismatch", "budget_fingerprint_mismatch"),
+ ("CallerMaxTokensOverrideForbidden", "caller_output_override_forbidden"),
+ ("SafeInputBudgetCapacityMismatch", "capacity_snapshot_mismatch"),
+ ("FutureBudgetError", "budget_resolution_failed"),
+ ],
+ )
+ def test_ac_007_resolve_safe_input_budget_maps_budget_error(
+ self, exception_name, reason
+ ):
+ capacity = MockModelCapacitySnapshot(model_name="invalid-model")
+ calculator = MagicMock()
+ exception_type = type(exception_name, (MockBudgetResolverError,), {})
+ calculator.calculate_safe_input_budget.side_effect = exception_type("internal details")
+ with patch(
+ "backend.agents.create_agent_info.SafeInputBudgetCalculator",
+ return_value=calculator,
+ ):
+ with pytest.raises(
+ create_agent_info_module.ModelCapacityConfigError,
+ match=f"capacity_config_invalid.{reason}",
+ ) as exc_info:
+ _resolve_safe_input_budget(
+ capacity_snapshot=capacity,
+ tenant_id="tenant-1",
+ agent_requested_output_tokens=None,
+ request_requested_output_tokens=None,
+ )
+
+ assert "internal details" not in str(exc_info.value)
+
def test_inject_plan_tools_adds_tools_once(self):
tools = []
mock_tool_config.reset_mock()
diff --git a/test/backend/app/test_monitoring_app.py b/test/backend/app/test_monitoring_app.py
index ae875f5663..30683b0b8d 100644
--- a/test/backend/app/test_monitoring_app.py
+++ b/test/backend/app/test_monitoring_app.py
@@ -88,6 +88,55 @@ def test_return_format(self, mock_session_fn):
assert isinstance(record["total_tokens"], int)
+class TestContextBudgetMetrics:
+ @patch("apps.monitoring_app.get_monitoring_db_session")
+ def test_rates_and_null_denominators(self, mock_session_fn):
+ from apps.monitoring_app import _query_context_budget_metrics_from_db
+
+ row = MagicMock()
+ row.provider_protocol = "dashscope"
+ row.model_name = "qwen3.7-plus"
+ row.capability_profile_version = "dashscope/qwen3.7-plus@1"
+ row.request_count = 4
+ row.overflow_count = 1
+ row.compacted_count = 2
+ row.avg_compression_ratio = 0.25
+ row.estimate_sample_count = 4
+ row.mean_absolute_estimate_error = 0.08
+ row.recovery_attempt_count = 1
+ row.recovery_success_count = 1
+ session = MagicMock()
+ mock_session_fn.return_value.__enter__ = MagicMock(return_value=session)
+ mock_session_fn.return_value.__exit__ = MagicMock(return_value=None)
+ session.execute.return_value.fetchall.return_value = [row]
+
+ result = _query_context_budget_metrics_from_db("24h", tenant_id="tenant-a")[0]
+
+ assert result["overflow_rate"] == 0.25
+ assert result["compaction_incidence"] == 0.5
+ assert result["recovery_success_rate"] == 1.0
+ sql, params = session.execute.call_args.args
+ assert "tenant_id = :tenant_id" in str(sql)
+ assert "compression_attempted')::boolean" in str(sql)
+ assert params == {"tenant_id": "tenant-a"}
+
+ @patch("apps.monitoring_app.get_monitoring_db_session")
+ def test_non_applicable_recovery_rate_is_null(self, mock_session_fn):
+ from apps.monitoring_app import _query_context_budget_metrics_from_db
+
+ row = MagicMock(provider_protocol="test", model_name="m", capability_profile_version="unknown")
+ row.request_count = 1
+ row.overflow_count = row.compacted_count = row.estimate_sample_count = 0
+ row.avg_compression_ratio = row.mean_absolute_estimate_error = None
+ row.recovery_attempt_count = row.recovery_success_count = 0
+ session = MagicMock()
+ mock_session_fn.return_value.__enter__ = MagicMock(return_value=session)
+ mock_session_fn.return_value.__exit__ = MagicMock(return_value=None)
+ session.execute.return_value.fetchall.return_value = [row]
+ result = _query_context_budget_metrics_from_db("7d", tenant_id="t")[0]
+ assert result["recovery_success_rate"] is None
+
+
class TestListModelsEndpoint:
"""Verify list_models_endpoint does not accept model_type parameter."""
diff --git a/test/backend/database/test_conversation_db.py b/test/backend/database/test_conversation_db.py
index c2c50f609d..463ac4c0cc 100644
--- a/test/backend/database/test_conversation_db.py
+++ b/test/backend/database/test_conversation_db.py
@@ -3203,6 +3203,12 @@ def test_parse_history_summary_requires_summary_and_positive_boundary():
assert _parse_history_summary_content('{"covered_through_message_id":24}') is None
assert _parse_history_summary_content(
'{"summary":{},"covered_through_message_id":0}') is None
+ assert _parse_history_summary_content(
+ '{"summary":"## Task overview\\nDone",'
+ '"covered_through_message_id":"25"}'
+ )["summary"] == "## Task overview\nDone"
+ assert _parse_history_summary_content(
+ '{"summary":" ","covered_through_message_id":25}') is None
assert _parse_history_summary_content("not-json") is None
@@ -3251,6 +3257,37 @@ def test_save_history_summary_appends_after_last_unit(monkeypatch, mock_session_
assert payload["trigger"] == "soft_budget_exceeded"
+def test_save_history_summary_accepts_structured_markdown(
+ monkeypatch, mock_session_ctx, fresh_insert_mock):
+ from types import SimpleNamespace
+ session, ctx = mock_session_ctx
+ monkeypatch.setattr(
+ "backend.database.conversation_db._get_user_tenant",
+ lambda _user_id: {"tenant_id": "tenant-a"})
+ message_index_column = MagicMock()
+ message_index_column.__gt__.return_value = MagicMock()
+ message_index_column.__le__.return_value = MagicMock()
+ monkeypatch.setattr(ConversationMessage, "message_index", message_index_column)
+ owner_result = MagicMock()
+ owner_result.first.return_value = SimpleNamespace(conversation_id=1)
+ covered_result = MagicMock()
+ covered_result.first.return_value = SimpleNamespace(
+ message_id=24, message_index=3, message_role="assistant",
+ status="completed")
+ insert_result = MagicMock()
+ insert_result.scalar_one.return_value = 1001
+ session.execute.side_effect = [owner_result, covered_result, insert_result]
+ session.scalar.side_effect = [0, 4]
+ monkeypatch.setattr("backend.database.conversation_db.get_db_session", lambda: ctx)
+
+ save_history_summary(
+ 1, "user-a", "tenant-a", "## Task overview\nDone", 24,
+ trigger="soft_budget_exceeded")
+
+ payload = __import__("json").loads(fresh_insert_mock["unit_content"])
+ assert payload["summary"] == "## Task overview\nDone"
+
+
def test_save_history_summary_rejects_incomplete_covered_range(
monkeypatch, mock_session_ctx):
from types import SimpleNamespace
diff --git a/test/backend/services/test_agent_service.py b/test/backend/services/test_agent_service.py
index afa70dd8e6..32917aa949 100644
--- a/test/backend/services/test_agent_service.py
+++ b/test/backend/services/test_agent_service.py
@@ -4230,10 +4230,8 @@ async def test_prepare_agent_run(
is_debug=False,
override_version_no=None,
override_model_id=None,
- requested_output_tokens=4096,
tool_params=None,
conversation_id=123,
- context_policy=None,
enable_planning=False,
)
mock_agent_run_manager.register_agent_run.assert_called_once_with(
@@ -15043,6 +15041,73 @@ async def fake_agent_run(*_, **__):
assert any('"type": "files"' in chunk for chunk in chunks)
+@pytest.mark.asyncio
+async def test_ac_tu_004_stream_persists_llm_and_turn_usage_units_atomically(monkeypatch):
+ """P7 usage units join the existing assistant terminal batch in stream order."""
+ agent_request = AgentRequest(
+ agent_id=1,
+ conversation_id=999,
+ query="usage",
+ history=[],
+ minio_files=[],
+ is_debug=False,
+ )
+ agent_run_info = MagicMock()
+ agent_run_info.stop_event = asyncio.Event()
+ llm_usage = {
+ "schema_version": 2,
+ "call_id": "call-1",
+ "usage": {"input_tokens": 10, "output_tokens": 2},
+ }
+ turn_usage = {
+ "schema_version": 2,
+ "turn_id": "turn-1",
+ "call_ids": ["call-1"],
+ }
+
+ async def fake_agent_run(*_, **__):
+ yield json.dumps({"type": "llm_usage", "content": json.dumps(llm_usage)})
+ yield json.dumps({"type": "turn_usage", "content": json.dumps(turn_usage)})
+ yield json.dumps({"type": "final_answer", "content": "done"})
+
+ persisted_batches = []
+ channel = MagicMock()
+ channel.publish = AsyncMock()
+ monkeypatch.setattr(agent_run_service, "agent_run", fake_agent_run)
+ monkeypatch.setattr(agent_run_service, "save_message", MagicMock(return_value=4242))
+ monkeypatch.setattr(
+ agent_run_service,
+ "persist_assistant_run_batch",
+ lambda **kwargs: persisted_batches.append(kwargs),
+ )
+ monkeypatch.setattr(agent_run_service.agent_run_manager, "unregister_agent_run", MagicMock())
+ monkeypatch.setattr(agent_run_service.streaming_channel_manager, "complete_channel", AsyncMock())
+ monkeypatch.setattr(agent_run_service, "_cleanup_channel_later", AsyncMock())
+
+ chunks = [
+ chunk
+ async for chunk in agent_run_service._stream_agent_chunks(
+ agent_request,
+ "user1",
+ "tenant1",
+ agent_run_info,
+ MagicMock(),
+ channel=channel,
+ )
+ ]
+
+ assert len(chunks) == 3
+ assert len(persisted_batches) == 1
+ units = persisted_batches[0]["message_units"]
+ assert [unit["unit_type"] for unit in units] == [
+ "llm_usage",
+ "turn_usage",
+ "final_answer",
+ ]
+ assert json.loads(units[0]["unit_content"])["call_id"] == "call-1"
+ assert json.loads(units[1]["unit_content"])["call_ids"] == ["call-1"]
+
+
@pytest.mark.asyncio
async def test_stream_agent_chunks_client_close_persists_partial_output(monkeypatch):
"""Closing the response iterator finalizes buffered output as failed."""
diff --git a/test/deploy/test_context_budget_p3_migration.py b/test/deploy/test_context_budget_p3_migration.py
new file mode 100644
index 0000000000..8ecc98073f
--- /dev/null
+++ b/test/deploy/test_context_budget_p3_migration.py
@@ -0,0 +1,16 @@
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+MIGRATION = ROOT / "deploy/sql/migrations/v2.6.0_0903_context_usage_observability.sql"
+
+
+def test_usage_observability_migration_is_nullable_idempotent_and_content_free():
+ sql = MIGRATION.read_text(encoding="utf-8").lower()
+
+ assert "add column if not exists context_budget_evidence jsonb default null" in sql
+ assert "update nexent.model_monitoring_record_t" not in sql
+ for content_field in ("api_key", "prompt", "messages", "tool_arguments", "endpoint"):
+ assert content_field not in sql
+ assert "begin;" in sql
+ assert "commit;" in sql
diff --git a/test/sdk/core/agents/test_context_helper_contracts.py b/test/sdk/core/agents/test_context_helper_contracts.py
index c167315d0c..baaa17cc11 100644
--- a/test/sdk/core/agents/test_context_helper_contracts.py
+++ b/test/sdk/core/agents/test_context_helper_contracts.py
@@ -1,6 +1,7 @@
from dataclasses import dataclass
from enum import Enum
from types import SimpleNamespace
+from unittest.mock import MagicMock
import pytest
@@ -547,6 +548,42 @@ def test_context_manager_management_and_diagnostic_helpers():
manager._purpose_messages(purpose="final_answer", task="task", final_answer_templates=None)
+def test_ac_p2_005_rebuild_target_tightens_both_context_budgets():
+ manager = ContextManager(
+ ContextManagerConfig(
+ token_threshold=100,
+ soft_input_budget_tokens=80,
+ hard_input_budget_tokens=100,
+ chars_per_token=1.0,
+ )
+ )
+ memory = MagicMock(system_prompt=None, steps=[])
+ run_context = manager.prepare_run_context(
+ memory,
+ "",
+ items=[
+ ContextItemInput(
+ id="system:large",
+ type="system",
+ content={"text": "x" * 60},
+ )
+ ],
+ )
+
+ rebuilt = manager.assemble_final_context(
+ model=MagicMock(),
+ memory=memory,
+ current_run_start_idx=0,
+ run_context=run_context,
+ target_input_budget_tokens=40,
+ )
+
+ assert rebuilt.evidence.soft_budget == 40
+ assert rebuilt.evidence.hard_budget == 40
+ assert rebuilt.evidence.over_hard_budget is True
+ assert rebuilt.evidence.budget_failure_reason == "single_context_item_oversize"
+
+
@dataclass
class _Payload:
value: int
diff --git a/test/sdk/core/agents/test_context_manager_assembly.py b/test/sdk/core/agents/test_context_manager_assembly.py
index c8361c9df3..4dbaab6836 100644
--- a/test/sdk/core/agents/test_context_manager_assembly.py
+++ b/test/sdk/core/agents/test_context_manager_assembly.py
@@ -90,6 +90,42 @@ def test_context_manager_assembles_stable_dynamic_and_history_messages():
assert final.tools == [{"name": "a"}, {"name": "z"}]
+def test_ac_p6_002_emergency_archive_keeps_recent_turns_and_indexes_older_turns():
+ manager = ContextManager(ContextManagerConfig(token_threshold=10000))
+ manager.register_item(_text_item("system:policy", "stable policy"))
+ for index in range(5):
+ manager.register_item(ContextItemInput(
+ id=f"turn:{index}",
+ type="conversation_turn",
+ content={
+ "user_message": f"user request {index}",
+ "assistant_final_answer": f"answer {index}",
+ "user_message_id": index * 2 + 1,
+ "assistant_message_id": index * 2 + 2,
+ },
+ metadata={"layout_order": index},
+ ))
+ memory = _Memory()
+ run_context = manager.prepare_run_context(memory=memory, fallback_system_prompt="")
+
+ final = manager.assemble_final_context(
+ model=None, memory=memory, current_run_start_idx=0,
+ run_context=run_context, target_input_budget_tokens=9000,
+ emergency_archive=True,
+ )
+
+ assert final.evidence.archive_active is True
+ assert final.evidence.archived_item_count == 2
+ assert "turn:0" not in final.evidence.selected_item_ids
+ assert "turn:4" in final.evidence.selected_item_ids
+ assert [tool.name for tool in final.runtime_tools] == ["search_archived_history"]
+ recall = manager.archive_tool.forward("user request 0", kinds=["chat_turn"])
+ assert recall["results"][0]["source_id"] == "turn:0"
+ rendered = "\n".join(_message_text(message) for message in final.messages)
+ assert "user request 0" not in rendered
+ assert "search_archived_history" in rendered
+
+
def test_context_fingerprint_bounds_cycles_and_excessive_depth():
manager = ContextManager()
cyclic = {}
diff --git a/test/sdk/core/agents/test_core_agent.py b/test/sdk/core/agents/test_core_agent.py
index 91e0823367..4760a2fa54 100644
--- a/test/sdk/core/agents/test_core_agent.py
+++ b/test/sdk/core/agents/test_core_agent.py
@@ -13,6 +13,7 @@
import os
import sys
import threading
+from dataclasses import dataclass
from types import ModuleType, SimpleNamespace
from unittest.mock import MagicMock, call, patch
from threading import Event
@@ -399,6 +400,14 @@ def test_length_truncated_non_code_output_is_not_a_final_answer():
assert core_agent_module._looks_like_incomplete_action_output(
"这是一个尚未完成的回答",
finish_reason="length",
+ ) is False
+
+
+def test_length_truncated_action_preamble_still_requires_a_tool_call():
+ assert core_agent_module._looks_like_incomplete_action_output(
+ "思考:我需要先调用 knowledge_base_search",
+ available_tool_names={"knowledge_base_search"},
+ finish_reason="length",
) is True
@@ -2285,6 +2294,120 @@ def test_step_stream_uses_context_runtime_for_uncompressed_est(self):
assert agent._last_uncompressed_est == 5000
+ def test_ac_p2_011_step_stream_supplies_source_backed_rebuild_for_w2(self):
+ module = self._load_core_agent_in_isolation()
+ CoreAgent = module.CoreAgent
+ agent = object.__new__(CoreAgent)
+ agent.agent_name = "test"
+ agent.observer = MagicMock()
+ agent.step_number = 1
+ agent.memory = MagicMock(steps=[], system_prompt=None)
+ agent.logger = MagicMock()
+ agent.monitor = MagicMock()
+ agent.context_runtime = self._context_runtime_mock()
+ agent.context_runtime.chars_per_token = 1.0
+ agent.context_runtime.token_counts.return_value = {"uncompressed": 10, "compressed": 10}
+ initial = MagicMock(messages=[MagicMock()])
+ initial.evidence.over_hard_budget = False
+ rebuilt = MagicMock(messages=[MagicMock()])
+ rebuilt.evidence.over_hard_budget = False
+ agent.context_runtime.prepare_step.side_effect = [initial, rebuilt]
+ response = MagicMock(content="ok")
+ agent.model = MagicMock(return_value=response)
+ agent.model.safe_input_budget_snapshot = {"fingerprint": "w2"}
+ agent._history_step_count = 0
+ agent._context_tools = MagicMock(return_value=[])
+ agent._use_structured_outputs_internally = False
+ action_step = MagicMock()
+
+ stream = agent._step_stream(action_step)
+ try:
+ next(stream)
+ except (StopIteration, ValueError):
+ pass
+
+ callback = agent.model.call_args.kwargs["context_rebuild"]
+ assert callback(123) is rebuilt
+ assert agent.context_runtime.prepare_step.call_args.kwargs[
+ "target_input_budget_tokens"
+ ] == 123
+
+ def test_ac_002_emergency_archive_tool_refreshes_live_executor_registry(self):
+ module = self._load_core_agent_in_isolation()
+ CoreAgent = module.CoreAgent
+ agent = object.__new__(CoreAgent)
+ existing_tool = MagicMock(name="existing_tool")
+ archive_tool = MagicMock(name="archive_tool")
+ archive_tool.name = "search_archived_history"
+ agent.tools = {"existing_tool": existing_tool}
+ agent.managed_agents = {}
+ agent.python_executor = MagicMock()
+ agent.context_runtime = MagicMock()
+ agent.context_runtime.context_manager.activate_emergency_archive.return_value = archive_tool
+ agent._guardrail_wrap_tools = MagicMock()
+ agent._wrap_visible_tool_events = MagicMock()
+
+ result = agent._activate_emergency_archive_tool(hard_budget=10_000)
+
+ assert result is archive_tool
+ assert agent.tools["search_archived_history"] is archive_tool
+ agent.python_executor.send_tools.assert_called_once_with({
+ "existing_tool": existing_tool,
+ "search_archived_history": archive_tool,
+ })
+
+ def test_ac_003_recall_execution_emits_updated_persistable_budget_event(self):
+ module = self._load_core_agent_in_isolation()
+ CoreAgent = module.CoreAgent
+ module.ProcessType.CONTEXT_BUDGET = "context_budget"
+
+ @dataclass(frozen=True)
+ class Evidence:
+ purpose: str = "step"
+ raw_token_estimate: int = 100
+ final_token_estimate: int = 80
+ compression_attempted: bool = True
+ fallback_compaction_used: bool = False
+ compression_records: tuple = ()
+ archive_active: bool = True
+ archived_item_count: int = 2
+ retained_item_count: int = 4
+ recall_invocation_count: int = 0
+ recalled_tokens: int = 0
+
+ agent = object.__new__(CoreAgent)
+ agent.agent_name = "test"
+ agent.step_number = 1
+ agent.observer = MagicMock()
+ archive = SimpleNamespace(recall_invocations=1, recalled_tokens=4000)
+ agent.context_runtime = SimpleNamespace(
+ context_manager=SimpleNamespace(
+ archive_tool=SimpleNamespace(archive=archive)
+ )
+ )
+ components = SimpleNamespace(
+ message_text=50, message_framing=5, tools=10,
+ media=0, reasoning=0, other_semantic=0,
+ )
+ preflight = SimpleNamespace(
+ components=components, soft_budget=100, hard_budget=90,
+ hard_count=70, count_source="estimated",
+ request_fingerprint="request", identity_fingerprint="budget",
+ retry_ordinal=2,
+ )
+ agent.model = SimpleNamespace(
+ last_context_evidence=Evidence(),
+ last_final_request_preflight=preflight,
+ _using_provisional_capacity=False,
+ )
+
+ agent._emit_archive_recall_budget_update()
+
+ emitted = json.loads(agent.observer.add_message.call_args.args[2])
+ assert emitted["recovery"]["recall_invocation_count"] == 1
+ assert emitted["recovery"]["recalled_tokens"] == 4000
+ assert agent.model.last_context_evidence.recalled_tokens == 4000
+
def test_step_stream_falls_back_without_uncompressed_runtime_count(self):
"""_step_stream estimates messages when the runtime has no raw sample."""
module = self._load_core_agent_in_isolation()
@@ -2935,6 +3058,25 @@ def test_handle_max_steps_reached_uses_context_runtime_final_answer(self):
# Model should be called with messages from ContextRuntime.
assert agent.model.called
+ def test_ac_p2_011_final_answer_supplies_source_backed_rebuild_for_w2(self):
+ agent, _module = self._create_agent_for_handle_max_steps_test()
+ initial = agent.context_runtime.prepare_final_answer.return_value
+ rebuilt = MagicMock(messages=[{"role": "user", "content": "short"}])
+ rebuilt.evidence.over_hard_budget = False
+ agent.context_runtime.prepare_final_answer.side_effect = [initial, rebuilt]
+ response = MagicMock(role="assistant", content="Summary.", token_usage=None)
+ agent.model = MagicMock(return_value=response)
+ agent.model.safe_input_budget_snapshot = {"fingerprint": "w2"}
+ agent._finalize_step = MagicMock()
+
+ agent._handle_max_steps_reached("my task prompt")
+
+ callback = agent.model.call_args.kwargs["context_rebuild"]
+ assert callback(321) is rebuilt
+ assert agent.context_runtime.prepare_final_answer.call_args.kwargs[
+ "target_input_budget_tokens"
+ ] == 321
+
# ----------------------------------------------------------------------------
# Tests for _log_model_call_parameters method
diff --git a/test/sdk/core/agents/test_history_archive.py b/test/sdk/core/agents/test_history_archive.py
new file mode 100755
index 0000000000..4ead926370
--- /dev/null
+++ b/test/sdk/core/agents/test_history_archive.py
@@ -0,0 +1,36 @@
+"""Acceptance tests for P6 run-local searchable history."""
+
+import pytest
+
+from nexent.core.agents.context.archive import RunHistoryArchive, SearchArchivedHistoryTool
+
+
+def test_ac_p6_003_unicode_ranking_redaction_filters_and_caps():
+ archive = RunHistoryArchive(run_id="tenant-a/run-a", hard_input_budget=100, chars_per_token=2)
+ archive.add(kind="chat_turn", source_id="turn:1", content={
+ "user": "请部署华为云服务", "assistant": "部署完成", "reasoning": "never reveal",
+ "api_key": "credential-value",
+ })
+ archive.add(kind="error", source_id="step:1", content="DashScope timeout")
+
+ chinese = archive.search("华为云部署", top_k=99, kinds=["chat_turn"])
+ english = SearchArchivedHistoryTool(archive).forward("timeout", kinds=["error"])
+
+ assert len(chinese["results"]) == 1
+ assert chinese["results"][0]["kind"] == "chat_turn"
+ assert "never reveal" not in chinese["results"][0]["content"]
+ assert "credential-value" not in chinese["results"][0]["content"]
+ assert english["results"][0]["source_id"] == "step:1"
+ assert chinese["recalled_tokens"] <= 20
+ assert english["recalled_tokens"] <= 20
+
+
+def test_ac_p6_003_stable_ids_and_kind_validation():
+ first = RunHistoryArchive(run_id="run-1", hard_input_budget=100)
+ second = RunHistoryArchive(run_id="run-2", hard_input_budget=100)
+ assert first.add(kind="result", source_id="step:1", content="ok").archive_id != second.add(
+ kind="result", source_id="step:1", content="ok"
+ ).archive_id
+ assert first.search("ok", top_k=0)["results"]
+ with pytest.raises(ValueError, match="unsupported archive kinds"):
+ first.search("ok", kinds=["reasoning"])
diff --git a/test/sdk/core/agents/test_nexent_agent.py b/test/sdk/core/agents/test_nexent_agent.py
index 7f056c52aa..25d3fd8d47 100644
--- a/test/sdk/core/agents/test_nexent_agent.py
+++ b/test/sdk/core/agents/test_nexent_agent.py
@@ -728,6 +728,36 @@ def test_create_model_deep_thinking_success(nexent_agent_with_models, mock_deep_
assert result.stop_event == nexent_agent_with_models.stop_event
+def test_ac_p2_011_create_model_threads_verified_count_identity_metadata(
+ nexent_agent_instance,
+):
+ """Managed and root agents share the same metadata-bearing model factory."""
+ config = ModelConfig(
+ cite_name="verified_model",
+ api_key="test_api_key",
+ model_name="qwen3.7-plus",
+ url="https://example.invalid/v1",
+ model_factory="openai",
+ canonical_model_id="qwen:qwen3.7-plus",
+ tokenizer_family="qwen",
+ model_identity_metadata={"status": "matched"},
+ tokenizer_match_metadata={"auto_applicable": True},
+ token_count_probe_metadata={"status": "supported"},
+ )
+ nexent_agent_instance.model_config_list = [config]
+ mock_openai_model_class.reset_mock()
+ mock_openai_model_class.return_value = MagicMock()
+
+ nexent_agent_instance.create_model("verified_model")
+
+ kwargs = mock_openai_model_class.call_args.kwargs
+ assert kwargs["canonical_model_id"] == "qwen:qwen3.7-plus"
+ assert kwargs["tokenizer_family"] == "qwen"
+ assert kwargs["model_identity_metadata"] == {"status": "matched"}
+ assert kwargs["tokenizer_match_metadata"] == {"auto_applicable": True}
+ assert kwargs["token_count_probe_metadata"] == {"status": "supported"}
+
+
def test_create_model_not_found(nexent_agent_with_models):
"""Test create_model raises ValueError when model cite_name is not found."""
with pytest.raises(ValueError, match="Model nonexistent_model not found"):
diff --git a/test/sdk/core/models/test_usage_aggregation.py b/test/sdk/core/models/test_usage_aggregation.py
new file mode 100644
index 0000000000..acf3e33337
--- /dev/null
+++ b/test/sdk/core/models/test_usage_aggregation.py
@@ -0,0 +1,63 @@
+from nexent.core.models.provider_usage import NormalizedTokenUsage, ProviderCallUsage
+from nexent.core.models.usage_aggregation import aggregate_turn_usage
+
+
+def _record(call_id, input_tokens, output_tokens, *, purpose="main_agent", source="provider", status="completed"):
+ return ProviderCallUsage(
+ call_id=call_id,
+ purpose=purpose,
+ source=source,
+ status=status,
+ usage=NormalizedTokenUsage(
+ input_tokens=input_tokens,
+ output_tokens=output_tokens,
+ total_tokens=(input_tokens + output_tokens) if input_tokens is not None and output_tokens is not None else None,
+ ),
+ )
+
+
+def test_ac_tu_004_deduplicates_replayed_calls_and_aggregates_once():
+ partial = _record("one", 10, None, status="partial")
+ completed = _record("one", 10, 2)
+ second = _record("two", 20, 3)
+
+ summary = aggregate_turn_usage([partial, completed, second], context_limit_tokens=100)
+
+ assert summary["schema_version"] == 3
+ assert summary["call_count"] == 2
+ assert summary["usage"]["input_tokens"] == 30
+ assert summary["usage"]["output_tokens"] == 5
+ assert summary["usage"]["total_tokens"] == 35
+
+
+def test_ac_tu_004_partial_sum_never_impersonates_complete_total():
+ summary = aggregate_turn_usage([_record("one", 10, 2), _record("two", 20, None)])
+
+ assert summary["usage"]["input_tokens"] == 30
+ assert summary["usage"]["output_tokens"] is None
+ assert summary["known_field_call_counts"]["output_tokens"] == 1
+
+
+def test_ac_tu_005_context_pressure_uses_latest_and_peak_not_sum():
+ summary = aggregate_turn_usage(
+ [
+ _record("one", 80, 2),
+ _record("summary", 200, 5, purpose="history_summary"),
+ _record("two", 50, 3, purpose="final_answer"),
+ ],
+ context_limit_tokens=1000,
+ )
+
+ assert summary["usage"]["input_tokens"] == 330
+ assert summary["peak_context"] == {"call_id": "one", "input_tokens": 80, "limit_tokens": 1000}
+ assert summary["latest_context"] == {"call_id": "two", "input_tokens": 50, "limit_tokens": 1000}
+
+
+def test_p10_ac_001_estimated_legacy_calls_do_not_define_context_pressure():
+ summary = aggregate_turn_usage(
+ [_record("legacy", 900, 20, source="estimated")],
+ context_limit_tokens=1000,
+ )
+
+ assert summary["latest_context"] is None
+ assert summary["peak_context"] is None