anima-core 0.2.1 → 1.0.0

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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +213 -43
  5. data/agents/codebase-analyzer.md +88 -0
  6. data/agents/codebase-pattern-finder.md +83 -0
  7. data/agents/documentation-researcher.md +59 -0
  8. data/agents/thoughts-analyzer.md +102 -0
  9. data/agents/web-search-researcher.md +71 -0
  10. data/anima-core.gemspec +3 -0
  11. data/app/channels/session_channel.rb +195 -45
  12. data/app/decorators/user_message_decorator.rb +16 -5
  13. data/app/jobs/agent_request_job.rb +55 -2
  14. data/app/jobs/analytical_brain_job.rb +33 -0
  15. data/app/jobs/count_event_tokens_job.rb +15 -4
  16. data/app/models/concerns/event/broadcasting.rb +81 -0
  17. data/app/models/event.rb +20 -1
  18. data/app/models/goal.rb +91 -0
  19. data/app/models/session.rb +366 -21
  20. data/config/application.rb +2 -0
  21. data/config/initializers/event_subscribers.rb +0 -1
  22. data/config/routes.rb +0 -6
  23. data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
  24. data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
  25. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  26. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  27. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  28. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  29. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  30. data/db/migrate/20260315140843_create_goals.rb +16 -0
  31. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  32. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  33. data/lib/agent_loop.rb +65 -6
  34. data/lib/agents/definition.rb +116 -0
  35. data/lib/agents/registry.rb +106 -0
  36. data/lib/analytical_brain/runner.rb +276 -0
  37. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  38. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  39. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  40. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  41. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  42. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  43. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  44. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  45. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  46. data/lib/analytical_brain.rb +23 -0
  47. data/lib/anima/cli/mcp/secrets.rb +76 -0
  48. data/lib/anima/cli/mcp.rb +197 -0
  49. data/lib/anima/cli.rb +5 -40
  50. data/lib/anima/installer.rb +168 -0
  51. data/lib/anima/settings.rb +226 -0
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +9 -0
  54. data/lib/credential_store.rb +103 -0
  55. data/lib/environment_probe.rb +232 -0
  56. data/lib/events/subscribers/persister.rb +1 -0
  57. data/lib/events/user_message.rb +17 -0
  58. data/lib/llm/client.rb +29 -10
  59. data/lib/mcp/client_manager.rb +86 -0
  60. data/lib/mcp/config.rb +213 -0
  61. data/lib/mcp/health_check.rb +77 -0
  62. data/lib/mcp/secrets.rb +73 -0
  63. data/lib/mcp/stdio_transport.rb +206 -0
  64. data/lib/providers/anthropic.rb +11 -20
  65. data/lib/shell_session.rb +11 -10
  66. data/lib/skills/definition.rb +97 -0
  67. data/lib/skills/registry.rb +105 -0
  68. data/lib/tools/edit.rb +226 -0
  69. data/lib/tools/mcp_tool.rb +114 -0
  70. data/lib/tools/read.rb +151 -0
  71. data/lib/tools/registry.rb +14 -12
  72. data/lib/tools/request_feature.rb +121 -0
  73. data/lib/tools/return_result.rb +81 -0
  74. data/lib/tools/spawn_specialist.rb +109 -0
  75. data/lib/tools/spawn_subagent.rb +111 -0
  76. data/lib/tools/subagent_prompts.rb +12 -0
  77. data/lib/tools/web_get.rb +8 -9
  78. data/lib/tools/write.rb +86 -0
  79. data/lib/tui/app.rb +985 -26
  80. data/lib/tui/cable_client.rb +69 -31
  81. data/lib/tui/message_store.rb +103 -8
  82. data/lib/tui/screens/chat.rb +293 -45
  83. data/lib/workflows/definition.rb +97 -0
  84. data/lib/workflows/registry.rb +89 -0
  85. data/skills/activerecord/SKILL.md +255 -0
  86. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  87. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  88. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  89. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  90. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  91. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  92. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  93. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  94. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  95. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  96. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  97. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  98. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  99. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  100. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  101. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  102. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  103. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  104. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  105. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  106. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  107. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  108. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  109. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  110. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  111. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  112. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  113. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  114. data/skills/activerecord/references/associations.md +709 -0
  115. data/skills/activerecord/references/basics.md +622 -0
  116. data/skills/activerecord/references/callbacks.md +738 -0
  117. data/skills/activerecord/references/migrations.md +657 -0
  118. data/skills/activerecord/references/querying.md +655 -0
  119. data/skills/activerecord/references/validations.md +596 -0
  120. data/skills/dragonruby/SKILL.md +250 -0
  121. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  122. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  123. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  124. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  125. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  126. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  127. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  128. data/skills/dragonruby/examples/core/labels.rb +22 -0
  129. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  130. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  131. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  132. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  133. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  134. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  135. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  136. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  137. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  138. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  139. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  140. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  141. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  142. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  143. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  144. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  145. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  146. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  147. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  148. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  149. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  150. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  151. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  152. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  153. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  154. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  155. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  156. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  157. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  158. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  159. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  160. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  161. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  162. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  163. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  164. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  165. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  166. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  167. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  168. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  169. data/skills/dragonruby/references/audio.md +396 -0
  170. data/skills/dragonruby/references/core.md +385 -0
  171. data/skills/dragonruby/references/distribution.md +434 -0
  172. data/skills/dragonruby/references/entities.md +516 -0
  173. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  174. data/skills/dragonruby/references/game-logic/state.md +389 -0
  175. data/skills/dragonruby/references/input.md +414 -0
  176. data/skills/dragonruby/references/rendering/animation.md +467 -0
  177. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  178. data/skills/dragonruby/references/scenes.md +443 -0
  179. data/skills/draper-decorators/SKILL.md +344 -0
  180. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  181. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  182. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  183. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  184. data/skills/draper-decorators/references/patterns.md +507 -0
  185. data/skills/draper-decorators/references/testing.md +559 -0
  186. data/skills/gh-issue.md +182 -0
  187. data/skills/mcp-server/SKILL.md +177 -0
  188. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  189. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  190. data/skills/mcp-server/examples/http_client.rb +48 -0
  191. data/skills/mcp-server/examples/http_server.rb +97 -0
  192. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  193. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  194. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  195. data/skills/mcp-server/references/gotchas.md +183 -0
  196. data/skills/mcp-server/references/prompts.md +98 -0
  197. data/skills/mcp-server/references/resources.md +53 -0
  198. data/skills/mcp-server/references/server.md +140 -0
  199. data/skills/mcp-server/references/tools.md +146 -0
  200. data/skills/mcp-server/references/transport.md +104 -0
  201. data/skills/ratatui-ruby/SKILL.md +315 -0
  202. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  203. data/skills/ratatui-ruby/references/events.md +387 -0
  204. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  205. data/skills/ratatui-ruby/references/layout.md +423 -0
  206. data/skills/ratatui-ruby/references/styling.md +268 -0
  207. data/skills/ratatui-ruby/references/testing.md +433 -0
  208. data/skills/ratatui-ruby/references/widgets.md +532 -0
  209. data/skills/rspec/SKILL.md +340 -0
  210. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  211. data/skills/rspec/examples/core/configuration.rb +126 -0
  212. data/skills/rspec/examples/core/hooks.rb +126 -0
  213. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  214. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  215. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  216. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  217. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  218. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  219. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  220. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  221. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  222. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  223. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  224. data/skills/rspec/examples/matchers/change.rb +115 -0
  225. data/skills/rspec/examples/matchers/collections.rb +154 -0
  226. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  227. data/skills/rspec/examples/matchers/composing.rb +155 -0
  228. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  229. data/skills/rspec/examples/matchers/equality.rb +58 -0
  230. data/skills/rspec/examples/matchers/errors.rb +136 -0
  231. data/skills/rspec/examples/matchers/output.rb +103 -0
  232. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  233. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  234. data/skills/rspec/examples/matchers/types.rb +82 -0
  235. data/skills/rspec/examples/matchers/yield.rb +147 -0
  236. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  237. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  238. data/skills/rspec/examples/mocks/constants.rb +177 -0
  239. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  240. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  241. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  242. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  243. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  244. data/skills/rspec/examples/mocks/responses.rb +223 -0
  245. data/skills/rspec/examples/mocks/spies.rb +149 -0
  246. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  247. data/skills/rspec/examples/rails/channels.rb +250 -0
  248. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  249. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  250. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  251. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  252. data/skills/rspec/examples/rails/matchers.rb +374 -0
  253. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  254. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  255. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  256. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  257. data/skills/rspec/examples/rails/transactions.rb +254 -0
  258. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  259. data/skills/rspec/references/core.md +816 -0
  260. data/skills/rspec/references/factory_bot.md +641 -0
  261. data/skills/rspec/references/matchers.md +516 -0
  262. data/skills/rspec/references/mocks.md +381 -0
  263. data/skills/rspec/references/rails.md +528 -0
  264. data/templates/soul.md +40 -0
  265. data/workflows/commit.md +45 -0
  266. data/workflows/create_handoff.md +98 -0
  267. data/workflows/create_note.md +82 -0
  268. data/workflows/create_plan.md +457 -0
  269. data/workflows/decompose_ticket.md +109 -0
  270. data/workflows/feature.md +91 -0
  271. data/workflows/implement_plan.md +87 -0
  272. data/workflows/iterate_plan.md +247 -0
  273. data/workflows/research_codebase.md +210 -0
  274. data/workflows/resume_handoff.md +217 -0
  275. data/workflows/review_pr.md +320 -0
  276. data/workflows/thoughts_init.md +71 -0
  277. data/workflows/validate_plan.md +166 -0
  278. metadata +290 -3
  279. data/app/controllers/api/sessions_controller.rb +0 -25
  280. data/lib/events/subscribers/action_cable_bridge.rb +0 -59
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Tools
6
+ # Creates a GitHub issue via the +gh+ CLI, letting the agent request
7
+ # capabilities it discovers are missing during real work. Every issue
8
+ # is tagged with the label from +[github] label+ in +config.toml+ so
9
+ # the developer can filter agent-originated requests from human ones.
10
+ #
11
+ # The repository is read from +[github] repo+ in +config.toml+; when
12
+ # unset, the tool falls back to parsing the +origin+ remote URL.
13
+ #
14
+ # @see https://github.com/hoblin/anima/issues/103
15
+ class RequestFeature < Base
16
+ # @return [String] tool identifier used in the Anthropic API schema
17
+ def self.tool_name = "request_feature"
18
+
19
+ # @return [String] motivational description shown to the LLM
20
+ def self.description
21
+ "Don't have the right tool for this task? Request it! " \
22
+ "Creates a GitHub issue so the developer knows what you need."
23
+ end
24
+
25
+ # @return [Hash] JSON Schema for the tool's input parameters
26
+ def self.input_schema
27
+ {
28
+ type: "object",
29
+ properties: {
30
+ title: {type: "string", description: "Short, descriptive title for the feature request"},
31
+ description: {type: "string", description: "What you need and why — what were you trying to do, and what's missing?"}
32
+ },
33
+ required: %w[title description]
34
+ }
35
+ end
36
+
37
+ # @param input [Hash<String, Object>] with +"title"+ and +"description"+ keys
38
+ # @return [String] formatted gh command output (stdout, stderr, and exit code if non-zero)
39
+ # @return [Hash{Symbol => String}] with +:error+ key on validation or repo resolution failure
40
+ def execute(input)
41
+ title = input["title"].to_s.strip
42
+ description = input["description"].to_s.strip
43
+ return {error: "Title cannot be blank"} if title.empty?
44
+ return {error: "Description cannot be blank"} if description.empty?
45
+
46
+ repo = resolve_repo
47
+ return repo if repo.is_a?(Hash)
48
+
49
+ run_gh(repo, title, description)
50
+ end
51
+
52
+ private
53
+
54
+ # Resolves the target repository: config.toml setting first, then git remote origin.
55
+ # @return [String] owner/repo identifier
56
+ # @return [Hash{Symbol => String}] error hash when no repository can be determined
57
+ def resolve_repo
58
+ repo = settings_repo || git_remote_repo
59
+ return {error: "Cannot determine repository. Set [github] repo in config.toml or ensure a git remote origin exists."} unless repo
60
+
61
+ repo
62
+ end
63
+
64
+ # @return [String, nil] repo from config.toml, nil when not configured
65
+ def settings_repo
66
+ value = Anima::Settings.github_repo
67
+ value unless value.to_s.strip.empty?
68
+ rescue Anima::Settings::MissingSettingError
69
+ nil
70
+ end
71
+
72
+ # @return [String, nil] owner/repo parsed from +git remote get-url origin+
73
+ def git_remote_repo
74
+ url, _status = Open3.capture2("git", "remote", "get-url", "origin")
75
+ parse_owner_repo(url.strip)
76
+ rescue Errno::ENOENT
77
+ nil
78
+ end
79
+
80
+ # Extracts +owner/repo+ from common GitHub remote URL formats.
81
+ # @param url [String] SSH or HTTPS remote URL
82
+ # @return [String, nil] owner/repo or nil when the URL is not recognizable
83
+ def parse_owner_repo(url)
84
+ case url
85
+ when %r{github\.com[:/]([^/]+/[^/]+?)(?:\.git)?$}
86
+ Regexp.last_match(1)
87
+ end
88
+ end
89
+
90
+ # Invokes +gh issue create+ and returns the formatted output.
91
+ # @param repo [String] owner/repo identifier
92
+ # @param title [String] issue title
93
+ # @param description [String] issue body
94
+ # @return [String] formatted command output
95
+ def run_gh(repo, title, description)
96
+ stdout, stderr, status = Open3.capture3(
97
+ "gh", "issue", "create",
98
+ "--repo", repo,
99
+ "--label", Anima::Settings.github_label,
100
+ "--title", title,
101
+ "--body", description
102
+ )
103
+ format_result(stdout, stderr, status.exitstatus)
104
+ end
105
+
106
+ # Combines stdout, stderr, and exit code into a single string response.
107
+ # @param stdout [String] captured standard output
108
+ # @param stderr [String] captured standard error
109
+ # @param exit_code [Integer] process exit status
110
+ # @return [String] joined non-empty parts separated by blank lines
111
+ def format_result(stdout, stderr, exit_code)
112
+ out = stdout.strip
113
+ err = stderr.strip
114
+ parts = []
115
+ parts << out unless out.empty?
116
+ parts << err unless err.empty?
117
+ parts << "exit_code: #{exit_code}" unless exit_code.zero?
118
+ parts.join("\n\n")
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Sub-agent-only tool that delivers a completed result back to the
5
+ # parent session as a tool_call/tool_response pair. The parent agent
6
+ # sees it as if it called a tool itself — no custom event types needed.
7
+ #
8
+ # Never registered for main sessions — only sub-agents see this tool.
9
+ class ReturnResult < Base
10
+ def self.tool_name = "return_result"
11
+
12
+ def self.description = "Return your completed result to the parent agent. " \
13
+ "Call this when you have fulfilled the assigned task."
14
+
15
+ def self.input_schema
16
+ {
17
+ type: "object",
18
+ properties: {
19
+ result: {
20
+ type: "string",
21
+ description: "The completed deliverable to send back to the parent agent"
22
+ }
23
+ },
24
+ required: ["result"]
25
+ }
26
+ end
27
+
28
+ # @param session [Session] the sub-agent session returning a result
29
+ def initialize(session:, **)
30
+ @session = session
31
+ end
32
+
33
+ # Emits a tool_call/tool_response pair in the parent session so the
34
+ # parent agent sees the sub-agent result as a regular tool interaction.
35
+ #
36
+ # @param input [Hash<String, Object>] with "result" key
37
+ # @return [String, Hash] confirmation message, or Hash with :error key on failure
38
+ def execute(input)
39
+ result = input["result"].to_s.strip
40
+ return {error: "Result cannot be blank"} if result.empty?
41
+
42
+ parent = @session.parent_session
43
+ return {error: "No parent session — only sub-agents can return results"} unless parent
44
+
45
+ tool_use_id = "toolu_subagent_#{@session.id}"
46
+ task = extract_task
47
+ # Specialists are spawned with a name from the registry; generic sub-agents have nil name.
48
+ origin_tool = @session.name ? SpawnSpecialist.tool_name : SpawnSubagent.tool_name
49
+
50
+ Events::Bus.emit(Events::ToolCall.new(
51
+ content: "Sub-agent result (session #{@session.id})",
52
+ tool_name: origin_tool,
53
+ tool_input: {"task" => task, "session_id" => @session.id},
54
+ tool_use_id: tool_use_id,
55
+ session_id: parent.id
56
+ ))
57
+
58
+ Events::Bus.emit(Events::ToolResponse.new(
59
+ content: result,
60
+ tool_name: origin_tool,
61
+ tool_use_id: tool_use_id,
62
+ session_id: parent.id
63
+ ))
64
+
65
+ "Result delivered to parent session #{parent.id}."
66
+ end
67
+
68
+ private
69
+
70
+ # Extracts the original task from the sub-agent's first user message.
71
+ # @return [String]
72
+ def extract_task
73
+ @session.events
74
+ .where(event_type: "user_message")
75
+ .order(:id)
76
+ .pick(:payload)
77
+ &.dig("content")
78
+ .to_s
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Spawns a named specialist sub-agent from the agent registry.
5
+ # The specialist has a predefined system prompt and tool set defined
6
+ # in its Markdown definition file under agents/.
7
+ #
8
+ # Results are delivered back through {Tools::ReturnResult}.
9
+ #
10
+ # @see Agents::Registry
11
+ # @see Agents::Definition
12
+ class SpawnSpecialist < Base
13
+ include SubagentPrompts
14
+
15
+ def self.tool_name = "spawn_specialist"
16
+
17
+ # Builds description dynamically to include available specialists.
18
+ def self.description
19
+ base = "Spawn a named specialist sub-agent to work on a task autonomously. " \
20
+ "The specialist has a predefined role, system prompt, and tool set."
21
+
22
+ registry = Agents::Registry.instance
23
+ return base unless registry.any?
24
+
25
+ specialist_list = registry.catalog.map { |name, desc| "- #{name}: #{desc}" }.join("\n")
26
+ "#{base}\n\nAvailable specialists:\n#{specialist_list}"
27
+ end
28
+
29
+ # Builds input schema dynamically to include named agent enum.
30
+ def self.input_schema
31
+ {
32
+ type: "object",
33
+ properties: {
34
+ name: name_property,
35
+ task: {
36
+ type: "string",
37
+ description: "What the specialist should do (emitted as its first user message)"
38
+ },
39
+ expected_output: {
40
+ type: "string",
41
+ description: "Description of the expected deliverable"
42
+ }
43
+ },
44
+ required: %w[name task expected_output]
45
+ }
46
+ end
47
+
48
+ # @return [Hash] JSON Schema property for the name parameter
49
+ def self.name_property
50
+ registry = Agents::Registry.instance
51
+ prop = {
52
+ type: "string",
53
+ description: "Named specialist agent to spawn from the registry."
54
+ }
55
+ prop[:enum] = registry.names if registry.any?
56
+ prop
57
+ end
58
+
59
+ private_class_method :name_property
60
+
61
+ # @param session [Session] the parent session spawning the specialist
62
+ # @param agent_registry [Agents::Registry, nil] injectable for testing
63
+ def initialize(session:, agent_registry: nil, **)
64
+ @session = session
65
+ @agent_registry = agent_registry || Agents::Registry.instance
66
+ end
67
+
68
+ # Creates a child session with the specialist's predefined prompt and tools,
69
+ # emits the task as a user message, and queues background processing.
70
+ #
71
+ # @param input [Hash<String, Object>] with "name", "task", and "expected_output"
72
+ # @return [String] confirmation with child session ID
73
+ # @return [Hash{Symbol => String}] with :error key on validation failure
74
+ def execute(input)
75
+ task = input["task"].to_s.strip
76
+ expected_output = input["expected_output"].to_s.strip
77
+ name = input["name"].to_s.strip
78
+
79
+ return {error: "Name cannot be blank"} if name.empty?
80
+ return {error: "Task cannot be blank"} if task.empty?
81
+ return {error: "Expected output cannot be blank"} if expected_output.empty?
82
+
83
+ definition = @agent_registry.get(name)
84
+ return {error: "Unknown agent: #{name}"} unless definition
85
+
86
+ child = spawn_child(definition, task, expected_output)
87
+ "Specialist '#{name}' spawned (session #{child.id}). Result will arrive as a tool response."
88
+ end
89
+
90
+ private
91
+
92
+ def spawn_child(definition, task, expected_output)
93
+ prompt = build_prompt(definition, expected_output)
94
+ child = Session.create!(
95
+ parent_session_id: @session.id,
96
+ prompt: prompt,
97
+ granted_tools: definition.tools,
98
+ name: definition.name
99
+ )
100
+ Events::Bus.emit(Events::UserMessage.new(content: task, session_id: child.id))
101
+ AgentRequestJob.perform_later(child.id)
102
+ child
103
+ end
104
+
105
+ def build_prompt(definition, expected_output)
106
+ "#{definition.prompt}\n\n#{RETURN_INSTRUCTION}\n\n#{EXPECTED_DELIVERABLE_PREFIX}#{expected_output}"
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Spawns a generic child session that works on a task autonomously.
5
+ # The sub-agent inherits the parent's viewport context at fork time,
6
+ # runs via {AgentRequestJob}, and delivers results back
7
+ # through {Tools::ReturnResult}.
8
+ #
9
+ # For named specialists with predefined prompts and tools, see {SpawnSpecialist}.
10
+ class SpawnSubagent < Base
11
+ include SubagentPrompts
12
+
13
+ GENERIC_PROMPT = "You are a focused sub-agent. #{RETURN_INSTRUCTION}\n"
14
+
15
+ def self.tool_name = "spawn_subagent"
16
+
17
+ def self.description
18
+ "Spawn a generic sub-agent to work on a task autonomously. " \
19
+ "The sub-agent inherits your conversation context, works independently, " \
20
+ "and returns results as a tool response when done."
21
+ end
22
+
23
+ def self.input_schema
24
+ {
25
+ type: "object",
26
+ properties: {
27
+ task: {
28
+ type: "string",
29
+ description: "What the sub-agent should do (emitted as its first user message)"
30
+ },
31
+ expected_output: {
32
+ type: "string",
33
+ description: "Description of the expected deliverable"
34
+ },
35
+ tools: {
36
+ type: "array",
37
+ items: {type: "string"},
38
+ description: "Tool names to grant the sub-agent. " \
39
+ "Omit for all standard tools. Empty array for pure reasoning (return_result only). " \
40
+ "Valid tools: #{AgentLoop::STANDARD_TOOLS_BY_NAME.keys.join(", ")}"
41
+ }
42
+ },
43
+ required: %w[task expected_output]
44
+ }
45
+ end
46
+
47
+ # @param session [Session] the parent session spawning the sub-agent
48
+ def initialize(session:, **)
49
+ @session = session
50
+ end
51
+
52
+ # Creates a child session, emits the task as a user message, and
53
+ # queues background processing. Returns immediately (non-blocking).
54
+ #
55
+ # @param input [Hash<String, Object>] with "task", "expected_output", and optional "tools"
56
+ # @return [String] confirmation with child session ID
57
+ # @return [Hash{Symbol => String}] with :error key on validation failure
58
+ def execute(input)
59
+ task = input["task"].to_s.strip
60
+ expected_output = input["expected_output"].to_s.strip
61
+
62
+ return {error: "Task cannot be blank"} if task.empty?
63
+ return {error: "Expected output cannot be blank"} if expected_output.empty?
64
+
65
+ tools = normalize_tools(input["tools"])
66
+
67
+ error = validate_tools(tools)
68
+ return error if error
69
+
70
+ child = spawn_child(task, expected_output, tools)
71
+ "Sub-agent spawned (session #{child.id}). Result will arrive as a tool response."
72
+ end
73
+
74
+ private
75
+
76
+ def spawn_child(task, expected_output, granted_tools)
77
+ child = Session.create!(
78
+ parent_session_id: @session.id,
79
+ prompt: "#{GENERIC_PROMPT}\n#{EXPECTED_DELIVERABLE_PREFIX}#{expected_output}",
80
+ granted_tools: granted_tools
81
+ )
82
+ Events::Bus.emit(Events::UserMessage.new(content: task, session_id: child.id))
83
+ AgentRequestJob.perform_later(child.id)
84
+ child
85
+ end
86
+
87
+ # Normalizes tool names to lowercase and removes duplicates.
88
+ # Returns non-array values unchanged for {#validate_tools} to catch.
89
+ #
90
+ # @param tools [Array, nil, Object] raw tools parameter from LLM
91
+ # @return [Array<String>, nil, Object] normalized tools
92
+ def normalize_tools(tools)
93
+ return nil unless tools
94
+ return tools unless tools.is_a?(Array)
95
+
96
+ tools.map { |tool| tool.to_s.downcase }.uniq
97
+ end
98
+
99
+ # @param tools [Array<String>, nil, Object] normalized tools parameter
100
+ # @return [Hash{Symbol => String}, nil] error hash if invalid, nil if valid
101
+ def validate_tools(tools)
102
+ return nil unless tools
103
+ return {error: "tools must be an array"} unless tools.is_a?(Array)
104
+
105
+ unknown = tools - AgentLoop::STANDARD_TOOLS_BY_NAME.keys
106
+ return {error: "Unknown tool: #{unknown.first}"} if unknown.any?
107
+
108
+ nil
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Shared prompt fragments for tools that spawn sub-agent sessions.
5
+ # Included by {SpawnSubagent} and {SpawnSpecialist} to avoid duplication.
6
+ module SubagentPrompts
7
+ RETURN_INSTRUCTION = "Complete the assigned task, then call the return_result tool with your deliverable. " \
8
+ "Do not ask follow-up questions — work with the context you have."
9
+
10
+ EXPECTED_DELIVERABLE_PREFIX = "Expected deliverable: "
11
+ end
12
+ end
data/lib/tools/web_get.rb CHANGED
@@ -4,13 +4,10 @@ require "httparty"
4
4
 
