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.
Files changed (211) hide show
  1. {utim_cli-2.5.3/utim_cli.egg-info → utim_cli-2.5.4}/PKG-INFO +1 -1
  2. {utim_cli-2.5.3 → utim_cli-2.5.4}/pyproject.toml +1 -1
  3. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim/__init__.py +1 -1
  4. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/__init__.py +1 -1
  5. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/_version.py +1 -1
  6. utim_cli-2.5.4/utim_cli/adaptation_engine.py +628 -0
  7. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/agent.py +18 -0
  8. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/client_utils.py +86 -18
  9. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/context_pruner.py +8 -1
  10. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/orchestrator.py +108 -42
  11. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/orchestrator_helpers.py +24 -0
  12. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/orchestrator_llm_transport.py +114 -1
  13. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/orchestrator_tool_exec.py +24 -6
  14. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/orchestrator_turn_history.py +9 -5
  15. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/api_models_db.py +31 -1
  16. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/auth.py +76 -23
  17. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/db.py +274 -21
  18. utim_cli-2.5.4/utim_cli/server/email_utils.py +1125 -0
  19. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/hobby_throttler.py +141 -5
  20. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/models.py +11 -12
  21. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/request_queue.py +574 -450
  22. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/router.py +16 -3
  23. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/__init__.py +3 -1
  24. utim_cli-2.5.4/utim_cli/server/routes/activity_routes.py +150 -0
  25. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/api_gateway_extensions_routes.py +5 -0
  26. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/api_models_admin_routes.py +102 -0
  27. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/api_models_public_routes.py +583 -68
  28. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/auth_routes.py +8 -1
  29. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/completion_routes.py +539 -31
  30. utim_cli-2.5.4/utim_cli/server/routes/discord_routes.py +275 -0
  31. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/provider_compat_routes.py +7 -4
  32. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/quota_routes.py +17 -17
  33. utim_cli-2.5.4/utim_cli/server/uptime_monitor.py +149 -0
  34. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tools.py +8710 -8656
  35. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/model_dialog.py +21 -2
  36. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/utim.py +2 -2
  37. {utim_cli-2.5.3 → utim_cli-2.5.4/utim_cli.egg-info}/PKG-INFO +1 -1
  38. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli.egg-info/SOURCES.txt +4 -0
  39. utim_cli-2.5.3/utim_cli/server/email_utils.py +0 -996
  40. {utim_cli-2.5.3 → utim_cli-2.5.4}/CHANGELOG.md +0 -0
  41. {utim_cli-2.5.3 → utim_cli-2.5.4}/LICENSE +0 -0
  42. {utim_cli-2.5.3 → utim_cli-2.5.4}/MANIFEST.in +0 -0
  43. {utim_cli-2.5.3 → utim_cli-2.5.4}/README.md +0 -0
  44. {utim_cli-2.5.3 → utim_cli-2.5.4}/setup.cfg +0 -0
  45. {utim_cli-2.5.3 → utim_cli-2.5.4}/setup.py +0 -0
  46. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/_chip_test.py +0 -0
  47. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/_verify_repro.py +0 -0
  48. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/_verify_syntax.py +0 -0
  49. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/_wizard_smoke_test.py +0 -0
  50. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/architecture_indexer.py +0 -0
  51. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/ask_helper.py +0 -0
  52. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/auth.py +0 -0
  53. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/backup.py +0 -0
  54. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/billing.py +0 -0
  55. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/blender_agent.py +0 -0
  56. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/bootstrap.py +0 -0
  57. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/brain.py +0 -0
  58. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/builtin/skills/utim-guide/SKILL.md +0 -0
  59. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/config.py +0 -0
  60. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/constants.py +0 -0
  61. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/context_inspector.py +0 -0
  62. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/crawler/__init__.py +0 -0
  63. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/crawler/engine.py +0 -0
  64. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/crawler/markdown_distiller.py +0 -0
  65. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/dependency_manager.py +0 -0
  66. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/doctor.py +0 -0
  67. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/evidence_registry.py +0 -0
  68. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/harbor.py +0 -0
  69. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/knowledge_graph.py +0 -0
  70. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/local_db.py +0 -0
  71. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/logger.py +0 -0
  72. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/mcp_clean_wrapper.py +0 -0
  73. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/mcp_client.py +0 -0
  74. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/mcp_registry.json +0 -0
  75. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/models.txt +0 -0
  76. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/personalities.py +0 -0
  77. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/platform_installer.py +0 -0
  78. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/__init__.py +0 -0
  79. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/hooks.py +0 -0
  80. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/loader.py +0 -0
  81. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/manager.py +0 -0
  82. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/registry.py +0 -0
  83. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/runtime.py +0 -0
  84. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/schema.py +0 -0
  85. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/plugins/tool_loader.py +0 -0
  86. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/reflection.py +0 -0
  87. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/report.py +0 -0
  88. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/scrapy_search.py +0 -0
  89. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/__init__.py +0 -0
  90. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/config.py +0 -0
  91. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/context.py +0 -0
  92. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/events.py +0 -0
  93. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/handlers.py +0 -0
  94. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/session.py +0 -0
  95. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/sdk/sidecar.py +0 -0
  96. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/__init__.py +0 -0
  97. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/admin_auth.py +0 -0
  98. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/api_batcher.py +0 -0
  99. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/attribution.py +0 -0
  100. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/audit_log.py +0 -0
  101. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/batch_processor.py +0 -0
  102. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/captcha.py +0 -0
  103. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/cli_auth.py +0 -0
  104. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/concurrency.py +0 -0
  105. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/crypto_utils.py +0 -0
  106. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
  107. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/about.md +0 -0
  108. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/changelog.md +0 -0
  109. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/docs.md +0 -0
  110. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/features.md +0 -0
  111. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/license.md +0 -0
  112. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/pricing.md +0 -0
  113. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/privacy.md +0 -0
  114. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/refund.md +0 -0
  115. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/support.md +0 -0
  116. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/docs_md/terms.md +0 -0
  117. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/exchange_rate.py +0 -0
  118. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/firebase.py +0 -0
  119. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/firewall.py +0 -0
  120. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/fix_duplicate_users.py +0 -0
  121. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/history.py +0 -0
  122. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/key_rotator.py +0 -0
  123. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/local_llm.py +0 -0
  124. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/logging_config.py +0 -0
  125. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/maintenance.py +0 -0
  126. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/micro_batcher.py +0 -0
  127. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/model_agent.py +0 -0
  128. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/plan_integrity.py +0 -0
  129. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/platform_billing.py +0 -0
  130. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/pricing_updater.py +0 -0
  131. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/provider_balance.py +0 -0
  132. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/provider_discovery.py +0 -0
  133. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/provider_router.py +0 -0
  134. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/provision_build.py +0 -0
  135. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/rate_limit.py +0 -0
  136. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/request_batcher.py +0 -0
  137. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/rewards_engine.py +0 -0
  138. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/admin_bonus_routes.py +0 -0
  139. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/admin_db_routes.py +0 -0
  140. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/admin_providers_routes.py +0 -0
  141. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/admin_separated_models_routes.py +0 -0
  142. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/api_key_routes.py +0 -0
  143. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/billing_routes.py +0 -0
  144. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/credit_routes.py +0 -0
  145. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/feedback_routes.py +0 -0
  146. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/marketplace_routes.py +0 -0
  147. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/quota_share_routes.py +0 -0
  148. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/referral_routes.py +0 -0
  149. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/rewards_routes.py +0 -0
  150. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/security_routes.py +0 -0
  151. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/session_routes.py +0 -0
  152. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/share_routes.py +0 -0
  153. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/wallet_routes.py +0 -0
  154. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/routes/webhook_routes.py +0 -0
  155. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/security_alerts.py +0 -0
  156. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/server.py +0 -0
  157. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/signup_guard.py +0 -0
  158. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/storage_nodes.py +0 -0
  159. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/server/utils.py +0 -0
  160. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/share.py +0 -0
  161. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/share_tui.py +0 -0
  162. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/situational_scoring.py +0 -0
  163. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/state.py +0 -0
  164. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/subagent_manager.py +0 -0
  165. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/syntax_fix.py +0 -0
  166. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/task_dispatcher.py +0 -0
  167. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/task_logger.py +0 -0
  168. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tools_grep.py +0 -0
  169. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/__init__.py +0 -0
  170. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/compression_dialog.py +0 -0
  171. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/feedback_dialog.py +0 -0
  172. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/history_dialog.py +0 -0
  173. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/marketplace_app_state.py +0 -0
  174. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/marketplace_dialog.py +0 -0
  175. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/marketplace_layout_engine.py +0 -0
  176. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/mcp_dialog.py +0 -0
  177. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/miniagents_dialog.py +0 -0
  178. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/personality_dialog.py +0 -0
  179. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/plugins_dialog.py +0 -0
  180. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/publish_dialog.py +0 -0
  181. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/quota_dialog.py +0 -0
  182. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/quota_redeem_dialog.py +0 -0
  183. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/quota_share_dialog.py +0 -0
  184. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/resume_dialog.py +0 -0
  185. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/rewards_tui.py +0 -0
  186. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/skills_dialog.py +0 -0
  187. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/subagents_dialog.py +0 -0
  188. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/tasks_dialog.py +0 -0
  189. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/thinking_display.py +0 -0
  190. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/tools_dialog.py +0 -0
  191. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/update_dialog.py +0 -0
  192. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/tui/verbosity_dialog.py +0 -0
  193. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/update_checker.py +0 -0
  194. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/utilities.py +0 -0
  195. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/utimmodel.txt +0 -0
  196. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/vector_memory.py +0 -0
  197. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice.py +0 -0
  198. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_coding.py +0 -0
  199. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_events.py +0 -0
  200. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_handler.py +0 -0
  201. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_instant.py +0 -0
  202. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_orchestrator.py +0 -0
  203. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_session.py +0 -0
  204. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_stt.py +0 -0
  205. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/voice_tts.py +0 -0
  206. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/wheel.py +0 -0
  207. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli/workspace.py +0 -0
  208. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli.egg-info/dependency_links.txt +0 -0
  209. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli.egg-info/entry_points.txt +0 -0
  210. {utim_cli-2.5.3 → utim_cli-2.5.4}/utim_cli.egg-info/requires.txt +0 -0
  211. {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
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utim-cli"
7
- version = "2.5.3"
7
+ version = "2.5.4"
8
8
 
9
9
  description = "UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal."
10
10
  readme = "README.md"
@@ -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.3"
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
@@ -5,7 +5,7 @@ try:
5
5
  from utim_cli._version import VERSION as __version__
6
6
  except ImportError:
7
7
  # Fallback during first-install / editable-install edge cases
8
- __version__ = "2.5.3"
8
+ __version__ = "2.5.4"
9
9
 
10
10
 
11
11
  import builtins, os, pathlib, urllib.parse, re
@@ -11,6 +11,6 @@
11
11
  # from here so every surface stays in sync automatically.
12
12
  # ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
- VERSION = "2.5.3"
14
+ VERSION = "2.5.4"
15
15
 
16
16
 
@@ -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