utim-cli 2.5.3__tar.gz → 2.5.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {utim_cli-2.5.3/utim_cli.egg-info → utim_cli-2.5.4}/PKG-INFO +1 -1
- {utim_cli-2.5.3 → utim_cli-2.5.4}/pyproject.toml +1 -1
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim/__init__.py +1 -1
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/__init__.py +1 -1
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/_version.py +1 -1
- utim_cli-2.5.4/utim_cli/adaptation_engine.py +628 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/agent.py +18 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/client_utils.py +86 -18
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/context_pruner.py +8 -1
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/orchestrator.py +108 -42
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/orchestrator_helpers.py +24 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/orchestrator_llm_transport.py +114 -1
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/orchestrator_tool_exec.py +24 -6
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/orchestrator_turn_history.py +9 -5
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/api_models_db.py +31 -1
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/auth.py +76 -23
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/db.py +274 -21
- utim_cli-2.5.4/utim_cli/server/email_utils.py +1125 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/hobby_throttler.py +141 -5
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/models.py +11 -12
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/request_queue.py +574 -450
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/router.py +16 -3
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/__init__.py +3 -1
- utim_cli-2.5.4/utim_cli/server/routes/activity_routes.py +150 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/api_gateway_extensions_routes.py +5 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/api_models_admin_routes.py +102 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/api_models_public_routes.py +583 -68
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/auth_routes.py +8 -1
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/completion_routes.py +539 -31
- utim_cli-2.5.4/utim_cli/server/routes/discord_routes.py +275 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/provider_compat_routes.py +7 -4
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/quota_routes.py +17 -17
- utim_cli-2.5.4/utim_cli/server/uptime_monitor.py +149 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tools.py +8710 -8656
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/model_dialog.py +21 -2
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/utim.py +2 -2
- {utim_cli-2.5.3 → utim_cli-2.5.4/utim_cli.egg-info}/PKG-INFO +1 -1
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli.egg-info/SOURCES.txt +4 -0
- utim_cli-2.5.3/utim_cli/server/email_utils.py +0 -996
- {utim_cli-2.5.3 → utim_cli-2.5.4}/CHANGELOG.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/LICENSE +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/MANIFEST.in +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/README.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/setup.cfg +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/setup.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/_chip_test.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/_verify_repro.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/_verify_syntax.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/_wizard_smoke_test.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/architecture_indexer.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/ask_helper.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/auth.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/backup.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/billing.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/blender_agent.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/bootstrap.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/brain.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/builtin/skills/utim-guide/SKILL.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/config.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/constants.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/context_inspector.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/crawler/__init__.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/crawler/engine.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/crawler/markdown_distiller.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/dependency_manager.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/doctor.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/evidence_registry.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/harbor.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/local_db.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/logger.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/mcp_client.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/mcp_registry.json +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/models.txt +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/personalities.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/platform_installer.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/__init__.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/hooks.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/loader.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/manager.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/registry.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/runtime.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/schema.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/tool_loader.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/reflection.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/report.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/__init__.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/config.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/context.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/events.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/handlers.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/session.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/sidecar.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/__init__.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/admin_auth.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/api_batcher.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/attribution.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/audit_log.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/batch_processor.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/captcha.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/cli_auth.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/concurrency.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/crypto_utils.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/about.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/changelog.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/docs.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/features.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/license.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/pricing.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/privacy.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/refund.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/support.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/terms.md +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/exchange_rate.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/firebase.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/firewall.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/fix_duplicate_users.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/history.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/key_rotator.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/local_llm.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/logging_config.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/maintenance.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/micro_batcher.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/model_agent.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/plan_integrity.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/platform_billing.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/pricing_updater.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/provider_balance.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/provider_discovery.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/provider_router.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/provision_build.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/rate_limit.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/request_batcher.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/rewards_engine.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/admin_bonus_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/admin_db_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/admin_providers_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/admin_separated_models_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/api_key_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/billing_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/credit_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/feedback_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/marketplace_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/quota_share_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/referral_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/rewards_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/security_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/session_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/share_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/wallet_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/webhook_routes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/security_alerts.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/server.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/signup_guard.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/storage_nodes.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/utils.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/share.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/share_tui.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/situational_scoring.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/state.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/subagent_manager.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/syntax_fix.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/task_dispatcher.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/task_logger.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tools_grep.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/__init__.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/compression_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/feedback_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/history_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/marketplace_app_state.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/marketplace_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/marketplace_layout_engine.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/mcp_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/miniagents_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/personality_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/plugins_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/publish_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/quota_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/quota_redeem_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/quota_share_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/resume_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/rewards_tui.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/skills_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/subagents_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/tasks_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/thinking_display.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/tools_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/update_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/verbosity_dialog.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/update_checker.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/utilities.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/utimmodel.txt +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/vector_memory.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_coding.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_events.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_handler.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_instant.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_orchestrator.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_session.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_stt.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_tts.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/wheel.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/workspace.py +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli.egg-info/entry_points.txt +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli.egg-info/requires.txt +0 -0
- {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: utim-cli
|
|
3
|
-
Version: 2.5.
|
|
3
|
+
Version: 2.5.4
|
|
4
4
|
Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
|
|
5
5
|
License: Emend AI Proprietary EULA
|
|
6
6
|
Project-URL: Homepage, https://utim.dev
|
|
@@ -14,7 +14,7 @@ Quickstart:
|
|
|
14
14
|
try:
|
|
15
15
|
from utim_cli._version import VERSION as __version__
|
|
16
16
|
except ImportError:
|
|
17
|
-
__version__ = "2.5.
|
|
17
|
+
__version__ = "2.5.4"
|
|
18
18
|
|
|
19
19
|
# Developer-friendly core classes (similar to `import genai` / `import openai`)
|
|
20
20
|
from utim_cli.sdk.session import UtimSession as Session
|
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UTIM Smart Dynamic Adaptation Engine — provider-agnostic resilience layer.
|
|
3
|
+
|
|
4
|
+
Goals:
|
|
5
|
+
1. Auto-detect ANY truncation / breakdown (token-limit, abrupt disconnect,
|
|
6
|
+
empty-stream, incomplete fence / JSON, unclosed <think>).
|
|
7
|
+
2. Seamlessly continue the response by stitching chunks — the user sees one
|
|
8
|
+
coherent answer, never a "Response truncated" banner.
|
|
9
|
+
3. Stay provider-agnostic: normalises every finish_reason / quirk (Atria
|
|
10
|
+
omits usage, Anthropic omits finish_reason, upstream 500 empty_stream …)
|
|
11
|
+
into a single canonical vocabulary.
|
|
12
|
+
4. Adapt max_tokens dynamically when the same model keeps hitting the cap.
|
|
13
|
+
5. Work for every model without hardcoded per-model branches.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
engine = AdaptationEngine(console=console, model_id="openai/gpt-4o")
|
|
17
|
+
signal = engine.analyze(msg, content, tool_calls)
|
|
18
|
+
if engine.should_continue(signal):
|
|
19
|
+
bump = engine.adapt_max_tokens(model_id)
|
|
20
|
+
stitched = engine.stitch(existing_content, new_content)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import re
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# ── Tunables ────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
MAX_CONTINUE_ROUNDS = 5 # hard cap to avoid infinite loops
|
|
39
|
+
CONTINUE_COOLDOWN_S = 2.0 # min gap between continue nudges (debounce)
|
|
40
|
+
FENCE_MARKER = "```"
|
|
41
|
+
|
|
42
|
+
# ── Model health / circuit breaker tunables ────────────────────────────────
|
|
43
|
+
HEALTH_FILE = Path(__file__).resolve().parent.parent / ".utim" / "model_health.json"
|
|
44
|
+
HEALTH_PENALTY_EMPTY_STREAM = 0.35 # transient glitch → moderate penalty
|
|
45
|
+
HEALTH_PENALTY_TRUNCATION = 0.20 # token-limit → light penalty (not the model's fault)
|
|
46
|
+
HEALTH_PENALTY_HARDCAP = 0.50 # 4xx/5xx errors → heavy penalty
|
|
47
|
+
HEALTH_RECOVERY_PER_SUCCESS = 0.15 # success reward
|
|
48
|
+
HEALTH_FLOOR = 0.0
|
|
49
|
+
HEALTH_CEILING = 1.0
|
|
50
|
+
HEALTH_COOLDOWN_S = 120 # circuit-open cooldown before half-open probe
|
|
51
|
+
HEALTH_CIRCUIT_THRESHOLD = 0.25 # below this health → circuit open
|
|
52
|
+
HEALTH_PERSIST_INTERVAL_S = 30.0 # throttle disk writes
|
|
53
|
+
MAX_TOKENS_GROWTH_CAP = 2.0 # never exceed 2× base output budget
|
|
54
|
+
THINK_OPEN_RE = re.compile(r"<think(?:\s[^>]*)?>", re.IGNORECASE)
|
|
55
|
+
THINK_CLOSE_RE = re.compile(r"</think>", re.IGNORECASE)
|
|
56
|
+
|
|
57
|
+
# Canonical finish reasons — everything maps into this tiny set.
|
|
58
|
+
CANONICAL_STOP = "stop"
|
|
59
|
+
CANONICAL_TOOL = "tool_calls"
|
|
60
|
+
CANONICAL_LENGTH = "length"
|
|
61
|
+
CANONICAL_ABRUPT = "abrupt_disconnect"
|
|
62
|
+
CANONICAL_REPETITION = "repetition_loop"
|
|
63
|
+
|
|
64
|
+
_ALLOWLIST_FINISH = {CANONICAL_STOP, CANONICAL_TOOL, CANONICAL_LENGTH, CANONICAL_ABRUPT, CANONICAL_REPETITION}
|
|
65
|
+
|
|
66
|
+
# Matches raw provider finish_reasons and normalises them.
|
|
67
|
+
_FINISH_ALIASES: Dict[str, str] = {
|
|
68
|
+
"stop": CANONICAL_STOP,
|
|
69
|
+
"end_turn": CANONICAL_STOP,
|
|
70
|
+
"stop_sequence": CANONICAL_STOP,
|
|
71
|
+
"end_of_turn": CANONICAL_STOP,
|
|
72
|
+
"tool_calls": CANONICAL_TOOL,
|
|
73
|
+
"function_call": CANONICAL_TOOL,
|
|
74
|
+
"tool_call": CANONICAL_TOOL,
|
|
75
|
+
"length": CANONICAL_LENGTH,
|
|
76
|
+
"max_tokens": CANONICAL_LENGTH,
|
|
77
|
+
"max_output_tokens": CANONICAL_LENGTH,
|
|
78
|
+
"repetition_loop": CANONICAL_REPETITION,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _normalize_finish_reason(raw: Optional[str]) -> str:
|
|
83
|
+
"""Map any provider finish_reason string to canonical vocabulary."""
|
|
84
|
+
if not raw:
|
|
85
|
+
return ""
|
|
86
|
+
key = raw.strip().lower()
|
|
87
|
+
return _FINISH_ALIASES.get(key, key)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_transient_empty_stream_error(err_text: str) -> bool:
|
|
91
|
+
"""Return True for transient upstream empty-stream glitches that should be
|
|
92
|
+
silently retried (never surfaced to the user)."""
|
|
93
|
+
low = (err_text or "").lower()
|
|
94
|
+
return any(
|
|
95
|
+
s in low
|
|
96
|
+
for s in (
|
|
97
|
+
"empty_stream",
|
|
98
|
+
"empty stream",
|
|
99
|
+
"contentblock",
|
|
100
|
+
"no content",
|
|
101
|
+
"upstream returned empty",
|
|
102
|
+
)
|
|
103
|
+
) or any(c in (err_text or "") for c in ("上游", "空流"))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _has_unclosed_fence(text: str) -> bool:
|
|
107
|
+
if not text:
|
|
108
|
+
return False
|
|
109
|
+
# Only count REAL fence markers: lines that (after stripping leading
|
|
110
|
+
# whitespace) begin with ```. Inline triple-backticks inside prose or
|
|
111
|
+
# code (e.g. `use ```sql fences``` carefully`) previously inflated the
|
|
112
|
+
# count and made complete responses look "unclosed", triggering
|
|
113
|
+
# spurious auto-continue loops.
|
|
114
|
+
fence_lines = [ln for ln in text.splitlines() if ln.lstrip().startswith(FENCE_MARKER)]
|
|
115
|
+
# Only odd count implies unclosed.
|
|
116
|
+
return len(fence_lines) % 2 != 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _has_unclosed_think(text: str, think_buf: str = "") -> bool:
|
|
120
|
+
combined = (text or "") + (think_buf or "")
|
|
121
|
+
opens = len(THINK_OPEN_RE.findall(combined))
|
|
122
|
+
closes = len(THINK_CLOSE_RE.findall(combined))
|
|
123
|
+
return opens > closes
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_truncated_json_args(tool_calls: List[Dict]) -> bool:
|
|
127
|
+
"""Return True if any tool_call has arguments that look like truncated JSON."""
|
|
128
|
+
for tc in tool_calls or []:
|
|
129
|
+
fn = tc.get("function") or {}
|
|
130
|
+
args_s = fn.get("arguments")
|
|
131
|
+
if not isinstance(args_s, str) or not args_s.strip():
|
|
132
|
+
continue
|
|
133
|
+
s = args_s.strip()
|
|
134
|
+
# Heuristic: starts like JSON but doesn't parse or ends mid-token.
|
|
135
|
+
if s.startswith("{") or s.startswith("["):
|
|
136
|
+
try:
|
|
137
|
+
json.loads(s)
|
|
138
|
+
except json.JSONDecodeError as exc:
|
|
139
|
+
# Incomplete JSON — likely truncation.
|
|
140
|
+
# Unterminated string / unexpected end are strong signals.
|
|
141
|
+
msg = str(exc).lower()
|
|
142
|
+
if any(kw in msg for kw in ("unterminated", "expecting", "unexpected end", "invalid")):
|
|
143
|
+
return True
|
|
144
|
+
return True
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _ends_mid_sentence(text: str) -> bool:
|
|
149
|
+
"""Heuristic: does text end abruptly mid-sentence/word (no terminal punct)?"""
|
|
150
|
+
if not text or not text.strip():
|
|
151
|
+
return False
|
|
152
|
+
stripped = text.rstrip()
|
|
153
|
+
# If it already ends with terminal punctuation / fence / bracket / markdown
|
|
154
|
+
# decoration, the response is almost certainly complete.
|
|
155
|
+
# Extended set: includes *, _, ~, : (list/section endings), / (paths/URLs),
|
|
156
|
+
# digits (numbered lists, version strings), and emoji-range chars.
|
|
157
|
+
last_char = stripped[-1]
|
|
158
|
+
if last_char in ".!?\"'`)}]>\n*_~:/\\":
|
|
159
|
+
return False
|
|
160
|
+
# Digit endings (e.g. "step 3", "v2.0", "exit code 0") are complete.
|
|
161
|
+
if last_char.isdigit():
|
|
162
|
+
return False
|
|
163
|
+
# Emoji / non-ASCII endings are almost always intentional.
|
|
164
|
+
if ord(last_char) > 127:
|
|
165
|
+
return False
|
|
166
|
+
last_line = stripped.splitlines()[-1].strip() if stripped.splitlines() else stripped
|
|
167
|
+
# Short last lines (preambles, labels) are not truncation evidence.
|
|
168
|
+
if len(last_line) < 12:
|
|
169
|
+
return False
|
|
170
|
+
# Text ending inside a properly CLOSED code fence is complete by
|
|
171
|
+
# definition — code rarely ends with terminal punctuation.
|
|
172
|
+
if _has_unclosed_fence(stripped):
|
|
173
|
+
pass # genuinely unclosed fence → keep mid-sentence check active
|
|
174
|
+
elif stripped.rstrip().endswith(FENCE_MARKER):
|
|
175
|
+
return False # ends with a closing fence line → complete
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── Signal ─────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
@dataclass
|
|
182
|
+
class TruncationSignal:
|
|
183
|
+
truncated: bool
|
|
184
|
+
kind: str = "" # token_limit | abrupt_disconnect | incomplete_tool_call | incomplete_fence | incomplete_think | empty_stream | none
|
|
185
|
+
confidence: float = 0.0 # 0–1
|
|
186
|
+
details: str = ""
|
|
187
|
+
suggested_action: str = "none" # continue | retry | fallback | none
|
|
188
|
+
canonical_finish: str = "" # normalised finish_reason
|
|
189
|
+
raw_finish: str = ""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── Engine ─────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class AdaptationEngine:
|
|
196
|
+
"""Stateful per-task adaptation engine. Create one per user turn."""
|
|
197
|
+
|
|
198
|
+
console: Any = None
|
|
199
|
+
max_continue_rounds: int = MAX_CONTINUE_ROUNDS
|
|
200
|
+
# runtime state
|
|
201
|
+
continue_rounds: int = 0
|
|
202
|
+
total_continues_for_task: int = 0
|
|
203
|
+
last_continue_ts: float = 0.0
|
|
204
|
+
stitched_content: str = ""
|
|
205
|
+
stitched_reasoning: str = ""
|
|
206
|
+
max_tokens_bumps: Dict[str, int] = field(default_factory=dict)
|
|
207
|
+
history: List[TruncationSignal] = field(default_factory=list)
|
|
208
|
+
# per-model truncation streak (for adaptive max_tokens)
|
|
209
|
+
_model_trunc_streak: Dict[str, int] = field(default_factory=dict)
|
|
210
|
+
# per-model health scores 0..1 (class-level so every engine instance shares)
|
|
211
|
+
_model_health: Dict[str, float] = field(default_factory=dict)
|
|
212
|
+
_model_health_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
213
|
+
_model_circuit_open_until: Dict[str, float] = field(default_factory=dict)
|
|
214
|
+
_health_last_persist: float = 0.0
|
|
215
|
+
|
|
216
|
+
# ── Model health / circuit breaker ─────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
def _load_health(self) -> None:
|
|
219
|
+
"""Load persisted health scores from disk (best-effort)."""
|
|
220
|
+
with self._model_health_lock:
|
|
221
|
+
if self._model_health:
|
|
222
|
+
return
|
|
223
|
+
try:
|
|
224
|
+
if HEALTH_FILE.exists():
|
|
225
|
+
data = json.loads(HEALTH_FILE.read_text(encoding="utf-8"))
|
|
226
|
+
now = time.time()
|
|
227
|
+
for mid, entry in (data or {}).items():
|
|
228
|
+
try:
|
|
229
|
+
score = float(entry.get("score", 1.0))
|
|
230
|
+
opened = float(entry.get("circuit_open_until", 0))
|
|
231
|
+
except (TypeError, ValueError):
|
|
232
|
+
continue
|
|
233
|
+
# Clamp into valid range; expired circuits reset
|
|
234
|
+
self._model_health[mid] = max(HEALTH_FLOOR, min(HEALTH_CEILING, score))
|
|
235
|
+
if opened > now:
|
|
236
|
+
self._model_circuit_open_until[mid] = opened
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
logger.debug(f"[AdaptationEngine] health load failed: {exc}")
|
|
239
|
+
|
|
240
|
+
def _persist_health(self, force: bool = False) -> None:
|
|
241
|
+
"""Throttled disk persistence of health scores (best-effort)."""
|
|
242
|
+
now = time.time()
|
|
243
|
+
if not force and (now - self._health_last_persist) < HEALTH_PERSIST_INTERVAL_S:
|
|
244
|
+
return
|
|
245
|
+
self._health_last_persist = now
|
|
246
|
+
try:
|
|
247
|
+
HEALTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
with self._model_health_lock:
|
|
249
|
+
payload = {
|
|
250
|
+
mid: {"score": round(score, 3), "circuit_open_until": self._model_circuit_open_until.get(mid, 0)}
|
|
251
|
+
for mid, score in self._model_health.items()
|
|
252
|
+
}
|
|
253
|
+
HEALTH_FILE.write_text(json.dumps(payload, indent=1), encoding="utf-8")
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
logger.debug(f"[AdaptationEngine] health persist failed: {exc}")
|
|
256
|
+
|
|
257
|
+
def _adjust_health(self, model_id: str, delta: float) -> None:
|
|
258
|
+
"""Apply a health delta, handle circuit-breaker transitions, persist."""
|
|
259
|
+
if not model_id:
|
|
260
|
+
return
|
|
261
|
+
self._load_health()
|
|
262
|
+
with self._model_health_lock:
|
|
263
|
+
cur = self._model_health.get(model_id, 1.0)
|
|
264
|
+
new = max(HEALTH_FLOOR, min(HEALTH_CEILING, cur + delta))
|
|
265
|
+
self._model_health[model_id] = new
|
|
266
|
+
# Open circuit when health degrades past threshold
|
|
267
|
+
if new < HEALTH_CIRCUIT_THRESHOLD and model_id not in self._model_circuit_open_until:
|
|
268
|
+
self._model_circuit_open_until[model_id] = time.time() + HEALTH_COOLDOWN_S
|
|
269
|
+
logger.warning(f"[AdaptationEngine] circuit OPEN for {model_id} (health={new:.2f}) — cooling down {HEALTH_COOLDOWN_S}s")
|
|
270
|
+
self._persist_health()
|
|
271
|
+
|
|
272
|
+
def record_failure(self, model_id: str, kind: str = "error", details: str = "") -> None:
|
|
273
|
+
"""Report a model failure. kind: empty_stream | truncation | error | rate_limit."""
|
|
274
|
+
penalties = {
|
|
275
|
+
"empty_stream": HEALTH_PENALTY_EMPTY_STREAM,
|
|
276
|
+
"truncation": HEALTH_PENALTY_TRUNCATION,
|
|
277
|
+
"rate_limit": HEALTH_PENALTY_HARDCAP * 0.6,
|
|
278
|
+
"error": HEALTH_PENALTY_HARDCAP,
|
|
279
|
+
}
|
|
280
|
+
self._adjust_health(model_id, -penalties.get(kind, HEALTH_PENALTY_HARDCAP * 0.5))
|
|
281
|
+
logger.info(f"[AdaptationEngine] failure recorded for {model_id} (kind={kind}) {details}")
|
|
282
|
+
|
|
283
|
+
def get_model_health(self, model_id: str) -> float:
|
|
284
|
+
"""Current health score for a model (1.0 = pristine)."""
|
|
285
|
+
self._load_health()
|
|
286
|
+
with self._model_health_lock:
|
|
287
|
+
return self._model_health.get(model_id, 1.0)
|
|
288
|
+
|
|
289
|
+
def is_circuit_open(self, model_id: str) -> bool:
|
|
290
|
+
"""True while a model is in circuit-breaker cooldown (skip it in fallback ordering)."""
|
|
291
|
+
self._load_health()
|
|
292
|
+
until = self._model_circuit_open_until.get(model_id, 0)
|
|
293
|
+
if until and time.time() >= until:
|
|
294
|
+
# Half-open: allow a probe, restore reduced trust
|
|
295
|
+
with self._model_health_lock:
|
|
296
|
+
self._model_circuit_open_until.pop(model_id, None)
|
|
297
|
+
self._model_health[model_id] = max(self._model_health.get(model_id, 0.0), HEALTH_CIRCUIT_THRESHOLD + 0.05)
|
|
298
|
+
self._persist_health()
|
|
299
|
+
return False
|
|
300
|
+
return until > time.time()
|
|
301
|
+
|
|
302
|
+
def get_health_ranked_fallbacks(self, candidates: List[str]) -> List[str]:
|
|
303
|
+
"""Order candidate fallback models by health (healthy first), skipping
|
|
304
|
+
circuit-open models. Stable sort keeps config order as tiebreak."""
|
|
305
|
+
self._load_health()
|
|
306
|
+
|
|
307
|
+
def _key(mid: str) -> Tuple[int, float, int]:
|
|
308
|
+
open_circuit = 1 if self.is_circuit_open(mid) else 0
|
|
309
|
+
return (open_circuit, -self.get_model_health(mid), candidates.index(mid))
|
|
310
|
+
|
|
311
|
+
return sorted(candidates, key=_key)
|
|
312
|
+
|
|
313
|
+
# ── Core analysis (provider-agnostic) ──────────────────────────────────
|
|
314
|
+
|
|
315
|
+
def analyze(
|
|
316
|
+
self,
|
|
317
|
+
msg: Dict[str, Any],
|
|
318
|
+
content: str,
|
|
319
|
+
tool_calls: List[Dict],
|
|
320
|
+
*,
|
|
321
|
+
think_buf: str = "",
|
|
322
|
+
) -> TruncationSignal:
|
|
323
|
+
"""Inspect a just-received assistant message and decide if it was
|
|
324
|
+
truncated or broken mid-response. Provider-agnostic."""
|
|
325
|
+
raw_fr = msg.get("finish_reason") or ""
|
|
326
|
+
canon = _normalize_finish_reason(raw_fr)
|
|
327
|
+
aborted = bool(msg.get("aborted"))
|
|
328
|
+
was_trunc_flag = bool(msg.get("was_truncated_by_limit"))
|
|
329
|
+
|
|
330
|
+
content = content or ""
|
|
331
|
+
tool_calls = tool_calls or []
|
|
332
|
+
|
|
333
|
+
# Snapshot for confidence scoring
|
|
334
|
+
reasons: List[Tuple[str, float, str]] = []
|
|
335
|
+
|
|
336
|
+
# ——— Empty-stream / abrupt disconnect ————————————————
|
|
337
|
+
if not content.strip() and not tool_calls and not (think_buf or "").strip():
|
|
338
|
+
if not canon or canon == CANONICAL_LENGTH or canon == CANONICAL_ABRUPT:
|
|
339
|
+
# Nothing produced at all — provider died mid-flight.
|
|
340
|
+
reasons.append(("empty_stream", 0.95, f"empty content+tools finish_reason={raw_fr!r}"))
|
|
341
|
+
elif canon == CANONICAL_STOP:
|
|
342
|
+
# Clean stop with no content is OK (e.g. immediate tool-call turn
|
|
343
|
+
# that got parsed elsewhere); don't flag.
|
|
344
|
+
pass
|
|
345
|
+
|
|
346
|
+
# ——— Explicit LENGTH signal (cross-checked vs token budget) ————
|
|
347
|
+
if canon == CANONICAL_LENGTH or was_trunc_flag:
|
|
348
|
+
# was_truncated_by_limit already cross-checks completion_tokens vs cap
|
|
349
|
+
# (including Atria's missing usage). Trust it but keep confidence high.
|
|
350
|
+
conf = 0.92 if was_trunc_flag else 0.85
|
|
351
|
+
reasons.append(("token_limit", conf, f"finish_reason={raw_fr!r} was_truncated={was_trunc_flag}"))
|
|
352
|
+
|
|
353
|
+
# ——— Incomplete tool-call JSON ————————————————
|
|
354
|
+
if tool_calls and _is_truncated_json_args(tool_calls):
|
|
355
|
+
reasons.append(("incomplete_tool_call", 0.90, "tool arguments look like truncated JSON"))
|
|
356
|
+
|
|
357
|
+
# ——— Unclosed fence ————————————————
|
|
358
|
+
# Fire even on CANONICAL_STOP — many providers (OpenRouter, Together,
|
|
359
|
+
# Groq) send stop even when the model cut off mid-code-block.
|
|
360
|
+
# We use a slightly lower confidence (0.72) for stop+unclosed vs
|
|
361
|
+
# non-stop (0.78) to reflect the ambiguity.
|
|
362
|
+
if content and _has_unclosed_fence(content):
|
|
363
|
+
conf_fence = 0.72 if canon == CANONICAL_STOP else 0.78
|
|
364
|
+
reasons.append(("incomplete_fence", conf_fence, f"odd ``` count finish_reason={raw_fr!r}"))
|
|
365
|
+
|
|
366
|
+
# ——— Unclosed <think> ————————————————
|
|
367
|
+
if _has_unclosed_think(content, think_buf):
|
|
368
|
+
reasons.append(("incomplete_think", 0.80, "unclosed <think> block"))
|
|
369
|
+
|
|
370
|
+
# ——— Mid-sentence hard stop ————————————————
|
|
371
|
+
# Models like Gemini Flash, Llama-3, cheap OpenRouter passthrough
|
|
372
|
+
# sometimes stop mid-word/sentence and still report finish_reason=stop.
|
|
373
|
+
# Only flag this on a canonical STOP (not length, not abrupt) because
|
|
374
|
+
# those are already covered above. Only fire when:
|
|
375
|
+
# - finish_reason is a clean stop (not length/abrupt)
|
|
376
|
+
# - no tool calls were produced this turn
|
|
377
|
+
# - content is substantial (>= 150 chars) — short preambles before
|
|
378
|
+
# tool calls look like mid-sentence but are intentionally brief
|
|
379
|
+
# - the text genuinely ends mid-word (no terminal punct/markdown)
|
|
380
|
+
# Confidence raised to 0.75 to avoid false-positive nudges on normal
|
|
381
|
+
# prose that ends with markdown, emoji, paths, or version strings.
|
|
382
|
+
if (content and canon == CANONICAL_STOP and not tool_calls
|
|
383
|
+
and len(content.strip()) >= 150
|
|
384
|
+
and not aborted
|
|
385
|
+
and _ends_mid_sentence(content)):
|
|
386
|
+
reasons.append(("mid_sentence_stop", 0.75, "text ends abruptly mid-sentence with clean stop finish_reason"))
|
|
387
|
+
|
|
388
|
+
# ——— Abrupt disconnect (no finish_reason but content present) ———
|
|
389
|
+
# The transport normalises this: if finish_reason missing and content exists,
|
|
390
|
+
# it defaults to "stop"; if truly empty it defaults to "length". So a true
|
|
391
|
+
# abrupt case arrives here as CANONICAL_ABRUPT or empty_stream above.
|
|
392
|
+
if canon == CANONICAL_ABRUPT:
|
|
393
|
+
reasons.append(("abrupt_disconnect", 0.88, f"finish_reason={raw_fr!r} normalised to abrupt_disconnect"))
|
|
394
|
+
|
|
395
|
+
if not reasons:
|
|
396
|
+
return TruncationSignal(
|
|
397
|
+
truncated=False,
|
|
398
|
+
kind="none",
|
|
399
|
+
confidence=0.0,
|
|
400
|
+
details="no truncation signal",
|
|
401
|
+
suggested_action="none",
|
|
402
|
+
canonical_finish=canon,
|
|
403
|
+
raw_finish=raw_fr,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Pick strongest signal
|
|
407
|
+
reasons.sort(key=lambda x: x[1], reverse=True)
|
|
408
|
+
kind, conf, det = reasons[0]
|
|
409
|
+
|
|
410
|
+
# Decide action
|
|
411
|
+
if kind in ("empty_stream", "abrupt_disconnect"):
|
|
412
|
+
action = "retry" # retry same model once, then fallback
|
|
413
|
+
elif kind in ("token_limit", "incomplete_tool_call", "incomplete_fence",
|
|
414
|
+
"incomplete_think", "mid_sentence_stop"):
|
|
415
|
+
action = "continue"
|
|
416
|
+
else:
|
|
417
|
+
action = "continue"
|
|
418
|
+
|
|
419
|
+
sig = TruncationSignal(
|
|
420
|
+
truncated=True,
|
|
421
|
+
kind=kind,
|
|
422
|
+
confidence=conf,
|
|
423
|
+
details=det,
|
|
424
|
+
suggested_action=action,
|
|
425
|
+
canonical_finish=canon,
|
|
426
|
+
raw_finish=raw_fr,
|
|
427
|
+
)
|
|
428
|
+
self.history.append(sig)
|
|
429
|
+
logger.debug(f"[AdaptationEngine] analyze → {sig}")
|
|
430
|
+
return sig
|
|
431
|
+
|
|
432
|
+
# ── Policy: should we continue? ────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
def should_continue(self, signal: TruncationSignal, *, has_tool_calls: bool = False) -> bool:
|
|
435
|
+
"""Return True if we should auto-continue (inject CONTINUE nudge)."""
|
|
436
|
+
if not signal.truncated:
|
|
437
|
+
return False
|
|
438
|
+
if has_tool_calls:
|
|
439
|
+
# If the model already emitted real tool calls, the ReAct loop will
|
|
440
|
+
# handle them; don't inject a spurious CONTINUE that would confuse it.
|
|
441
|
+
# Except for incomplete_tool_call — there the args are broken so we must continue.
|
|
442
|
+
if signal.kind != "incomplete_tool_call":
|
|
443
|
+
return False
|
|
444
|
+
if self.continue_rounds >= self.max_continue_rounds:
|
|
445
|
+
logger.warning(f"[AdaptationEngine] max continues ({self.max_continue_rounds}) reached — stopping")
|
|
446
|
+
return False
|
|
447
|
+
# Debounce rapid double-continues
|
|
448
|
+
if time.time() - self.last_continue_ts < CONTINUE_COOLDOWN_S and self.continue_rounds > 0:
|
|
449
|
+
# Still allow it, just log — provider truncations can be back-to-back
|
|
450
|
+
pass
|
|
451
|
+
# Don't continue on empty_stream/abrupt if we already retried fallback path
|
|
452
|
+
if signal.suggested_action == "retry" and signal.kind == "empty_stream":
|
|
453
|
+
# Let transport's fallback handle this — orchestrator should NOT inject CONTINUE
|
|
454
|
+
return False
|
|
455
|
+
# Per-kind loop protection: heuristic/ambiguous kinds (fence parity,
|
|
456
|
+
# mid-sentence detection) may fire at most ONCE per task. If the
|
|
457
|
+
# response still "looks truncated" by the same heuristic after one
|
|
458
|
+
# nudge, it was almost certainly a false positive — continuing again
|
|
459
|
+
# just spams the user and can ping-pong the model. Hard-evidence
|
|
460
|
+
# kinds (token_limit, tool-call JSON, think tags) keep the full
|
|
461
|
+
# max_continue_rounds budget because genuine long output needs them.
|
|
462
|
+
_AMBIGUOUS_KINDS = ("incomplete_fence", "mid_sentence_stop")
|
|
463
|
+
if signal.kind in _AMBIGUOUS_KINDS and self.continue_rounds > 0:
|
|
464
|
+
logger.info(
|
|
465
|
+
f"[AdaptationEngine] kind={signal.kind!r} already fired once this task "
|
|
466
|
+
f"(rounds={self.continue_rounds}) — treating repeat as false positive, not continuing"
|
|
467
|
+
)
|
|
468
|
+
return False
|
|
469
|
+
return signal.suggested_action == "continue"
|
|
470
|
+
|
|
471
|
+
def should_retry_or_fallback(self, signal: TruncationSignal) -> bool:
|
|
472
|
+
return signal.truncated and signal.suggested_action in ("retry", "fallback")
|
|
473
|
+
|
|
474
|
+
# ── Continuation helpers ───────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
def build_continuation_messages(
|
|
477
|
+
self,
|
|
478
|
+
content: str,
|
|
479
|
+
tool_calls: List[Dict],
|
|
480
|
+
signal: TruncationSignal,
|
|
481
|
+
) -> List[Dict]:
|
|
482
|
+
"""Build the two messages to append before re-calling the LLM:
|
|
483
|
+
[assistant partial] + [user CONTINUE nudge]."""
|
|
484
|
+
msgs: List[Dict] = []
|
|
485
|
+
# Preserve the partial assistant turn so the model has exact cutoff context
|
|
486
|
+
msgs.append({
|
|
487
|
+
"role": "assistant",
|
|
488
|
+
"content": content or None,
|
|
489
|
+
"tool_calls": tool_calls if tool_calls else None,
|
|
490
|
+
})
|
|
491
|
+
# Tailored nudge per kind — keeps model from repeating itself
|
|
492
|
+
if signal.kind == "incomplete_tool_call":
|
|
493
|
+
nudge = (
|
|
494
|
+
"Your previous tool-call arguments were cut off mid-JSON (truncated). "
|
|
495
|
+
"Please re-emit the COMPLETE tool call(s) with full JSON arguments. "
|
|
496
|
+
"Do not repeat any already-executed calls — just provide the corrected tool calls now."
|
|
497
|
+
)
|
|
498
|
+
elif signal.kind == "incomplete_fence":
|
|
499
|
+
nudge = (
|
|
500
|
+
"Your previous response ended inside an unclosed code block (``` not closed). "
|
|
501
|
+
"Please continue EXACTLY where you left off — resume inside the same code block and close it properly. "
|
|
502
|
+
"Do not restart or repeat the block header."
|
|
503
|
+
)
|
|
504
|
+
elif signal.kind == "incomplete_think":
|
|
505
|
+
nudge = (
|
|
506
|
+
"Your previous reasoning block (<think>) was not closed. "
|
|
507
|
+
"Please continue your reasoning and then provide the final answer. Do not repeat earlier reasoning."
|
|
508
|
+
)
|
|
509
|
+
else:
|
|
510
|
+
nudge = (
|
|
511
|
+
"You were cut off mid-response (output limit reached). "
|
|
512
|
+
"Please continue EXACTLY where you left off — do not repeat what you already wrote, just resume from the cutoff point and complete the response."
|
|
513
|
+
)
|
|
514
|
+
msgs.append({"role": "user", "content": nudge})
|
|
515
|
+
self.continue_rounds += 1
|
|
516
|
+
self.total_continues_for_task += 1
|
|
517
|
+
self.last_continue_ts = time.time()
|
|
518
|
+
return msgs
|
|
519
|
+
|
|
520
|
+
# ── Stitching (multi-chunk merge) ──────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
def stitch(self, previous: str, new_chunk: str) -> str:
|
|
523
|
+
"""Merge a continuation chunk onto previous content, fixing common
|
|
524
|
+
glitches (duplicate headers, double fences, stray CONTINUE echoes)."""
|
|
525
|
+
if not previous:
|
|
526
|
+
return new_chunk or ""
|
|
527
|
+
if not new_chunk:
|
|
528
|
+
return previous
|
|
529
|
+
prev = previous.rstrip()
|
|
530
|
+
chunk = new_chunk.lstrip()
|
|
531
|
+
# If the model echoed the nudge or repeated the last line, strip dup prefix
|
|
532
|
+
# Simple heuristic: if first line of chunk == last line of prev, drop it
|
|
533
|
+
prev_lines = prev.splitlines()
|
|
534
|
+
chunk_lines = chunk.splitlines()
|
|
535
|
+
if prev_lines and chunk_lines and prev_lines[-1].strip() == chunk_lines[0].strip() and len(prev_lines[-1].strip()) > 20:
|
|
536
|
+
chunk = "\n".join(chunk_lines[1:]).lstrip()
|
|
537
|
+
# Avoid double-closing a fence if prev already closed it and chunk re-opens
|
|
538
|
+
# (just concatenate with a newline — markdown handles it)
|
|
539
|
+
return prev + "\n" + chunk
|
|
540
|
+
|
|
541
|
+
# ── Adaptive max_tokens ────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
def adapt_max_tokens(self, model_id: str, signal: Optional[TruncationSignal] = None) -> Optional[int]:
|
|
544
|
+
"""If this model keeps hitting token_limit, bump its effective
|
|
545
|
+
max_tokens for the next call. Returns the new cap or None if no bump."""
|
|
546
|
+
if not model_id:
|
|
547
|
+
return None
|
|
548
|
+
if signal and signal.kind != "token_limit":
|
|
549
|
+
return None
|
|
550
|
+
try:
|
|
551
|
+
from .orchestrator_helpers import _get_model_max_output
|
|
552
|
+
|
|
553
|
+
base = _get_model_max_output(model_id)
|
|
554
|
+
# Guard: if registry/config returns bogus 0/negative, use fallback
|
|
555
|
+
if not base or base <= 0:
|
|
556
|
+
base = 16_384
|
|
557
|
+
streak = self._model_trunc_streak.get(model_id, 0) + 1
|
|
558
|
+
self._model_trunc_streak[model_id] = streak
|
|
559
|
+
|
|
560
|
+
# Only bump after 2 consecutive truncations to avoid overshoot
|
|
561
|
+
if streak < 2:
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
# Bump by 25% per streak step, capped at 1.5× base and at model's hard max
|
|
565
|
+
# (the server will clamp anyway, so being generous is safe)
|
|
566
|
+
bump_factor = min(1.0 + 0.25 * (streak - 1), 1.5)
|
|
567
|
+
bumped = int(base * bump_factor)
|
|
568
|
+
# Never exceed MAX_TOKENS_GROWTH_CAP × base (server clamps anyway)
|
|
569
|
+
bumped = min(bumped, int(base * MAX_TOKENS_GROWTH_CAP), 200_000)
|
|
570
|
+
if bumped <= base:
|
|
571
|
+
return None
|
|
572
|
+
self.max_tokens_bumps[model_id] = bumped
|
|
573
|
+
logger.info(f"[AdaptationEngine] adaptive max_tokens bump for {model_id}: {base} → {bumped} (streak={streak})")
|
|
574
|
+
# Subtle console hint — dim so it doesn't alarm the user
|
|
575
|
+
try:
|
|
576
|
+
if self.console:
|
|
577
|
+
self.console.print(f"[dim]↻ Adapting output budget for this task ({base:,} → {bumped:,} tokens)…[/dim]")
|
|
578
|
+
except Exception:
|
|
579
|
+
pass
|
|
580
|
+
return bumped
|
|
581
|
+
except Exception as exc:
|
|
582
|
+
logger.debug(f"[AdaptationEngine] adapt_max_tokens failed: {exc}")
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
def record_success(self, model_id: str) -> None:
|
|
586
|
+
"""Call after a non-truncated turn to reset the streak and heal health."""
|
|
587
|
+
self._model_trunc_streak[model_id] = 0
|
|
588
|
+
# If health is below perfect, recover it a step (positive reinforcement)
|
|
589
|
+
if self.get_model_health(model_id) < HEALTH_CEILING:
|
|
590
|
+
self._adjust_health(model_id, HEALTH_RECOVERY_PER_SUCCESS)
|
|
591
|
+
|
|
592
|
+
def retry_delay_hint(self, model_id: str, attempt: int, exc: Optional[Exception] = None) -> float:
|
|
593
|
+
"""Exponential backoff hint (seconds) the transport can sleep before
|
|
594
|
+
retrying the same model. Honours provider Retry-After when present."""
|
|
595
|
+
# 1) Provider-specified delay wins
|
|
596
|
+
try:
|
|
597
|
+
ra_hdr = getattr(getattr(exc, "response", None), "headers", {}).get("Retry-After", "")
|
|
598
|
+
if ra_hdr:
|
|
599
|
+
return min(max(float(ra_hdr), 0.5), 120.0)
|
|
600
|
+
except (TypeError, ValueError, AttributeError):
|
|
601
|
+
pass
|
|
602
|
+
# 2) Exponential backoff: 1s, 2s, 4s … capped at 30s, ±20% jitter
|
|
603
|
+
base = min(2 ** max(attempt, 0), 30.0)
|
|
604
|
+
import random
|
|
605
|
+
return base * (0.8 + 0.4 * random.random())
|
|
606
|
+
|
|
607
|
+
def on_task_reset(self) -> None:
|
|
608
|
+
"""Call at the start of a new user task."""
|
|
609
|
+
self.continue_rounds = 0
|
|
610
|
+
self.stitched_content = ""
|
|
611
|
+
self.stitched_reasoning = ""
|
|
612
|
+
self.history.clear()
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# ── Silent provider-error classifier (for orchestrator_llm_transport) ──────
|
|
616
|
+
|
|
617
|
+
def is_transient_provider_breakdown(exc: Exception, response_text: str = "") -> bool:
|
|
618
|
+
"""Unified predicate the transport can use to decide 'silently retry next
|
|
619
|
+
model' vs 'surface to user'."""
|
|
620
|
+
code = getattr(getattr(exc, "response", None), "status_code", None)
|
|
621
|
+
text = f"{exc} {response_text}".strip()
|
|
622
|
+
if code in (500, 502, 520, 521, 522, 523, 524):
|
|
623
|
+
return True
|
|
624
|
+
if _is_transient_empty_stream_error(text):
|
|
625
|
+
return True
|
|
626
|
+
if "provider returned error" in text.lower():
|
|
627
|
+
return True
|
|
628
|
+
return False
|