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,1872 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ import re
6
+ from collections.abc import Iterable
7
+ from typing import Any
8
+ from urllib.parse import urlsplit, urlunsplit
9
+
10
+ # Brackets can be legitimate URL path/query characters, such as ?foo[bar]=1.
11
+ # Surrounding wrapper brackets are removed later by context-aware cleanup.
12
+ _URL_RE = re.compile(r"https?://[^\s<>{}\"]+", re.IGNORECASE)
13
+ _URL_CANDIDATE_STOP_CHARS = set('<>{}"')
14
+ _STRUCTURED_URL_FORBIDDEN_CHARS = set('<>{}"')
15
+ _SIMPLE_TRAILING_URL_PUNCTUATION = ".,;:?"
16
+ _TRAILING_URL_QUOTES = '"'
17
+ _URL_QUOTE_WRAPPERS = {"'", '"'}
18
+ _URL_WRAPPER_PAIRS = {
19
+ "(": ")",
20
+ "[": "]",
21
+ "{": "}",
22
+ '"': '"',
23
+ "'": "'",
24
+ }
25
+ _URL_CLOSING_WRAPPERS = {")": "(", "]": "[", "}": "{"}
26
+ _MARKDOWN_URL_WRAPPER_MARKERS = ("`", "*", "_")
27
+ _USER_PROVIDED = "user_provided"
28
+ _RETURNED_BY_WEB_SEARCH = "returned_by_web_search"
29
+ _CANONICAL_REDIRECT = "canonical_redirect"
30
+ _SEARCH_MEDIATED_RECOVERY = "search_mediated_recovery"
31
+ _SAME_ORIGIN_DERIVED = "same_origin_derived_search_result"
32
+ _FETCHED_PAGE_LINK = "fetched_page_link"
33
+ _TRUSTED_LOCAL_FILE = "trusted_local_file"
34
+ _TRUSTED_TOOL_OUTPUT = "trusted_tool_output"
35
+ _MAX_PROVENANCE_NODES = 512
36
+ _MAX_URLS_PER_EVENT = 24
37
+ _MAX_URL_TEXT_CHARS = 32_000
38
+ _MAX_PROVENANCE_URL_LENGTH = 2048
39
+ _FETCHABLE_PROVENANCE_CLASSES = {
40
+ _USER_PROVIDED,
41
+ _RETURNED_BY_WEB_SEARCH,
42
+ _FETCHED_PAGE_LINK,
43
+ _TRUSTED_LOCAL_FILE,
44
+ _TRUSTED_TOOL_OUTPUT,
45
+ _CANONICAL_REDIRECT,
46
+ _SEARCH_MEDIATED_RECOVERY,
47
+ _SAME_ORIGIN_DERIVED,
48
+ }
49
+
50
+
51
+ def _domain_for_url(url: str) -> str:
52
+ try:
53
+ return (urlsplit(url).hostname or "").rstrip(".").lower()
54
+ except ValueError:
55
+ return ""
56
+
57
+
58
+ def _dedupe_ordered(items: list[str]) -> list[str]:
59
+ out: list[str] = []
60
+ seen: set[str] = set()
61
+ for item in items:
62
+ normalized = str(item or "").strip()
63
+ if not normalized or normalized in seen:
64
+ continue
65
+ seen.add(normalized)
66
+ out.append(normalized)
67
+ return out
68
+
69
+
70
+ def _canonical_json(value: Any) -> str:
71
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
72
+
73
+
74
+ def normalize_web_query(raw_query: Any) -> str:
75
+ return re.sub(r"\s+", " ", str(raw_query or "").strip()).casefold()
76
+
77
+
78
+ def build_web_fetch_recovery_search_query(raw_url: Any) -> str:
79
+ normalized = normalize_web_url(raw_url) or canonicalize_web_url_input(raw_url)
80
+ if normalized is None:
81
+ return ""
82
+ try:
83
+ split = urlsplit(normalized)
84
+ except ValueError:
85
+ return ""
86
+ host = (split.hostname or "").rstrip(".").lower()
87
+ path_terms = [
88
+ segment for segment in re.split(r"[^A-Za-z0-9]+", split.path or "") if len(segment) >= 2
89
+ ][:4]
90
+ return " ".join([host, *path_terms]).strip()
91
+
92
+
93
+ def normalize_web_url(raw_url: Any) -> str | None:
94
+ text = str(raw_url or "").strip()
95
+ if not text:
96
+ return None
97
+ if any(ch.isspace() or ch in _STRUCTURED_URL_FORBIDDEN_CHARS for ch in text):
98
+ return None
99
+ try:
100
+ split = urlsplit(text)
101
+ except ValueError:
102
+ return None
103
+ scheme = str(split.scheme or "").lower()
104
+ if scheme not in {"http", "https"}:
105
+ return None
106
+ if split.username is not None or split.password is not None:
107
+ return None
108
+ hostname = (split.hostname or "").rstrip(".").lower()
109
+ if not hostname:
110
+ return None
111
+ try:
112
+ port = split.port
113
+ except ValueError:
114
+ return None
115
+ if (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
116
+ port = None
117
+ netloc = hostname if port is None else f"{hostname}:{port}"
118
+ path = split.path or "/"
119
+ return urlunsplit((scheme, netloc, path, split.query, ""))
120
+
121
+
122
+ def _equivalent_fetch_url_variants(normalized_url: str) -> set[str]:
123
+ normalized = normalize_web_url(normalized_url)
124
+ if normalized is None:
125
+ return set()
126
+ variants = {normalized}
127
+ split = urlsplit(normalized)
128
+ if not split.query:
129
+ path = split.path or "/"
130
+ if path == "/":
131
+ variants.add(urlunsplit((split.scheme, split.netloc, "", "", "")))
132
+ elif path.endswith("/"):
133
+ variants.add(urlunsplit((split.scheme, split.netloc, path.rstrip("/"), "", "")))
134
+ else:
135
+ variants.add(urlunsplit((split.scheme, split.netloc, path + "/", "", "")))
136
+ return {variant for variant in variants if normalize_web_url(variant) is not None}
137
+
138
+
139
+ def _same_origin_bounded_derivative(source_url: str, requested_url: str) -> bool:
140
+ source = normalize_web_url(source_url)
141
+ requested = normalize_web_url(requested_url)
142
+ if source is None or requested is None:
143
+ return False
144
+ source_split = urlsplit(source)
145
+ requested_split = urlsplit(requested)
146
+ if (
147
+ source_split.scheme,
148
+ source_split.netloc,
149
+ ) != (
150
+ requested_split.scheme,
151
+ requested_split.netloc,
152
+ ):
153
+ return False
154
+ source_path = source_split.path.rstrip("/")
155
+ requested_path = requested_split.path.rstrip("/")
156
+ if source_path == requested_path:
157
+ return True
158
+ if not source_path or source_path == "/":
159
+ return False
160
+ bounded_pairs = (
161
+ ("/blob/", "/raw/"),
162
+ ("/-/blob/", "/-/raw/"),
163
+ )
164
+ for source_marker, requested_marker in bounded_pairs:
165
+ if source_marker not in source_path:
166
+ continue
167
+ expected = source_path.replace(source_marker, requested_marker, 1)
168
+ if requested_path == expected:
169
+ return True
170
+ return False
171
+
172
+
173
+ def _looks_like_http_url(text: str) -> bool:
174
+ lowered = str(text or "").strip().casefold()
175
+ return lowered.startswith("http://") or lowered.startswith("https://")
176
+
177
+
178
+ def _strip_outer_markdown_url_wrappers(text: str) -> str:
179
+ candidate = str(text or "").strip()
180
+ for marker in _MARKDOWN_URL_WRAPPER_MARKERS:
181
+ leading = len(candidate) - len(candidate.lstrip(marker))
182
+ if leading == 0:
183
+ continue
184
+ trailing = len(candidate) - len(candidate.rstrip(marker))
185
+ if trailing == 0:
186
+ continue
187
+ trim = min(leading, trailing)
188
+ inner = candidate[trim:-trim].strip()
189
+ if _looks_like_http_url(inner):
190
+ return inner
191
+ return candidate
192
+
193
+
194
+ def _looks_like_wrapped_http_url_candidate(text: str) -> bool:
195
+ candidate = str(text or "").strip()
196
+ if _looks_like_http_url(candidate):
197
+ return True
198
+ stripped = _strip_outer_markdown_url_wrappers(candidate)
199
+ return stripped != candidate and _looks_like_http_url(stripped)
200
+
201
+
202
+ def _strip_outer_url_wrappers(text: str) -> str:
203
+ candidate = str(text or "").strip()
204
+ while len(candidate) >= 2:
205
+ opener = candidate[0]
206
+ closer = candidate[-1]
207
+ if _URL_WRAPPER_PAIRS.get(opener) != closer:
208
+ break
209
+ inner = candidate[1:-1].strip()
210
+ if not _looks_like_wrapped_http_url_candidate(inner):
211
+ break
212
+ candidate = inner
213
+ return candidate
214
+
215
+
216
+ def _strip_simple_trailing_url_punctuation(text: str) -> str:
217
+ candidate = str(text or "").strip()
218
+ while candidate:
219
+ tail = candidate[-1]
220
+ if tail in _SIMPLE_TRAILING_URL_PUNCTUATION or tail in _TRAILING_URL_QUOTES:
221
+ candidate = candidate[:-1]
222
+ continue
223
+ break
224
+ return candidate
225
+
226
+
227
+ def _leading_url_wrapper_closer_budget(text: str, *, start: int) -> dict[str, int]:
228
+ budget: dict[str, int] = {}
229
+ index = start - 1
230
+ while index >= 0:
231
+ ch = text[index]
232
+ if ch in _MARKDOWN_URL_WRAPPER_MARKERS:
233
+ index -= 1
234
+ continue
235
+ closer = _URL_WRAPPER_PAIRS.get(ch)
236
+ if closer is None or closer not in _URL_CLOSING_WRAPPERS:
237
+ break
238
+ budget[closer] = budget.get(closer, 0) + 1
239
+ index -= 1
240
+ return budget
241
+
242
+
243
+ def _leading_url_quote_wrapper_budget(text: str, *, start: int) -> dict[str, int]:
244
+ budget: dict[str, int] = {}
245
+ index = start - 1
246
+ while index >= 0:
247
+ ch = text[index]
248
+ if ch in _MARKDOWN_URL_WRAPPER_MARKERS:
249
+ index -= 1
250
+ continue
251
+ if ch not in _URL_QUOTE_WRAPPERS:
252
+ break
253
+ budget[ch] = budget.get(ch, 0) + 1
254
+ index -= 1
255
+ return budget
256
+
257
+
258
+ def _markdown_link_label_start(text: str, *, close_bracket: int) -> int | None:
259
+ depth = 0
260
+ for index in range(close_bracket - 1, -1, -1):
261
+ ch = text[index]
262
+ if ch == "]":
263
+ depth += 1
264
+ continue
265
+ if ch != "[":
266
+ continue
267
+ if depth > 0:
268
+ depth -= 1
269
+ continue
270
+ if "\n" in text[index + 1 : close_bracket]:
271
+ return None
272
+ return index
273
+ return None
274
+
275
+
276
+ def _scan_public_web_url_candidate_end(text: str, *, start: int) -> int:
277
+ end = start
278
+ while end < len(text):
279
+ ch = text[end]
280
+ if ch.isspace() or ch in _URL_CANDIDATE_STOP_CHARS:
281
+ break
282
+ end += 1
283
+ return end
284
+
285
+
286
+ def _strip_bounded_trailing_url_closers(
287
+ text: str,
288
+ *,
289
+ closer_budget: dict[str, int] | None = None,
290
+ ) -> tuple[str, dict[str, int]]:
291
+ candidate = str(text or "").strip()
292
+ remaining_budget = {
293
+ str(closer): int(count)
294
+ for closer, count in dict(closer_budget or {}).items()
295
+ if int(count) > 0
296
+ }
297
+ while candidate:
298
+ tail = candidate[-1]
299
+ opener = _URL_CLOSING_WRAPPERS.get(tail)
300
+ if opener is None:
301
+ break
302
+ if remaining_budget.get(tail, 0) <= 0:
303
+ break
304
+ if candidate.count(tail) <= candidate.count(opener):
305
+ break
306
+ candidate = candidate[:-1]
307
+ remaining_budget[tail] -= 1
308
+ if remaining_budget[tail] <= 0:
309
+ remaining_budget.pop(tail, None)
310
+ return candidate, remaining_budget
311
+
312
+
313
+ def _strip_bounded_trailing_url_quotes(
314
+ text: str,
315
+ *,
316
+ quote_budget: dict[str, int] | None = None,
317
+ ) -> tuple[str, dict[str, int]]:
318
+ candidate = str(text or "").strip()
319
+ remaining_budget = {
320
+ str(quote): int(count)
321
+ for quote, count in dict(quote_budget or {}).items()
322
+ if str(quote) in _URL_QUOTE_WRAPPERS and int(count) > 0
323
+ }
324
+ while candidate:
325
+ tail = candidate[-1]
326
+ if tail not in _URL_QUOTE_WRAPPERS:
327
+ break
328
+ if remaining_budget.get(tail, 0) <= 0:
329
+ break
330
+ candidate = candidate[:-1]
331
+ remaining_budget[tail] -= 1
332
+ if remaining_budget[tail] <= 0:
333
+ remaining_budget.pop(tail, None)
334
+ return candidate, remaining_budget
335
+
336
+
337
+ def _trailing_markdown_url_wrapper_marker(
338
+ text: str,
339
+ *,
340
+ closer_budget: dict[str, int] | None = None,
341
+ quote_budget: dict[str, int] | None = None,
342
+ ) -> str | None:
343
+ candidate = _strip_simple_trailing_url_punctuation(text)
344
+ candidate, _remaining_budget = _strip_bounded_trailing_url_closers(
345
+ candidate,
346
+ closer_budget=closer_budget,
347
+ )
348
+ candidate, _remaining_quote_budget = _strip_bounded_trailing_url_quotes(
349
+ candidate,
350
+ quote_budget=quote_budget,
351
+ )
352
+ for marker in _MARKDOWN_URL_WRAPPER_MARKERS:
353
+ if candidate.endswith(marker):
354
+ return marker
355
+ return None
356
+
357
+
358
+ def cleanup_public_web_url_candidate(
359
+ raw_url: Any,
360
+ *,
361
+ closer_budget: dict[str, int] | None = None,
362
+ quote_budget: dict[str, int] | None = None,
363
+ ) -> str:
364
+ candidate = str(raw_url or "").strip()
365
+ previous = None
366
+ remaining_closer_budget = dict(closer_budget or {})
367
+ remaining_quote_budget = dict(quote_budget or {})
368
+ while candidate and candidate != previous:
369
+ previous = candidate
370
+ candidate = _strip_outer_url_wrappers(candidate)
371
+ candidate = _strip_outer_markdown_url_wrappers(candidate)
372
+ candidate = _strip_simple_trailing_url_punctuation(candidate)
373
+ candidate, remaining_closer_budget = _strip_bounded_trailing_url_closers(
374
+ candidate,
375
+ closer_budget=remaining_closer_budget,
376
+ )
377
+ candidate, remaining_quote_budget = _strip_bounded_trailing_url_quotes(
378
+ candidate,
379
+ quote_budget=remaining_quote_budget,
380
+ )
381
+ return candidate
382
+
383
+
384
+ def cleanup_structured_web_url_target(
385
+ raw_url: Any,
386
+ *,
387
+ closer_budget: dict[str, int] | None = None,
388
+ quote_budget: dict[str, int] | None = None,
389
+ ) -> str:
390
+ candidate = str(raw_url or "").strip()
391
+ previous = None
392
+ remaining_closer_budget = dict(closer_budget or {})
393
+ remaining_quote_budget = dict(quote_budget or {})
394
+ while candidate and candidate != previous:
395
+ previous = candidate
396
+ candidate = _strip_outer_url_wrappers(candidate)
397
+ candidate = _strip_outer_markdown_url_wrappers(candidate)
398
+ candidate, remaining_closer_budget = _strip_bounded_trailing_url_closers(
399
+ candidate,
400
+ closer_budget=remaining_closer_budget,
401
+ )
402
+ candidate, remaining_quote_budget = _strip_bounded_trailing_url_quotes(
403
+ candidate,
404
+ quote_budget=remaining_quote_budget,
405
+ )
406
+ return candidate
407
+
408
+
409
+ def canonicalize_web_url_input(raw_url: Any) -> str | None:
410
+ return normalize_web_url(cleanup_public_web_url_candidate(raw_url))
411
+
412
+
413
+ def _strip_markdown_link_target_syntax(
414
+ raw_url: str,
415
+ *,
416
+ outer_closer_budget: dict[str, int] | None = None,
417
+ ) -> str:
418
+ candidate = str(raw_url or "").strip()
419
+ while candidate and candidate[-1] in _SIMPLE_TRAILING_URL_PUNCTUATION and ")" in candidate:
420
+ candidate = candidate[:-1].rstrip()
421
+ if candidate.endswith(")"):
422
+ candidate = candidate[:-1]
423
+ return cleanup_structured_web_url_target(
424
+ candidate,
425
+ closer_budget=outer_closer_budget,
426
+ )
427
+
428
+
429
+ def _scan_markdown_link_title_end(text: str, *, start: int) -> int | None:
430
+ index = start
431
+ if index >= len(text) or not text[index].isspace():
432
+ return None
433
+ while index < len(text) and text[index].isspace():
434
+ index += 1
435
+ if index >= len(text):
436
+ return None
437
+
438
+ opener = text[index]
439
+ if opener in {"'", '"'}:
440
+ closer = opener
441
+ index += 1
442
+ while index < len(text):
443
+ ch = text[index]
444
+ if ch == "\\":
445
+ index += 2
446
+ continue
447
+ if ch == closer:
448
+ index += 1
449
+ break
450
+ index += 1
451
+ else:
452
+ return None
453
+ elif opener == "(":
454
+ depth = 1
455
+ index += 1
456
+ while index < len(text):
457
+ ch = text[index]
458
+ if ch == "\\":
459
+ index += 2
460
+ continue
461
+ if ch == "(":
462
+ depth += 1
463
+ elif ch == ")":
464
+ depth -= 1
465
+ if depth == 0:
466
+ index += 1
467
+ break
468
+ index += 1
469
+ else:
470
+ return None
471
+ else:
472
+ return None
473
+
474
+ while index < len(text) and text[index].isspace():
475
+ index += 1
476
+ if index < len(text) and text[index] == ")":
477
+ return index + 1
478
+ return None
479
+
480
+
481
+ def _public_web_url_entry(raw_url: str) -> dict[str, str] | None:
482
+ normalized = normalize_web_url(raw_url)
483
+ if normalized is None:
484
+ return None
485
+ return {
486
+ "url": raw_url,
487
+ "normalized_url": normalized,
488
+ "domain": _domain_for_url(normalized),
489
+ }
490
+
491
+
492
+ def _extract_markdown_autolink_web_urls(text: str) -> list[tuple[dict[str, str], tuple[int, int]]]:
493
+ extracted: list[tuple[dict[str, str], tuple[int, int]]] = []
494
+ cursor = 0
495
+ while True:
496
+ opener = text.find("<", cursor)
497
+ if opener < 0:
498
+ break
499
+ url_start = opener + 1
500
+ if not _looks_like_http_url(text[url_start:]):
501
+ cursor = url_start
502
+ continue
503
+ candidate_end = text.find(">", url_start)
504
+ if candidate_end < 0:
505
+ cursor = url_start
506
+ continue
507
+ raw = cleanup_structured_web_url_target(text[url_start:candidate_end])
508
+ entry = _public_web_url_entry(raw)
509
+ if entry is not None:
510
+ extracted.append((entry, (url_start, candidate_end)))
511
+ cursor = candidate_end + 1
512
+ return extracted
513
+
514
+
515
+ def _extract_markdown_link_web_urls(text: str) -> list[tuple[dict[str, str], tuple[int, int]]]:
516
+ extracted: list[tuple[dict[str, str], tuple[int, int]]] = []
517
+ cursor = 0
518
+ while True:
519
+ close_bracket = text.find("](", cursor)
520
+ if close_bracket < 0:
521
+ break
522
+ label_start = _markdown_link_label_start(text, close_bracket=close_bracket)
523
+ target_start = close_bracket + 2
524
+ while target_start < len(text) and text[target_start].isspace():
525
+ target_start += 1
526
+ if label_start is None or target_start >= len(text):
527
+ cursor = close_bracket + 2
528
+ continue
529
+
530
+ if text[target_start] == "<":
531
+ url_start = target_start + 1
532
+ candidate_end = text.find(">", url_start)
533
+ if candidate_end < 0:
534
+ cursor = close_bracket + 2
535
+ continue
536
+ title_end = _scan_markdown_link_title_end(text, start=candidate_end + 1)
537
+ skip_end = title_end or candidate_end + 1
538
+ raw = cleanup_structured_web_url_target(text[url_start:candidate_end])
539
+ else:
540
+ url_start = target_start
541
+ if not _looks_like_http_url(text[url_start:]):
542
+ cursor = close_bracket + 2
543
+ continue
544
+ candidate_end = _scan_public_web_url_candidate_end(text, start=url_start)
545
+ raw_candidate = text[url_start:candidate_end]
546
+ title_end = _scan_markdown_link_title_end(text, start=candidate_end)
547
+ skip_end = title_end or candidate_end
548
+ outer_closer_budget = _leading_url_wrapper_closer_budget(text, start=label_start)
549
+ if title_end is not None:
550
+ raw = cleanup_structured_web_url_target(
551
+ raw_candidate,
552
+ closer_budget=outer_closer_budget,
553
+ )
554
+ else:
555
+ raw = _strip_markdown_link_target_syntax(
556
+ raw_candidate,
557
+ outer_closer_budget=outer_closer_budget,
558
+ )
559
+
560
+ entry = _public_web_url_entry(raw)
561
+ if entry is not None:
562
+ extracted.append((entry, (url_start, skip_end)))
563
+ cursor = max(close_bracket + 2, skip_end)
564
+ return extracted
565
+
566
+
567
+ def _span_overlaps(start: int, end: int, spans: list[tuple[int, int]]) -> bool:
568
+ return any(start < span_end and end > span_start for span_start, span_end in spans)
569
+
570
+
571
+ def _expand_markdown_wrapped_url_candidate(
572
+ text: str,
573
+ *,
574
+ start: int,
575
+ end: int,
576
+ closer_budget: dict[str, int] | None = None,
577
+ ) -> str:
578
+ candidate = text[start:end]
579
+ quote_budget = _leading_url_quote_wrapper_budget(text, start=start)
580
+ marker = _trailing_markdown_url_wrapper_marker(
581
+ candidate,
582
+ closer_budget=closer_budget,
583
+ quote_budget=quote_budget,
584
+ )
585
+ if marker is None:
586
+ return candidate
587
+ expanded_start = start
588
+ while expanded_start > 0 and text[expanded_start - 1] == marker:
589
+ expanded_start -= 1
590
+ if expanded_start == start:
591
+ return candidate
592
+ if expanded_start > 0 and text[expanded_start - 1].isalnum():
593
+ return candidate
594
+ return text[expanded_start:end]
595
+
596
+
597
+ def extract_public_web_urls(text: Any) -> list[dict[str, str]]:
598
+ body = str(text or "")
599
+ candidates: list[tuple[int, dict[str, str]]] = []
600
+ markdown_target_spans: list[tuple[int, int]] = []
601
+ for entry, span in _extract_markdown_autolink_web_urls(body):
602
+ markdown_target_spans.append(span)
603
+ candidates.append((span[0], entry))
604
+ for entry, span in _extract_markdown_link_web_urls(body):
605
+ markdown_target_spans.append(span)
606
+ candidates.append((span[0], entry))
607
+
608
+ for match in _URL_RE.finditer(body):
609
+ if _span_overlaps(match.start(), match.end(), markdown_target_spans):
610
+ continue
611
+ closer_budget = _leading_url_wrapper_closer_budget(body, start=match.start())
612
+ quote_budget = _leading_url_quote_wrapper_budget(body, start=match.start())
613
+ raw_candidate = _expand_markdown_wrapped_url_candidate(
614
+ body,
615
+ start=match.start(),
616
+ end=match.end(),
617
+ closer_budget=closer_budget,
618
+ )
619
+ raw = cleanup_public_web_url_candidate(
620
+ raw_candidate,
621
+ closer_budget=closer_budget,
622
+ quote_budget=quote_budget,
623
+ )
624
+ entry = _public_web_url_entry(raw)
625
+ if entry is not None:
626
+ candidates.append((match.start(), entry))
627
+
628
+ extracted: list[dict[str, str]] = []
629
+ seen: set[str] = set()
630
+ for _start, entry in sorted(candidates, key=lambda item: item[0]):
631
+ normalized = entry["normalized_url"]
632
+ if normalized in seen:
633
+ continue
634
+ seen.add(normalized)
635
+ extracted.append(entry)
636
+ return extracted
637
+
638
+
639
+ class SessionWebResearchTracker:
640
+ def __init__(self) -> None:
641
+ self._user_urls: dict[str, dict[str, Any]] = {}
642
+ self._returned_source_urls: dict[str, dict[str, Any]] = {}
643
+ self._canonical_redirect_urls: dict[str, dict[str, Any]] = {}
644
+ self._search_mediated_urls: dict[str, dict[str, Any]] = {}
645
+ self._provenance_nodes: dict[str, dict[str, Any]] = {}
646
+ self._searches: list[dict[str, Any]] = []
647
+ self._fetches: list[dict[str, Any]] = []
648
+ self._pending_search_indices: list[int] = []
649
+ self._pending_fetch_indices: list[int] = []
650
+
651
+ def classify_fetch_url(self, raw_url: Any) -> str | None:
652
+ classification, _effective_url = self.resolve_fetch_url(raw_url)
653
+ return classification
654
+
655
+ def resolve_fetch_url(self, raw_url: Any) -> tuple[str | None, str | None]:
656
+ strict_normalized = normalize_web_url(raw_url)
657
+ if strict_normalized:
658
+ classification = self._classification_for_normalized_url(strict_normalized)
659
+ if classification is not None:
660
+ return classification, strict_normalized
661
+ effective_normalized = canonicalize_web_url_input(raw_url)
662
+ if effective_normalized:
663
+ return self._classification_for_normalized_url(
664
+ effective_normalized
665
+ ), effective_normalized
666
+ return None, strict_normalized
667
+
668
+ def fetchable_urls(self, *, limit: int = 10) -> list[str]:
669
+ """Normalized URLs that ``web_fetch`` will currently authorize.
670
+
671
+ These are the exact canonical forms ``resolve_fetch_url`` matches against,
672
+ so the model can copy one verbatim into ``web_fetch``. web_search source
673
+ URLs come first (the common "fetch a result" case), then user-provided
674
+ URLs; order within each group is the order they were observed (so "the
675
+ first one" maps to the first source). Bounded so a long session does not
676
+ bloat an error payload.
677
+ """
678
+ ordered: list[str] = []
679
+ seen: set[str] = set()
680
+ for url in (*self._returned_source_urls.keys(), *self._user_urls.keys()):
681
+ normalized = str(url or "").strip()
682
+ if normalized and normalized not in seen:
683
+ seen.add(normalized)
684
+ ordered.append(normalized)
685
+ if limit is not None and limit > 0:
686
+ return ordered[:limit]
687
+ return ordered
688
+
689
+ def observe_event(
690
+ self,
691
+ *,
692
+ event_type: str,
693
+ payload: dict[str, Any],
694
+ ts: str | None,
695
+ event_id: str | None = None,
696
+ ) -> bool:
697
+ event_name = str(event_type or "").strip()
698
+ if not isinstance(payload, dict):
699
+ return False
700
+ if event_name == "user_message":
701
+ return self._record_user_message(payload=payload, ts=ts, event_id=event_id)
702
+ if event_name == "tool_call":
703
+ name = str(payload.get("name") or "").strip()
704
+ if name == "web_search":
705
+ return self._record_web_search_call(payload=payload, ts=ts, event_id=event_id)
706
+ if name == "web_fetch":
707
+ return self._record_web_fetch_call(payload=payload, ts=ts, event_id=event_id)
708
+ return False
709
+ if event_name == "tool_result":
710
+ name = str(payload.get("name") or "").strip()
711
+ if name == "web_search":
712
+ return self._record_web_search_result(payload=payload, ts=ts, event_id=event_id)
713
+ if name == "web_fetch":
714
+ return self._record_web_fetch_result(payload=payload, ts=ts, event_id=event_id)
715
+ return self._record_trusted_tool_result_urls(
716
+ tool_name=name,
717
+ payload=payload,
718
+ ts=ts,
719
+ event_id=event_id,
720
+ )
721
+ return False
722
+
723
+ def artifact_payload(self) -> dict[str, Any]:
724
+ searches = [copy.deepcopy(entry) for entry in self._searches]
725
+ fetches = [copy.deepcopy(entry) for entry in self._fetches]
726
+ user_urls = [copy.deepcopy(entry) for entry in self._user_urls.values()]
727
+ returned_urls = [copy.deepcopy(entry) for entry in self._returned_source_urls.values()]
728
+ redirect_urls = [copy.deepcopy(entry) for entry in self._canonical_redirect_urls.values()]
729
+ search_mediated_urls = [
730
+ copy.deepcopy(entry) for entry in self._search_mediated_urls.values()
731
+ ]
732
+ provenance_nodes = [copy.deepcopy(entry) for entry in self._provenance_nodes.values()]
733
+ deduped_normalized_queries = _dedupe_ordered(
734
+ [str(entry.get("normalized_query") or "") for entry in searches]
735
+ + [
736
+ str(normalized)
737
+ for entry in searches
738
+ for normalized in list(entry.get("normalized_queries") or [])
739
+ ]
740
+ )
741
+ deduped_normalized_fetch_urls = _dedupe_ordered(
742
+ [str(entry.get("normalized_requested_url") or "") for entry in fetches]
743
+ )
744
+ deduped_normalized_final_fetch_urls = _dedupe_ordered(
745
+ [str(entry.get("normalized_final_url") or "") for entry in fetches]
746
+ )
747
+ return {
748
+ "schema_version": 2,
749
+ "user_provided_urls": user_urls,
750
+ "returned_by_web_search_urls": returned_urls,
751
+ "canonical_redirect_urls": redirect_urls,
752
+ "search_mediated_recovery_urls": search_mediated_urls,
753
+ "url_provenance_graph": {
754
+ "node_count": len(provenance_nodes),
755
+ "max_nodes": _MAX_PROVENANCE_NODES,
756
+ "nodes": provenance_nodes,
757
+ },
758
+ "searches": searches,
759
+ "fetches": fetches,
760
+ "deduped_normalized_queries": deduped_normalized_queries,
761
+ "deduped_normalized_user_urls": list(self._user_urls.keys()),
762
+ "deduped_normalized_search_source_urls": list(self._returned_source_urls.keys()),
763
+ "deduped_normalized_canonical_redirect_urls": list(
764
+ self._canonical_redirect_urls.keys()
765
+ ),
766
+ "deduped_normalized_search_mediated_recovery_urls": list(
767
+ self._search_mediated_urls.keys()
768
+ ),
769
+ "deduped_normalized_provenance_graph_urls": list(self._provenance_nodes.keys()),
770
+ "deduped_normalized_fetch_urls": deduped_normalized_fetch_urls,
771
+ "deduped_normalized_final_fetch_urls": deduped_normalized_final_fetch_urls,
772
+ }
773
+
774
+ def metrics_payload(self) -> dict[str, int]:
775
+ normalized_queries = [
776
+ str(entry.get("normalized_query") or "")
777
+ for entry in self._searches
778
+ if str(entry.get("normalized_query") or "").strip()
779
+ ]
780
+ normalized_fetches = [
781
+ str(entry.get("normalized_requested_url") or "")
782
+ for entry in self._fetches
783
+ if str(entry.get("normalized_requested_url") or "").strip()
784
+ ]
785
+ query_counts = {
786
+ query: normalized_queries.count(query) for query in _dedupe_ordered(normalized_queries)
787
+ }
788
+ fetch_counts = {
789
+ url: normalized_fetches.count(url) for url in _dedupe_ordered(normalized_fetches)
790
+ }
791
+ return {
792
+ "web_search_calls": len(self._searches),
793
+ "web_fetch_calls": len(self._fetches),
794
+ "unique_web_queries": len(query_counts),
795
+ "unique_web_fetch_urls": len(fetch_counts),
796
+ "duplicate_web_queries": sum(max(count - 1, 0) for count in query_counts.values()),
797
+ "duplicate_web_fetches": sum(max(count - 1, 0) for count in fetch_counts.values()),
798
+ "total_web_sources_returned": sum(
799
+ len(list(entry.get("returned_sources") or [])) for entry in self._searches
800
+ ),
801
+ "total_web_sources_fetched": sum(
802
+ 1 for entry in self._fetches if str(entry.get("normalized_final_url") or "").strip()
803
+ ),
804
+ }
805
+
806
+ def has_activity(self) -> bool:
807
+ return bool(
808
+ self._user_urls
809
+ or self._returned_source_urls
810
+ or self._canonical_redirect_urls
811
+ or self._search_mediated_urls
812
+ or self._provenance_nodes
813
+ or self._searches
814
+ or self._fetches
815
+ )
816
+
817
+ def _classification_for_normalized_url(self, normalized: str) -> str | None:
818
+ for variant in _equivalent_fetch_url_variants(normalized):
819
+ if variant in self._user_urls:
820
+ return _USER_PROVIDED
821
+ if variant in self._returned_source_urls:
822
+ return _RETURNED_BY_WEB_SEARCH
823
+ if variant in self._canonical_redirect_urls:
824
+ return _CANONICAL_REDIRECT
825
+ if variant in self._search_mediated_urls:
826
+ return _SEARCH_MEDIATED_RECOVERY
827
+ node = self._provenance_nodes.get(variant)
828
+ if node is not None:
829
+ classification = str(node.get("provenance_classification") or "").strip()
830
+ if classification in _FETCHABLE_PROVENANCE_CLASSES:
831
+ return classification
832
+ if self._is_same_origin_derived_search_url(normalized):
833
+ return _SAME_ORIGIN_DERIVED
834
+ return None
835
+
836
+ def establish_search_mediated_fetch_url(
837
+ self,
838
+ *,
839
+ raw_url: Any,
840
+ query: str,
841
+ source_url: str | None = None,
842
+ ) -> tuple[bool, str | None]:
843
+ normalized = normalize_web_url(raw_url) or canonicalize_web_url_input(raw_url)
844
+ if normalized is None:
845
+ return False, None
846
+ if normalized not in self._search_mediated_urls:
847
+ self._search_mediated_urls[normalized] = {
848
+ "url": str(raw_url or "").strip() or normalized,
849
+ "normalized_url": normalized,
850
+ "domain": _domain_for_url(normalized),
851
+ "query": str(query or "").strip(),
852
+ "source_url": str(source_url or "").strip(),
853
+ "provenance_classification": _SEARCH_MEDIATED_RECOVERY,
854
+ }
855
+ self._add_provenance_node(
856
+ raw_url=raw_url,
857
+ provenance_classification=_SEARCH_MEDIATED_RECOVERY,
858
+ source_event_id=None,
859
+ parent_url=source_url or "",
860
+ parent_source_event_id=None,
861
+ discovery_mechanism="search_mediated_recovery",
862
+ ts=None,
863
+ redirect_chain=(),
864
+ security_validation_status="syntax_validated_fetch_revalidates_target",
865
+ )
866
+ return True, normalized
867
+ return False, normalized
868
+
869
+ def _is_same_origin_derived_search_url(self, normalized: str) -> bool:
870
+ return any(
871
+ _same_origin_bounded_derivative(source_url, normalized)
872
+ for source_url in self._returned_source_urls
873
+ )
874
+
875
+ def hydrate_from_artifact_payload(self, payload: dict[str, Any]) -> bool:
876
+ if not isinstance(payload, dict):
877
+ return False
878
+ user_urls = payload.get("user_provided_urls")
879
+ returned_urls = payload.get("returned_by_web_search_urls")
880
+ redirect_urls = payload.get("canonical_redirect_urls")
881
+ search_mediated_urls = payload.get("search_mediated_recovery_urls")
882
+ provenance_graph = payload.get("url_provenance_graph")
883
+ searches = payload.get("searches")
884
+ fetches = payload.get("fetches")
885
+ if not any(
886
+ isinstance(value, list) and value
887
+ for value in (
888
+ user_urls,
889
+ returned_urls,
890
+ redirect_urls,
891
+ search_mediated_urls,
892
+ provenance_graph.get("nodes") if isinstance(provenance_graph, dict) else None,
893
+ searches,
894
+ fetches,
895
+ )
896
+ ):
897
+ return False
898
+ self._user_urls = self._hydrate_url_index(
899
+ raw_entries=user_urls,
900
+ fallback_classification=_USER_PROVIDED,
901
+ )
902
+ self._returned_source_urls = self._hydrate_url_index(
903
+ raw_entries=returned_urls,
904
+ fallback_classification=_RETURNED_BY_WEB_SEARCH,
905
+ )
906
+ self._canonical_redirect_urls = self._hydrate_url_index(
907
+ raw_entries=redirect_urls,
908
+ fallback_classification=_CANONICAL_REDIRECT,
909
+ )
910
+ self._search_mediated_urls = self._hydrate_url_index(
911
+ raw_entries=search_mediated_urls,
912
+ fallback_classification=_SEARCH_MEDIATED_RECOVERY,
913
+ )
914
+ raw_nodes = provenance_graph.get("nodes") if isinstance(provenance_graph, dict) else None
915
+ self._provenance_nodes = self._hydrate_url_index(
916
+ raw_entries=raw_nodes,
917
+ fallback_classification=_TRUSTED_TOOL_OUTPUT,
918
+ )
919
+ self._searches = self._hydrate_event_entries(searches)
920
+ self._fetches = self._hydrate_event_entries(fetches)
921
+ self._pending_search_indices = []
922
+ self._pending_fetch_indices = []
923
+ return self.has_activity()
924
+
925
+ def merge_from_artifact_payload(self, payload: dict[str, Any]) -> bool:
926
+ other = SessionWebResearchTracker()
927
+ if not other.hydrate_from_artifact_payload(payload):
928
+ return False
929
+ return self.merge_from_tracker(other)
930
+
931
+ def merge_from_tracker(self, other: SessionWebResearchTracker) -> bool:
932
+ changed = False
933
+ changed |= self._merge_url_index(self._user_urls, other._user_urls)
934
+ changed |= self._merge_url_index(self._returned_source_urls, other._returned_source_urls)
935
+ changed |= self._merge_url_index(
936
+ self._canonical_redirect_urls,
937
+ other._canonical_redirect_urls,
938
+ )
939
+ changed |= self._merge_url_index(
940
+ self._search_mediated_urls,
941
+ other._search_mediated_urls,
942
+ )
943
+ changed |= self._merge_url_index(self._provenance_nodes, other._provenance_nodes)
944
+ changed |= self._merge_search_entries(other._searches)
945
+ changed |= self._merge_fetch_entries(other._fetches)
946
+ return changed
947
+
948
+ def clear_pending(self) -> None:
949
+ self._pending_search_indices = []
950
+ self._pending_fetch_indices = []
951
+
952
+ def _add_provenance_node(
953
+ self,
954
+ *,
955
+ raw_url: Any,
956
+ provenance_classification: str,
957
+ source_event_id: str | None,
958
+ parent_url: str,
959
+ parent_source_event_id: str | None,
960
+ discovery_mechanism: str,
961
+ ts: str | None,
962
+ redirect_chain: tuple[str, ...],
963
+ security_validation_status: str,
964
+ source_type: str = "",
965
+ ) -> bool:
966
+ normalized = normalize_web_url(raw_url) or canonicalize_web_url_input(raw_url)
967
+ if normalized is None:
968
+ return False
969
+ if len(normalized) > _MAX_PROVENANCE_URL_LENGTH:
970
+ return False
971
+ existing = self._provenance_nodes.get(normalized)
972
+ entry = {
973
+ "url": str(raw_url or "").strip() or normalized,
974
+ "normalized_url": normalized,
975
+ "domain": _domain_for_url(normalized),
976
+ "provenance_classification": str(provenance_classification or "").strip(),
977
+ "source_event_id": str(source_event_id or "").strip() or None,
978
+ "parent_url": str(parent_url or "").strip(),
979
+ "parent_source_event_id": str(parent_source_event_id or "").strip() or None,
980
+ "discovery_mechanism": str(discovery_mechanism or "").strip(),
981
+ "source_type": str(source_type or "").strip(),
982
+ "ts": ts,
983
+ "redirect_chain": [
984
+ item
985
+ for item in (
986
+ normalize_web_url(url) or canonicalize_web_url_input(url) or ""
987
+ for url in redirect_chain
988
+ )
989
+ if item
990
+ ][:_MAX_URLS_PER_EVENT],
991
+ "security_validation_status": str(security_validation_status or "").strip(),
992
+ }
993
+ if existing is None:
994
+ if len(self._provenance_nodes) >= _MAX_PROVENANCE_NODES:
995
+ return False
996
+ self._provenance_nodes[normalized] = entry
997
+ return True
998
+
999
+ changed = False
1000
+ for key, value in entry.items():
1001
+ if key == "redirect_chain":
1002
+ if not existing.get(key) and value:
1003
+ existing[key] = value
1004
+ changed = True
1005
+ continue
1006
+ current = existing.get(key)
1007
+ if (current is None or current == "") and value not in (None, ""):
1008
+ existing[key] = value
1009
+ changed = True
1010
+ return changed
1011
+
1012
+ def _record_urls_from_text(
1013
+ self,
1014
+ *,
1015
+ text: Any,
1016
+ provenance_classification: str,
1017
+ source_event_id: str | None,
1018
+ parent_url: str,
1019
+ parent_source_event_id: str | None,
1020
+ discovery_mechanism: str,
1021
+ source_type: str,
1022
+ ts: str | None,
1023
+ ) -> bool:
1024
+ body = str(text or "")
1025
+ if not body:
1026
+ return False
1027
+ changed = False
1028
+ for entry in extract_public_web_urls(body[:_MAX_URL_TEXT_CHARS])[:_MAX_URLS_PER_EVENT]:
1029
+ changed |= self._add_provenance_node(
1030
+ raw_url=entry["url"],
1031
+ provenance_classification=provenance_classification,
1032
+ source_event_id=source_event_id,
1033
+ parent_url=parent_url,
1034
+ parent_source_event_id=parent_source_event_id,
1035
+ discovery_mechanism=discovery_mechanism,
1036
+ source_type=source_type,
1037
+ ts=ts,
1038
+ redirect_chain=(),
1039
+ security_validation_status="syntax_validated_fetch_revalidates_target",
1040
+ )
1041
+ return changed
1042
+
1043
+ def _record_trusted_tool_result_urls(
1044
+ self,
1045
+ *,
1046
+ tool_name: str,
1047
+ payload: dict[str, Any],
1048
+ ts: str | None,
1049
+ event_id: str | None,
1050
+ ) -> bool:
1051
+ result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
1052
+ if not result or "error" in result or "error_code" in result:
1053
+ return False
1054
+ if tool_name in {"fs_read", "fs_read_lines"}:
1055
+ return self._record_urls_from_text(
1056
+ text=result.get("content"),
1057
+ provenance_classification=_TRUSTED_LOCAL_FILE,
1058
+ source_event_id=event_id,
1059
+ parent_url=str(result.get("path") or ""),
1060
+ parent_source_event_id=event_id,
1061
+ discovery_mechanism="workspace_file_content",
1062
+ source_type=tool_name,
1063
+ ts=ts,
1064
+ )
1065
+ if tool_name in {"web_search", "web_fetch"}:
1066
+ return False
1067
+ snippets = list(self._iter_result_text_snippets(result))
1068
+ if not snippets:
1069
+ return False
1070
+ changed = False
1071
+ for snippet in snippets[:8]:
1072
+ changed |= self._record_urls_from_text(
1073
+ text=snippet,
1074
+ provenance_classification=_TRUSTED_TOOL_OUTPUT,
1075
+ source_event_id=event_id,
1076
+ parent_url=tool_name,
1077
+ parent_source_event_id=event_id,
1078
+ discovery_mechanism="registered_tool_output",
1079
+ source_type=tool_name,
1080
+ ts=ts,
1081
+ )
1082
+ return changed
1083
+
1084
+ def _iter_result_text_snippets(
1085
+ self,
1086
+ value: Any,
1087
+ *,
1088
+ depth: int = 0,
1089
+ ) -> Iterable[str]:
1090
+ if depth > 4:
1091
+ return
1092
+ if isinstance(value, str):
1093
+ if "http://" in value or "https://" in value:
1094
+ yield value[:_MAX_URL_TEXT_CHARS]
1095
+ return
1096
+ if isinstance(value, dict):
1097
+ for item in value.values():
1098
+ yield from self._iter_result_text_snippets(item, depth=depth + 1)
1099
+ return
1100
+ if isinstance(value, list):
1101
+ for item in value[:64]:
1102
+ yield from self._iter_result_text_snippets(item, depth=depth + 1)
1103
+
1104
+ def _record_user_message(
1105
+ self,
1106
+ *,
1107
+ payload: dict[str, Any],
1108
+ ts: str | None,
1109
+ event_id: str | None,
1110
+ ) -> bool:
1111
+ content = payload.get("content")
1112
+ if not isinstance(content, str):
1113
+ return False
1114
+ changed = False
1115
+ for entry in extract_public_web_urls(content):
1116
+ normalized = entry["normalized_url"]
1117
+ if normalized in self._user_urls:
1118
+ continue
1119
+ self._user_urls[normalized] = {
1120
+ "url": entry["url"],
1121
+ "normalized_url": normalized,
1122
+ "domain": entry["domain"],
1123
+ "ts": ts,
1124
+ "step": None,
1125
+ "source_event_id": event_id,
1126
+ "provenance_classification": _USER_PROVIDED,
1127
+ }
1128
+ self._add_provenance_node(
1129
+ raw_url=entry["url"],
1130
+ provenance_classification=_USER_PROVIDED,
1131
+ source_event_id=event_id,
1132
+ parent_url="",
1133
+ parent_source_event_id=None,
1134
+ discovery_mechanism="user_message_url",
1135
+ ts=ts,
1136
+ redirect_chain=(),
1137
+ security_validation_status="syntax_validated_fetch_revalidates_target",
1138
+ )
1139
+ changed = True
1140
+ return changed
1141
+
1142
+ def _record_web_search_call(
1143
+ self,
1144
+ *,
1145
+ payload: dict[str, Any],
1146
+ ts: str | None,
1147
+ event_id: str | None,
1148
+ ) -> bool:
1149
+ arguments = payload.get("arguments") if isinstance(payload.get("arguments"), dict) else {}
1150
+ query = str(arguments.get("query") or "").strip()
1151
+ normalized_query = normalize_web_query(query)
1152
+ allowed_domains = [
1153
+ str(item or "").strip().lower()
1154
+ for item in list(arguments.get("allowed_domains") or [])
1155
+ if str(item or "").strip()
1156
+ ]
1157
+ raw_external = arguments.get("external_web_access")
1158
+ external_web_access = raw_external if isinstance(raw_external, bool) else None
1159
+ step = payload.get("step") if isinstance(payload.get("step"), int) else None
1160
+ entry = {
1161
+ "step": step,
1162
+ "call_ts": ts,
1163
+ "result_ts": None,
1164
+ "query": query,
1165
+ "normalized_query": normalized_query,
1166
+ "queries": [query] if query else [],
1167
+ "normalized_queries": [normalized_query] if normalized_query else [],
1168
+ "backend": "",
1169
+ "backend_adapter": "",
1170
+ "protocol": "",
1171
+ "chat_protocol": "",
1172
+ "search_protocol": "",
1173
+ "provider": "",
1174
+ "provider_hosted_search": False,
1175
+ "external_search_provider": "",
1176
+ "citation_count": 0,
1177
+ "source_count": 0,
1178
+ "allowed_domains": allowed_domains,
1179
+ "external_web_access": external_web_access,
1180
+ "source_event_id": event_id,
1181
+ "response_id": "",
1182
+ "sources_truncated": False,
1183
+ "returned_sources": [],
1184
+ "error": "",
1185
+ }
1186
+ self._searches.append(entry)
1187
+ self._pending_search_indices.append(len(self._searches) - 1)
1188
+ return True
1189
+
1190
+ def _record_web_search_result(
1191
+ self,
1192
+ *,
1193
+ payload: dict[str, Any],
1194
+ ts: str | None,
1195
+ event_id: str | None,
1196
+ ) -> bool:
1197
+ result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
1198
+ step = payload.get("step") if isinstance(payload.get("step"), int) else None
1199
+ if self._pending_search_indices:
1200
+ entry = self._searches[self._pending_search_indices.pop(0)]
1201
+ else:
1202
+ entry = {
1203
+ "step": step,
1204
+ "call_ts": ts,
1205
+ "result_ts": None,
1206
+ "query": "",
1207
+ "normalized_query": "",
1208
+ "queries": [],
1209
+ "normalized_queries": [],
1210
+ "backend": "",
1211
+ "backend_adapter": "",
1212
+ "protocol": "",
1213
+ "chat_protocol": "",
1214
+ "search_protocol": "",
1215
+ "provider": "",
1216
+ "provider_hosted_search": False,
1217
+ "external_search_provider": "",
1218
+ "citation_count": 0,
1219
+ "source_count": 0,
1220
+ "allowed_domains": [],
1221
+ "external_web_access": None,
1222
+ "source_event_id": event_id,
1223
+ "response_id": "",
1224
+ "sources_truncated": False,
1225
+ "returned_sources": [],
1226
+ "error": "",
1227
+ }
1228
+ self._searches.append(entry)
1229
+ entry["step"] = entry.get("step") if entry.get("step") is not None else step
1230
+ entry["result_ts"] = ts
1231
+ if "error" in result:
1232
+ entry["error"] = str(result.get("error") or "").strip()
1233
+ return True
1234
+
1235
+ query = str(result.get("query") or entry.get("query") or "").strip()
1236
+ normalized_query = normalize_web_query(query)
1237
+ raw_queries = result.get("queries")
1238
+ query_list = (
1239
+ [str(item or "").strip() for item in raw_queries]
1240
+ if isinstance(raw_queries, list)
1241
+ else []
1242
+ )
1243
+ query_list = [item for item in query_list if item]
1244
+ if query and query not in query_list:
1245
+ query_list.insert(0, query)
1246
+ normalized_queries = _dedupe_ordered([normalize_web_query(item) for item in query_list])
1247
+ returned_sources: list[dict[str, Any]] = []
1248
+ for raw_source in list(result.get("sources") or []):
1249
+ if not isinstance(raw_source, dict):
1250
+ continue
1251
+ raw_url = str(raw_source.get("url") or "").strip()
1252
+ normalized_url = normalize_web_url(raw_url)
1253
+ if normalized_url is None:
1254
+ continue
1255
+ source_entry = {
1256
+ "title": str(raw_source.get("title") or "").strip(),
1257
+ "url": raw_url,
1258
+ "normalized_url": normalized_url,
1259
+ "domain": _domain_for_url(normalized_url),
1260
+ "snippet": str(raw_source.get("snippet") or "").strip(),
1261
+ "provenance_classification": _RETURNED_BY_WEB_SEARCH,
1262
+ }
1263
+ returned_sources.append(source_entry)
1264
+ if normalized_url not in self._returned_source_urls:
1265
+ self._returned_source_urls[normalized_url] = {
1266
+ "title": source_entry["title"],
1267
+ "url": raw_url,
1268
+ "normalized_url": normalized_url,
1269
+ "domain": source_entry["domain"],
1270
+ "snippet": source_entry["snippet"],
1271
+ "ts": ts,
1272
+ "step": entry.get("step"),
1273
+ "backend": str(result.get("backend") or "").strip(),
1274
+ "source_event_id": event_id,
1275
+ "provenance_classification": _RETURNED_BY_WEB_SEARCH,
1276
+ }
1277
+ self._add_provenance_node(
1278
+ raw_url=raw_url,
1279
+ provenance_classification=_RETURNED_BY_WEB_SEARCH,
1280
+ source_event_id=event_id,
1281
+ parent_url="",
1282
+ parent_source_event_id=None,
1283
+ discovery_mechanism="web_search_source",
1284
+ ts=ts,
1285
+ redirect_chain=(),
1286
+ security_validation_status="syntax_validated_fetch_revalidates_target",
1287
+ )
1288
+
1289
+ entry["query"] = query
1290
+ entry["normalized_query"] = normalized_query
1291
+ entry["queries"] = query_list
1292
+ entry["normalized_queries"] = normalized_queries
1293
+ entry["backend"] = str(result.get("backend") or "").strip()
1294
+ entry["backend_adapter"] = str(
1295
+ result.get("backend_adapter") or result.get("backend") or ""
1296
+ ).strip()
1297
+ entry["protocol"] = str(result.get("protocol") or entry["backend_adapter"]).strip()
1298
+ entry["chat_protocol"] = str(result.get("chat_protocol") or entry["protocol"]).strip()
1299
+ entry["search_protocol"] = str(
1300
+ result.get("search_protocol") or entry["backend_adapter"]
1301
+ ).strip()
1302
+ entry["provider"] = str(result.get("backend") or "").strip()
1303
+ entry["provider_hosted_search"] = bool(result.get("provider_hosted_search"))
1304
+ entry["external_search_provider"] = str(
1305
+ result.get("external_search_provider") or ""
1306
+ ).strip()
1307
+ raw_citation_count = result.get("citation_count")
1308
+ if isinstance(raw_citation_count, int):
1309
+ entry["citation_count"] = raw_citation_count
1310
+ else:
1311
+ citations = result.get("citations")
1312
+ entry["citation_count"] = len(citations) if isinstance(citations, list) else 0
1313
+ raw_source_count = result.get("source_count")
1314
+ if isinstance(raw_source_count, int):
1315
+ entry["source_count"] = raw_source_count
1316
+ else:
1317
+ entry["source_count"] = len(returned_sources)
1318
+ entry["allowed_domains"] = [
1319
+ str(item or "").strip().lower()
1320
+ for item in list(result.get("allowed_domains") or entry.get("allowed_domains") or [])
1321
+ if str(item or "").strip()
1322
+ ]
1323
+ raw_external = result.get("external_web_access")
1324
+ if isinstance(raw_external, bool):
1325
+ entry["external_web_access"] = raw_external
1326
+ entry["response_id"] = str(result.get("response_id") or "").strip()
1327
+ entry["sources_truncated"] = bool(result.get("sources_truncated"))
1328
+ entry["returned_sources"] = returned_sources
1329
+ entry["error"] = ""
1330
+ return True
1331
+
1332
+ def _record_web_fetch_call(
1333
+ self,
1334
+ *,
1335
+ payload: dict[str, Any],
1336
+ ts: str | None,
1337
+ event_id: str | None,
1338
+ ) -> bool:
1339
+ arguments = payload.get("arguments") if isinstance(payload.get("arguments"), dict) else {}
1340
+ raw_requested_url = str(arguments.get("url") or "").strip()
1341
+ provenance_classification, effective_requested_url = self.resolve_fetch_url(
1342
+ raw_requested_url
1343
+ )
1344
+ requested_url = effective_requested_url or raw_requested_url
1345
+ normalized_requested_url = normalize_web_url(requested_url)
1346
+ step = payload.get("step") if isinstance(payload.get("step"), int) else None
1347
+ entry = {
1348
+ "step": step,
1349
+ "call_ts": ts,
1350
+ "result_ts": None,
1351
+ "requested_url": requested_url,
1352
+ "normalized_requested_url": normalized_requested_url,
1353
+ "raw_input_url": (
1354
+ raw_requested_url
1355
+ if raw_requested_url and raw_requested_url != requested_url
1356
+ else ""
1357
+ ),
1358
+ "final_url": "",
1359
+ "normalized_final_url": "",
1360
+ "status_code": None,
1361
+ "content_type": "",
1362
+ "title": "",
1363
+ "backend": "",
1364
+ "provenance_classification": provenance_classification or "",
1365
+ "source_event_id": event_id,
1366
+ "error": "",
1367
+ "error_code": "",
1368
+ }
1369
+ self._fetches.append(entry)
1370
+ self._pending_fetch_indices.append(len(self._fetches) - 1)
1371
+ return True
1372
+
1373
+ def _record_web_fetch_result(
1374
+ self,
1375
+ *,
1376
+ payload: dict[str, Any],
1377
+ ts: str | None,
1378
+ event_id: str | None,
1379
+ ) -> bool:
1380
+ result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
1381
+ step = payload.get("step") if isinstance(payload.get("step"), int) else None
1382
+ if self._pending_fetch_indices:
1383
+ entry = self._fetches[self._pending_fetch_indices.pop(0)]
1384
+ else:
1385
+ entry = {
1386
+ "step": step,
1387
+ "call_ts": ts,
1388
+ "result_ts": None,
1389
+ "requested_url": "",
1390
+ "normalized_requested_url": None,
1391
+ "raw_input_url": "",
1392
+ "final_url": "",
1393
+ "normalized_final_url": "",
1394
+ "status_code": None,
1395
+ "content_type": "",
1396
+ "title": "",
1397
+ "backend": "",
1398
+ "provenance_classification": "",
1399
+ "source_event_id": event_id,
1400
+ "error": "",
1401
+ "error_code": "",
1402
+ }
1403
+ self._fetches.append(entry)
1404
+ entry["step"] = entry.get("step") if entry.get("step") is not None else step
1405
+ entry["result_ts"] = ts
1406
+
1407
+ requested_url = str(result.get("url") or entry.get("requested_url") or "").strip()
1408
+ raw_input_url = str(result.get("raw_input_url") or entry.get("raw_input_url") or "").strip()
1409
+ normalized_requested_url = normalize_web_url(requested_url)
1410
+ if requested_url:
1411
+ entry["requested_url"] = requested_url
1412
+ if normalized_requested_url:
1413
+ entry["normalized_requested_url"] = normalized_requested_url
1414
+ if raw_input_url and raw_input_url != requested_url:
1415
+ entry["raw_input_url"] = raw_input_url
1416
+ if not entry.get("provenance_classification"):
1417
+ entry["provenance_classification"] = (
1418
+ self.classify_fetch_url(raw_input_url or requested_url) or ""
1419
+ )
1420
+
1421
+ if "error" in result:
1422
+ entry["error"] = str(result.get("error") or "").strip()
1423
+ entry["error_code"] = str(result.get("error_code") or "").strip()
1424
+ return True
1425
+
1426
+ final_url = str(result.get("final_url") or "").strip()
1427
+ normalized_final_url = normalize_web_url(final_url)
1428
+ entry["final_url"] = final_url
1429
+ entry["normalized_final_url"] = normalized_final_url or ""
1430
+ entry["status_code"] = (
1431
+ result.get("status_code") if result.get("status_code") is not None else None
1432
+ )
1433
+ entry["content_type"] = str(result.get("content_type") or "").strip()
1434
+ entry["title"] = str(result.get("title") or "").strip()
1435
+ entry["backend"] = str(result.get("backend") or "").strip()
1436
+ entry["error"] = ""
1437
+ entry["error_code"] = ""
1438
+ provenance = str(entry.get("provenance_classification") or "").strip()
1439
+ if (
1440
+ normalized_final_url
1441
+ and normalized_requested_url
1442
+ and normalized_final_url != normalized_requested_url
1443
+ and provenance
1444
+ in {
1445
+ _USER_PROVIDED,
1446
+ _RETURNED_BY_WEB_SEARCH,
1447
+ _SEARCH_MEDIATED_RECOVERY,
1448
+ _SAME_ORIGIN_DERIVED,
1449
+ }
1450
+ and normalized_final_url not in self._canonical_redirect_urls
1451
+ ):
1452
+ self._canonical_redirect_urls[normalized_final_url] = {
1453
+ "url": final_url,
1454
+ "normalized_url": normalized_final_url,
1455
+ "domain": _domain_for_url(normalized_final_url),
1456
+ "source_url": requested_url,
1457
+ "source_provenance_classification": provenance,
1458
+ "source_event_id": event_id,
1459
+ "provenance_classification": _CANONICAL_REDIRECT,
1460
+ }
1461
+ self._add_provenance_node(
1462
+ raw_url=final_url,
1463
+ provenance_classification=_CANONICAL_REDIRECT,
1464
+ source_event_id=event_id,
1465
+ parent_url=requested_url,
1466
+ parent_source_event_id=str(entry.get("source_event_id") or "").strip() or None,
1467
+ discovery_mechanism="validated_redirect",
1468
+ ts=ts,
1469
+ redirect_chain=(requested_url, final_url),
1470
+ security_validation_status="validated_by_web_fetch",
1471
+ )
1472
+ self._record_urls_from_text(
1473
+ text=result.get("content"),
1474
+ provenance_classification=_FETCHED_PAGE_LINK,
1475
+ source_event_id=event_id,
1476
+ parent_url=final_url or requested_url,
1477
+ parent_source_event_id=event_id,
1478
+ discovery_mechanism="fetched_page_content",
1479
+ source_type="web_fetch_result",
1480
+ ts=ts,
1481
+ )
1482
+ return True
1483
+
1484
+ def _hydrate_url_index(
1485
+ self,
1486
+ *,
1487
+ raw_entries: Any,
1488
+ fallback_classification: str,
1489
+ ) -> dict[str, dict[str, Any]]:
1490
+ hydrated: dict[str, dict[str, Any]] = {}
1491
+ if not isinstance(raw_entries, list):
1492
+ return hydrated
1493
+ for raw_entry in raw_entries:
1494
+ if not isinstance(raw_entry, dict):
1495
+ continue
1496
+ normalized_url = normalize_web_url(
1497
+ raw_entry.get("normalized_url") or raw_entry.get("url")
1498
+ )
1499
+ if not normalized_url or normalized_url in hydrated:
1500
+ continue
1501
+ hydrated[normalized_url] = {
1502
+ "url": str(raw_entry.get("url") or "").strip() or normalized_url,
1503
+ "normalized_url": normalized_url,
1504
+ "domain": str(raw_entry.get("domain") or _domain_for_url(normalized_url)).strip(),
1505
+ "ts": str(raw_entry.get("ts") or "").strip() or None,
1506
+ "step": raw_entry.get("step") if isinstance(raw_entry.get("step"), int) else None,
1507
+ "source_event_id": str(raw_entry.get("source_event_id") or "").strip() or None,
1508
+ "parent_url": str(raw_entry.get("parent_url") or "").strip(),
1509
+ "parent_source_event_id": (
1510
+ str(raw_entry.get("parent_source_event_id") or "").strip() or None
1511
+ ),
1512
+ "discovery_mechanism": str(raw_entry.get("discovery_mechanism") or "").strip(),
1513
+ "source_type": str(raw_entry.get("source_type") or "").strip(),
1514
+ "redirect_chain": [
1515
+ str(item or "").strip()
1516
+ for item in list(raw_entry.get("redirect_chain") or [])
1517
+ if str(item or "").strip()
1518
+ ][:_MAX_URLS_PER_EVENT],
1519
+ "security_validation_status": str(
1520
+ raw_entry.get("security_validation_status") or ""
1521
+ ).strip(),
1522
+ "provenance_classification": str(
1523
+ raw_entry.get("provenance_classification") or fallback_classification
1524
+ ).strip()
1525
+ or fallback_classification,
1526
+ "title": str(raw_entry.get("title") or "").strip(),
1527
+ "snippet": str(raw_entry.get("snippet") or "").strip(),
1528
+ "backend": str(raw_entry.get("backend") or "").strip(),
1529
+ }
1530
+ return hydrated
1531
+
1532
+ def _hydrate_event_entries(self, raw_entries: Any) -> list[dict[str, Any]]:
1533
+ if not isinstance(raw_entries, list):
1534
+ return []
1535
+ hydrated: list[dict[str, Any]] = []
1536
+ for raw_entry in raw_entries:
1537
+ if not isinstance(raw_entry, dict):
1538
+ continue
1539
+ hydrated.append(copy.deepcopy(raw_entry))
1540
+ return hydrated
1541
+
1542
+ def _merge_url_index(
1543
+ self,
1544
+ existing: dict[str, dict[str, Any]],
1545
+ incoming: dict[str, dict[str, Any]],
1546
+ ) -> bool:
1547
+ changed = False
1548
+ for normalized_url, incoming_entry in incoming.items():
1549
+ current = existing.get(normalized_url)
1550
+ if current is None:
1551
+ existing[normalized_url] = copy.deepcopy(incoming_entry)
1552
+ changed = True
1553
+ continue
1554
+ merged = self._merge_url_index_entry(current, incoming_entry)
1555
+ if merged != current:
1556
+ existing[normalized_url] = merged
1557
+ changed = True
1558
+ return changed
1559
+
1560
+ def _merge_url_index_entry(
1561
+ self,
1562
+ existing: dict[str, Any],
1563
+ incoming: dict[str, Any],
1564
+ ) -> dict[str, Any]:
1565
+ merged = copy.deepcopy(existing)
1566
+ for key in (
1567
+ "url",
1568
+ "domain",
1569
+ "ts",
1570
+ "title",
1571
+ "snippet",
1572
+ "backend",
1573
+ "provenance_classification",
1574
+ "source_event_id",
1575
+ "parent_url",
1576
+ "parent_source_event_id",
1577
+ "discovery_mechanism",
1578
+ "source_type",
1579
+ "security_validation_status",
1580
+ ):
1581
+ current_value = str(merged.get(key) or "").strip()
1582
+ incoming_value = str(incoming.get(key) or "").strip()
1583
+ if not current_value and incoming_value:
1584
+ merged[key] = incoming_value
1585
+ if not list(merged.get("redirect_chain") or []) and list(
1586
+ incoming.get("redirect_chain") or []
1587
+ ):
1588
+ merged["redirect_chain"] = list(incoming.get("redirect_chain") or [])[
1589
+ :_MAX_URLS_PER_EVENT
1590
+ ]
1591
+ if not str(merged.get("normalized_url") or "").strip():
1592
+ merged["normalized_url"] = str(incoming.get("normalized_url") or "").strip()
1593
+ if not isinstance(merged.get("step"), int) and isinstance(incoming.get("step"), int):
1594
+ merged["step"] = incoming["step"]
1595
+ return merged
1596
+
1597
+ def _merge_search_entries(self, incoming_entries: list[dict[str, Any]]) -> bool:
1598
+ changed = False
1599
+ for incoming_entry in incoming_entries:
1600
+ incoming = copy.deepcopy(incoming_entry)
1601
+ match_index = self._find_matching_search_index(incoming)
1602
+ if match_index is None:
1603
+ self._searches.append(incoming)
1604
+ changed = True
1605
+ continue
1606
+ merged = self._merge_search_entry(self._searches[match_index], incoming)
1607
+ if merged != self._searches[match_index]:
1608
+ self._searches[match_index] = merged
1609
+ changed = True
1610
+ return changed
1611
+
1612
+ def _merge_fetch_entries(self, incoming_entries: list[dict[str, Any]]) -> bool:
1613
+ changed = False
1614
+ for incoming_entry in incoming_entries:
1615
+ incoming = copy.deepcopy(incoming_entry)
1616
+ match_index = self._find_matching_fetch_index(incoming)
1617
+ if match_index is None:
1618
+ self._fetches.append(incoming)
1619
+ changed = True
1620
+ continue
1621
+ merged = self._merge_fetch_entry(self._fetches[match_index], incoming)
1622
+ if merged != self._fetches[match_index]:
1623
+ self._fetches[match_index] = merged
1624
+ changed = True
1625
+ return changed
1626
+
1627
+ def _find_matching_search_index(self, incoming: dict[str, Any]) -> int | None:
1628
+ incoming_fingerprint = _canonical_json(incoming)
1629
+ incoming_keys = self._search_merge_keys(incoming)
1630
+ for index, existing in enumerate(self._searches):
1631
+ if _canonical_json(existing) == incoming_fingerprint:
1632
+ return index
1633
+ if not incoming_keys:
1634
+ return None
1635
+ for index, existing in enumerate(self._searches):
1636
+ if incoming_keys & self._search_merge_keys(existing):
1637
+ return index
1638
+ return None
1639
+
1640
+ def _find_matching_fetch_index(self, incoming: dict[str, Any]) -> int | None:
1641
+ incoming_fingerprint = _canonical_json(incoming)
1642
+ incoming_keys = self._fetch_merge_keys(incoming)
1643
+ for index, existing in enumerate(self._fetches):
1644
+ if _canonical_json(existing) == incoming_fingerprint:
1645
+ return index
1646
+ if not incoming_keys:
1647
+ return None
1648
+ for index, existing in enumerate(self._fetches):
1649
+ if incoming_keys & self._fetch_merge_keys(existing):
1650
+ return index
1651
+ return None
1652
+
1653
+ def _search_merge_keys(self, entry: dict[str, Any]) -> set[tuple[Any, ...]]:
1654
+ keys: set[tuple[Any, ...]] = set()
1655
+ step = entry.get("step") if isinstance(entry.get("step"), int) else None
1656
+ call_ts = str(entry.get("call_ts") or "").strip()
1657
+ result_ts = str(entry.get("result_ts") or "").strip()
1658
+ normalized_query = str(entry.get("normalized_query") or "").strip()
1659
+ normalized_queries = tuple(
1660
+ _dedupe_ordered(
1661
+ [str(item or "").strip() for item in list(entry.get("normalized_queries") or [])]
1662
+ )
1663
+ )
1664
+ allowed_domains = tuple(
1665
+ _dedupe_ordered(
1666
+ [
1667
+ str(item or "").strip().lower()
1668
+ for item in list(entry.get("allowed_domains") or [])
1669
+ ]
1670
+ )
1671
+ )
1672
+ if step is not None and call_ts and normalized_query:
1673
+ keys.add(("search", "step_call_query", step, call_ts, normalized_query))
1674
+ if step is not None and result_ts and normalized_query:
1675
+ keys.add(("search", "step_result_query", step, result_ts, normalized_query))
1676
+ if call_ts and normalized_query:
1677
+ keys.add(("search", "call_query", call_ts, normalized_query))
1678
+ if result_ts and normalized_query:
1679
+ keys.add(("search", "result_query", result_ts, normalized_query))
1680
+ if step is not None and normalized_query and allowed_domains:
1681
+ keys.add(("search", "step_query_domains", step, normalized_query, allowed_domains))
1682
+ if step is not None and normalized_query:
1683
+ keys.add(("search", "step_query", step, normalized_query))
1684
+ if step is not None and normalized_queries:
1685
+ keys.add(("search", "step_queries", step, normalized_queries))
1686
+ return keys
1687
+
1688
+ def _fetch_merge_keys(self, entry: dict[str, Any]) -> set[tuple[Any, ...]]:
1689
+ keys: set[tuple[Any, ...]] = set()
1690
+ step = entry.get("step") if isinstance(entry.get("step"), int) else None
1691
+ call_ts = str(entry.get("call_ts") or "").strip()
1692
+ result_ts = str(entry.get("result_ts") or "").strip()
1693
+ normalized_requested_url = str(entry.get("normalized_requested_url") or "").strip()
1694
+ normalized_final_url = str(entry.get("normalized_final_url") or "").strip()
1695
+ if step is not None and call_ts and normalized_requested_url:
1696
+ keys.add(("fetch", "step_call_requested", step, call_ts, normalized_requested_url))
1697
+ if step is not None and result_ts and normalized_requested_url:
1698
+ keys.add(("fetch", "step_result_requested", step, result_ts, normalized_requested_url))
1699
+ if call_ts and normalized_requested_url:
1700
+ keys.add(("fetch", "call_requested", call_ts, normalized_requested_url))
1701
+ if result_ts and normalized_requested_url:
1702
+ keys.add(("fetch", "result_requested", result_ts, normalized_requested_url))
1703
+ if step is not None and normalized_requested_url:
1704
+ keys.add(("fetch", "step_requested", step, normalized_requested_url))
1705
+ if step is not None and normalized_final_url:
1706
+ keys.add(("fetch", "step_final", step, normalized_final_url))
1707
+ return keys
1708
+
1709
+ def _merge_search_entry(
1710
+ self, existing: dict[str, Any], incoming: dict[str, Any]
1711
+ ) -> dict[str, Any]:
1712
+ merged = copy.deepcopy(existing)
1713
+ if not isinstance(merged.get("step"), int) and isinstance(incoming.get("step"), int):
1714
+ merged["step"] = incoming["step"]
1715
+ for key in (
1716
+ "call_ts",
1717
+ "result_ts",
1718
+ "query",
1719
+ "normalized_query",
1720
+ "backend",
1721
+ "provider",
1722
+ "response_id",
1723
+ "error",
1724
+ ):
1725
+ current_value = str(merged.get(key) or "").strip()
1726
+ incoming_value = str(incoming.get(key) or "").strip()
1727
+ if not current_value and incoming_value:
1728
+ merged[key] = incoming_value
1729
+ if merged.get("external_web_access") is None and isinstance(
1730
+ incoming.get("external_web_access"), bool
1731
+ ):
1732
+ merged["external_web_access"] = incoming["external_web_access"]
1733
+ merged["sources_truncated"] = bool(
1734
+ merged.get("sources_truncated") or incoming.get("sources_truncated")
1735
+ )
1736
+ merged["queries"] = _dedupe_ordered(
1737
+ [str(item or "").strip() for item in list(merged.get("queries") or [])]
1738
+ + [str(item or "").strip() for item in list(incoming.get("queries") or [])]
1739
+ )
1740
+ merged["normalized_queries"] = _dedupe_ordered(
1741
+ [str(item or "").strip() for item in list(merged.get("normalized_queries") or [])]
1742
+ + [str(item or "").strip() for item in list(incoming.get("normalized_queries") or [])]
1743
+ )
1744
+ merged["allowed_domains"] = _dedupe_ordered(
1745
+ [str(item or "").strip().lower() for item in list(merged.get("allowed_domains") or [])]
1746
+ + [
1747
+ str(item or "").strip().lower()
1748
+ for item in list(incoming.get("allowed_domains") or [])
1749
+ ]
1750
+ )
1751
+ merged["returned_sources"] = self._merge_source_entries(
1752
+ list(merged.get("returned_sources") or []),
1753
+ list(incoming.get("returned_sources") or []),
1754
+ )
1755
+ return merged
1756
+
1757
+ def _merge_fetch_entry(
1758
+ self, existing: dict[str, Any], incoming: dict[str, Any]
1759
+ ) -> dict[str, Any]:
1760
+ merged = copy.deepcopy(existing)
1761
+ if not isinstance(merged.get("step"), int) and isinstance(incoming.get("step"), int):
1762
+ merged["step"] = incoming["step"]
1763
+ for key in (
1764
+ "call_ts",
1765
+ "result_ts",
1766
+ "requested_url",
1767
+ "normalized_requested_url",
1768
+ "raw_input_url",
1769
+ "final_url",
1770
+ "normalized_final_url",
1771
+ "content_type",
1772
+ "title",
1773
+ "backend",
1774
+ "provenance_classification",
1775
+ "error",
1776
+ "error_code",
1777
+ ):
1778
+ current_value = str(merged.get(key) or "").strip()
1779
+ incoming_value = str(incoming.get(key) or "").strip()
1780
+ if not current_value and incoming_value:
1781
+ merged[key] = incoming_value
1782
+ if merged.get("status_code") is None and incoming.get("status_code") is not None:
1783
+ merged["status_code"] = incoming.get("status_code")
1784
+ return merged
1785
+
1786
+ def _merge_source_entries(
1787
+ self,
1788
+ existing_entries: list[dict[str, Any]],
1789
+ incoming_entries: list[dict[str, Any]],
1790
+ ) -> list[dict[str, Any]]:
1791
+ merged_entries = [
1792
+ copy.deepcopy(entry) for entry in existing_entries if isinstance(entry, dict)
1793
+ ]
1794
+ index_by_url: dict[str, int] = {}
1795
+ for index, entry in enumerate(merged_entries):
1796
+ normalized_url = normalize_web_url(entry.get("normalized_url") or entry.get("url"))
1797
+ if normalized_url:
1798
+ entry["normalized_url"] = normalized_url
1799
+ index_by_url[normalized_url] = index
1800
+ for incoming_entry in incoming_entries:
1801
+ if not isinstance(incoming_entry, dict):
1802
+ continue
1803
+ normalized_url = normalize_web_url(
1804
+ incoming_entry.get("normalized_url") or incoming_entry.get("url")
1805
+ )
1806
+ if not normalized_url:
1807
+ continue
1808
+ incoming_copy = copy.deepcopy(incoming_entry)
1809
+ incoming_copy["normalized_url"] = normalized_url
1810
+ existing_index = index_by_url.get(normalized_url)
1811
+ if existing_index is None:
1812
+ merged_entries.append(incoming_copy)
1813
+ index_by_url[normalized_url] = len(merged_entries) - 1
1814
+ continue
1815
+ current = merged_entries[existing_index]
1816
+ for key in ("title", "url", "domain", "snippet", "provenance_classification"):
1817
+ current_value = str(current.get(key) or "").strip()
1818
+ incoming_value = str(incoming_copy.get(key) or "").strip()
1819
+ if not current_value and incoming_value:
1820
+ current[key] = incoming_value
1821
+ return merged_entries
1822
+
1823
+
1824
+ def build_web_research_artifact_from_events(events: list[dict[str, Any]]) -> dict[str, Any]:
1825
+ tracker = SessionWebResearchTracker()
1826
+ for index, event in enumerate(events, start=1):
1827
+ payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
1828
+ tracker.observe_event(
1829
+ event_type=str(event.get("type") or "").strip(),
1830
+ payload=payload,
1831
+ ts=str(event.get("ts") or "").strip() or None,
1832
+ event_id=str(event.get("event_id") or "").strip() or f"event:{index}",
1833
+ )
1834
+ return tracker.artifact_payload()
1835
+
1836
+
1837
+ def build_web_research_metrics_from_events(events: list[dict[str, Any]]) -> dict[str, int]:
1838
+ tracker = SessionWebResearchTracker()
1839
+ for index, event in enumerate(events, start=1):
1840
+ payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
1841
+ tracker.observe_event(
1842
+ event_type=str(event.get("type") or "").strip(),
1843
+ payload=payload,
1844
+ ts=str(event.get("ts") or "").strip() or None,
1845
+ event_id=str(event.get("event_id") or "").strip() or f"event:{index}",
1846
+ )
1847
+ return tracker.metrics_payload()
1848
+
1849
+
1850
+ def build_web_research_metrics_from_artifact_payload(payload: dict[str, Any]) -> dict[str, int]:
1851
+ tracker = SessionWebResearchTracker()
1852
+ if not tracker.hydrate_from_artifact_payload(payload):
1853
+ return SessionWebResearchTracker().metrics_payload()
1854
+ return tracker.metrics_payload()
1855
+
1856
+
1857
+ def web_research_artifact_has_activity(payload: dict[str, Any] | None) -> bool:
1858
+ if not isinstance(payload, dict):
1859
+ return False
1860
+ for key in (
1861
+ "user_provided_urls",
1862
+ "returned_by_web_search_urls",
1863
+ "searches",
1864
+ "fetches",
1865
+ ):
1866
+ value = payload.get(key)
1867
+ if isinstance(value, list) and value:
1868
+ return True
1869
+ graph = payload.get("url_provenance_graph")
1870
+ if isinstance(graph, dict) and isinstance(graph.get("nodes"), list) and graph["nodes"]:
1871
+ return True
1872
+ return False