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.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")} - -
- - {/* Progress bar */} -
-
- {t("chat.tokenUsage.context")} - - {tokenCount.toLocaleString()} / 128000 - -
-
-
-
+ + +
+ + {/* Progress bar */} +
+
+ + {t("chat.tokenUsage.context")} + + + {tokenCount.toLocaleString()} / 128000 +
+
+
+
+
- {/* Details */} -
+ {/* Details */} +
+
+ + + {t("chat.tokenUsage.output")} + + + {tokenCount.toLocaleString()} + +
+ {timing.tokensPerSecond !== undefined && (
- - {t("chat.tokenUsage.output")} + + {t("chat.tokenUsage.speed")} - {tokenCount.toLocaleString()} + {timing.tokensPerSecond.toFixed(1)} tok/s
- {timing.tokensPerSecond !== undefined && ( -
- - - {t("chat.tokenUsage.speed")} - - - {timing.tokensPerSecond.toFixed(1)} tok/s - -
- )} -
+ )}
- )} -
+ + ); }; @@ -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 ( -
- +
+ {budget?.recovery?.auto_continued && ( + + {t("taskWindow.contextBudget.autoContinued")} + + )} + {recoveryExhausted && budget?.recovery?.partial_preserved && ( + + {t("chat.tokenUsage.recoveryExhausted")} + + )} + + + + - {/* 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