qwen-sci 0.2.2__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 (549) hide show
  1. qwen_sci-0.2.2.dist-info/METADATA +492 -0
  2. qwen_sci-0.2.2.dist-info/RECORD +549 -0
  3. qwen_sci-0.2.2.dist-info/WHEEL +4 -0
  4. qwen_sci-0.2.2.dist-info/entry_points.txt +6 -0
  5. qwen_sci-0.2.2.dist-info/licenses/LICENSE +21 -0
  6. src/1.txt +1 -0
  7. src/__init__.py +1 -0
  8. src/__main__.py +5 -0
  9. src/agents/__init__.py +1 -0
  10. src/agents/experiment_design_agent/__init__.py +268 -0
  11. src/agents/experiment_design_agent/artifacts.py +747 -0
  12. src/agents/experiment_design_agent/cache.py +319 -0
  13. src/agents/experiment_design_agent/completeness.py +130 -0
  14. src/agents/experiment_design_agent/contracts.py +771 -0
  15. src/agents/experiment_design_agent/counterexample_analyzer.py +187 -0
  16. src/agents/experiment_design_agent/design_evidence_paper_screener.py +470 -0
  17. src/agents/experiment_design_agent/discipline_catalog.py +178 -0
  18. src/agents/experiment_design_agent/evidence_cards.py +338 -0
  19. src/agents/experiment_design_agent/evidence_planner.py +452 -0
  20. src/agents/experiment_design_agent/formal_reasoning_planner.py +884 -0
  21. src/agents/experiment_design_agent/fulltext_acquisition.py +863 -0
  22. src/agents/experiment_design_agent/idea_adapter.py +100 -0
  23. src/agents/experiment_design_agent/idea_intake.py +87 -0
  24. src/agents/experiment_design_agent/llm_json.py +328 -0
  25. src/agents/experiment_design_agent/orchestrator.py +1220 -0
  26. src/agents/experiment_design_agent/reasoning_context.py +183 -0
  27. src/agents/experiment_design_agent/reasoning_validation.py +546 -0
  28. src/agents/experiment_design_agent/run.py +481 -0
  29. src/agents/experiment_design_agent/run_logging.py +292 -0
  30. src/agents/experiment_design_agent/scope_gate.py +198 -0
  31. src/agents/experiment_design_agent/study_type_composer.py +1360 -0
  32. src/agents/experiment_design_agent/survey_evidence.py +1381 -0
  33. src/agents/experiment_design_agent/template_router.py +195 -0
  34. src/agents/experiment_design_agent/variable_claim_extractor.py +93 -0
  35. src/agents/idea_agent/__init__.py +1 -0
  36. src/agents/idea_agent/agent/__init__.py +6 -0
  37. src/agents/idea_agent/agent/artifacts.py +308 -0
  38. src/agents/idea_agent/agent/base.py +122 -0
  39. src/agents/idea_agent/agent/ligagent.py +488 -0
  40. src/agents/idea_agent/agent/mcts.py +2060 -0
  41. src/agents/idea_agent/agent/prompts/__init__.py +48 -0
  42. src/agents/idea_agent/agent/prompts/advanced_analysis.py +235 -0
  43. src/agents/idea_agent/agent/prompts/algorithm_alignment.py +55 -0
  44. src/agents/idea_agent/agent/prompts/algorithm_structuring.py +48 -0
  45. src/agents/idea_agent/agent/prompts/component_extraction.py +62 -0
  46. src/agents/idea_agent/agent/prompts/component_novelty_evaluation.py +54 -0
  47. src/agents/idea_agent/agent/prompts/experiment_findings_extraction.py +39 -0
  48. src/agents/idea_agent/agent/prompts/idea_fusion.py +295 -0
  49. src/agents/idea_agent/agent/prompts/idea_introduction.py +30 -0
  50. src/agents/idea_agent/agent/prompts/idea_result_alignment.py +39 -0
  51. src/agents/idea_agent/agent/prompts/input_interpreter.py +52 -0
  52. src/agents/idea_agent/agent/prompts/keynote_ops.py +78 -0
  53. src/agents/idea_agent/agent/prompts/mcts_evaluation.py +235 -0
  54. src/agents/idea_agent/agent/prompts/mcts_generation.py +140 -0
  55. src/agents/idea_agent/agent/prompts/mechanism_commit_query.py +99 -0
  56. src/agents/idea_agent/agent/prompts/memory_selection.py +0 -0
  57. src/agents/idea_agent/agent/prompts/prompt_modes.py +16 -0
  58. src/agents/idea_agent/agent/prompts/rag_query.py +34 -0
  59. src/agents/idea_agent/agent/prompts/re_analysis_replan.py +48 -0
  60. src/agents/idea_agent/agent/prompts/reference_grounding.py +34 -0
  61. src/agents/idea_agent/agent/prompts/root_domain_classification.py +32 -0
  62. src/agents/idea_agent/agent/prompts/scientific_debate.py +88 -0
  63. src/agents/idea_agent/agent/prompts/scientific_materialization.py +45 -0
  64. src/agents/idea_agent/agent/prompts/skill_instantiation.py +193 -0
  65. src/agents/idea_agent/agent/prompts/theory_transfer_query.py +93 -0
  66. src/agents/idea_agent/agent/prompts/topic_background.py +15 -0
  67. src/agents/idea_agent/agent/skills/DEFAULT_SKILL_TEMPLATES.json +263 -0
  68. src/agents/idea_agent/agent/skills/edit_operator_skills/alternative-path-contrast/SKILL.md +40 -0
  69. src/agents/idea_agent/agent/skills/edit_operator_skills/alternative-path-contrast/references/contrast_patterns.md +22 -0
  70. src/agents/idea_agent/agent/skills/edit_operator_skills/feedback-closed-loop/SKILL.md +40 -0
  71. src/agents/idea_agent/agent/skills/edit_operator_skills/feedback-closed-loop/references/mechanism_cards.md +24 -0
  72. src/agents/idea_agent/agent/skills/edit_operator_skills/hierarchical-decomposition/SKILL.md +39 -0
  73. src/agents/idea_agent/agent/skills/edit_operator_skills/hierarchical-decomposition/references/hierarchy_patterns.md +22 -0
  74. src/agents/idea_agent/agent/skills/edit_operator_skills/mechanism-commit-innovation/SKILL.md +39 -0
  75. src/agents/idea_agent/agent/skills/edit_operator_skills/mechanism-commit-innovation/references/mechanism_commit_patterns.md +24 -0
  76. src/agents/idea_agent/agent/skills/edit_operator_skills/multi-scale-coordinator/SKILL.md +39 -0
  77. src/agents/idea_agent/agent/skills/edit_operator_skills/multi-scale-coordinator/references/coordination_patterns.md +22 -0
  78. src/agents/idea_agent/agent/skills/edit_operator_skills/speculative-execution-with-repair/SKILL.md +41 -0
  79. src/agents/idea_agent/agent/skills/edit_operator_skills/speculative-execution-with-repair/references/speculation_patterns.md +22 -0
  80. src/agents/idea_agent/agent/skills/edit_operator_skills/surgical-modularity/SKILL.md +39 -0
  81. src/agents/idea_agent/agent/skills/edit_operator_skills/surgical-modularity/references/modularity_patterns.md +22 -0
  82. src/agents/idea_agent/agent/skills/edit_operator_skills/theory-transfer-injection/SKILL.md +40 -0
  83. src/agents/idea_agent/agent/skills/edit_operator_skills/theory-transfer-injection/references/transfer_patterns.md +24 -0
  84. src/agents/idea_agent/example/idea_result.json +900 -0
  85. src/agents/idea_agent/run.py +276 -0
  86. src/agents/idea_agent/tests/test_advanced_analysis_contract.py +90 -0
  87. src/agents/idea_agent/tests/test_gap_hypothesis_bridge.py +166 -0
  88. src/agents/idea_agent/tests/test_idea_debate.py +480 -0
  89. src/agents/idea_agent/tests/test_idea_result_v5.py +99 -0
  90. src/agents/idea_agent/tests/test_idea_synthesis.py +92 -0
  91. src/agents/idea_agent/tests/test_mature_idea_sources.py +27 -0
  92. src/agents/idea_agent/tests/test_multimodal_idea_integration.py +386 -0
  93. src/agents/idea_agent/tests/test_profile_materialization_and_scoring.py +219 -0
  94. src/agents/idea_agent/tests/test_profile_native_skill_rendering.py +122 -0
  95. src/agents/idea_agent/tests/test_result_persistence.py +183 -0
  96. src/agents/idea_agent/tests/test_scientific_intervention_profiles.py +241 -0
  97. src/agents/idea_agent/tests/test_scientific_rubric_and_prompts.py +559 -0
  98. src/agents/idea_agent/tests/test_strict_json_object_responses.py +48 -0
  99. src/agents/idea_agent/utils/core/__init__.py +1 -0
  100. src/agents/idea_agent/utils/core/ablation_inputs.py +60 -0
  101. src/agents/idea_agent/utils/core/chat_errors.py +95 -0
  102. src/agents/idea_agent/utils/core/chat_router.py +130 -0
  103. src/agents/idea_agent/utils/core/chat_transport.py +188 -0
  104. src/agents/idea_agent/utils/core/config_loader.py +166 -0
  105. src/agents/idea_agent/utils/core/json_utils.py +28 -0
  106. src/agents/idea_agent/utils/core/logger.py +287 -0
  107. src/agents/idea_agent/utils/core/progress.py +58 -0
  108. src/agents/idea_agent/utils/core/response_parsing.py +57 -0
  109. src/agents/idea_agent/utils/core/run_inputs.py +198 -0
  110. src/agents/idea_agent/utils/mcts/__init__.py +1 -0
  111. src/agents/idea_agent/utils/mcts/component_novelty.py +342 -0
  112. src/agents/idea_agent/utils/mcts/defect_registry.py +302 -0
  113. src/agents/idea_agent/utils/mcts/idea_routes.py +61 -0
  114. src/agents/idea_agent/utils/mcts/idea_taste_presets.py +312 -0
  115. src/agents/idea_agent/utils/mcts/mcts_helpers.py +738 -0
  116. src/agents/idea_agent/utils/mcts/mcts_runtime.py +3000 -0
  117. src/agents/idea_agent/utils/mcts/scientific_intervention_ontology.py +1561 -0
  118. src/agents/idea_agent/utils/mcts/scientific_rubric.py +211 -0
  119. src/agents/idea_agent/utils/mcts/skill_parsing.py +92 -0
  120. src/agents/idea_agent/utils/papers/__init__.py +1 -0
  121. src/agents/idea_agent/utils/papers/paper_graph_vector_store.py +499 -0
  122. src/agents/idea_agent/utils/papers/paper_repository.py +372 -0
  123. src/agents/idea_agent/utils/prompting/__init__.py +1 -0
  124. src/agents/idea_agent/utils/prompting/prompt_views.py +637 -0
  125. src/agents/idea_agent/utils/workflow/__init__.py +1 -0
  126. src/agents/idea_agent/utils/workflow/advanced_analysis_contract.py +244 -0
  127. src/agents/idea_agent/utils/workflow/gap_hypothesis_bridge.py +461 -0
  128. src/agents/idea_agent/utils/workflow/idea_contract.py +426 -0
  129. src/agents/idea_agent/utils/workflow/idea_debate.py +1238 -0
  130. src/agents/idea_agent/utils/workflow/idea_diversity.py +163 -0
  131. src/agents/idea_agent/utils/workflow/idea_fusion.py +889 -0
  132. src/agents/idea_agent/utils/workflow/idea_helpers.py +478 -0
  133. src/agents/idea_agent/utils/workflow/idea_portfolio.py +422 -0
  134. src/agents/idea_agent/utils/workflow/idea_synthesis.py +482 -0
  135. src/agents/idea_agent/utils/workflow/ligagent_flow.py +581 -0
  136. src/agents/idea_agent/utils/workflow/ligagent_handlers.py +1836 -0
  137. src/agents/idea_agent/utils/workflow/ligagent_helpers.py +1331 -0
  138. src/agents/idea_agent/utils/workflow/ligagent_utils.py +322 -0
  139. src/agents/idea_agent/utils/workflow/mature_idea_sources.py +648 -0
  140. src/agents/idea_agent/utils/workflow/multimodal_data_anchoring.py +578 -0
  141. src/agents/idea_agent/utils/workflow/result_persistence.py +774 -0
  142. src/agents/idea_agent/utils/workflow/stage_contract.py +76 -0
  143. src/agents/idea_agent/utils/workflow/workflow_runtime.py +288 -0
  144. src/agents/research_plan_author/__init__.py +50 -0
  145. src/agents/research_plan_author/artifacts.py +270 -0
  146. src/agents/research_plan_author/authoring_blueprint.py +1246 -0
  147. src/agents/research_plan_author/bibtex_renderer.py +252 -0
  148. src/agents/research_plan_author/contract_repair.py +131 -0
  149. src/agents/research_plan_author/contracts.py +745 -0
  150. src/agents/research_plan_author/cross_section_editor.py +232 -0
  151. src/agents/research_plan_author/document_quality.py +987 -0
  152. src/agents/research_plan_author/idea_evolution.py +223 -0
  153. src/agents/research_plan_author/input_loader.py +76 -0
  154. src/agents/research_plan_author/latex_compiler.py +260 -0
  155. src/agents/research_plan_author/latex_safety.py +159 -0
  156. src/agents/research_plan_author/llm_json.py +74 -0
  157. src/agents/research_plan_author/markdown_renderer.py +275 -0
  158. src/agents/research_plan_author/pdf_validator.py +149 -0
  159. src/agents/research_plan_author/render.py +166 -0
  160. src/agents/research_plan_author/render_artifacts.py +187 -0
  161. src/agents/research_plan_author/run.py +765 -0
  162. src/agents/research_plan_author/run_logging.py +209 -0
  163. src/agents/research_plan_author/section_cache.py +184 -0
  164. src/agents/research_plan_author/section_composer.py +1764 -0
  165. src/agents/research_plan_author/section_router.py +209 -0
  166. src/agents/research_plan_author/semantic_validator.py +254 -0
  167. src/agents/research_plan_author/source_bundle.py +46 -0
  168. src/agents/research_plan_author/source_registry.py +208 -0
  169. src/agents/research_plan_author/survey_source_loader.py +137 -0
  170. src/agents/research_plan_author/template_adapter.py +173 -0
  171. src/agents/research_plan_author/template_profile.py +204 -0
  172. src/agents/research_plan_author/tex_renderer.py +496 -0
  173. src/agents/research_plan_author/theory_presentation.py +134 -0
  174. src/agents/research_plan_author/theory_spine.py +703 -0
  175. src/agents/survey_agent/Dockerfile +54 -0
  176. src/agents/survey_agent/README.md +350 -0
  177. src/agents/survey_agent/config/deep_survey.yaml +217 -0
  178. src/agents/survey_agent/config/deep_survey_batch.yaml +176 -0
  179. src/agents/survey_agent/config/deep_survey_fast.yaml +183 -0
  180. src/agents/survey_agent/config/evaluate_baseline.yaml +197 -0
  181. src/agents/survey_agent/config/outcomeRAG.yaml +159 -0
  182. src/agents/survey_agent/env_report.md +348 -0
  183. src/agents/survey_agent/modules/__init__.py +0 -0
  184. src/agents/survey_agent/modules/code_collector.py +2194 -0
  185. src/agents/survey_agent/modules/code_report_generator.py +1282 -0
  186. src/agents/survey_agent/modules/data_manager.py +1713 -0
  187. src/agents/survey_agent/modules/database.py +167 -0
  188. src/agents/survey_agent/modules/fulltext_download_cache.py +462 -0
  189. src/agents/survey_agent/modules/fulltext_http.py +134 -0
  190. src/agents/survey_agent/modules/fulltext_resolution.py +749 -0
  191. src/agents/survey_agent/modules/judge.py +434 -0
  192. src/agents/survey_agent/modules/outcome_RAG.py +236 -0
  193. src/agents/survey_agent/modules/paper_graph_retriever.py +1075 -0
  194. src/agents/survey_agent/modules/pe.py +1729 -0
  195. src/agents/survey_agent/modules/pseudo_creator.py +782 -0
  196. src/agents/survey_agent/modules/pseudo_reviser.py +537 -0
  197. src/agents/survey_agent/modules/refine_agent.py +1952 -0
  198. src/agents/survey_agent/modules/survey_generator.py +5691 -0
  199. src/agents/survey_agent/modules/survey_visualizer.py +1817 -0
  200. src/agents/survey_agent/modules/visual_prompts.py +327 -0
  201. src/agents/survey_agent/modules/work_analyzer.py +2750 -0
  202. src/agents/survey_agent/modules/work_collector.py +5649 -0
  203. src/agents/survey_agent/scripts/baseline_evaluation.py +411 -0
  204. src/agents/survey_agent/scripts/run_deep_survey.py +224 -0
  205. src/agents/survey_agent/scripts/run_deep_survey_adapter.py +407 -0
  206. src/agents/survey_agent/scripts/run_deep_survey_adapter_with_input.py +409 -0
  207. src/agents/survey_agent/scripts/run_deep_survey_batch.py +228 -0
  208. src/agents/survey_agent/scripts/run_deep_survey_batch_arg.py +243 -0
  209. src/agents/survey_agent/scripts/run_deep_survey_batch_settings.py +218 -0
  210. src/agents/survey_agent/scripts/run_keynotes_gen.py +128 -0
  211. src/agents/survey_agent/scripts/run_work_clusters.py +140 -0
  212. src/agents/survey_agent/utils/__init__.py +0 -0
  213. src/agents/survey_agent/utils/ablation_utils.py +317 -0
  214. src/agents/survey_agent/utils/api_call.py +2518 -0
  215. src/agents/survey_agent/utils/chat_utils.py +82 -0
  216. src/agents/survey_agent/utils/config_utils.py +70 -0
  217. src/agents/survey_agent/utils/convert_to_md.py +29 -0
  218. src/agents/survey_agent/utils/err_info.py +24 -0
  219. src/agents/survey_agent/utils/file_utils.py +176 -0
  220. src/agents/survey_agent/utils/gpu_utils.py +145 -0
  221. src/agents/survey_agent/utils/graph_visualizer.py +279 -0
  222. src/agents/survey_agent/utils/html_utils.py +181 -0
  223. src/agents/survey_agent/utils/latex_generator_intra_cluster.py +529 -0
  224. src/agents/survey_agent/utils/latex_generator_relation_table.py +193 -0
  225. src/agents/survey_agent/utils/mineru_section_packer.py +334 -0
  226. src/agents/survey_agent/utils/mineru_utils.py +396 -0
  227. src/agents/survey_agent/utils/paper.py +144 -0
  228. src/agents/survey_agent/utils/read_yaml.py +15 -0
  229. src/agents/survey_agent/utils/repo_utils.py +167 -0
  230. src/agents/survey_agent/utils/rich_logger.py +77 -0
  231. src/agents/survey_agent/utils/step2v2.py +874 -0
  232. src/agents/survey_agent/utils/step2v2_extractor.py +856 -0
  233. src/agents/survey_agent/utils/topic_survey_storage.py +309 -0
  234. src/agents/survey_agent/utils/utils.py +118 -0
  235. src/agents/survey_agent/utils/work.py +2 -0
  236. src/cli.py +1611 -0
  237. src/config/__init__.py +213 -0
  238. src/config/default.yaml +1122 -0
  239. src/harness/LICENSE +21 -0
  240. src/harness/VENDORED.md +14 -0
  241. src/harness/src/__init__.py +0 -0
  242. src/harness/src/openharness/__init__.py +0 -0
  243. src/harness/src/openharness/__main__.py +6 -0
  244. src/harness/src/openharness/api/__init__.py +21 -0
  245. src/harness/src/openharness/api/client.py +281 -0
  246. src/harness/src/openharness/api/codex_client.py +407 -0
  247. src/harness/src/openharness/api/copilot_auth.py +240 -0
  248. src/harness/src/openharness/api/copilot_client.py +134 -0
  249. src/harness/src/openharness/api/errors.py +19 -0
  250. src/harness/src/openharness/api/openai_client.py +577 -0
  251. src/harness/src/openharness/api/provider.py +186 -0
  252. src/harness/src/openharness/api/registry.py +437 -0
  253. src/harness/src/openharness/api/usage.py +17 -0
  254. src/harness/src/openharness/auth/__init__.py +29 -0
  255. src/harness/src/openharness/auth/external.py +610 -0
  256. src/harness/src/openharness/auth/flows.py +189 -0
  257. src/harness/src/openharness/auth/manager.py +482 -0
  258. src/harness/src/openharness/auth/storage.py +269 -0
  259. src/harness/src/openharness/autopilot/__init__.py +23 -0
  260. src/harness/src/openharness/autopilot/service.py +2239 -0
  261. src/harness/src/openharness/autopilot/types.py +91 -0
  262. src/harness/src/openharness/bridge/__init__.py +20 -0
  263. src/harness/src/openharness/bridge/manager.py +106 -0
  264. src/harness/src/openharness/bridge/session_runner.py +46 -0
  265. src/harness/src/openharness/bridge/types.py +37 -0
  266. src/harness/src/openharness/bridge/work_secret.py +41 -0
  267. src/harness/src/openharness/channels/UPSTREAM +6 -0
  268. src/harness/src/openharness/channels/__init__.py +22 -0
  269. src/harness/src/openharness/channels/adapter.py +131 -0
  270. src/harness/src/openharness/channels/bus/__init__.py +6 -0
  271. src/harness/src/openharness/channels/bus/events.py +38 -0
  272. src/harness/src/openharness/channels/bus/queue.py +44 -0
  273. src/harness/src/openharness/channels/impl/__init__.py +6 -0
  274. src/harness/src/openharness/channels/impl/base.py +141 -0
  275. src/harness/src/openharness/channels/impl/dingtalk.py +445 -0
  276. src/harness/src/openharness/channels/impl/discord.py +311 -0
  277. src/harness/src/openharness/channels/impl/email.py +410 -0
  278. src/harness/src/openharness/channels/impl/feishu.py +1342 -0
  279. src/harness/src/openharness/channels/impl/manager.py +257 -0
  280. src/harness/src/openharness/channels/impl/matrix.py +700 -0
  281. src/harness/src/openharness/channels/impl/mochat.py +897 -0
  282. src/harness/src/openharness/channels/impl/qq.py +141 -0
  283. src/harness/src/openharness/channels/impl/slack.py +284 -0
  284. src/harness/src/openharness/channels/impl/telegram.py +525 -0
  285. src/harness/src/openharness/channels/impl/whatsapp.py +159 -0
  286. src/harness/src/openharness/cli.py +2551 -0
  287. src/harness/src/openharness/commands/__init__.py +21 -0
  288. src/harness/src/openharness/commands/registry.py +2783 -0
  289. src/harness/src/openharness/config/__init__.py +34 -0
  290. src/harness/src/openharness/config/paths.py +160 -0
  291. src/harness/src/openharness/config/schema.py +119 -0
  292. src/harness/src/openharness/config/settings.py +1104 -0
  293. src/harness/src/openharness/coordinator/__init__.py +12 -0
  294. src/harness/src/openharness/coordinator/agent_definitions.py +975 -0
  295. src/harness/src/openharness/coordinator/coordinator_mode.py +520 -0
  296. src/harness/src/openharness/engine/__init__.py +80 -0
  297. src/harness/src/openharness/engine/cost_tracker.py +24 -0
  298. src/harness/src/openharness/engine/messages.py +222 -0
  299. src/harness/src/openharness/engine/query.py +1188 -0
  300. src/harness/src/openharness/engine/query_engine.py +313 -0
  301. src/harness/src/openharness/engine/stream_events.py +90 -0
  302. src/harness/src/openharness/hooks/__init__.py +50 -0
  303. src/harness/src/openharness/hooks/events.py +20 -0
  304. src/harness/src/openharness/hooks/executor.py +242 -0
  305. src/harness/src/openharness/hooks/hot_reload.py +31 -0
  306. src/harness/src/openharness/hooks/loader.py +68 -0
  307. src/harness/src/openharness/hooks/schemas.py +66 -0
  308. src/harness/src/openharness/hooks/types.py +38 -0
  309. src/harness/src/openharness/keybindings/__init__.py +14 -0
  310. src/harness/src/openharness/keybindings/default_bindings.py +11 -0
  311. src/harness/src/openharness/keybindings/loader.py +22 -0
  312. src/harness/src/openharness/keybindings/parser.py +18 -0
  313. src/harness/src/openharness/keybindings/resolver.py +13 -0
  314. src/harness/src/openharness/mcp/__init__.py +79 -0
  315. src/harness/src/openharness/mcp/client.py +298 -0
  316. src/harness/src/openharness/mcp/config.py +16 -0
  317. src/harness/src/openharness/mcp/types.py +76 -0
  318. src/harness/src/openharness/memory/__init__.py +25 -0
  319. src/harness/src/openharness/memory/agent.py +91 -0
  320. src/harness/src/openharness/memory/manager.py +183 -0
  321. src/harness/src/openharness/memory/memdir.py +52 -0
  322. src/harness/src/openharness/memory/migrate.py +137 -0
  323. src/harness/src/openharness/memory/paths.py +22 -0
  324. src/harness/src/openharness/memory/relevance.py +145 -0
  325. src/harness/src/openharness/memory/scan.py +116 -0
  326. src/harness/src/openharness/memory/schema.py +443 -0
  327. src/harness/src/openharness/memory/search.py +71 -0
  328. src/harness/src/openharness/memory/team.py +90 -0
  329. src/harness/src/openharness/memory/types.py +33 -0
  330. src/harness/src/openharness/memory/usage.py +153 -0
  331. src/harness/src/openharness/output_styles/__init__.py +5 -0
  332. src/harness/src/openharness/output_styles/loader.py +42 -0
  333. src/harness/src/openharness/permissions/__init__.py +26 -0
  334. src/harness/src/openharness/permissions/checker.py +200 -0
  335. src/harness/src/openharness/permissions/modes.py +13 -0
  336. src/harness/src/openharness/personalization/__init__.py +6 -0
  337. src/harness/src/openharness/personalization/extractor.py +138 -0
  338. src/harness/src/openharness/personalization/rules.py +64 -0
  339. src/harness/src/openharness/personalization/session_hook.py +65 -0
  340. src/harness/src/openharness/platforms.py +86 -0
  341. src/harness/src/openharness/plugins/__init__.py +53 -0
  342. src/harness/src/openharness/plugins/bundled/__init__.py +0 -0
  343. src/harness/src/openharness/plugins/installer.py +39 -0
  344. src/harness/src/openharness/plugins/loader.py +730 -0
  345. src/harness/src/openharness/plugins/schemas.py +24 -0
  346. src/harness/src/openharness/plugins/types.py +59 -0
  347. src/harness/src/openharness/prompts/__init__.py +14 -0
  348. src/harness/src/openharness/prompts/claudemd.py +48 -0
  349. src/harness/src/openharness/prompts/context.py +187 -0
  350. src/harness/src/openharness/prompts/environment.py +135 -0
  351. src/harness/src/openharness/prompts/system_prompt.py +109 -0
  352. src/harness/src/openharness/sandbox/Dockerfile +6 -0
  353. src/harness/src/openharness/sandbox/__init__.py +33 -0
  354. src/harness/src/openharness/sandbox/adapter.py +148 -0
  355. src/harness/src/openharness/sandbox/docker_backend.py +232 -0
  356. src/harness/src/openharness/sandbox/docker_image.py +103 -0
  357. src/harness/src/openharness/sandbox/path_validator.py +37 -0
  358. src/harness/src/openharness/sandbox/session.py +63 -0
  359. src/harness/src/openharness/services/__init__.py +30 -0
  360. src/harness/src/openharness/services/autodream/__init__.py +34 -0
  361. src/harness/src/openharness/services/autodream/backup.py +104 -0
  362. src/harness/src/openharness/services/autodream/lock.py +138 -0
  363. src/harness/src/openharness/services/autodream/prompt.py +128 -0
  364. src/harness/src/openharness/services/autodream/service.py +313 -0
  365. src/harness/src/openharness/services/compact/__init__.py +1725 -0
  366. src/harness/src/openharness/services/cron.py +137 -0
  367. src/harness/src/openharness/services/cron_scheduler.py +595 -0
  368. src/harness/src/openharness/services/lsp/__init__.py +216 -0
  369. src/harness/src/openharness/services/memory_extract/__init__.py +261 -0
  370. src/harness/src/openharness/services/oauth/__init__.py +0 -0
  371. src/harness/src/openharness/services/session_backend.py +97 -0
  372. src/harness/src/openharness/services/session_memory/__init__.py +139 -0
  373. src/harness/src/openharness/services/session_storage.py +230 -0
  374. src/harness/src/openharness/services/token_estimation.py +15 -0
  375. src/harness/src/openharness/services/tool_outputs.py +55 -0
  376. src/harness/src/openharness/skills/__init__.py +44 -0
  377. src/harness/src/openharness/skills/_frontmatter.py +97 -0
  378. src/harness/src/openharness/skills/bundled/__init__.py +75 -0
  379. src/harness/src/openharness/skills/bundled/content/commit.md +25 -0
  380. src/harness/src/openharness/skills/bundled/content/debug.md +25 -0
  381. src/harness/src/openharness/skills/bundled/content/diagnose.md +35 -0
  382. src/harness/src/openharness/skills/bundled/content/plan.md +39 -0
  383. src/harness/src/openharness/skills/bundled/content/review.md +33 -0
  384. src/harness/src/openharness/skills/bundled/content/simplify.md +26 -0
  385. src/harness/src/openharness/skills/bundled/content/skill-creator.md +175 -0
  386. src/harness/src/openharness/skills/bundled/content/test.md +38 -0
  387. src/harness/src/openharness/skills/loader.py +229 -0
  388. src/harness/src/openharness/skills/registry.py +29 -0
  389. src/harness/src/openharness/skills/types.py +24 -0
  390. src/harness/src/openharness/state/__init__.py +6 -0
  391. src/harness/src/openharness/state/app_state.py +30 -0
  392. src/harness/src/openharness/state/store.py +40 -0
  393. src/harness/src/openharness/swarm/__init__.py +70 -0
  394. src/harness/src/openharness/swarm/in_process.py +693 -0
  395. src/harness/src/openharness/swarm/lockfile.py +24 -0
  396. src/harness/src/openharness/swarm/mailbox.py +522 -0
  397. src/harness/src/openharness/swarm/permission_sync.py +1168 -0
  398. src/harness/src/openharness/swarm/registry.py +410 -0
  399. src/harness/src/openharness/swarm/spawn_utils.py +229 -0
  400. src/harness/src/openharness/swarm/subprocess_backend.py +171 -0
  401. src/harness/src/openharness/swarm/team_lifecycle.py +910 -0
  402. src/harness/src/openharness/swarm/types.py +398 -0
  403. src/harness/src/openharness/swarm/worktree.py +315 -0
  404. src/harness/src/openharness/tasks/__init__.py +18 -0
  405. src/harness/src/openharness/tasks/local_agent_task.py +28 -0
  406. src/harness/src/openharness/tasks/local_shell_task.py +17 -0
  407. src/harness/src/openharness/tasks/manager.py +475 -0
  408. src/harness/src/openharness/tasks/stop_task.py +11 -0
  409. src/harness/src/openharness/tasks/types.py +32 -0
  410. src/harness/src/openharness/themes/__init__.py +21 -0
  411. src/harness/src/openharness/themes/builtin.py +89 -0
  412. src/harness/src/openharness/themes/loader.py +55 -0
  413. src/harness/src/openharness/themes/schema.py +54 -0
  414. src/harness/src/openharness/tools/__init__.py +107 -0
  415. src/harness/src/openharness/tools/agent_tool.py +141 -0
  416. src/harness/src/openharness/tools/ask_user_question_tool.py +46 -0
  417. src/harness/src/openharness/tools/base.py +80 -0
  418. src/harness/src/openharness/tools/bash_tool.py +259 -0
  419. src/harness/src/openharness/tools/brief_tool.py +33 -0
  420. src/harness/src/openharness/tools/config_tool.py +54 -0
  421. src/harness/src/openharness/tools/cron_create_tool.py +105 -0
  422. src/harness/src/openharness/tools/cron_delete_tool.py +32 -0
  423. src/harness/src/openharness/tools/cron_list_tool.py +69 -0
  424. src/harness/src/openharness/tools/cron_toggle_tool.py +37 -0
  425. src/harness/src/openharness/tools/enter_plan_mode_tool.py +28 -0
  426. src/harness/src/openharness/tools/enter_worktree_tool.py +80 -0
  427. src/harness/src/openharness/tools/exit_plan_mode_tool.py +28 -0
  428. src/harness/src/openharness/tools/exit_worktree_tool.py +42 -0
  429. src/harness/src/openharness/tools/file_edit_tool.py +95 -0
  430. src/harness/src/openharness/tools/file_read_tool.py +72 -0
  431. src/harness/src/openharness/tools/file_write_tool.py +87 -0
  432. src/harness/src/openharness/tools/glob_tool.py +175 -0
  433. src/harness/src/openharness/tools/grep_tool.py +374 -0
  434. src/harness/src/openharness/tools/image_generation_tool.py +447 -0
  435. src/harness/src/openharness/tools/image_to_text_tool.py +237 -0
  436. src/harness/src/openharness/tools/list_mcp_resources_tool.py +36 -0
  437. src/harness/src/openharness/tools/lsp_tool.py +154 -0
  438. src/harness/src/openharness/tools/mcp_auth_tool.py +71 -0
  439. src/harness/src/openharness/tools/mcp_tool.py +72 -0
  440. src/harness/src/openharness/tools/notebook_edit_tool.py +97 -0
  441. src/harness/src/openharness/tools/read_mcp_resource_tool.py +38 -0
  442. src/harness/src/openharness/tools/remote_trigger_tool.py +74 -0
  443. src/harness/src/openharness/tools/send_message_tool.py +58 -0
  444. src/harness/src/openharness/tools/skill_tool.py +43 -0
  445. src/harness/src/openharness/tools/sleep_tool.py +32 -0
  446. src/harness/src/openharness/tools/task_create_tool.py +56 -0
  447. src/harness/src/openharness/tools/task_get_tool.py +33 -0
  448. src/harness/src/openharness/tools/task_list_tool.py +35 -0
  449. src/harness/src/openharness/tools/task_output_tool.py +35 -0
  450. src/harness/src/openharness/tools/task_stop_tool.py +30 -0
  451. src/harness/src/openharness/tools/task_update_tool.py +50 -0
  452. src/harness/src/openharness/tools/team_create_tool.py +31 -0
  453. src/harness/src/openharness/tools/team_delete_tool.py +30 -0
  454. src/harness/src/openharness/tools/todo_write_tool.py +46 -0
  455. src/harness/src/openharness/tools/tool_search_tool.py +38 -0
  456. src/harness/src/openharness/tools/web_fetch_tool.py +117 -0
  457. src/harness/src/openharness/tools/web_search_tool.py +119 -0
  458. src/harness/src/openharness/ui/__init__.py +5 -0
  459. src/harness/src/openharness/ui/app.py +320 -0
  460. src/harness/src/openharness/ui/backend_host.py +941 -0
  461. src/harness/src/openharness/ui/coordinator_drain.py +198 -0
  462. src/harness/src/openharness/ui/input.py +32 -0
  463. src/harness/src/openharness/ui/output.py +265 -0
  464. src/harness/src/openharness/ui/permission_dialog.py +14 -0
  465. src/harness/src/openharness/ui/protocol.py +247 -0
  466. src/harness/src/openharness/ui/react_launcher.py +179 -0
  467. src/harness/src/openharness/ui/runtime.py +799 -0
  468. src/harness/src/openharness/ui/textual_app.py +495 -0
  469. src/harness/src/openharness/utils/__init__.py +0 -0
  470. src/harness/src/openharness/utils/file_lock.py +86 -0
  471. src/harness/src/openharness/utils/fs.py +98 -0
  472. src/harness/src/openharness/utils/helpers.py +77 -0
  473. src/harness/src/openharness/utils/network_guard.py +340 -0
  474. src/harness/src/openharness/utils/shell.py +147 -0
  475. src/harness/src/openharness/vim/__init__.py +5 -0
  476. src/harness/src/openharness/vim/transitions.py +8 -0
  477. src/harness/src/openharness/voice/__init__.py +7 -0
  478. src/harness/src/openharness/voice/keyterms.py +10 -0
  479. src/harness/src/openharness/voice/stream_stt.py +8 -0
  480. src/harness/src/openharness/voice/voice_mode.py +44 -0
  481. src/llm/__init__.py +29 -0
  482. src/llm/image_generation.py +402 -0
  483. src/llm/provider_registry.py +419 -0
  484. src/llm/runtime_env.py +64 -0
  485. src/llm/structured_output.py +131 -0
  486. src/llm/vision.py +198 -0
  487. src/memory/__init__.py +2 -0
  488. src/memory/api/__init__.py +1 -0
  489. src/memory/api/base_memory_system_api.py +98 -0
  490. src/memory/api/base_symbolic_memory_system_api.py +190 -0
  491. src/memory/api/base_vector_memory_system_api.py +116 -0
  492. src/memory/api/faiss_memory_system_api.py +228 -0
  493. src/memory/api/slot_process_api.py +827 -0
  494. src/memory/api/symbolic_memory_system_api.py +572 -0
  495. src/memory/memory_system/__init__.py +48 -0
  496. src/memory/memory_system/component_taxonomy.py +430 -0
  497. src/memory/memory_system/llm.py +437 -0
  498. src/memory/memory_system/models.py +168 -0
  499. src/memory/memory_system/user_prompt.py +418 -0
  500. src/memory/memory_system/utils.py +282 -0
  501. src/memory/memory_system/vectorstore.py +311 -0
  502. src/memory/memory_system/working_slot.py +99 -0
  503. src/pipeline/__init__.py +129 -0
  504. src/pipeline/contracts.py +531 -0
  505. src/pipeline/discipline_taxonomy.py +916 -0
  506. src/pipeline/evidence_coverage_ledger.py +1150 -0
  507. src/pipeline/evidence_refinement.py +620 -0
  508. src/pipeline/experiment_to_symbolic.py +137 -0
  509. src/pipeline/multimodal_evidence/__init__.py +28 -0
  510. src/pipeline/multimodal_evidence/capabilities.py +107 -0
  511. src/pipeline/multimodal_evidence/claims.py +135 -0
  512. src/pipeline/multimodal_evidence/contract.py +291 -0
  513. src/pipeline/multimodal_evidence/data_sh_compiler.py +505 -0
  514. src/pipeline/multimodal_evidence/handoff_binding.py +135 -0
  515. src/pipeline/multimodal_evidence/manifest.py +254 -0
  516. src/pipeline/multimodal_evidence/native_analysis.py +479 -0
  517. src/pipeline/multimodal_evidence/perception.py +277 -0
  518. src/pipeline/multimodal_evidence/query_binding.py +65 -0
  519. src/pipeline/multimodal_evidence/rendering.py +120 -0
  520. src/pipeline/multimodal_evidence/safety.py +42 -0
  521. src/pipeline/multimodal_evidence/sampling.py +69 -0
  522. src/pipeline/multimodal_evidence/service.py +148 -0
  523. src/pipeline/multimodal_evidence/survey_integration.py +460 -0
  524. src/pipeline/paper_identity.py +52 -0
  525. src/pipeline/research_design_inventory.py +338 -0
  526. src/pipeline/research_domain_resolution.py +669 -0
  527. src/pipeline/research_identity.py +548 -0
  528. src/pipeline/research_question_contract.py +646 -0
  529. src/pipeline/retrieval_lanes.py +889 -0
  530. src/pipeline/run_loop.py +862 -0
  531. src/pipeline/science_events.py +30 -0
  532. src/pipeline/science_manifests.py +602 -0
  533. src/pipeline/science_run.py +918 -0
  534. src/pipeline/science_stages.py +709 -0
  535. src/pipeline/science_workflow.py +807 -0
  536. src/pipeline/sh_cluster_projection.py +306 -0
  537. src/pipeline/sh_graph_provenance.py +648 -0
  538. src/pipeline/subhypothesis_decomposition.py +392 -0
  539. src/pipeline/subhypothesis_evidence.py +856 -0
  540. src/pipeline/survey_evidence_plan.py +858 -0
  541. src/pipeline/survey_gap_adjudication.py +647 -0
  542. src/pipeline/survey_gap_candidates.py +840 -0
  543. src/pipeline/survey_gap_ledger.py +683 -0
  544. src/pipeline/survey_gap_triage.py +401 -0
  545. src/pipeline/survey_handoff_persistence.py +481 -0
  546. src/pipeline/survey_handoff_projection.py +457 -0
  547. src/pipeline/survey_idea_handoff.py +1112 -0
  548. src/pipeline/survey_idea_loader.py +289 -0
  549. src/research_run_ids.py +11 -0
