alysis-code 0.13.0__py3-none-any.whl

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 (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,774 @@
1
+ """Sampling determinism controls and provider-response provenance.
2
+
3
+ Two production incidents motivate this module, and both were *unprovable* after
4
+ the fact rather than merely unfixed.
5
+
6
+ *Silent model drift.* Three Terminal-Bench trials of one pinned build under one
7
+ pinned config disagreed on 30 of 89 tasks. Two SWE-bench runs of a
8
+ byte-identical build scored 80 and then 71 four days apart, with the delegation
9
+ pattern changing beyond recognition (one subagent went from 128 invocations to
10
+ zero). Server-side drift was the obvious suspect and stayed a suspicion,
11
+ because nothing in the run recorded which model actually answered. The response
12
+ ``model`` field, the ``system_fingerprint`` and a small set of routing headers
13
+ are the only evidence a client can collect without the provider's cooperation,
14
+ so this module selects them and hands them to provider telemetry.
15
+
16
+ *Unpinned sampling.* The client pinned no seed, requested no ``top_p``, and let
17
+ ``temperature`` follow whichever code path built the request, so two runs of one
18
+ build were free to sample differently from an identical prompt. The three
19
+ settings here are opt-in precisely because switching them on changes the wire
20
+ request: when none is configured the payload is left exactly as the transport
21
+ built it. :func:`apply_sampling_to_payload` guarantees that by construction --
22
+ it is the single place any of the three fields can enter a payload -- and
23
+ ``tests/test_sampling_config.py`` asserts it byte-for-byte.
24
+
25
+ Stdlib only, no package imports: the transport, the telemetry recorder and the
26
+ session bootstrap all pull this in, and the tests load it straight from this
27
+ file path in a bare interpreter.
28
+
29
+ Redaction boundary
30
+ ------------------
31
+ This module *shapes* values, it never persists them. Every caller writes
32
+ through a sink that funnels its serialized line through
33
+ ``logging_redaction.redact_log_text`` immediately before the write
34
+ (``SessionStore.append`` and ``provider_telemetry._append_to_sink``), and
35
+ ``provider_telemetry`` redacts each selected header value a second time before
36
+ it reaches the in-memory history. Independently of redaction, the header
37
+ allowlist here is bounded by a deny list that no user-supplied allowlist can
38
+ widen, so a credential-bearing header cannot be selected in the first place.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import fnmatch
44
+ import math
45
+ import os
46
+ import threading
47
+ from collections.abc import Mapping
48
+ from dataclasses import dataclass, field
49
+ from typing import Any
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Sampling determinism controls
53
+ # ---------------------------------------------------------------------------
54
+
55
+ SAMPLING_TEMPERATURE_ENV = "ALYSIS_SAMPLING_TEMPERATURE"
56
+ SAMPLING_TOP_P_ENV = "ALYSIS_SAMPLING_TOP_P"
57
+ SAMPLING_SEED_ENV = "ALYSIS_SAMPLING_SEED"
58
+
59
+ SAMPLING_TEMPERATURE_CONFIG_KEY = "sampling_temperature"
60
+ SAMPLING_TOP_P_CONFIG_KEY = "sampling_top_p"
61
+ SAMPLING_SEED_CONFIG_KEY = "sampling_seed"
62
+
63
+ #: Accepted ranges. Deliberately the widest range every OpenAI-compatible
64
+ #: endpoint we target documents, so a legitimate value is never dropped; a
65
+ #: provider that is stricter still rejects it on the wire, which is visible.
66
+ TEMPERATURE_RANGE = (0.0, 2.0)
67
+ TOP_P_RANGE = (0.0, 1.0)
68
+ #: Signed 64-bit, the widest seed any of these endpoints accepts.
69
+ SEED_RANGE = (-(2**63), 2**63 - 1)
70
+
71
+ #: Longest raw value echoed back in a warning event. A misconfigured variable
72
+ #: is sometimes a pasted credential, and while the sink redacts on write, a
73
+ #: bounded echo keeps the blast radius small even before that.
74
+ MAX_WARNING_VALUE_CHARS = 40
75
+
76
+ _SAMPLING_SETTING_NAMES = ("temperature", "top_p", "seed")
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class SamplingWarning:
81
+ """One sampling value that was rejected and ignored.
82
+
83
+ An invalid sampling setting must never abort a run: an operator typo in a
84
+ benchmark harness would otherwise take down the whole campaign. The value
85
+ is dropped, the default behavior stands, and this record explains why.
86
+ """
87
+
88
+ setting: str
89
+ source: str
90
+ reason: str
91
+ raw: str
92
+
93
+ def message(self) -> str:
94
+ return (
95
+ f"ignoring {self.source}={self.raw!r}: {self.reason}; "
96
+ f"sampling {self.setting} is left unset"
97
+ )
98
+
99
+ def payload(self) -> dict[str, str]:
100
+ return {
101
+ "setting": self.setting,
102
+ "source": self.source,
103
+ "reason": self.reason,
104
+ "raw": self.raw,
105
+ }
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class SamplingSettings:
110
+ """The effective sampling overrides for a run.
111
+
112
+ ``None`` means "not configured", which is not the same as "configured to
113
+ the provider default": an unconfigured setting is never written to a
114
+ request at all.
115
+ """
116
+
117
+ temperature: float | None = None
118
+ top_p: float | None = None
119
+ seed: int | None = None
120
+ sources: Mapping[str, str] = field(default_factory=dict)
121
+ warnings: tuple[SamplingWarning, ...] = ()
122
+
123
+ @property
124
+ def is_configured(self) -> bool:
125
+ return self.temperature is not None or self.top_p is not None or self.seed is not None
126
+
127
+ def telemetry_payload(self) -> dict[str, Any]:
128
+ """Per-request sampling record. Present on every call, configured or not.
129
+
130
+ The absence of sampling controls is itself the finding when two runs
131
+ of one build diverge, so this never collapses to ``None``.
132
+ """
133
+ return {
134
+ "configured": self.is_configured,
135
+ "temperature": self.temperature,
136
+ "top_p": self.top_p,
137
+ "seed": self.seed,
138
+ "sources": {str(k): str(v) for k, v in sorted(dict(self.sources).items())},
139
+ }
140
+
141
+ def session_event_payload(self) -> dict[str, Any]:
142
+ """Once-per-run record, including anything that was rejected."""
143
+ payload = self.telemetry_payload()
144
+ payload["warnings"] = [warning.payload() for warning in self.warnings]
145
+ payload["warning_count"] = len(self.warnings)
146
+ return payload
147
+
148
+
149
+ def _clean_text(value: Any) -> str:
150
+ if value is None:
151
+ return ""
152
+ return str(value).strip()
153
+
154
+
155
+ def _truncate(value: str, limit: int) -> str:
156
+ if len(value) <= limit:
157
+ return value
158
+ return value[:limit] + "..."
159
+
160
+
161
+ def _parse_bounded_float(
162
+ raw: str,
163
+ *,
164
+ setting: str,
165
+ source: str,
166
+ low: float,
167
+ high: float,
168
+ ) -> tuple[float | None, SamplingWarning | None]:
169
+ echo = _truncate(raw, MAX_WARNING_VALUE_CHARS)
170
+ try:
171
+ value = float(raw)
172
+ except (TypeError, ValueError):
173
+ return None, SamplingWarning(setting, source, "not a number", echo)
174
+ if not math.isfinite(value):
175
+ return None, SamplingWarning(setting, source, "not finite", echo)
176
+ if value < low or value > high:
177
+ return None, SamplingWarning(
178
+ setting,
179
+ source,
180
+ f"outside the accepted range [{low}, {high}]",
181
+ echo,
182
+ )
183
+ return value, None
184
+
185
+
186
+ def _parse_bounded_int(
187
+ raw: str,
188
+ *,
189
+ setting: str,
190
+ source: str,
191
+ low: int,
192
+ high: int,
193
+ ) -> tuple[int | None, SamplingWarning | None]:
194
+ echo = _truncate(raw, MAX_WARNING_VALUE_CHARS)
195
+ try:
196
+ value = int(raw, 10) if isinstance(raw, str) else int(raw)
197
+ except (TypeError, ValueError):
198
+ return None, SamplingWarning(setting, source, "not an integer", echo)
199
+ if value < low or value > high:
200
+ return None, SamplingWarning(
201
+ setting,
202
+ source,
203
+ "outside the accepted 64-bit range",
204
+ echo,
205
+ )
206
+ return value, None
207
+
208
+
209
+ def _pick_source(
210
+ *,
211
+ env_name: str,
212
+ config_key: str,
213
+ environ: Mapping[str, str],
214
+ config_values: Mapping[str, Any],
215
+ ) -> tuple[str, str] | None:
216
+ """Return ``(raw_value, source_label)``, env winning over config.
217
+
218
+ Matches the house precedence used everywhere else in the CLI: an
219
+ environment variable is how a benchmark harness pins a run, and it must
220
+ beat whatever happens to be in the operator's ``config.json``.
221
+ """
222
+ resolved_env_name = env_name
223
+ raw_env = _clean_text(environ.get(env_name))
224
+ if not raw_env and env_name.startswith("ALYSIS_"):
225
+ legacy_env_name = "SYLLIPTOR_" + env_name.removeprefix("ALYSIS_")
226
+ raw_env = _clean_text(environ.get(legacy_env_name))
227
+ if raw_env:
228
+ resolved_env_name = legacy_env_name
229
+ if raw_env:
230
+ return raw_env, f"env:{resolved_env_name}"
231
+ if config_key in config_values:
232
+ raw_config = _clean_text(config_values.get(config_key))
233
+ if raw_config:
234
+ return raw_config, f"config:{config_key}"
235
+ return None
236
+
237
+
238
+ def resolve_sampling_settings(
239
+ *,
240
+ config_values: Mapping[str, Any] | None = None,
241
+ environ: Mapping[str, str] | None = None,
242
+ ) -> SamplingSettings:
243
+ """Resolve the three sampling controls from env, then config.
244
+
245
+ Every failure mode -- unparseable, non-finite, out of range -- resolves to
246
+ "not configured" plus a warning. Nothing here can raise.
247
+ """
248
+ source_env: Mapping[str, str] = os.environ if environ is None else environ
249
+ values: Mapping[str, Any] = {} if config_values is None else config_values
250
+
251
+ resolved: dict[str, Any] = {}
252
+ sources: dict[str, str] = {}
253
+ warnings: list[SamplingWarning] = []
254
+
255
+ plan = (
256
+ ("temperature", SAMPLING_TEMPERATURE_ENV, SAMPLING_TEMPERATURE_CONFIG_KEY),
257
+ ("top_p", SAMPLING_TOP_P_ENV, SAMPLING_TOP_P_CONFIG_KEY),
258
+ ("seed", SAMPLING_SEED_ENV, SAMPLING_SEED_CONFIG_KEY),
259
+ )
260
+ for setting, env_name, config_key in plan:
261
+ picked = _pick_source(
262
+ env_name=env_name,
263
+ config_key=config_key,
264
+ environ=source_env,
265
+ config_values=values,
266
+ )
267
+ if picked is None:
268
+ continue
269
+ raw, source = picked
270
+ if setting == "seed":
271
+ value, warning = _parse_bounded_int(
272
+ raw,
273
+ setting=setting,
274
+ source=source,
275
+ low=SEED_RANGE[0],
276
+ high=SEED_RANGE[1],
277
+ )
278
+ else:
279
+ low, high = TEMPERATURE_RANGE if setting == "temperature" else TOP_P_RANGE
280
+ value, warning = _parse_bounded_float(
281
+ raw,
282
+ setting=setting,
283
+ source=source,
284
+ low=low,
285
+ high=high,
286
+ )
287
+ if warning is not None:
288
+ warnings.append(warning)
289
+ continue
290
+ resolved[setting] = value
291
+ sources[setting] = source
292
+
293
+ return SamplingSettings(
294
+ temperature=resolved.get("temperature"),
295
+ top_p=resolved.get("top_p"),
296
+ seed=resolved.get("seed"),
297
+ sources=sources,
298
+ warnings=tuple(warnings),
299
+ )
300
+
301
+
302
+ def apply_sampling_to_payload(
303
+ payload: dict[str, Any],
304
+ settings: SamplingSettings,
305
+ *,
306
+ allow_temperature_override: bool = True,
307
+ ) -> tuple[str, ...]:
308
+ """Write the configured sampling fields into an already-built payload.
309
+
310
+ This is the *only* place any of the three fields may enter a
311
+ chat-completions request, which is what makes the "unset means unchanged"
312
+ guarantee checkable rather than aspirational: with an unconfigured
313
+ ``settings`` this returns ``()`` and leaves ``payload`` untouched -- same
314
+ object, same keys, same insertion order, hence the same serialized bytes
315
+ as the pre-PR6 transport produced.
316
+
317
+ ``temperature`` is only *overridden*, never introduced. When the transport
318
+ deliberately omitted it (a documented model policy, or a cached provider
319
+ rejection) that omission is load-bearing, and re-adding the field would
320
+ reintroduce the 400 the omission exists to avoid. ``allow_temperature_override``
321
+ lets the caller additionally decline when it has already rewritten the
322
+ value for provider-compatibility reasons.
323
+
324
+ Returns the field names actually written, in insertion order.
325
+ """
326
+ applied: list[str] = []
327
+ override_temperature = (
328
+ settings.temperature is not None and allow_temperature_override and "temperature" in payload
329
+ )
330
+ if override_temperature:
331
+ payload["temperature"] = settings.temperature
332
+ applied.append("temperature")
333
+ if settings.top_p is not None:
334
+ payload["top_p"] = settings.top_p
335
+ applied.append("top_p")
336
+ if settings.seed is not None:
337
+ payload["seed"] = settings.seed
338
+ applied.append("seed")
339
+ return tuple(applied)
340
+
341
+
342
+ # The process-wide active settings. The transport is several call layers below
343
+ # the place that knows the effective config, and threading a settings object
344
+ # through every constructor would be the refactor this change is meant to
345
+ # avoid; this mirrors the existing process-wide provider-telemetry sink.
346
+ _ACTIVE_LOCK = threading.Lock()
347
+ _ACTIVE_SETTINGS: SamplingSettings | None = None
348
+
349
+
350
+ def set_active_sampling_settings(settings: SamplingSettings | None) -> None:
351
+ """Install the run's resolved sampling settings (``None`` restores env-only)."""
352
+ global _ACTIVE_SETTINGS
353
+ with _ACTIVE_LOCK:
354
+ _ACTIVE_SETTINGS = settings
355
+
356
+
357
+ def active_sampling_settings() -> SamplingSettings:
358
+ """Return the installed settings, falling back to an env-only resolution.
359
+
360
+ The fallback matters for every entry point that reaches the transport
361
+ without building a session (``config`` subcommands, doctors, probes): those
362
+ still honor the environment, and still report "not configured" when the
363
+ environment is empty.
364
+ """
365
+ global _ACTIVE_SETTINGS
366
+ with _ACTIVE_LOCK:
367
+ if _ACTIVE_SETTINGS is None:
368
+ _ACTIVE_SETTINGS = resolve_sampling_settings()
369
+ return _ACTIVE_SETTINGS
370
+
371
+
372
+ def reset_active_sampling_settings_for_tests() -> None:
373
+ global _ACTIVE_SETTINGS
374
+ with _ACTIVE_LOCK:
375
+ _ACTIVE_SETTINGS = None
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Response fingerprint capture
380
+ # ---------------------------------------------------------------------------
381
+
382
+ #: Headers worth keeping by name. Each one has, at some point, been the only
383
+ #: thing distinguishing two responses that claimed the same model.
384
+ DEFAULT_RESPONSE_HEADER_NAMES = (
385
+ "openai-organization",
386
+ "server",
387
+ "via",
388
+ "x-model-version",
389
+ "x-request-id",
390
+ "x-served-by",
391
+ )
392
+ #: Plus anything a custom endpoint chose to call a model or a version. Custom
393
+ #: OpenAI-compatible gateways name these fields freely; the MiMo endpoint that
394
+ #: exposed the drift is exactly such a gateway.
395
+ DEFAULT_RESPONSE_HEADER_PATTERNS = ("*model*", "*version*")
396
+
397
+ RESPONSE_HEADER_ALLOWLIST_ENV = "ALYSIS_RESPONSE_HEADER_ALLOWLIST"
398
+
399
+ #: Never captured, whatever the allowlist says. An operator who sets the
400
+ #: allowlist to ``*`` is asking for provenance, not for their bearer token in
401
+ #: a JSONL file, and PR1 exists because that exact class of mistake already
402
+ #: put a live credential on disk once.
403
+ RESPONSE_HEADER_DENY_PATTERNS = (
404
+ "*auth*",
405
+ "*cookie*",
406
+ "*credential*",
407
+ "*key*",
408
+ "*password*",
409
+ "*secret*",
410
+ "*token*",
411
+ )
412
+
413
+ MAX_RESPONSE_HEADER_VALUE_CHARS = 200
414
+ MAX_RESPONSE_HEADERS = 24
415
+
416
+ _ALLOWLIST_DISABLED_WORDS = frozenset({"none", "off", "-"})
417
+ _GLOB_CHARS = ("*", "?", "[")
418
+
419
+
420
+ def _normalize_header_name(name: Any) -> str:
421
+ return _clean_text(name).casefold()
422
+
423
+
424
+ def header_name_is_denied(name: str) -> bool:
425
+ """True when a header may never be captured, allowlist notwithstanding."""
426
+ normalized = _normalize_header_name(name)
427
+ if not normalized:
428
+ return True
429
+ return any(
430
+ fnmatch.fnmatchcase(normalized, pattern) for pattern in RESPONSE_HEADER_DENY_PATTERNS
431
+ )
432
+
433
+
434
+ @dataclass(frozen=True)
435
+ class ResponseHeaderAllowlist:
436
+ """Exact names plus glob patterns, matched case-insensitively."""
437
+
438
+ names: frozenset[str] = frozenset()
439
+ patterns: tuple[str, ...] = ()
440
+
441
+ @property
442
+ def is_empty(self) -> bool:
443
+ return not self.names and not self.patterns
444
+
445
+ def matches(self, header_name: str) -> bool:
446
+ normalized = _normalize_header_name(header_name)
447
+ if not normalized or header_name_is_denied(normalized):
448
+ return False
449
+ if normalized in self.names:
450
+ return True
451
+ return any(fnmatch.fnmatchcase(normalized, pattern) for pattern in self.patterns)
452
+
453
+ def describe(self) -> dict[str, Any]:
454
+ return {
455
+ "names": sorted(self.names),
456
+ "patterns": list(self.patterns),
457
+ }
458
+
459
+
460
+ DEFAULT_RESPONSE_HEADER_ALLOWLIST = ResponseHeaderAllowlist(
461
+ names=frozenset(DEFAULT_RESPONSE_HEADER_NAMES),
462
+ patterns=DEFAULT_RESPONSE_HEADER_PATTERNS,
463
+ )
464
+
465
+
466
+ def parse_response_header_allowlist(raw: str | None) -> ResponseHeaderAllowlist:
467
+ """Parse a comma/whitespace-separated allowlist spec.
468
+
469
+ Unset or blank keeps the default. The words ``none``/``off``/``-`` disable
470
+ header capture entirely, which is the escape hatch for an operator who
471
+ considers response headers sensitive in their deployment.
472
+ """
473
+ text = _clean_text(raw)
474
+ if not text:
475
+ return DEFAULT_RESPONSE_HEADER_ALLOWLIST
476
+ entries = [
477
+ _normalize_header_name(part)
478
+ for chunk in text.split(",")
479
+ for part in chunk.split()
480
+ if _clean_text(part)
481
+ ]
482
+ entries = [entry for entry in entries if entry]
483
+ if not entries:
484
+ return DEFAULT_RESPONSE_HEADER_ALLOWLIST
485
+ if len(entries) == 1 and entries[0] in _ALLOWLIST_DISABLED_WORDS:
486
+ return ResponseHeaderAllowlist()
487
+ names = {entry for entry in entries if not any(char in entry for char in _GLOB_CHARS)}
488
+ patterns = tuple(
489
+ dict.fromkeys(entry for entry in entries if any(char in entry for char in _GLOB_CHARS))
490
+ )
491
+ return ResponseHeaderAllowlist(names=frozenset(names), patterns=patterns)
492
+
493
+
494
+ def resolve_response_header_allowlist(
495
+ *,
496
+ environ: Mapping[str, str] | None = None,
497
+ ) -> ResponseHeaderAllowlist:
498
+ source: Mapping[str, str] = os.environ if environ is None else environ
499
+ return parse_response_header_allowlist(source.get(RESPONSE_HEADER_ALLOWLIST_ENV))
500
+
501
+
502
+ def select_response_headers(
503
+ headers: Any,
504
+ *,
505
+ allowlist: ResponseHeaderAllowlist | None = None,
506
+ ) -> dict[str, str]:
507
+ """Return the allowlisted response headers, lowercased, bounded and sorted.
508
+
509
+ Accepts anything mapping-like, including ``httpx.Headers``, and tolerates a
510
+ ``None`` or a broken object -- provenance capture must never be the reason
511
+ a provider call fails.
512
+ """
513
+ active = DEFAULT_RESPONSE_HEADER_ALLOWLIST if allowlist is None else allowlist
514
+ if active.is_empty:
515
+ return {}
516
+ try:
517
+ items = list(headers.items())
518
+ except Exception: # noqa: BLE001 - never let capture break a provider call
519
+ return {}
520
+ selected: dict[str, str] = {}
521
+ for raw_name, raw_value in items:
522
+ name = _normalize_header_name(raw_name)
523
+ if not active.matches(name):
524
+ continue
525
+ selected[name] = _truncate(_clean_text(raw_value), MAX_RESPONSE_HEADER_VALUE_CHARS)
526
+ if len(selected) >= MAX_RESPONSE_HEADERS:
527
+ break
528
+ return dict(sorted(selected.items()))
529
+
530
+
531
+ def extract_system_fingerprint(raw: Any) -> str | None:
532
+ """Pull ``system_fingerprint`` out of a parsed response body, if present."""
533
+ if not isinstance(raw, Mapping):
534
+ return None
535
+ value = _clean_text(raw.get("system_fingerprint"))
536
+ return value or None
537
+
538
+
539
+ def response_fingerprint_payload(
540
+ *,
541
+ response_model: str | None = None,
542
+ system_fingerprint: str | None = None,
543
+ headers: Mapping[str, str] | None = None,
544
+ ) -> dict[str, Any]:
545
+ """Shape the per-response provenance record for provider telemetry.
546
+
547
+ Always returns every key. A run where ``system_fingerprint`` is
548
+ consistently absent is a different finding from a run where it changed
549
+ mid-flight, and only an always-present field can tell them apart.
550
+
551
+ The header key is ``response_headers``, not ``headers``: provider
552
+ telemetry's own redactor replaces any value under a key named ``headers``
553
+ with ``[omitted]``, so the plain name would silently discard exactly the
554
+ evidence this record exists to carry.
555
+ """
556
+ safe_headers = {str(k): str(v) for k, v in dict(headers or {}).items()}
557
+ return {
558
+ "response_model": _clean_text(response_model) or None,
559
+ "system_fingerprint": _clean_text(system_fingerprint) or None,
560
+ "response_headers": safe_headers,
561
+ "response_header_count": len(safe_headers),
562
+ }
563
+
564
+
565
+ def fingerprint_drift_payload(calls: Any) -> dict[str, Any]:
566
+ """Roll per-call fingerprints up into a drift verdict for one run.
567
+
568
+ This is the question the earlier investigations could not answer. Two runs
569
+ of a byte-identical build scored 80 and then 71, and the hypothesis --
570
+ that the endpoint had quietly started serving something else -- could
571
+ neither be confirmed nor dismissed. Grouping by the model *requested*
572
+ answers it directly: one requested model that came back under two different
573
+ ``system_fingerprint`` values, or under two different response ``model``
574
+ names, is drift observed rather than inferred.
575
+
576
+ Pure over a list of already-recorded provider-call payloads, so it can be
577
+ run against the in-memory history or against a replayed telemetry JSONL.
578
+ """
579
+ groups: dict[str, dict[str, Any]] = {}
580
+ models: set[str] = set()
581
+ fingerprints: set[str] = set()
582
+ call_count = 0
583
+ fingerprint_present = 0
584
+
585
+ for call in calls or []:
586
+ if not isinstance(call, Mapping):
587
+ continue
588
+ call_count += 1
589
+ requested = _clean_text(call.get("model"))
590
+ record = call.get("response_fingerprint")
591
+ record = record if isinstance(record, Mapping) else {}
592
+ response_model = _clean_text(record.get("response_model"))
593
+ fingerprint = _clean_text(record.get("system_fingerprint"))
594
+ if fingerprint:
595
+ fingerprint_present += 1
596
+ fingerprints.add(fingerprint)
597
+ if response_model:
598
+ models.add(response_model)
599
+ group = groups.setdefault(
600
+ requested,
601
+ {"requested_model": requested, "call_count": 0, "_models": set(), "_fps": set()},
602
+ )
603
+ group["call_count"] += 1
604
+ if response_model:
605
+ group["_models"].add(response_model)
606
+ if fingerprint:
607
+ group["_fps"].add(fingerprint)
608
+
609
+ by_requested_model = []
610
+ drift = False
611
+ for _key, group in sorted(groups.items()):
612
+ group_models = sorted(group.pop("_models"))
613
+ group_fps = sorted(group.pop("_fps"))
614
+ group_drift = len(group_models) > 1 or len(group_fps) > 1
615
+ drift = drift or group_drift
616
+ group["response_models"] = group_models
617
+ group["system_fingerprints"] = group_fps
618
+ group["drift_detected"] = group_drift
619
+ by_requested_model.append(group)
620
+
621
+ return {
622
+ "window_call_count": call_count,
623
+ "response_models": sorted(models),
624
+ "system_fingerprints": sorted(fingerprints),
625
+ "distinct_response_model_count": len(models),
626
+ "distinct_system_fingerprint_count": len(fingerprints),
627
+ "system_fingerprint_present_call_count": fingerprint_present,
628
+ # Absence is a finding too: an endpoint that never sends a fingerprint
629
+ # cannot be monitored this way, and saying so beats an empty field that
630
+ # reads like "no drift".
631
+ "system_fingerprint_absent_call_count": max(0, call_count - fingerprint_present),
632
+ "drift_detected": drift,
633
+ "by_requested_model": by_requested_model,
634
+ }
635
+
636
+
637
+ # ---------------------------------------------------------------------------
638
+ # Effective-configuration snapshot
639
+ # ---------------------------------------------------------------------------
640
+
641
+ CONFIG_SNAPSHOT_EVENT = "config_snapshot"
642
+ CONFIG_SNAPSHOT_SCHEMA_VERSION = 1
643
+
644
+ #: Config/env key names whose *value* is masked structurally, before any
645
+ #: text-level redaction. Exact names first so that ordinary settings which
646
+ #: merely contain a scary substring -- ``max_tokens``, ``reasoning_tokens`` --
647
+ #: survive intact and stay analyzable.
648
+ _SECRET_EXACT_KEYS = frozenset(
649
+ {
650
+ "api_key",
651
+ "apikey",
652
+ "authorization",
653
+ "bearer_token",
654
+ "cookie",
655
+ "credential",
656
+ "id_token",
657
+ "passwd",
658
+ "password",
659
+ "secret",
660
+ "token",
661
+ }
662
+ )
663
+ _SECRET_KEY_FRAGMENTS = (
664
+ "access_token",
665
+ "api_key",
666
+ "apikey",
667
+ "auth_token",
668
+ "client_secret",
669
+ "credential",
670
+ "passwd",
671
+ "password",
672
+ "private_key",
673
+ "refresh_token",
674
+ "secret",
675
+ )
676
+
677
+ MASKED_VALUE = "[secret]"
678
+
679
+ #: Only Alysis Code's own variables are snapshotted. The full environment is
680
+ #: both enormous and the exact thing that leaked a credential into a session
681
+ #: log before PR1 existed.
682
+ CONFIG_SNAPSHOT_ENV_PREFIX = "ALYSIS_"
683
+ MAX_SNAPSHOT_ENV_VARS = 200
684
+ MAX_SNAPSHOT_ENV_VALUE_CHARS = 300
685
+
686
+
687
+ def _key_is_secret(key: Any) -> bool:
688
+ normalized = _clean_text(key).casefold().replace("-", "_")
689
+ if not normalized:
690
+ return False
691
+ if normalized in _SECRET_EXACT_KEYS:
692
+ return True
693
+ return any(fragment in normalized for fragment in _SECRET_KEY_FRAGMENTS)
694
+
695
+
696
+ def scrub_config_values(value: Any, *, key: Any = None) -> Any:
697
+ """Recursively mask secret-named entries in an already-dumped config tree.
698
+
699
+ Structural masking, deliberately independent of the write-path text
700
+ redactor: a value that never looked credential-shaped -- a short shared
701
+ token, a passphrase of dictionary words -- is caught by its *key* here even
702
+ though no entropy heuristic would flag it.
703
+ """
704
+ if key is not None and _key_is_secret(key):
705
+ return MASKED_VALUE
706
+ if isinstance(value, Mapping):
707
+ return {str(k): scrub_config_values(v, key=k) for k, v in value.items()}
708
+ if isinstance(value, (list, tuple)):
709
+ return [scrub_config_values(item) for item in value]
710
+ if isinstance(value, (bool, int, float)) or value is None:
711
+ return value
712
+ if isinstance(value, str):
713
+ return value
714
+ return str(value)
715
+
716
+
717
+ def snapshot_environment(
718
+ *,
719
+ environ: Mapping[str, str] | None = None,
720
+ prefix: str = CONFIG_SNAPSHOT_ENV_PREFIX,
721
+ ) -> dict[str, str]:
722
+ """Capture the Alysis Code environment variables that are actually set.
723
+
724
+ A benchmark run configures almost everything by environment, so the file on
725
+ disk is not the effective configuration; without this, reconstructing a run
726
+ means guessing at the harness.
727
+ """
728
+ source: Mapping[str, str] = os.environ if environ is None else environ
729
+ captured: dict[str, str] = {}
730
+ for name in sorted(source):
731
+ if not str(name).startswith(prefix):
732
+ continue
733
+ if _key_is_secret(name[len(prefix) :]) or _key_is_secret(name):
734
+ captured[str(name)] = MASKED_VALUE
735
+ else:
736
+ captured[str(name)] = _truncate(
737
+ _clean_text(source.get(name)),
738
+ MAX_SNAPSHOT_ENV_VALUE_CHARS,
739
+ )
740
+ if len(captured) >= MAX_SNAPSHOT_ENV_VARS:
741
+ break
742
+ return captured
743
+
744
+
745
+ def config_snapshot_payload(
746
+ *,
747
+ config_values: Mapping[str, Any] | None = None,
748
+ version: str = "",
749
+ build_info: Mapping[str, Any] | None = None,
750
+ sampling: SamplingSettings | None = None,
751
+ response_header_allowlist: ResponseHeaderAllowlist | None = None,
752
+ environ: Mapping[str, str] | None = None,
753
+ ) -> dict[str, Any]:
754
+ """Build the once-per-run ``config_snapshot`` session-log event payload.
755
+
756
+ Takes an already-dumped config mapping (``AppConfig.model_dump()``) rather
757
+ than the model itself, which keeps this module free of pydantic and lets
758
+ the tests exercise it in a bare interpreter.
759
+ """
760
+ allowlist = (
761
+ DEFAULT_RESPONSE_HEADER_ALLOWLIST
762
+ if response_header_allowlist is None
763
+ else response_header_allowlist
764
+ )
765
+ effective_sampling = resolve_sampling_settings() if sampling is None else sampling
766
+ return {
767
+ "schema_version": CONFIG_SNAPSHOT_SCHEMA_VERSION,
768
+ "version": _clean_text(version),
769
+ "build": dict(build_info or {}),
770
+ "sampling": effective_sampling.session_event_payload(),
771
+ "response_header_allowlist": allowlist.describe(),
772
+ "config": scrub_config_values(dict(config_values or {})),
773
+ "environment": snapshot_environment(environ=environ),
774
+ }