5
5
  module Tools
6
6
  # Fetches content from a URL via HTTP GET. Returns the response body
7
- # as plain text, truncated to {MAX_RESPONSE_BYTES} to prevent memory issues.
7
+ # as plain text, truncated to {Anima::Settings.max_web_response_bytes} to prevent memory issues.
8
8
  #
9
9
  # Only http and https schemes are allowed.
10
10
  class WebGet < Base
11
- MAX_RESPONSE_BYTES = 100_000
12
- REQUEST_TIMEOUT = 10
13
-
14
11
  def self.tool_name = "web_get"
15
12
 
16
13
  def self.description = "Fetch content from a URL via HTTP GET and return the response body"
@@ -35,17 +32,18 @@ module Tools
35
32
  private
36
33
 
37
34
  def validate_and_fetch(url)
35
+ timeout = Anima::Settings.web_request_timeout
38
36
  scheme = URI.parse(url).scheme
39
37
 
40
38
  unless %w[http https].include?(scheme)
41
39
  return {error: "Only http and https URLs are supported, got: #{scheme.inspect}"}
42
40
  end
43
41
 
44
- truncate_body(HTTParty.get(url, timeout: REQUEST_TIMEOUT, follow_redirects: false).body.to_s)
42
+ truncate_body(HTTParty.get(url, timeout: timeout, follow_redirects: false).body.to_s)
45
43
  rescue URI::InvalidURIError => error
