octo-agent 0.11.2

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 (319) hide show
  1. checksums.yaml +7 -0
  2. data/.clacky/skills/commit/SKILL.md +423 -0
  3. data/.clacky/skills/gem-release/SKILL.md +199 -0
  4. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  5. data/.clacky/skills/oss-upload/SKILL.md +47 -0
  6. data/.octorules +106 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +8 -0
  9. data/CHANGELOG.md +76 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/CONTRIBUTING.md +92 -0
  12. data/Dockerfile +28 -0
  13. data/LICENSE.txt +22 -0
  14. data/POSITIONING.md +46 -0
  15. data/README.md +134 -0
  16. data/README_CN.md +134 -0
  17. data/Rakefile +34 -0
  18. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  19. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  20. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  21. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  22. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  23. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  24. data/benchmark/results/baseline_20260511_174424.json +128 -0
  25. data/benchmark/results/report_20260511_175256.json +271 -0
  26. data/benchmark/results/report_20260511_175444.json +271 -0
  27. data/benchmark/results/treatment_20260511_175103.json +130 -0
  28. data/benchmark/runner.rb +441 -0
  29. data/bin/octo +7 -0
  30. data/docs/agent-first-ui-design.md +77 -0
  31. data/docs/billing-system.md +318 -0
  32. data/docs/channel-architecture.md +235 -0
  33. data/docs/engineering-article.md +343 -0
  34. data/docs/session-skill-invocation.md +69 -0
  35. data/docs/time_machine_design.md +247 -0
  36. data/docs/ui2-architecture.md +124 -0
  37. data/homebrew/README.md +96 -0
  38. data/homebrew/openocto.rb +24 -0
  39. data/lib/octo/agent/hook_manager.rb +61 -0
  40. data/lib/octo/agent/llm_caller.rb +800 -0
  41. data/lib/octo/agent/memory_updater.rb +246 -0
  42. data/lib/octo/agent/message_compressor.rb +225 -0
  43. data/lib/octo/agent/message_compressor_helper.rb +869 -0
  44. data/lib/octo/agent/next_message_suggester.rb +215 -0
  45. data/lib/octo/agent/session_serializer.rb +685 -0
  46. data/lib/octo/agent/skill_auto_creator.rb +114 -0
  47. data/lib/octo/agent/skill_evolution.rb +61 -0
  48. data/lib/octo/agent/skill_manager.rb +466 -0
  49. data/lib/octo/agent/skill_reflector.rb +89 -0
  50. data/lib/octo/agent/system_prompt_builder.rb +101 -0
  51. data/lib/octo/agent/time_machine.rb +214 -0
  52. data/lib/octo/agent/tool_executor.rb +454 -0
  53. data/lib/octo/agent/tool_registry.rb +150 -0
  54. data/lib/octo/agent.rb +2180 -0
  55. data/lib/octo/agent_config.rb +989 -0
  56. data/lib/octo/agent_profile.rb +112 -0
  57. data/lib/octo/anthropic_stream_aggregator.rb +137 -0
  58. data/lib/octo/background_task_registry.rb +324 -0
  59. data/lib/octo/banner.rb +34 -0
  60. data/lib/octo/bedrock_stream_aggregator.rb +137 -0
  61. data/lib/octo/block_font.rb +331 -0
  62. data/lib/octo/cli.rb +968 -0
  63. data/lib/octo/client.rb +623 -0
  64. data/lib/octo/default_agents/SOUL.md +3 -0
  65. data/lib/octo/default_agents/USER.md +1 -0
  66. data/lib/octo/default_agents/base_prompt.md +66 -0
  67. data/lib/octo/default_agents/coding/profile.yml +2 -0
  68. data/lib/octo/default_agents/coding/system_prompt.md +67 -0
  69. data/lib/octo/default_agents/general/profile.yml +2 -0
  70. data/lib/octo/default_agents/general/system_prompt.md +16 -0
  71. data/lib/octo/default_parsers/doc_parser.rb +69 -0
  72. data/lib/octo/default_parsers/docx_parser.rb +188 -0
  73. data/lib/octo/default_parsers/pdf_parser.rb +120 -0
  74. data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
  75. data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
  76. data/lib/octo/default_parsers/pptx_parser.rb +140 -0
  77. data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
  78. data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
  79. data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
  80. data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  81. data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
  82. data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
  83. data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
  84. data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  85. data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
  86. data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
  87. data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
  88. data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
  89. data/lib/octo/default_skills/onboard/SKILL.md +578 -0
  90. data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
  91. data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
  92. data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
  93. data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
  94. data/lib/octo/default_skills/personal-website/publish.rb +235 -0
  95. data/lib/octo/default_skills/product-help/SKILL.md +123 -0
  96. data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
  97. data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
  98. data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
  99. data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
  100. data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
  101. data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
  102. data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
  103. data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
  104. data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
  105. data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
  106. data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
  107. data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
  108. data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
  109. data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
  110. data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
  111. data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
  112. data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
  113. data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
  114. data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
  115. data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
  116. data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
  117. data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
  118. data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
  119. data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
  120. data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
  121. data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
  122. data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
  123. data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
  124. data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
  125. data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  126. data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
  127. data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
  128. data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
  129. data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
  130. data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
  131. data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
  132. data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
  133. data/lib/octo/idle_compression_timer.rb +115 -0
  134. data/lib/octo/json_ui_controller.rb +204 -0
  135. data/lib/octo/message_format/anthropic.rb +409 -0
  136. data/lib/octo/message_format/bedrock.rb +361 -0
  137. data/lib/octo/message_format/open_ai.rb +222 -0
  138. data/lib/octo/message_history.rb +373 -0
  139. data/lib/octo/openai_stream_aggregator.rb +130 -0
  140. data/lib/octo/plain_ui_controller.rb +166 -0
  141. data/lib/octo/providers.rb +534 -0
  142. data/lib/octo/server/browser_manager.rb +397 -0
  143. data/lib/octo/server/channel/adapters/base.rb +82 -0
  144. data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
  145. data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
  146. data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
  147. data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
  148. data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
  149. data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
  150. data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
  151. data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
  152. data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
  153. data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
  154. data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
  155. data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
  156. data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
  157. data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
  158. data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
  159. data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
  160. data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
  161. data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
  162. data/lib/octo/server/channel/channel_config.rb +178 -0
  163. data/lib/octo/server/channel/channel_manager.rb +468 -0
  164. data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
  165. data/lib/octo/server/channel.rb +33 -0
  166. data/lib/octo/server/discover.rb +77 -0
  167. data/lib/octo/server/epipe_safe_io.rb +105 -0
  168. data/lib/octo/server/http_server.rb +3554 -0
  169. data/lib/octo/server/scheduler.rb +317 -0
  170. data/lib/octo/server/server_master.rb +325 -0
  171. data/lib/octo/server/session_registry.rb +431 -0
  172. data/lib/octo/server/web_ui_controller.rb +487 -0
  173. data/lib/octo/session_manager.rb +385 -0
  174. data/lib/octo/skill.rb +466 -0
  175. data/lib/octo/skill_loader.rb +328 -0
  176. data/lib/octo/tools/base.rb +118 -0
  177. data/lib/octo/tools/browser.rb +625 -0
  178. data/lib/octo/tools/edit.rb +165 -0
  179. data/lib/octo/tools/file_reader.rb +549 -0
  180. data/lib/octo/tools/glob.rb +162 -0
  181. data/lib/octo/tools/grep.rb +356 -0
  182. data/lib/octo/tools/invoke_skill.rb +96 -0
  183. data/lib/octo/tools/list_tasks.rb +54 -0
  184. data/lib/octo/tools/redo_task.rb +41 -0
  185. data/lib/octo/tools/request_user_feedback.rb +84 -0
  186. data/lib/octo/tools/security.rb +333 -0
  187. data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
  188. data/lib/octo/tools/terminal/persistent_session.rb +268 -0
  189. data/lib/octo/tools/terminal/safe_rm.sh +106 -0
  190. data/lib/octo/tools/terminal/session_manager.rb +213 -0
  191. data/lib/octo/tools/terminal.rb +1828 -0
  192. data/lib/octo/tools/todo_manager.rb +374 -0
  193. data/lib/octo/tools/trash_manager.rb +388 -0
  194. data/lib/octo/tools/undo_task.rb +35 -0
  195. data/lib/octo/tools/web_fetch.rb +242 -0
  196. data/lib/octo/tools/web_search.rb +260 -0
  197. data/lib/octo/tools/write.rb +77 -0
  198. data/lib/octo/ui2/block_font.rb +10 -0
  199. data/lib/octo/ui2/components/base_component.rb +163 -0
  200. data/lib/octo/ui2/components/command_suggestions.rb +290 -0
  201. data/lib/octo/ui2/components/common_component.rb +96 -0
  202. data/lib/octo/ui2/components/inline_input.rb +226 -0
  203. data/lib/octo/ui2/components/input_area.rb +1338 -0
  204. data/lib/octo/ui2/components/message_component.rb +99 -0
  205. data/lib/octo/ui2/components/modal_component.rb +419 -0
  206. data/lib/octo/ui2/components/todo_area.rb +149 -0
  207. data/lib/octo/ui2/components/tool_component.rb +107 -0
  208. data/lib/octo/ui2/components/welcome_banner.rb +139 -0
  209. data/lib/octo/ui2/layout_manager.rb +807 -0
  210. data/lib/octo/ui2/line_editor.rb +363 -0
  211. data/lib/octo/ui2/markdown_renderer.rb +100 -0
  212. data/lib/octo/ui2/output_buffer.rb +370 -0
  213. data/lib/octo/ui2/progress_handle.rb +362 -0
  214. data/lib/octo/ui2/progress_indicator.rb +55 -0
  215. data/lib/octo/ui2/screen_buffer.rb +273 -0
  216. data/lib/octo/ui2/terminal_detector.rb +119 -0
  217. data/lib/octo/ui2/theme_manager.rb +85 -0
  218. data/lib/octo/ui2/themes/base_theme.rb +105 -0
  219. data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
  220. data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
  221. data/lib/octo/ui2/thinking_verbs.rb +26 -0
  222. data/lib/octo/ui2/ui_controller.rb +1625 -0
  223. data/lib/octo/ui2/view_renderer.rb +177 -0
  224. data/lib/octo/ui2.rb +40 -0
  225. data/lib/octo/ui_interface.rb +154 -0
  226. data/lib/octo/utils/arguments_parser.rb +191 -0
  227. data/lib/octo/utils/browser_detector.rb +195 -0
  228. data/lib/octo/utils/encoding.rb +92 -0
  229. data/lib/octo/utils/environment_detector.rb +140 -0
  230. data/lib/octo/utils/file_ignore_helper.rb +170 -0
  231. data/lib/octo/utils/file_processor.rb +601 -0
  232. data/lib/octo/utils/gitignore_parser.rb +154 -0
  233. data/lib/octo/utils/limit_stack.rb +152 -0
  234. data/lib/octo/utils/logger.rb +124 -0
  235. data/lib/octo/utils/login_shell.rb +72 -0
  236. data/lib/octo/utils/model_pricing.rb +646 -0
  237. data/lib/octo/utils/parser_manager.rb +165 -0
  238. data/lib/octo/utils/path_helper.rb +15 -0
  239. data/lib/octo/utils/scripts_manager.rb +59 -0
  240. data/lib/octo/utils/string_matcher.rb +158 -0
  241. data/lib/octo/utils/trash_directory.rb +112 -0
  242. data/lib/octo/utils/workspace_rules.rb +46 -0
  243. data/lib/octo/version.rb +5 -0
  244. data/lib/octo/web/app.css +7141 -0
  245. data/lib/octo/web/app.js +543 -0
  246. data/lib/octo/web/apple-touch-icon.png +0 -0
  247. data/lib/octo/web/auth.js +150 -0
  248. data/lib/octo/web/channels.js +276 -0
  249. data/lib/octo/web/datepicker.js +205 -0
  250. data/lib/octo/web/favicon.png +0 -0
  251. data/lib/octo/web/i18n.js +1073 -0
  252. data/lib/octo/web/icon-512.png +0 -0
  253. data/lib/octo/web/icon-dark.svg +25 -0
  254. data/lib/octo/web/icon.svg +29 -0
  255. data/lib/octo/web/index.html +871 -0
  256. data/lib/octo/web/marked.min.js +69 -0
  257. data/lib/octo/web/onboard.js +491 -0
  258. data/lib/octo/web/profile.js +442 -0
  259. data/lib/octo/web/sessions.js +4421 -0
  260. data/lib/octo/web/settings.js +913 -0
  261. data/lib/octo/web/sidebar.js +32 -0
  262. data/lib/octo/web/skills.js +885 -0
  263. data/lib/octo/web/tasks.js +297 -0
  264. data/lib/octo/web/theme.js +105 -0
  265. data/lib/octo/web/trash.js +343 -0
  266. data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
  267. data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
  268. data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
  269. data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  270. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  271. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  272. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  273. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  274. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  275. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  276. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  277. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  278. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  279. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  280. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  281. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  282. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  283. data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  284. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  285. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  286. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  287. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  288. data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  289. data/lib/octo/web/vendor/katex/katex.min.css +1 -0
  290. data/lib/octo/web/vendor/katex/katex.min.js +1 -0
  291. data/lib/octo/web/version.js +449 -0
  292. data/lib/octo/web/weixin-qr.html +209 -0
  293. data/lib/octo/web/ws-dispatcher.js +357 -0
  294. data/lib/octo/web/ws.js +128 -0
  295. data/lib/octo.rb +145 -0
  296. data/scripts/build/build.sh +329 -0
  297. data/scripts/build/lib/apt.sh +56 -0
  298. data/scripts/build/lib/brew.sh +89 -0
  299. data/scripts/build/lib/colors.sh +17 -0
  300. data/scripts/build/lib/gem.sh +95 -0
  301. data/scripts/build/lib/mise.sh +125 -0
  302. data/scripts/build/lib/network.sh +157 -0
  303. data/scripts/build/lib/os.sh +57 -0
  304. data/scripts/build/lib/shell.sh +37 -0
  305. data/scripts/build/src/install.sh.cc +174 -0
  306. data/scripts/build/src/install_browser.sh.cc +101 -0
  307. data/scripts/build/src/install_full.sh.cc +290 -0
  308. data/scripts/build/src/install_rails_deps.sh.cc +145 -0
  309. data/scripts/build/src/install_system_deps.sh.cc +123 -0
  310. data/scripts/build/src/uninstall.sh.cc +101 -0
  311. data/scripts/install.ps1 +532 -0
  312. data/scripts/install.sh +567 -0
  313. data/scripts/install_browser.sh +479 -0
  314. data/scripts/install_full.sh +838 -0
  315. data/scripts/install_rails_deps.sh +746 -0
  316. data/scripts/install_system_deps.sh +518 -0
  317. data/scripts/uninstall.sh +287 -0
  318. data/sig/octo.rbs +4 -0
  319. metadata +614 -0