@@ -0,0 +1,1188 @@
1
+ """Core tool-aware query loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import re
8
+ import time
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, AsyncIterator, Awaitable, Callable
12
+ from uuid import uuid4
13
+
14
+ from openharness.api.client import (
15
+ ApiMessageCompleteEvent,
16
+ ApiMessageRequest,
17
+ ApiRetryEvent,
18
+ ApiTextDeltaEvent,
19
+ SupportsStreamingMessages,
20
+ )
21
+ from openharness.api.provider import is_model_multimodal
22
+ from openharness.api.usage import UsageSnapshot
23
+ from openharness.config.paths import get_data_dir
24
+ from openharness.engine.messages import (
25
+ ConversationMessage,
26
+ ImageBlock,
27
+ TextBlock,
28
+ ToolResultBlock,
29
+ )
30
+ from openharness.engine.stream_events import (
31
+ AssistantTextDelta,
32
+ AssistantTurnComplete,
33
+ CompactProgressEvent,
34
+ ErrorEvent,
35
+ StatusEvent,
36
+ StreamEvent,
37
+ ToolExecutionCompleted,
38
+ ToolExecutionStarted,
39
+ )
40
+ from openharness.hooks import HookEvent, HookExecutor
41
+ from openharness.permissions.checker import PermissionChecker
42
+ from openharness.services.tool_outputs import tool_output_inline_chars, tool_output_preview_chars
43
+ from openharness.tools.base import ToolExecutionContext
44
+ from openharness.tools.base import ToolRegistry
45
+
46
+ AUTO_COMPACT_STATUS_MESSAGE = "Auto-compacting conversation memory to keep things fast and focused."
47
+ REACTIVE_COMPACT_STATUS_MESSAGE = "Prompt too long; compacting conversation memory and retrying."
48
+ MAX_SAFE_COMPLETION_TOKENS = 128_000
49
+
50
+ log = logging.getLogger(__name__)
51
+
52
+
53
+ PermissionPrompt = Callable[[str, str], Awaitable[bool]]
54
+ AskUserPrompt = Callable[[str], Awaitable[str]]
55
+
56
+ MAX_TRACKED_READ_FILES = 6
57
+ MAX_TRACKED_SKILLS = 8
58
+ MAX_TRACKED_ASYNC_AGENT_EVENTS = 8
59
+ MAX_TRACKED_ASYNC_AGENT_TASKS = 12
60
+ MAX_TRACKED_WORK_LOG = 10
61
+ MAX_TRACKED_USER_GOALS = 5
62
+ MAX_TRACKED_ACTIVE_ARTIFACTS = 8
63
+ MAX_TRACKED_VERIFIED_WORK = 10
64
+
65
+
66
+ def _is_prompt_too_long_error(exc: Exception) -> bool:
67
+ text = str(exc).lower()
68
+ return any(
69
+ needle in text
70
+ for needle in (
71
+ "prompt too long",
72
+ "context_length_exceeded",
73
+ "context length",
74
+ "maximum context",
75
+ "context window",
76
+ "input tokens exceed",
77
+ "messages resulted in",
78
+ "reduce the length of the messages",
79
+ "configured limit",
80
+ "too many tokens",
81
+ "too large for the model",
82
+ "maximum context length",
83
+ "exceed_context",
84
+ "exceeds the available context size",
85
+ "available context size",
86
+ )
87
+ )
88
+
89
+
90
+ def _bounded_completion_tokens(max_tokens: int, context_window_tokens: int | None = None) -> int:
91
+ """Return a conservative per-request output token cap.
92
+
93
+ Some OpenAI-compatible providers reject very large ``max_tokens`` before
94
+ the request reaches model-side context management. Keep oversized user
95
+ config from making every turn fail while preserving normal defaults.
96
+ """
97
+ limit = MAX_SAFE_COMPLETION_TOKENS
98
+ if context_window_tokens is not None and context_window_tokens > 0:
99
+ limit = min(limit, int(context_window_tokens))
100
+ return max(1, min(int(max_tokens), limit))
101
+
102
+
103
+ def _extract_completion_token_limit(exc: Exception) -> int | None:
104
+ """Parse provider errors such as "supports at most 128000 completion tokens"."""
105
+ text = str(exc).lower().replace(",", "")
106
+ patterns = (
107
+ r"supports at most\s+(\d+)\s+completion tokens",
108
+ r"at most\s+(\d+)\s+completion tokens",
109
+ r"max(?:imum)?(?:_completion)?[_\s-]tokens.*?(?:<=|less than or equal to|at most)\s+(\d+)",
110
+ )
111
+ for pattern in patterns:
112
+ match = re.search(pattern, text)
113
+ if match:
114
+ try:
115
+ return max(1, int(match.group(1)))
116
+ except ValueError:
117
+ return None
118
+ return None
119
+
120
+
121
+ def _is_completion_token_limit_error(exc: Exception) -> bool:
122
+ text = str(exc).lower()
123
+ return (
124
+ ("max_tokens" in text or "max_completion_tokens" in text)
125
+ and ("too large" in text or "at most" in text or "completion tokens" in text)
126
+ )
127
+
128
+
129
+ class MaxTurnsExceeded(RuntimeError):
130
+ """Raised when the agent exceeds the configured max_turns for one user prompt."""
131
+
132
+ def __init__(self, max_turns: int) -> None:
133
+ super().__init__(f"Exceeded maximum turn limit ({max_turns})")
134
+ self.max_turns = max_turns
135
+
136
+
137
+ @dataclass
138
+ class QueryContext:
139
+ """Context shared across a query run."""
140
+
141
+ api_client: SupportsStreamingMessages
142
+ tool_registry: ToolRegistry
143
+ permission_checker: PermissionChecker
144
+ cwd: Path
145
+ model: str
146
+ system_prompt: str
147
+ max_tokens: int
148
+ effort: str | None = None
149
+ context_window_tokens: int | None = None
150
+ auto_compact_threshold_tokens: int | None = None
151
+ permission_prompt: PermissionPrompt | None = None
152
+ ask_user_prompt: AskUserPrompt | None = None
153
+ max_turns: int | None = 200
154
+ hook_executor: HookExecutor | None = None
155
+ tool_metadata: dict[str, object] | None = None
156
+
157
+
158
+ def _append_capped_unique(bucket: list[Any], value: Any, *, limit: int) -> None:
159
+ if value in bucket:
160
+ bucket.remove(value)
161
+ bucket.append(value)
162
+ if len(bucket) > limit:
163
+ del bucket[:-limit]
164
+
165
+
166
+ def _task_focus_state(tool_metadata: dict[str, object] | None) -> dict[str, object]:
167
+ if tool_metadata is None:
168
+ return {}
169
+ value = tool_metadata.setdefault(
170
+ "task_focus_state",
171
+ {
172
+ "goal": "",
173
+ "recent_goals": [],
174
+ "active_artifacts": [],
175
+ "verified_state": [],
176
+ "next_step": "",
177
+ },
178
+ )
179
+ if isinstance(value, dict):
180
+ value.setdefault("goal", "")
181
+ value.setdefault("recent_goals", [])
182
+ value.setdefault("active_artifacts", [])
183
+ value.setdefault("verified_state", [])
184
+ value.setdefault("next_step", "")
185
+ return value
186
+ replacement = {
187
+ "goal": "",
188
+ "recent_goals": [],
189
+ "active_artifacts": [],
190
+ "verified_state": [],
191
+ "next_step": "",
192
+ }
193
+ tool_metadata["task_focus_state"] = replacement
194
+ return replacement
195
+
196
+
197
+ def _summarize_focus_text(text: str) -> str:
198
+ normalized = " ".join(text.split())
199
+ if not normalized:
200
+ return ""
201
+ return normalized[:240]
202
+
203
+
204
+ def remember_user_goal(
205
+ tool_metadata: dict[str, object] | None,
206
+ prompt: str,
207
+ ) -> None:
208
+ state = _task_focus_state(tool_metadata)
209
+ summary = _summarize_focus_text(prompt)
210
+ if not summary:
211
+ return
212
+ recent_goals = state.setdefault("recent_goals", [])
213
+ if isinstance(recent_goals, list):
214
+ _append_capped_unique(recent_goals, summary, limit=MAX_TRACKED_USER_GOALS)
215
+ state["goal"] = summary
216
+
217
+
218
+ def _remember_active_artifact(
219
+ tool_metadata: dict[str, object] | None,
220
+ artifact: str,
221
+ ) -> None:
222
+ normalized = artifact.strip()
223
+ if not normalized:
224
+ return
225
+ state = _task_focus_state(tool_metadata)
226
+ artifacts = state.setdefault("active_artifacts", [])
227
+ if isinstance(artifacts, list):
228
+ _append_capped_unique(artifacts, normalized[:240], limit=MAX_TRACKED_ACTIVE_ARTIFACTS)
229
+
230
+
231
+ def _remember_verified_work(
232
+ tool_metadata: dict[str, object] | None,
233
+ entry: str,
234
+ ) -> None:
235
+ normalized = entry.strip()
236
+ if not normalized:
237
+ return
238
+ bucket = _tool_metadata_bucket(tool_metadata, "recent_verified_work")
239
+ _append_capped_unique(bucket, normalized[:320], limit=MAX_TRACKED_VERIFIED_WORK)
240
+ state = _task_focus_state(tool_metadata)
241
+ verified_state = state.setdefault("verified_state", [])
242
+ if isinstance(verified_state, list):
243
+ _append_capped_unique(verified_state, normalized[:320], limit=MAX_TRACKED_VERIFIED_WORK)
244
+
245
+
246
+ def _tool_metadata_bucket(
247
+ tool_metadata: dict[str, object] | None,
248
+ key: str,
249
+ ) -> list[Any]:
250
+ if tool_metadata is None:
251
+ return []
252
+ value = tool_metadata.setdefault(key, [])
253
+ if isinstance(value, list):
254
+ return value
255
+ replacement: list[Any] = []
256
+ tool_metadata[key] = replacement
257
+ return replacement
258
+
259
+
260
+ def _remember_read_file(
261
+ tool_metadata: dict[str, object] | None,
262
+ *,
263
+ path: str,
264
+ offset: int,
265
+ limit: int,
266
+ output: str,
267
+ ) -> None:
268
+ bucket = _tool_metadata_bucket(tool_metadata, "read_file_state")
269
+ preview_lines = [line.strip() for line in output.splitlines()[:6] if line.strip()]
270
+ entry = {
271
+ "path": path,
272
+ "span": f"lines {offset + 1}-{offset + limit}",
273
+ "preview": " | ".join(preview_lines)[:320],
274
+ "timestamp": time.time(),
275
+ }
276
+ if isinstance(bucket, list):
277
+ bucket[:] = [
278
+ existing
279
+ for existing in bucket
280
+ if not isinstance(existing, dict) or str(existing.get("path") or "") != path
281
+ ]
282
+ bucket.append(entry)
283
+ if len(bucket) > MAX_TRACKED_READ_FILES:
284
+ del bucket[:-MAX_TRACKED_READ_FILES]
285
+
286
+
287
+ def _remember_skill_invocation(
288
+ tool_metadata: dict[str, object] | None,
289
+ *,
290
+ skill_name: str,
291
+ ) -> None:
292
+ bucket = _tool_metadata_bucket(tool_metadata, "invoked_skills")
293
+ normalized = skill_name.strip()
294
+ if not normalized:
295
+ return
296
+ if normalized in bucket:
297
+ bucket.remove(normalized)
298
+ bucket.append(normalized)
299
+ if len(bucket) > MAX_TRACKED_SKILLS:
300
+ del bucket[:-MAX_TRACKED_SKILLS]
301
+
302
+
303
+ def _remember_async_agent_activity(
304
+ tool_metadata: dict[str, object] | None,
305
+ *,
306
+ tool_name: str,
307
+ tool_input: dict[str, object],
308
+ output: str,
309
+ ) -> None:
310
+ bucket = _tool_metadata_bucket(tool_metadata, "async_agent_state")
311
+ if tool_name == "agent":
312
+ description = str(tool_input.get("description") or tool_input.get("prompt") or "").strip()
313
+ summary = f"Spawned async agent. {description}".strip()
314
+ if output.strip():
315
+ summary = f"{summary} [{output.strip()[:180]}]".strip()
316
+ elif tool_name == "send_message":
317
+ target = str(tool_input.get("task_id") or "").strip()
318
+ summary = f"Sent follow-up message to async agent {target}".strip()
319
+ else:
320
+ summary = output.strip()[:220] or f"Async agent activity via {tool_name}"
321
+ bucket.append(summary)
322
+ if len(bucket) > MAX_TRACKED_ASYNC_AGENT_EVENTS:
323
+ del bucket[:-MAX_TRACKED_ASYNC_AGENT_EVENTS]
324
+
325
+
326
+ def _parse_spawned_agent_identity(
327
+ output: str,
328
+ metadata: dict[str, object] | None = None,
329
+ ) -> tuple[str, str] | None:
330
+ if isinstance(metadata, dict):
331
+ agent_id = str(metadata.get("agent_id") or "").strip()
332
+ task_id = str(metadata.get("task_id") or "").strip()
333
+ if agent_id and task_id:
334
+ return agent_id, task_id
335
+ match = re.search(r"Spawned agent (.+?) \(task_id=(\S+?)(?:[,)]|$)", output.strip())
336
+ if match is None:
337
+ return None
338
+ return match.group(1).strip(), match.group(2).strip()
339
+
340
+
341
+ def _remember_async_agent_task(
342
+ tool_metadata: dict[str, object] | None,
343
+ *,
344
+ tool_name: str,
345
+ tool_input: dict[str, object],
346
+ output: str,
347
+ result_metadata: dict[str, object] | None = None,
348
+ ) -> None:
349
+ if tool_name != "agent":
350
+ return
351
+ identity = _parse_spawned_agent_identity(output, result_metadata)
352
+ if identity is None:
353
+ return
354
+ agent_id, task_id = identity
355
+ bucket = _tool_metadata_bucket(tool_metadata, "async_agent_tasks")
356
+ description = str(tool_input.get("description") or tool_input.get("prompt") or "").strip()
357
+ entry = {
358
+ "agent_id": agent_id,
359
+ "task_id": task_id,
360
+ "description": description[:240],
361
+ "status": "spawned",
362
+ "notification_sent": False,
363
+ "spawned_at": time.time(),
364
+ }
365
+ bucket[:] = [
366
+ existing
367
+ for existing in bucket
368
+ if not isinstance(existing, dict) or str(existing.get("task_id") or "") != task_id
369
+ ]
370
+ bucket.append(entry)
371
+ if len(bucket) > MAX_TRACKED_ASYNC_AGENT_TASKS:
372
+ del bucket[:-MAX_TRACKED_ASYNC_AGENT_TASKS]
373
+
374
+
375
+ def _remember_work_log(
376
+ tool_metadata: dict[str, object] | None,
377
+ *,
378
+ entry: str,
379
+ ) -> None:
380
+ bucket = _tool_metadata_bucket(tool_metadata, "recent_work_log")
381
+ normalized = entry.strip()
382
+ if not normalized:
383
+ return
384
+ bucket.append(normalized[:320])
385
+ if len(bucket) > MAX_TRACKED_WORK_LOG:
386
+ del bucket[:-MAX_TRACKED_WORK_LOG]
387
+
388
+
389
+ def _update_plan_mode(tool_metadata: dict[str, object] | None, mode: str) -> None:
390
+ if tool_metadata is None:
391
+ return
392
+ tool_metadata["permission_mode"] = mode
393
+
394
+
395
+ def _record_tool_carryover(
396
+ context: QueryContext,
397
+ *,
398
+ tool_name: str,
399
+ tool_input: dict[str, object],
400
+ tool_output: str,
401
+ tool_result_metadata: dict[str, object] | None,
402
+ is_error: bool,
403
+ resolved_file_path: str | None,
404
+ ) -> None:
405
+ if is_error:
406
+ return
407
+ if resolved_file_path is not None:
408
+ _remember_active_artifact(context.tool_metadata, resolved_file_path)
409
+ if tool_name == "read_file" and resolved_file_path is not None:
410
+ offset = int(tool_input.get("offset") or 0)
411
+ limit = int(tool_input.get("limit") or 200)
412
+ _remember_read_file(
413
+ context.tool_metadata,
414
+ path=resolved_file_path,
415
+ offset=offset,
416
+ limit=limit,
417
+ output=tool_output,
418
+ )
419
+ _remember_verified_work(
420
+ context.tool_metadata,
421
+ f"Inspected file {resolved_file_path} (lines {offset + 1}-{offset + limit})",
422
+ )
423
+ elif tool_name == "skill":
424
+ _remember_skill_invocation(
425
+ context.tool_metadata,
426
+ skill_name=str(tool_input.get("name") or ""),
427
+ )
428
+ skill_name = str(tool_input.get("name") or "").strip()
429
+ if skill_name:
430
+ _remember_active_artifact(context.tool_metadata, f"skill:{skill_name}")
431
+ _remember_verified_work(context.tool_metadata, f"Loaded skill {skill_name}")
432
+ elif tool_name in {"agent", "send_message"}:
433
+ _remember_async_agent_activity(
434
+ context.tool_metadata,
435
+ tool_name=tool_name,
436
+ tool_input=tool_input,
437
+ output=tool_output,
438
+ )
439
+ _remember_async_agent_task(
440
+ context.tool_metadata,
441
+ tool_name=tool_name,
442
+ tool_input=tool_input,
443
+ output=tool_output,
444
+ result_metadata=tool_result_metadata,
445
+ )
446
+ description = str(tool_input.get("description") or tool_input.get("prompt") or tool_name).strip()
447
+ _remember_verified_work(
448
+ context.tool_metadata,
449
+ f"Confirmed async-agent activity via {tool_name}: {description[:180]}",
450
+ )
451
+ elif tool_name == "enter_plan_mode":
452
+ _update_plan_mode(context.tool_metadata, "plan")
453
+ elif tool_name == "exit_plan_mode":
454
+ _update_plan_mode(context.tool_metadata, "default")
455
+ elif tool_name == "web_fetch":
456
+ url = str(tool_input.get("url") or "").strip()
457
+ if url:
458
+ _remember_active_artifact(context.tool_metadata, url)
459
+ _remember_verified_work(context.tool_metadata, f"Fetched remote content from {url}")
460
+ elif tool_name == "web_search":
461
+ query = str(tool_input.get("query") or "").strip()
462
+ if query:
463
+ _remember_verified_work(context.tool_metadata, f"Ran web search for {query[:180]}")
464
+ elif tool_name == "glob":
465
+ pattern = str(tool_input.get("pattern") or "").strip()
466
+ if pattern:
467
+ _remember_verified_work(context.tool_metadata, f"Expanded glob pattern {pattern[:180]}")
468
+ elif tool_name == "grep":
469
+ pattern = str(tool_input.get("pattern") or "").strip()
470
+ if pattern:
471
+ _remember_verified_work(context.tool_metadata, f"Checked repository matches for grep pattern {pattern[:180]}")
472
+ elif tool_name == "bash":
473
+ command = str(tool_input.get("command") or "").strip()
474
+ summary = tool_output.splitlines()[0].strip() if tool_output.strip() else "no output"
475
+ _remember_verified_work(
476
+ context.tool_metadata,
477
+ f"Ran bash command {command[:160]} [{summary[:120]}]",
478
+ )
479
+ if tool_name == "read_file" and resolved_file_path is not None:
480
+ _remember_work_log(
481
+ context.tool_metadata,
482
+ entry=f"Read file {resolved_file_path}",
483
+ )
484
+ elif tool_name == "bash":
485
+ command = str(tool_input.get("command") or "").strip()
486
+ summary = tool_output.splitlines()[0].strip() if tool_output.strip() else "no output"
487
+ _remember_work_log(
488
+ context.tool_metadata,
489
+ entry=f"Ran bash: {command[:160]} [{summary[:120]}]",
490
+ )
491
+ elif tool_name == "grep":
492
+ pattern = str(tool_input.get("pattern") or "").strip()
493
+ _remember_work_log(
494
+ context.tool_metadata,
495
+ entry=f"Searched with grep pattern={pattern[:160]}",
496
+ )
497
+ elif tool_name == "skill":
498
+ _remember_work_log(
499
+ context.tool_metadata,
500
+ entry=f"Loaded skill {str(tool_input.get('name') or '').strip()}",
501
+ )
502
+ elif tool_name in {"agent", "send_message"}:
503
+ _remember_work_log(
504
+ context.tool_metadata,
505
+ entry=f"Async agent action via {tool_name}",
506
+ )
507
+ elif tool_name == "enter_plan_mode":
508
+ _remember_work_log(context.tool_metadata, entry="Entered plan mode")
509
+ elif tool_name == "exit_plan_mode":
510
+ _remember_work_log(context.tool_metadata, entry="Exited plan mode")
511
+
512
+
513
+ def _tool_artifact_dir() -> Path:
514
+ artifact_dir = get_data_dir() / "tool_artifacts"
515
+ artifact_dir.mkdir(parents=True, exist_ok=True)
516
+ return artifact_dir
517
+
518
+
519
+ def _safe_tool_artifact_name(tool_name: str) -> str:
520
+ normalized = re.sub(r"[^A-Za-z0-9_.-]+", "_", tool_name.strip())
521
+ return (normalized or "tool")[:80]
522
+
523
+
524
+ def _offload_tool_output_if_needed(
525
+ *,
526
+ tool_name: str,
527
+ tool_use_id: str,
528
+ output: str,
529
+ ) -> tuple[str, Path | None]:
530
+ inline_limit = tool_output_inline_chars()
531
+ if len(output) <= inline_limit:
532
+ return output, None
533
+
534
+ artifact_path = (
535
+ _tool_artifact_dir()
536
+ / f"{time.strftime('%Y%m%d-%H%M%S')}-{_safe_tool_artifact_name(tool_name)}-{uuid4().hex[:12]}.txt"
537
+ )
538
+ artifact_path.write_text(output, encoding="utf-8", errors="replace")
539
+ preview = output[:tool_output_preview_chars()]
540
+ omitted = max(0, len(output) - len(preview))
541
+ inline = (
542
+ "[Tool output truncated]\n"
543
+ f"Tool: {tool_name}\n"
544
+ f"Tool use id: {tool_use_id}\n"
545
+ f"Original size: {len(output)} chars\n"
546
+ f"Full output saved to: {artifact_path}\n"
547
+ f"Inline preview: first {len(preview)} chars"
548
+ )
549
+ if omitted:
550
+ inline += f" ({omitted} chars omitted)"
551
+ if preview:
552
+ inline += f"\n\nPreview:\n{preview}"
553
+ return inline, artifact_path
554
+
555
+
556
+ # ---------------------------------------------------------------------------
557
+ # Image preprocessing — convert ImageBlocks to text for non-multimodal models
558
+ # ---------------------------------------------------------------------------
559
+
560
+ _IMAGE_PREPROCESS_STATUS = "Converting image to text description via vision model…"
561
+
562
+
563
+ async def _preprocess_images_in_messages(
564
+ messages: list[ConversationMessage],
565
+ context: QueryContext,
566
+ ) -> AsyncIterator[StreamEvent]:
567
+ """Scan messages for ImageBlocks and convert them to text if the active
568
+ model does not support multimodal input.
569
+
570
+ Yields status events during conversion so the UI stays responsive.
571
+ """
572
+ if is_model_multimodal(context.model):
573
+ return
574
+
575
+ vision_config = context.tool_metadata.get("vision_model_config")
576
+ if not vision_config:
577
+ # No vision model configured — skip preprocessing.
578
+ return
579
+
580
+ # Collect all ImageBlocks with their parent message index and block index
581
+ pending: list[tuple[int, int, ImageBlock]] = []
582
+ for msg_idx, msg in enumerate(messages):
583
+ if msg.role != "user":
584
+ continue
585
+ for blk_idx, block in enumerate(msg.content):
586
+ if isinstance(block, ImageBlock):
587
+ pending.append((msg_idx, blk_idx, block))
588
+
589
+ if not pending:
590
+ return
591
+
592
+ yield StatusEvent(message=_IMAGE_PREPROCESS_STATUS)
593
+
594
+ # Process images in parallel
595
+ async def _describe(msg_idx: int, blk_idx: int, block: ImageBlock) -> tuple[int, int, str]:
596
+ tool = context.tool_registry.get("image_to_text")
597
+ if tool is None:
598
+ return msg_idx, blk_idx, "[Image: could not describe — image_to_text tool not available]"
599
+
600
+ # Build tool input
601
+ tool_input_data: dict[str, object] = {
602
+ "image_data": block.data,
603
+ "media_type": block.media_type,
604
+ "prompt": "Describe this image in detail, including any text, "
605
+ "UI elements, code, diagrams, or visual information present.",
606
+ }
607
+
608
+ try:
609
+ parsed = tool.input_model.model_validate(tool_input_data)
610
+ except Exception:
611
+ return msg_idx, blk_idx, "[Image: could not parse image data]"
612
+
613
+ exec_context = ToolExecutionContext(
614
+ cwd=context.cwd,
615
+ metadata={
616
+ "vision_model_config": vision_config,
617
+ **(context.tool_metadata or {}),
618
+ },
619
+ )
620
+ result = await tool.execute(parsed, exec_context)
621
+ if result.is_error:
622
+ return msg_idx, blk_idx, f"[Image description failed: {result.output}]"
623
+ return msg_idx, blk_idx, result.output
624
+
625
+ results = await asyncio.gather(*[_describe(mi, bi, blk) for mi, bi, blk in pending])
626
+
627
+ # Replace ImageBlocks with TextBlocks in-place
628
+ for msg_idx, blk_idx, description in results:
629
+ msg = messages[msg_idx]
630
+ msg.content[blk_idx] = TextBlock(text=description)
631
+
632
+
633
+ async def run_query(
634
+ context: QueryContext,
635
+ messages: list[ConversationMessage],
636
+ ) -> AsyncIterator[tuple[StreamEvent, UsageSnapshot | None]]:
637
+ """Run the conversation loop until the model stops requesting tools.
638
+
639
+ Auto-compaction is checked at the start of each turn. When the
640
+ estimated token count exceeds the model's auto-compact threshold,
641
+ the engine first tries a cheap microcompact (clearing old tool result
642
+ content) and, if that is not enough, performs a full LLM-based
643
+ summarization of older messages.
644
+ """
645
+ from openharness.services.compact import (
646
+ AutoCompactState,
647
+ auto_compact_if_needed,
648
+ )
649
+
650
+ compact_state = AutoCompactState()
651
+ reactive_compact_attempted = False
652
+ last_compaction_result: tuple[list[ConversationMessage], bool] = (messages, False)
653
+ effective_max_tokens = _bounded_completion_tokens(
654
+ context.max_tokens,
655
+ context.context_window_tokens,
656
+ )
657
+ reported_token_clamp = False
658
+
659
+ async def _stream_compaction(
660
+ *,
661
+ trigger: str,
662
+ force: bool = False,
663
+ ) -> AsyncIterator[tuple[StreamEvent, UsageSnapshot | None]]:
664
+ nonlocal last_compaction_result
665
+ progress_queue: asyncio.Queue[CompactProgressEvent] = asyncio.Queue()
666
+
667
+ async def _progress(event: CompactProgressEvent) -> None:
668
+ await progress_queue.put(event)
669
+
670
+ task = asyncio.create_task(
671
+ auto_compact_if_needed(
672
+ messages,
673
+ api_client=context.api_client,
674
+ model=context.model,
675
+ system_prompt=context.system_prompt,
676
+ state=compact_state,
677
+ progress_callback=_progress,
678
+ force=force,
679
+ trigger=trigger,
680
+ hook_executor=context.hook_executor,
681
+ carryover_metadata=context.tool_metadata,
682
+ context_window_tokens=context.context_window_tokens,
683
+ auto_compact_threshold_tokens=context.auto_compact_threshold_tokens,
684
+ )
685
+ )
686
+ while True:
687
+ try:
688
+ event = await asyncio.wait_for(progress_queue.get(), timeout=0.05)
689
+ yield event, None
690
+ except asyncio.TimeoutError:
691
+ if task.done():
692
+ break
693
+ continue
694
+ while not progress_queue.empty():
695
+ yield progress_queue.get_nowait(), None
696
+ last_compaction_result = await task
697
+ return
698
+
699
+ turn_count = 0
700
+ while context.max_turns is None or turn_count < context.max_turns:
701
+ turn_count += 1
702
+ if effective_max_tokens != context.max_tokens and not reported_token_clamp:
703
+ reported_token_clamp = True
704
+ yield StatusEvent(
705
+ message=(
706
+ "Requested max_tokens="
707
+ f"{context.max_tokens} exceeds the safe per-request output cap; "
708
+ f"using {effective_max_tokens}."
709
+ )
710
+ ), None
711
+ # --- auto-compact check before calling the model ---------------
712
+ async for event, usage in _stream_compaction(trigger="auto"):
713
+ yield event, usage
714
+ compacted_messages, was_compacted = last_compaction_result
715
+ if compacted_messages is not messages:
716
+ messages[:] = compacted_messages
717
+ # ---------------------------------------------------------------
718
+
719
+ # --- image preprocessing: convert ImageBlocks to text for non-vision models ---
720
+ async for event in _preprocess_images_in_messages(messages, context):
721
+ yield event, None
722
+ # -----------------------------------------------------------------------------
723
+
724
+ final_message: ConversationMessage | None = None
725
+ usage = UsageSnapshot()
726
+
727
+ try:
728
+ async for event in context.api_client.stream_message(
729
+ ApiMessageRequest(
730
+ model=context.model,
731
+ messages=messages,
732
+ system_prompt=context.system_prompt,
733
+ max_tokens=effective_max_tokens,
734
+ tools=context.tool_registry.to_api_schema(),
735
+ effort=context.effort,
736
+ )
737
+ ):
738
+ if isinstance(event, ApiTextDeltaEvent):
739
+ yield AssistantTextDelta(text=event.text), None
740
+ continue
741
+ if isinstance(event, ApiRetryEvent):
742
+ yield StatusEvent(
743
+ message=(
744
+ f"Request failed; retrying in {event.delay_seconds:.1f}s "
745
+ f"(attempt {event.attempt + 1} of {event.max_attempts}): {event.message}"
746
+ )
747
+ ), None
748
+ continue
749
+
750
+ if isinstance(event, ApiMessageCompleteEvent):
751
+ final_message = event.message
752
+ usage = event.usage
753
+ except Exception as exc:
754
+ error_msg = str(exc)
755
+ if _is_completion_token_limit_error(exc):
756
+ supported_limit = _extract_completion_token_limit(exc)
757
+ if supported_limit is not None and effective_max_tokens > supported_limit:
758
+ previous_max_tokens = effective_max_tokens
759
+ effective_max_tokens = supported_limit
760
+ yield StatusEvent(
761
+ message=(
762
+ f"Model rejected max_tokens={previous_max_tokens}; "
763
+ f"retrying with provider limit {effective_max_tokens}."
764
+ )
765
+ ), None
766
+ turn_count = max(0, turn_count - 1)
767
+ continue
768
+ if not reactive_compact_attempted and _is_prompt_too_long_error(exc):
769
+ reactive_compact_attempted = True
770
+ yield StatusEvent(message=REACTIVE_COMPACT_STATUS_MESSAGE), None
771
+ async for event, usage in _stream_compaction(trigger="reactive", force=True):
772
+ yield event, usage
773
+ compacted_messages, was_compacted = last_compaction_result
774
+ if compacted_messages is not messages:
775
+ messages[:] = compacted_messages
776
+ if was_compacted:
777
+ continue
778
+ if "connect" in error_msg.lower() or "timeout" in error_msg.lower() or "network" in error_msg.lower():
779
+ yield ErrorEvent(message=f"Network error: {error_msg}. Check your internet connection and try again."), None
780
+ else:
781
+ yield ErrorEvent(message=f"API error: {error_msg}"), None
782
+ return
783
+
784
+ if final_message is None:
785
+ raise RuntimeError("Model stream finished without a final message")
786
+
787
+ coordinator_context_message: ConversationMessage | None = None
788
+ if context.system_prompt.startswith("You are a **coordinator**."):
789
+ if messages and messages[-1].role == "user" and messages[-1].text.startswith("# Coordinator User Context"):
790
+ coordinator_context_message = messages.pop()
791
+
792
+ if final_message.role == "assistant" and final_message.is_effectively_empty():
793
+ log.warning("dropping empty assistant message from provider response")
794
+ yield ErrorEvent(
795
+ message=(
796
+ "Model returned an empty assistant message. "
797
+ "The turn was ignored to keep the session healthy."
798
+ )
799
+ ), usage
800
+ return
801
+
802
+ if not final_message.tool_uses:
803
+ messages.append(final_message)
804
+ if context.hook_executor is not None:
805
+ stop_hooks = await context.hook_executor.execute(
806
+ HookEvent.STOP,
807
+ {
808
+ "event": HookEvent.STOP.value,
809
+ "stop_reason": "tool_uses_empty",
810
+ "assistant_text": final_message.text,
811
+ "assistant_message": final_message.model_dump(mode="json"),
812
+ },
813
+ )
814
+ if stop_hooks.blocked:
815
+ reason = stop_hooks.reason or "STOP hook blocked completion."
816
+ yield StatusEvent(message=reason), None
817
+ if coordinator_context_message is not None:
818
+ messages.append(coordinator_context_message)
819
+ coordinator_context_message = None
820
+ messages.append(
821
+ ConversationMessage.from_user_text(
822
+ "# Hook Feedback: Completion Blocked\n\n"
823
+ f"{reason}\n\n"
824
+ "Fix the reported issues with the available tools, then finish again."
825
+ )
826
+ )
827
+ continue
828
+ yield AssistantTurnComplete(message=final_message, usage=usage), usage
829
+ if coordinator_context_message is not None:
830
+ messages.append(coordinator_context_message)
831
+ return
832
+
833
+ messages.append(final_message)
834
+ yield AssistantTurnComplete(message=final_message, usage=usage), usage
835
+
836
+ if coordinator_context_message is not None:
837
+ messages.append(coordinator_context_message)
838
+
839
+ tool_calls = final_message.tool_uses
840
+
841
+ if len(tool_calls) == 1:
842
+ # Single tool: sequential (stream events immediately)
843
+ tc = tool_calls[0]
844
+ yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input), None
845
+ try:
846
+ result = await _execute_tool_call(context, tc.name, tc.id, tc.input)
847
+ except Exception as exc:
848
+ log.exception("tool execution raised: name=%s id=%s", tc.name, tc.id)
849
+ result = ToolResultBlock(
850
+ tool_use_id=tc.id,
851
+ content=f"Tool {tc.name} failed: {type(exc).__name__}: {exc}",
852
+ is_error=True,
853
+ )
854
+ yield ToolExecutionCompleted(
855
+ tool_name=tc.name,
856
+ output=result.content,
857
+ is_error=result.is_error,
858
+ metadata=result.result_metadata,
859
+ ), None
860
+ tool_results = [result]
861
+ else:
862
+ # Multiple tools: execute concurrently, emit events after
863
+ for tc in tool_calls:
864
+ yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input), None
865
+
866
+ async def _run(tc):
867
+ return await _execute_tool_call(context, tc.name, tc.id, tc.input)
868
+
869
+ # Use return_exceptions=True so a single failing tool does not abandon
870
+ # its siblings as cancelled coroutines and leave the conversation with
871
+ # un-replied tool_use blocks (Anthropic's API rejects the next request
872
+ # on the session if any tool_use is missing a matching tool_result).
873
+ raw_results = await asyncio.gather(
874
+ *[_run(tc) for tc in tool_calls], return_exceptions=True
875
+ )
876
+ tool_results = []
877
+ for tc, result in zip(tool_calls, raw_results):
878
+ if isinstance(result, BaseException):
879
+ log.exception(
880
+ "tool execution raised: name=%s id=%s",
881
+ tc.name,
882
+ tc.id,
883
+ exc_info=result,
884
+ )
885
+ result = ToolResultBlock(
886
+ tool_use_id=tc.id,
887
+ content=f"Tool {tc.name} failed: {type(result).__name__}: {result}",
888
+ is_error=True,
889
+ )
890
+ tool_results.append(result)
891
+
892
+ for tc, result in zip(tool_calls, tool_results):
893
+ yield ToolExecutionCompleted(
894
+ tool_name=tc.name,
895
+ output=result.content,
896
+ is_error=result.is_error,
897
+ metadata=result.result_metadata,
898
+ ), None
899
+
900
+ messages.append(ConversationMessage(role="user", content=tool_results))
901
+
902
+ if context.max_turns is not None:
903
+ raise MaxTurnsExceeded(context.max_turns)
904
+ raise RuntimeError("Query loop exited without a max_turns limit or final response")
905
+
906
+
907
+ async def _execute_tool_call(
908
+ context: QueryContext,
909
+ tool_name: str,
910
+ tool_use_id: str,
911
+ tool_input: dict[str, object],
912
+ ) -> ToolResultBlock:
913
+ if context.tool_metadata is None:
914
+ context.tool_metadata = {}
915
+ cache = _tool_call_result_cache(context.tool_metadata)
916
+ cached = cache.get(tool_use_id) if tool_use_id else None
917
+ if isinstance(cached, ToolResultBlock):
918
+ return cached.model_copy(deep=True)
919
+
920
+ inflight = _tool_call_inflight_cache(context.tool_metadata)
921
+ if tool_use_id:
922
+ existing = inflight.get(tool_use_id)
923
+ if isinstance(existing, asyncio.Future):
924
+ return (await asyncio.shield(existing)).model_copy(deep=True)
925
+ future = asyncio.get_running_loop().create_future()
926
+ inflight[tool_use_id] = future
927
+ else:
928
+ future = None
929
+
930
+ try:
931
+ result = await _execute_tool_call_uncached(
932
+ context,
933
+ tool_name,
934
+ tool_use_id,
935
+ tool_input,
936
+ )
937
+ if future is not None and not future.done():
938
+ future.set_result(result.model_copy(deep=True))
939
+ return result
940
+ except BaseException as exc:
941
+ if future is not None and not future.done():
942
+ future.set_exception(exc)
943
+ raise
944
+ finally:
945
+ if tool_use_id and inflight.get(tool_use_id) is future:
946
+ inflight.pop(tool_use_id, None)
947
+
948
+
949
+ async def _execute_tool_call_uncached(
950
+ context: QueryContext,
951
+ tool_name: str,
952
+ tool_use_id: str,
953
+ tool_input: dict[str, object],
954
+ ) -> ToolResultBlock:
955
+ cache = _tool_call_result_cache(context.tool_metadata)
956
+ cached = cache.get(tool_use_id) if tool_use_id else None
957
+ if isinstance(cached, ToolResultBlock):
958
+ return cached.model_copy(deep=True)
959
+
960
+ def _finish(result: ToolResultBlock) -> ToolResultBlock:
961
+ if tool_use_id:
962
+ cache[tool_use_id] = result.model_copy(deep=True)
963
+ return result
964
+
965
+ if context.hook_executor is not None:
966
+ pre_hooks = await context.hook_executor.execute(
967
+ HookEvent.PRE_TOOL_USE,
968
+ {"tool_name": tool_name, "tool_input": tool_input, "event": HookEvent.PRE_TOOL_USE.value},
969
+ )
970
+ if pre_hooks.blocked:
971
+ return _finish(ToolResultBlock(
972
+ tool_use_id=tool_use_id,
973
+ content=pre_hooks.reason or f"pre_tool_use hook blocked {tool_name}",
974
+ is_error=True,
975
+ ))
976
+
977
+ log.debug("tool_call start: %s id=%s", tool_name, tool_use_id)
978
+
979
+ argument_error = tool_input.get("__openharness_tool_arguments_error")
980
+ if argument_error:
981
+ return _finish(ToolResultBlock(
982
+ tool_use_id=tool_use_id,
983
+ content=str(argument_error),
984
+ is_error=True,
985
+ ))
986
+
987
+ tool = context.tool_registry.get(tool_name)
988
+ if tool is None:
989
+ log.warning("unknown tool: %s", tool_name)
990
+ return _finish(ToolResultBlock(
991
+ tool_use_id=tool_use_id,
992
+ content=f"Unknown tool: {tool_name}",
993
+ is_error=True,
994
+ ))
995
+
996
+ try:
997
+ parsed_input = tool.input_model.model_validate(tool_input)
998
+ except Exception as exc:
999
+ log.warning("invalid input for %s: %s", tool_name, exc)
1000
+ return _finish(ToolResultBlock(
1001
+ tool_use_id=tool_use_id,
1002
+ content=f"Invalid input for {tool_name}: {exc}",
1003
+ is_error=True,
1004
+ ))
1005
+
1006
+ # Normalize common tool inputs before permission checks so path rules apply
1007
+ # consistently across built-in tools that use `file_path`, `path`, or
1008
+ # directory-scoped roots such as `glob`/`grep`.
1009
+ _file_path = _resolve_permission_file_path(context.cwd, tool_input, parsed_input)
1010
+ _command = _extract_permission_command(tool_input, parsed_input)
1011
+ log.debug("permission check: %s read_only=%s path=%s cmd=%s",
1012
+ tool_name, tool.is_read_only(parsed_input), _file_path, _command and _command[:80])
1013
+ decision = context.permission_checker.evaluate(
1014
+ tool_name,
1015
+ is_read_only=tool.is_read_only(parsed_input),
1016
+ file_path=_file_path,
1017
+ command=_command,
1018
+ )
1019
+ if not decision.allowed:
1020
+ if decision.requires_confirmation and context.permission_prompt is not None:
1021
+ log.debug("permission prompt for %s: %s", tool_name, decision.reason)
1022
+ if context.hook_executor is not None:
1023
+ await context.hook_executor.execute(
1024
+ HookEvent.NOTIFICATION,
1025
+ {
1026
+ "event": HookEvent.NOTIFICATION.value,
1027
+ "notification_type": "permission_prompt",
1028
+ "tool_name": tool_name,
1029
+ "reason": decision.reason,
1030
+ },
1031
+ )
1032
+ confirmed = await context.permission_prompt(tool_name, decision.reason)
1033
+ if not confirmed:
1034
+ log.debug("permission denied by user for %s", tool_name)
1035
+ return _finish(ToolResultBlock(
1036
+ tool_use_id=tool_use_id,
1037
+ content=decision.reason or f"Permission denied for {tool_name}",
1038
+ is_error=True,
1039
+ ))
1040
+ else:
1041
+ log.debug("permission blocked for %s: %s", tool_name, decision.reason)
1042
+ return _finish(ToolResultBlock(
1043
+ tool_use_id=tool_use_id,
1044
+ content=decision.reason or f"Permission denied for {tool_name}",
1045
+ is_error=True,
1046
+ ))
1047
+
1048
+ log.debug("executing %s ...", tool_name)
1049
+ t0 = time.monotonic()
1050
+ try:
1051
+ result = await tool.execute(
1052
+ parsed_input,
1053
+ ToolExecutionContext(
1054
+ cwd=context.cwd,
1055
+ metadata={
1056
+ "tool_registry": context.tool_registry,
1057
+ "ask_user_prompt": context.ask_user_prompt,
1058
+ **(context.tool_metadata or {}),
1059
+ },
1060
+ hook_executor=context.hook_executor,
1061
+ ),
1062
+ )
1063
+ except Exception as exc:
1064
+ return _finish(ToolResultBlock(
1065
+ tool_use_id=tool_use_id,
1066
+ content=f"Tool {tool_name} failed: {type(exc).__name__}: {exc}",
1067
+ is_error=True,
1068
+ ))
1069
+ elapsed = time.monotonic() - t0
1070
+ log.debug("executed %s in %.2fs err=%s output_len=%d",
1071
+ tool_name, elapsed, result.is_error, len(result.output or ""))
1072
+ inline_output, artifact_path = _offload_tool_output_if_needed(
1073
+ tool_name=tool_name,
1074
+ tool_use_id=tool_use_id,
1075
+ output=result.output,
1076
+ )
1077
+ if artifact_path is not None:
1078
+ _remember_active_artifact(context.tool_metadata, str(artifact_path))
1079
+ tool_result = ToolResultBlock(
1080
+ tool_use_id=tool_use_id,
1081
+ content=inline_output,
1082
+ is_error=result.is_error,
1083
+ result_metadata=dict(result.metadata or {}),
1084
+ )
1085
+ if context.hook_executor is not None:
1086
+ try:
1087
+ post_hooks = await context.hook_executor.execute(
1088
+ HookEvent.POST_TOOL_USE,
1089
+ {
1090
+ "tool_name": tool_name,
1091
+ "tool_input": tool_input,
1092
+ "tool_output": tool_result.content,
1093
+ "tool_is_error": tool_result.is_error,
1094
+ "event": HookEvent.POST_TOOL_USE.value,
1095
+ },
1096
+ )
1097
+ except Exception as exc:
1098
+ post_hooks = None
1099
+ tool_result = ToolResultBlock(
1100
+ tool_use_id=tool_use_id,
1101
+ content=f"post_tool_use hook failed for {tool_name}: {type(exc).__name__}: {exc}",
1102
+ is_error=True,
1103
+ result_metadata=dict(result.metadata or {}),
1104
+ )
1105
+ if post_hooks is not None and post_hooks.blocked:
1106
+ tool_result = ToolResultBlock(
1107
+ tool_use_id=tool_use_id,
1108
+ content=post_hooks.reason or f"post_tool_use hook blocked {tool_name}",
1109
+ is_error=True,
1110
+ result_metadata=dict(result.metadata or {}),
1111
+ )
1112
+ finished_result = _finish(tool_result)
1113
+ try:
1114
+ _record_tool_carryover(
1115
+ context,
1116
+ tool_name=tool_name,
1117
+ tool_input=tool_input,
1118
+ tool_output=finished_result.content,
1119
+ tool_result_metadata=finished_result.result_metadata,
1120
+ is_error=finished_result.is_error,
1121
+ resolved_file_path=_file_path,
1122
+ )
1123
+ except Exception:
1124
+ log.exception("tool carryover recording failed: name=%s id=%s", tool_name, tool_use_id)
1125
+ return finished_result
1126
+
1127
+
1128
+ def _tool_call_result_cache(
1129
+ tool_metadata: dict[str, object] | None,
1130
+ ) -> dict[str, ToolResultBlock]:
1131
+ if tool_metadata is None:
1132
+ return {}
1133
+ value = tool_metadata.setdefault("tool_call_result_cache", {})
1134
+ if isinstance(value, dict):
1135
+ return value # type: ignore[return-value]
1136
+ replacement: dict[str, ToolResultBlock] = {}
1137
+ tool_metadata["tool_call_result_cache"] = replacement
1138
+ return replacement
1139
+
1140
+
1141
+ def _tool_call_inflight_cache(
1142
+ tool_metadata: dict[str, object],
1143
+ ) -> dict[str, asyncio.Future]:
1144
+ value = tool_metadata.setdefault("tool_call_inflight_cache", {})
1145
+ if isinstance(value, dict):
1146
+ return value # type: ignore[return-value]
1147
+ replacement: dict[str, asyncio.Future] = {}
1148
+ tool_metadata["tool_call_inflight_cache"] = replacement
1149
+ return replacement
1150
+
1151
+
1152
+ def _resolve_permission_file_path(
1153
+ cwd: Path,
1154
+ raw_input: dict[str, object],
1155
+ parsed_input: object,
1156
+ ) -> str | None:
1157
+ for key in ("file_path", "path", "root"):
1158
+ value = raw_input.get(key)
1159
+ if isinstance(value, str) and value.strip():
1160
+ path = Path(value).expanduser()
1161
+ if not path.is_absolute():
1162
+ path = cwd / path
1163
+ return str(path.resolve())
1164
+
1165
+ for attr in ("file_path", "path", "root"):
1166
+ value = getattr(parsed_input, attr, None)
1167
+ if isinstance(value, str) and value.strip():
1168
+ path = Path(value).expanduser()
1169
+ if not path.is_absolute():
1170
+ path = cwd / path
1171
+ return str(path.resolve())
1172
+
1173
+ return None
1174
+
1175
+
1176
+ def _extract_permission_command(
1177
+ raw_input: dict[str, object],
1178
+ parsed_input: object,
1179
+ ) -> str | None:
1180
+ value = raw_input.get("command")
1181
+ if isinstance(value, str) and value.strip():
1182
+ return value
1183
+
1184
+ value = getattr(parsed_input, "command", None)
1185
+ if isinstance(value, str) and value.strip():
1186
+ return value
1187
+
1188
+ return None