46
44
  {error: "Invalid URL: #{error.message}"}
47
45
  rescue Net::OpenTimeout, Net::ReadTimeout
48
- {error: "Request timed out after #{REQUEST_TIMEOUT} seconds"}
46
+ {error: "Request timed out after #{timeout} seconds"}
49
47
  rescue Errno::ECONNREFUSED
50
48
  {error: "Connection refused: #{url}"}
51
49
  rescue => error
@@ -53,10 +51,11 @@ module Tools
53
51
  end
54
52
 
55
53
  def truncate_body(body)
56
- return body if body.bytesize <= MAX_RESPONSE_BYTES
54
+ max_bytes = Anima::Settings.max_web_response_bytes
55
+ return body if body.bytesize <= max_bytes
57
56
 
58
- body.byteslice(0, MAX_RESPONSE_BYTES) +
59
- "\n\n[Truncated: response exceeded #{MAX_RESPONSE_BYTES} bytes]"
57
+ body.byteslice(0, max_bytes) +
58
+ "\n\n[Truncated: response exceeded #{max_bytes} bytes]"
60
59
  end
61
60
  end
62
61
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Tools
6
+ # Creates or overwrites files with automatic intermediate directory creation.
7
+ # Writes content exactly as given — no line ending normalization, no BOM
8
+ # handling. Full replacement only; no append or merge.
9
+ #
10
+ # @example Creating a new file
11
+ # tool.execute("path" => "config/new.yml", "content" => "key: value\n")
12
+ # # => "Wrote 11 bytes to /home/user/project/config/new.yml"
13
+ #
14
+ # @example Overwriting an existing file
15
+ # tool.execute("path" => "README.md", "content" => "# Title\n")
16
+ # # => "Wrote 9 bytes to /home/user/project/README.md"
17
+ class Write < Base
18
+ def self.tool_name = "write"
19
+
20
+ def self.description = "Create or overwrite a file. Creates intermediate directories automatically. Use for new files or full replacement."
21
+
22
+ def self.input_schema
23
+ {
24
+ type: "object",
25
+ properties: {
26
+ path: {type: "string", description: "Absolute or relative file path (relative resolved against working directory)"},
27
+ content: {type: "string", description: "Full file content to write"}
28
+ },
29
+ required: %w[path content]
30
+ }
31
+ end
32
+
33
+ # @param shell_session [ShellSession, nil] provides working directory for resolving relative paths
34
+ def initialize(shell_session: nil, **)
35
+ @working_directory = shell_session&.pwd
36
+ end
37
+
38
+ # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
39
+ # @return [String] confirmation with bytes written and resolved path
40
+ # @return [Hash] with :error key on failure
41
+ def execute(input)
42
+ path, content = extract_params(input)
43
+ return {error: "Path cannot be blank"} if path.empty?
44
+
45
+ path = resolve_path(path)
46
+
47
+ error = validate_target(path)
48
+ return error if error
49
+
50
+ write_file(path, content)
51
+ end
52
+
53
+ private
54
+
55
+ def extract_params(input)
56
+ path = input["path"].to_s.strip
57
+ content = input["content"].to_s
58
+ [path, content]
59
+ end
60
+
61
+ def resolve_path(path)
62
+ if @working_directory
63
+ File.expand_path(path, @working_directory)
64
+ else
65
+ File.expand_path(path)
66
+ end
67
+ end
68
+
69
+ def validate_target(path)
70
+ return {error: "Is a directory: #{path}"} if File.directory?(path)
71
+ {error: "Not writable: #{path}"} if File.exist?(path) && !File.writable?(path)
72
+ end
73
+
74
+ def write_file(path, content)
75
+ FileUtils.mkdir_p(File.dirname(path))
76
+ bytes = File.write(path, content)
77
+ "Wrote #{bytes} bytes to #{path}"
78
+ rescue Errno::EACCES
79
+ {error: "Permission denied: #{path}"}
80
+ rescue Errno::ENOSPC
81
+ {error: "No space left on device: #{path}"}
82
+ rescue Errno::EROFS
83
+ {error: "Read-only file system: #{path}"}
84
+ end
85
+ end
86
+ end