@@ -0,0 +1,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ class Agent
5
+ # Tool execution and permission management
6
+ # Handles tool confirmation, preview, and result building
7
+ module ToolExecutor
8
+ # Check if a tool should be auto-executed based on permission mode
9
+ # @param tool_name [String] Name of the tool
10
+ # @param tool_params [Hash, String] Tool parameters
11
+ # @return [Boolean] true if should auto-execute
12
+ def should_auto_execute?(tool_name, tool_params = {})
13
+ case @config.permission_mode
14
+ when :auto_approve, :confirm_all
15
+ # Both modes auto-execute all file/shell tools without confirmation.
16
+ # The difference is only in request_user_feedback handling:
17
+ # auto_approve → no human present, inject auto_reply
18
+ # confirm_all → human present, truly wait for user input
19
+ true
20
+ when :confirm_safes
21
+ # Use Security module to check auto-execution safety
22
+ is_safe_operation?(tool_name, tool_params)
23
+ else
24
+ false
25
+ end
26
+ end
27
+
28
+ # Check if an operation is considered safe for auto-execution
29
+ # @param tool_name [String] Name of the tool
30
+ # @param tool_params [Hash, String] Tool parameters
31
+ # @return [Boolean] true if safe operation
32
+ def is_safe_operation?(tool_name, tool_params = {})
33
+ # For terminal commands, defer to Security layer for the verdict.
34
+ if tool_name.to_s.downcase == 'terminal'
35
+ params = tool_params.is_a?(String) ? JSON.parse(tool_params) : tool_params
36
+ command = params[:command] || params['command']
37
+ # No command = session_id continuation / kill / action → safe by default.
38
+ return true unless command
39
+
40
+ return Octo::Tools::Security.command_safe_for_auto_execution?(command)
41
+ end
42
+
43
+ if tool_name.to_s.downcase == 'edit' || tool_name.to_s.downcase == 'write'
44
+ return false
45
+ end
46
+
47
+ true
48
+ end
49
+
50
+ # Request user confirmation for tool execution
51
+ # Shows preview and returns approval status
52
+ # @param call [Hash] Tool call with :name and :arguments
53
+ # @return [Hash] { approved: Boolean, feedback: String, system_injected: Boolean }
54
+ def confirm_tool_use?(call)
55
+ # Show preview first and check for errors
56
+ preview_error = show_tool_preview(call)
57
+
58
+ # If preview detected an error, auto-deny and provide feedback
59
+ if preview_error && preview_error[:error]
60
+ feedback = build_preview_error_feedback(call[:name], preview_error)
61
+ return { approved: false, feedback: feedback, system_injected: true }
62
+ end
63
+
64
+ # Request confirmation via UI
65
+ if @ui
66
+ prompt_text = format_tool_prompt(call)
67
+ result = @ui.request_confirmation(prompt_text, default: true)
68
+
69
+ case result
70
+ when true
71
+ { approved: true, feedback: nil }
72
+ when false, nil
73
+ # User denied - add visual marker based on tool type
74
+ tool_name_capitalized = call[:name].capitalize
75
+ @ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
76
+ { approved: false, feedback: nil }
77
+ else
78
+ # String feedback - also add visual marker
79
+ tool_name_capitalized = call[:name].capitalize
80
+ @ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
81
+ { approved: false, feedback: result.to_s }
82
+ end
83
+ else
84
+ # Fallback: auto-approve if no UI
85
+ { approved: true, feedback: nil }
86
+ end
87
+ end
88
+
89
+ # Show preview for tool execution
90
+ # @param call [Hash] Tool call with :name and :arguments
91
+ # @return [Hash, nil] Error information if preview detected issues
92
+ def show_tool_preview(call)
93
+ return nil unless @ui
94
+
95
+ begin
96
+ args = JSON.parse(call[:arguments], symbolize_names: true)
97
+
98
+ preview_error = nil
99
+ case call[:name]
100
+ when "write"
101
+ preview_error = show_write_preview(args)
102
+ when "edit"
103
+ preview_error = show_edit_preview(args)
104
+ # Shell and other tools don't need special preview
105
+ # They will be shown via show_tool_call in the main flow
106
+ end
107
+
108
+ preview_error
109
+ rescue JSON::ParserError
110
+ nil
111
+ end
112
+ end
113
+
114
+ # Format tool call for user confirmation prompt
115
+ # @param call [Hash] Tool call with :name and :arguments
116
+ # @return [String] Formatted prompt text
117
+ def format_tool_prompt(call)
118
+ begin
119
+ args = JSON.parse(call[:arguments], symbolize_names: true)
120
+
121
+ # Try to use tool's format_call method for better formatting
122
+ tool = @tool_registry.get(call[:name]) rescue nil
123
+ if tool
124
+ formatted = tool.format_call(args) rescue nil
125
+ return formatted if formatted
126
+ end
127
+
128
+ # Fallback to manual formatting for common tools
129
+ case call[:name]
130
+ when "edit"
131
+ path = args[:path] || args[:file_path]
132
+ filename = Utils::PathHelper.safe_basename(path)
133
+ "Edit(#{filename})"
134
+ when "write"
135
+ filename = Utils::PathHelper.safe_basename(args[:path])
136
+ if args[:path] && File.exist?(args[:path])
137
+ "Write(#{filename}) - overwrite existing"
138
+ else
139
+ "Write(#{filename}) - create new"
140
+ end
141
+ when "terminal"
142
+ cmd = args[:command] || ''
143
+ display_cmd = cmd.length > 30 ? "#{cmd[0..27]}..." : cmd
144
+ "terminal(\"#{display_cmd}\")"
145
+ else
146
+ "Allow #{call[:name]}"
147
+ end
148
+ rescue JSON::ParserError
149
+ "Allow #{call[:name]}"
150
+ end
151
+ end
152
+
153
+ # Build success result for tool execution
154
+ # @param call [Hash] Tool call
155
+ # @param result [Object] Tool execution result
156
+ # @return [Hash] Formatted result for LLM
157
+ def build_success_result(call, result)
158
+ # Try to get tool instance to use its format_result_for_llm method
159
+ tool = @tool_registry.get(call[:name]) rescue nil
160
+
161
+ formatted_result = if tool && tool.respond_to?(:format_result_for_llm)
162
+ # Tool provides a custom LLM-friendly format
163
+ tool.format_result_for_llm(result)
164
+ else
165
+ # Fallback: use the original result
166
+ result
167
+ end
168
+
169
+ # Inject TODO reminder for non-todo_manager tools
170
+ formatted_result = inject_todo_reminder(call[:name], formatted_result)
171
+
172
+ # Extract image_inject sidecar before building the tool content string.
173
+ # image_inject carries the base64 payload that must be delivered as a
174
+ # follow-up `role:"user"` message (OpenAI/OpenRouter/Gemini only accept
175
+ # image_url blocks in user messages, not in tool messages).
176
+ # Strip it from the content sent to the API so it isn't tokenised as text.
177
+ image_inject = nil
178
+ if formatted_result.is_a?(Hash) && formatted_result[:image_inject]
179
+ image_inject = formatted_result[:image_inject]
180
+ formatted_result = formatted_result.reject { |k, _| k == :image_inject }
181
+ if formatted_result[:content_string]
182
+ formatted_result = formatted_result[:content_string]
183
+ end
184
+ end
185
+
186
+ # If the tool returned a plain string, use it directly (avoids double-escaping).
187
+ # If it returned an Array (e.g. multipart vision blocks with image + text),
188
+ # pass it through as-is so format_tool_results can send it to the API.
189
+ # Otherwise JSON-encode Hash/other values.
190
+ content = if formatted_result.is_a?(String)
191
+ formatted_result
192
+ elsif formatted_result.is_a?(Array)
193
+ formatted_result
194
+ else
195
+ JSON.generate(formatted_result)
196
+ end
197
+
198
+ result = { id: call[:id], content: content }
199
+ result[:image_inject] = image_inject if image_inject
200
+ result
201
+ end
202
+
203
+ # Build error result for tool execution
204
+ # @param call [Hash] Tool call
205
+ # @param error_message [String] Error message
206
+ # @return [Hash] Formatted error result
207
+ def build_error_result(call, error_message)
208
+ {
209
+ id: call[:id],
210
+ content: JSON.generate({ error: error_message })
211
+ }
212
+ end
213
+
214
+ # Build denied result when user denies tool execution
215
+ # @param call [Hash] Tool call
216
+ # @param user_feedback [String, nil] User's feedback message
217
+ # @param system_injected [Boolean] Whether this is a system-generated denial
218
+ # @return [Hash] Formatted denial result
219
+ def build_denied_result(call, user_feedback = nil, system_injected = false)
220
+ if system_injected
221
+ # System-generated feedback (e.g., from preview errors)
222
+ tool_content = {
223
+ error: "Tool #{call[:name]} denied: #{user_feedback}",
224
+ system_injected: true
225
+ }
226
+ else
227
+ # User manually denied or provided feedback
228
+ # Clearly state the action was NOT performed so the LLM knows the change did not happen
229
+ message = if user_feedback && !user_feedback.empty?
230
+ "Tool use denied by user. This action was NOT performed. User feedback: #{user_feedback}"
231
+ else
232
+ "Tool use denied by user. This action was NOT performed."
233
+ end
234
+
235
+ tool_content = {
236
+ error: message,
237
+ action_performed: false,
238
+ user_feedback: user_feedback
239
+ }
240
+ end
241
+
242
+ {
243
+ id: call[:id],
244
+ content: JSON.generate(tool_content)
245
+ }
246
+ end
247
+
248
+ # Check if a tool is potentially slow and should show progress
249
+ # @param tool_name [String] Name of the tool
250
+ # @param args [Hash] Tool arguments
251
+ # @return [Boolean] true if tool is potentially slow
252
+ private def potentially_slow_tool?(tool_name, args)
253
+ case tool_name.to_s.downcase
254
+ when 'terminal'
255
+ # Check if the command is a slow command
256
+ command = args[:command] || args['command']
257
+ return false unless command
258
+
259
+ # List of slow command patterns
260
+ slow_patterns = [
261
+ /bundle\s+(install|exec\s+rspec|exec\s+rake)/,
262
+ /npm\s+(install|run\s+test|run\s+build)/,
263
+ /yarn\s+(install|test|build)/,
264
+ /pnpm\s+install/,
265
+ /cargo\s+(build|test)/,
266
+ /go\s+(build|test)/,
267
+ /make\s+(test|build)/,
268
+ /pytest/,
269
+ /jest/,
270
+ /sleep\s+\d+/
271
+ ]
272
+
273
+ slow_patterns.any? { |pattern| command.match?(pattern) }
274
+ when 'web_fetch', 'web_search'
275
+ true
276
+ else
277
+ false
278
+ end
279
+ end
280
+
281
+ private def build_tool_progress_message(tool_name, args)
282
+ case tool_name.to_s.downcase
283
+ when 'terminal'
284
+ "Running command"
285
+ when 'web_fetch'
286
+ "Fetching web page"
287
+ when 'web_search'
288
+ "Searching web"
289
+ else
290
+ "Executing #{tool_name}"
291
+ end
292
+ end
293
+
294
+ # Inject TODO reminder into tool results for non-todo_manager tools
295
+ # This helps AI remember to mark TODOs as complete after executing tasks
296
+ # @param tool_name [String] Name of the tool
297
+ # @param result [Object] Tool execution result
298
+ # @return [Object] Result with optional TODO reminder
299
+ private def inject_todo_reminder(tool_name, result)
300
+ # Skip injection for todo_manager tool itself to avoid redundancy
301
+ return result if tool_name == "todo_manager"
302
+
303
+ # Get pending TODOs
304
+ todo_tool = @tool_registry.get("todo_manager")
305
+ return result unless todo_tool
306
+
307
+ pending_todos = begin
308
+ todo_result = todo_tool.execute(action: "list", todos_storage: @todos)
309
+ if todo_result.is_a?(Hash) && todo_result[:todos]
310
+ todo_result[:todos].select { |t| t[:status] == "pending" }
311
+ else
312
+ []
313
+ end
314
+ rescue
315
+ []
316
+ end
317
+
318
+ # Only inject reminder if there are pending TODOs
319
+ return result unless pending_todos && !pending_todos.empty?
320
+
321
+ # Create a friendly reminder message
322
+ reminder = "\n\n📋 REMINDER: You have #{pending_todos.length} pending TODO(s). " \
323
+ "After completing each task, remember to mark it as complete using " \
324
+ "todo_manager with action 'complete' and the task id."
325
+
326
+ # Inject reminder based on result type
327
+ case result
328
+ when String
329
+ result + reminder
330
+ when Hash
331
+ result.merge({ _todo_reminder: reminder.strip })
332
+ when Array
333
+ result + [{ _todo_reminder: reminder.strip }]
334
+ else
335
+ result
336
+ end
337
+ end
338
+
339
+ # Build feedback message from preview error
340
+ # @param tool_name [String] Name of the tool
341
+ # @param error_info [Hash] Error information from preview
342
+ # @return [String] Feedback message
343
+ private def build_preview_error_feedback(tool_name, error_info)
344
+ case tool_name
345
+ when "edit"
346
+ "Tool edit denied: The edit operation will fail because the old_string was not found in the file. " \
347
+ "Please use file_reader to read '#{error_info[:path]}' first, " \
348
+ "find the correct string to replace, and try again with the exact string (including whitespace)."
349
+ else
350
+ "Tool preview error: #{error_info[:error]}"
351
+ end
352
+ end
353
+
354
+ # Show preview for write tool
355
+ # @param args [Hash] Write tool arguments
356
+ # @return [nil] Always returns nil (no errors for write)
357
+ private def show_write_preview(args)
358
+ path = args[:path] || args['path']
359
+ # Expand ~ to home directory so File.exist? works correctly
360
+ expanded_path = path&.start_with?("~") ? File.expand_path(path) : path
361
+ new_content = args[:content] || args['content'] || ""
362
+
363
+ is_new_file = !(expanded_path && File.exist?(expanded_path))
364
+ @ui&.show_file_write_preview(path, is_new_file: is_new_file)
365
+
366
+ if is_new_file
367
+ @ui&.show_diff("", new_content, max_lines: 50)
368
+ else
369
+ old_content = File.read(expanded_path)
370
+ old_content = old_content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}") unless old_content.encoding == Encoding::UTF_8 && old_content.valid_encoding?
371
+ @ui&.show_diff(old_content, new_content, max_lines: 50)
372
+ end
373
+ nil
374
+ end
375
+
376
+ # Show preview for edit tool
377
+ # @param args [Hash] Edit tool arguments
378
+ # @return [Hash, nil] Error information if preview detected issues
379
+ private def show_edit_preview(args)
380
+ path = args[:path] || args[:file_path] || args['path'] || args['file_path']
381
+ old_string = args[:old_string] || args['old_string'] || ""
382
+ new_string = args[:new_string] || args['new_string'] || ""
383
+ replace_all = args[:replace_all] || args['replace_all'] || false
384
+
385
+ # Expand ~ to home directory so File.exist? and File.read work correctly
386
+ expanded_path = path&.start_with?("~") ? File.expand_path(path) : path
387
+
388
+ @ui&.show_file_edit_preview(path)
389
+
390
+ if !expanded_path || expanded_path.empty?
391
+ @ui&.show_file_error("No file path provided")
392
+ return { error: "No file path provided for edit operation" }
393
+ end
394
+
395
+ unless File.exist?(expanded_path)
396
+ @ui&.show_file_error("File not found: #{path}")
397
+ return { error: "File not found: #{path}", path: path }
398
+ end
399
+
400
+ if old_string.empty?
401
+ @ui&.show_file_error("No old_string provided (nothing to replace)")
402
+ return { error: "No old_string provided (nothing to replace)" }
403
+ end
404
+
405
+ file_content = File.read(expanded_path)
406
+
407
+ # Use the same find_match logic as Edit tool to handle fuzzy matching
408
+ # (trim, unescape, smart line matching) — prevents diff from being blank
409
+ # when simple include? fails but Edit#execute's fuzzy match would succeed
410
+ match_result = Utils::StringMatcher.find_match(file_content, old_string)
411
+
412
+ unless match_result
413
+ # Log debug info for troubleshooting
414
+ @debug_logs << {
415
+ timestamp: Time.now.iso8601,
416
+ event: "edit_preview_failed",
417
+ path: path,
418
+ looking_for: old_string[0..500],
419
+ file_content_preview: file_content[0..1000],
420
+ file_size: file_content.length
421
+ }
422
+
423
+ @ui&.show_file_error("Edit file error")
424
+ return {
425
+ error: "String to replace not found in file",
426
+ path: path,
427
+ looking_for: old_string[0..200]
428
+ }
429
+ end
430
+
431
+ # Use the actual matched string (may differ via trim/unescape) for replacement
432
+ actual_old_string = match_result[:matched_string]
433
+
434
+ # Use the same replace logic as the actual tool execution
435
+ new_content = if replace_all
436
+ file_content.gsub(actual_old_string, new_string)
437
+ else
438
+ file_content.sub(actual_old_string, new_string)
439
+ end
440
+ @ui&.show_diff(file_content, new_content, max_lines: 50)
441
+ nil # No error
442
+ end
443
+
444
+ # Show preview for shell tool
445
+ # @param args [Hash] Shell tool arguments
446
+ # @return [nil] Always returns nil
447
+ private def show_shell_preview(args)
448
+ command = args[:command] || ""
449
+ @ui&.show_shell_preview(command)
450
+ nil
451
+ end
452
+ end
453
+ end
454
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ class ToolRegistry
5
+ # Common aliases that LLMs frequently use instead of the registered tool names.
6
+ # Keys are downcased aliases; values are the canonical registered names.
7
+ TOOL_ALIASES = {
8
+ # file_reader aliases
9
+ "read" => "file_reader",
10
+ "read_file" => "file_reader",
11
+ "filereader" => "file_reader",
12
+ "file_read" => "file_reader",
13
+ "cat" => "file_reader",
14
+ # write aliases
15
+ "write_file" => "write",
16
+ "create_file" => "write",
17
+ "file_write" => "write",
18
+ # edit aliases
19
+ "file_edit" => "edit",
20
+ "replace" => "edit",
21
+ "replace_in_file" => "edit",
22
+ "str_replace" => "edit",
23
+ # terminal aliases
24
+ "shell" => "terminal",
25
+ "bash" => "terminal",
26
+ "exec" => "terminal",
27
+ "execute" => "terminal",
28
+ "run_command" => "terminal",
29
+ "run" => "terminal",
30
+ "command" => "terminal",
31
+ # web_search aliases
32
+ "search" => "web_search",
33
+ "websearch" => "web_search",
34
+ "internet_search" => "web_search",
35
+ "online_search" => "web_search",
36
+ # web_fetch aliases
37
+ "fetch" => "web_fetch",
38
+ "webfetch" => "web_fetch",
39
+ "browse" => "web_fetch",
40
+ "url_fetch" => "web_fetch",
41
+ "http_get" => "web_fetch",
42
+ # grep aliases
43
+ "search_files" => "grep",
44
+ "search_in_files" => "grep",
45
+ "find_in_files" => "grep",
46
+ "search_code" => "grep",
47
+ # glob aliases
48
+ "find_files" => "glob",
49
+ "list_files" => "glob",
50
+ "file_glob" => "glob",
51
+ "search_filenames" => "glob",
52
+ # invoke_skill aliases
53
+ "skill" => "invoke_skill",
54
+ "run_skill" => "invoke_skill",
55
+ # todo_manager aliases
56
+ "todo" => "todo_manager",
57
+ "task_manager" => "todo_manager",
58
+ # request_user_feedback aliases
59
+ "ask_user" => "request_user_feedback",
60
+ "user_feedback" => "request_user_feedback",
61
+ "ask" => "request_user_feedback",
62
+ # undo_task aliases
63
+ "undo" => "undo_task",
64
+ # redo_task aliases
65
+ "redo" => "redo_task",
66
+ # list_tasks aliases
67
+ "tasks" => "list_tasks",
68
+ "task_history" => "list_tasks",
69
+ # trash_manager aliases
70
+ "trash" => "trash_manager",
71
+ "delete" => "trash_manager",
72
+ "rm" => "trash_manager",
73
+ "remove" => "trash_manager",
74
+ }.freeze
75
+
76
+ def initialize
77
+ @tools = {}
78
+ # Downcased index for case-insensitive lookups
79
+ @downcased_index = {}
80
+ end
81
+
82
+ def register(tool)
83
+ @tools[tool.name] = tool
84
+ @downcased_index[tool.name.downcase] = tool.name
85
+ end
86
+
87
+ def get(name)
88
+ @tools[name] || raise(Octo::ToolCallError, "Tool not found: #{name}")
89
+ end
90
+
91
+ # Resolve a tool name (possibly misspelt or aliased) to the canonical
92
+ # registered name. Resolution order:
93
+ # 1. Exact match in the registry
94
+ # 2. Case-insensitive match (e.g. "Read" → "file_reader")
95
+ # 3. Alias lookup (e.g. "read_file" → "file_reader")
96
+ # Returns the canonical tool name, or nil if nothing matched.
97
+ def resolve(name)
98
+ return name if @tools.key?(name)
99
+
100
+ downcased = name.downcase
101
+
102
+ # Case-insensitive match
103
+ if @downcased_index.key?(downcased)
104
+ return @downcased_index[downcased]
105
+ end
106
+
107
+ # Alias lookup
108
+ if TOOL_ALIASES.key?(downcased)
109
+ return TOOL_ALIASES[downcased]
110
+ end
111
+
112
+ # Fuzzy: try underscore / hyphen normalisation (e.g. "file-reader" → "file_reader")
113
+ normalized = downcased.tr("-", "_")
114
+ if normalized != downcased
115
+ if @downcased_index.key?(normalized)
116
+ return @downcased_index[normalized]
117
+ end
118
+ if TOOL_ALIASES.key?(normalized)
119
+ return TOOL_ALIASES[normalized]
120
+ end
121
+ end
122
+
123
+ nil
124
+ end
125
+
126
+ def all
127
+ @tools.values
128
+ end
129
+
130
+ def all_definitions
131
+ @tools.values.map(&:to_function_definition)
132
+ end
133
+
134
+ def allowed_definitions(allowed_tools = nil)
135
+ return all_definitions if allowed_tools.nil? || allowed_tools.include?("all")
136
+
137
+ @tools.select { |name, _| allowed_tools.include?(name) }
138
+ .values
139
+ .map(&:to_function_definition)
140
+ end
141
+
142
+ def tool_names
143
+ @tools.keys
144
+ end
145
+
146
+ def by_category(category)
147
+ @tools.values.select { |tool| tool.category == category }
148
+ end
149
+ end
150
+ end