anima-core 0.3.0 → 1.0.1

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 (270) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +4 -0
  4. data/README.md +219 -25
  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 +4 -1
  11. data/app/channels/session_channel.rb +76 -28
  12. data/app/jobs/agent_request_job.rb +24 -0
  13. data/app/jobs/analytical_brain_job.rb +33 -0
  14. data/app/jobs/count_event_tokens_job.rb +1 -1
  15. data/app/models/concerns/event/broadcasting.rb +20 -2
  16. data/app/models/event.rb +1 -1
  17. data/app/models/goal.rb +91 -0
  18. data/app/models/session.rb +347 -22
  19. data/config/application.rb +2 -0
  20. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  21. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  22. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  23. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  24. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  25. data/db/migrate/20260315140843_create_goals.rb +16 -0
  26. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  27. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  28. data/lib/agent_loop.rb +65 -9
  29. data/lib/agents/definition.rb +116 -0
  30. data/lib/agents/registry.rb +106 -0
  31. data/lib/analytical_brain/runner.rb +276 -0
  32. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  33. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  34. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  35. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  36. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  37. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  38. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  39. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  40. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  41. data/lib/analytical_brain.rb +23 -0
  42. data/lib/anima/cli/mcp/secrets.rb +76 -0
  43. data/lib/anima/cli/mcp.rb +197 -0
  44. data/lib/anima/cli.rb +4 -0
  45. data/lib/anima/installer.rb +182 -6
  46. data/lib/anima/settings.rb +226 -0
  47. data/lib/anima/version.rb +1 -1
  48. data/lib/anima.rb +9 -0
  49. data/lib/credential_store.rb +103 -0
  50. data/lib/environment_probe.rb +232 -0
  51. data/lib/llm/client.rb +29 -10
  52. data/lib/mcp/client_manager.rb +86 -0
  53. data/lib/mcp/config.rb +213 -0
  54. data/lib/mcp/health_check.rb +77 -0
  55. data/lib/mcp/secrets.rb +73 -0
  56. data/lib/mcp/stdio_transport.rb +206 -0
  57. data/lib/providers/anthropic.rb +8 -7
  58. data/lib/shell_session.rb +11 -10
  59. data/lib/skills/definition.rb +97 -0
  60. data/lib/skills/registry.rb +105 -0
  61. data/lib/tools/edit.rb +3 -4
  62. data/lib/tools/mcp_tool.rb +114 -0
  63. data/lib/tools/read.rb +15 -16
  64. data/lib/tools/registry.rb +14 -12
  65. data/lib/tools/request_feature.rb +121 -0
  66. data/lib/tools/return_result.rb +81 -0
  67. data/lib/tools/spawn_specialist.rb +109 -0
  68. data/lib/tools/spawn_subagent.rb +111 -0
  69. data/lib/tools/subagent_prompts.rb +12 -0
  70. data/lib/tools/web_get.rb +8 -9
  71. data/lib/tui/app.rb +332 -43
  72. data/lib/tui/message_store.rb +20 -0
  73. data/lib/tui/screens/chat.rb +207 -20
  74. data/lib/workflows/definition.rb +97 -0
  75. data/lib/workflows/registry.rb +89 -0
  76. data/skills/activerecord/SKILL.md +255 -0
  77. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  78. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  79. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  80. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  81. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  82. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  83. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  84. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  85. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  86. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  87. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  88. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  89. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  90. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  91. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  92. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  93. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  94. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  95. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  96. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  97. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  98. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  99. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  100. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  101. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  102. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  103. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  104. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  105. data/skills/activerecord/references/associations.md +709 -0
  106. data/skills/activerecord/references/basics.md +622 -0
  107. data/skills/activerecord/references/callbacks.md +738 -0
  108. data/skills/activerecord/references/migrations.md +657 -0
  109. data/skills/activerecord/references/querying.md +655 -0
  110. data/skills/activerecord/references/validations.md +596 -0
  111. data/skills/dragonruby/SKILL.md +250 -0
  112. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  113. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  114. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  115. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  116. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  117. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  118. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  119. data/skills/dragonruby/examples/core/labels.rb +22 -0
  120. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  121. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  122. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  123. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  124. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  125. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  126. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  127. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  128. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  129. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  130. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  131. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  132. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  133. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  134. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  135. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  136. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  137. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  138. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  139. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  140. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  141. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  142. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  143. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  144. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  145. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  146. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  147. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  148. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  149. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  150. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  151. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  152. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  153. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  154. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  155. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  156. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  157. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  158. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  159. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  160. data/skills/dragonruby/references/audio.md +396 -0
  161. data/skills/dragonruby/references/core.md +385 -0
  162. data/skills/dragonruby/references/distribution.md +434 -0
  163. data/skills/dragonruby/references/entities.md +516 -0
  164. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  165. data/skills/dragonruby/references/game-logic/state.md +389 -0
  166. data/skills/dragonruby/references/input.md +414 -0
  167. data/skills/dragonruby/references/rendering/animation.md +467 -0
  168. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  169. data/skills/dragonruby/references/scenes.md +443 -0
  170. data/skills/draper-decorators/SKILL.md +344 -0
  171. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  172. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  173. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  174. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  175. data/skills/draper-decorators/references/patterns.md +507 -0
  176. data/skills/draper-decorators/references/testing.md +559 -0
  177. data/skills/gh-issue.md +182 -0
  178. data/skills/mcp-server/SKILL.md +177 -0
  179. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  180. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  181. data/skills/mcp-server/examples/http_client.rb +48 -0
  182. data/skills/mcp-server/examples/http_server.rb +97 -0
  183. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  184. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  185. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  186. data/skills/mcp-server/references/gotchas.md +183 -0
  187. data/skills/mcp-server/references/prompts.md +98 -0
  188. data/skills/mcp-server/references/resources.md +53 -0
  189. data/skills/mcp-server/references/server.md +140 -0
  190. data/skills/mcp-server/references/tools.md +146 -0
  191. data/skills/mcp-server/references/transport.md +104 -0
  192. data/skills/ratatui-ruby/SKILL.md +315 -0
  193. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  194. data/skills/ratatui-ruby/references/events.md +387 -0
  195. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  196. data/skills/ratatui-ruby/references/layout.md +423 -0
  197. data/skills/ratatui-ruby/references/styling.md +268 -0
  198. data/skills/ratatui-ruby/references/testing.md +433 -0
  199. data/skills/ratatui-ruby/references/widgets.md +532 -0
  200. data/skills/rspec/SKILL.md +340 -0
  201. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  202. data/skills/rspec/examples/core/configuration.rb +126 -0
  203. data/skills/rspec/examples/core/hooks.rb +126 -0
  204. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  205. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  206. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  207. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  208. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  209. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  210. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  211. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  212. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  213. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  214. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  215. data/skills/rspec/examples/matchers/change.rb +115 -0
  216. data/skills/rspec/examples/matchers/collections.rb +154 -0
  217. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  218. data/skills/rspec/examples/matchers/composing.rb +155 -0
  219. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  220. data/skills/rspec/examples/matchers/equality.rb +58 -0
  221. data/skills/rspec/examples/matchers/errors.rb +136 -0
  222. data/skills/rspec/examples/matchers/output.rb +103 -0
  223. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  224. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  225. data/skills/rspec/examples/matchers/types.rb +82 -0
  226. data/skills/rspec/examples/matchers/yield.rb +147 -0
  227. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  228. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  229. data/skills/rspec/examples/mocks/constants.rb +177 -0
  230. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  231. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  232. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  233. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  234. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  235. data/skills/rspec/examples/mocks/responses.rb +223 -0
  236. data/skills/rspec/examples/mocks/spies.rb +149 -0
  237. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  238. data/skills/rspec/examples/rails/channels.rb +250 -0
  239. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  240. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  241. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  242. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  243. data/skills/rspec/examples/rails/matchers.rb +374 -0
  244. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  245. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  246. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  247. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  248. data/skills/rspec/examples/rails/transactions.rb +254 -0
  249. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  250. data/skills/rspec/references/core.md +816 -0
  251. data/skills/rspec/references/factory_bot.md +641 -0
  252. data/skills/rspec/references/matchers.md +516 -0
  253. data/skills/rspec/references/mocks.md +381 -0
  254. data/skills/rspec/references/rails.md +528 -0
  255. data/templates/soul.md +40 -0
  256. data/workflows/commit.md +45 -0
  257. data/workflows/create_handoff.md +98 -0
  258. data/workflows/create_note.md +82 -0
  259. data/workflows/create_plan.md +457 -0
  260. data/workflows/decompose_ticket.md +109 -0
  261. data/workflows/feature.md +91 -0
  262. data/workflows/implement_plan.md +87 -0
  263. data/workflows/iterate_plan.md +247 -0
  264. data/workflows/research_codebase.md +210 -0
  265. data/workflows/resume_handoff.md +217 -0
  266. data/workflows/review_pr.md +320 -0
  267. data/workflows/thoughts_init.md +71 -0
  268. data/workflows/validate_plan.md +166 -0
  269. metadata +284 -2
  270. data/.mise.toml +0 -2
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Wraps a single MCP server tool for use with {Tools::Registry}.
5
+ # Registered as an instance (not a class) — the Registry calls
6
+ # +#execute+ directly without instantiation, since MCP tools
7
+ # carry their own client reference and are effectively stateless
8
+ # from the LLM's perspective.
9
+ #
10
+ # Implements the same duck-typed interface as {Tools::Base} subclasses:
11
+ # - +#tool_name+ — unique identifier
12
+ # - +#description+ — human-readable description
13
+ # - +#input_schema+ — JSON Schema for parameters
14
+ # - +#schema+ — Anthropic API tool definition
15
+ # - +#execute(input)+ — run the tool
16
+ #
17
+ # Tool names are namespaced as `<server_name>__<tool_name>` to prevent
18
+ # collisions between servers and with built-in tools.
19
+ #
20
+ # @example
21
+ # wrapper = Tools::McpTool.new(
22
+ # server_name: "mythonix",
23
+ # mcp_client: client,
24
+ # mcp_tool: client.tools.first
25
+ # )
26
+ # wrapper.tool_name # => "mythonix__create_image"
27
+ # wrapper.execute({"prompt" => "a red dragon"})
28
+ class McpTool
29
+ # Separator between server name and tool name in namespaced identifiers.
30
+ NAMESPACE_SEPARATOR = "__"
31
+
32
+ # @return [String] namespaced tool identifier (<server>__<tool>)
33
+ attr_reader :tool_name
34
+
35
+ # @param server_name [String] MCP server name from config
36
+ # @param mcp_client [MCP::Client] the client instance for this server
37
+ # @param mcp_tool [MCP::Client::Tool] tool metadata from the server
38
+ def initialize(server_name:, mcp_client:, mcp_tool:)
39
+ @tool_name = "#{server_name}#{NAMESPACE_SEPARATOR}#{mcp_tool.name}"
40
+ @mcp_client = mcp_client
41
+ @mcp_tool = mcp_tool
42
+ end
43
+
44
+ # @return [String] tool description from the MCP server
45
+ def description
46
+ @mcp_tool.description
47
+ end
48
+
49
+ # @return [Hash] JSON Schema for tool input parameters
50
+ def input_schema
51
+ @mcp_tool.input_schema
52
+ end
53
+
54
+ # Builds the schema hash expected by the Anthropic tools API.
55
+ # @return [Hash] with :name, :description, and :input_schema keys
56
+ def schema
57
+ {name: tool_name, description: description, input_schema: input_schema}
58
+ end
59
+
60
+ # Calls the MCP server tool and normalizes the response.
61
+ #
62
+ # @param input [Hash] tool input parameters from the LLM
63
+ # @return [String] normalized tool output
64
+ # @return [Hash] with :error key on failure
65
+ def execute(input)
66
+ response = @mcp_client.call_tool(tool: @mcp_tool, arguments: input)
67
+ normalize_response(response)
68
+ rescue MCP::Client::RequestHandlerError => error
69
+ {error: "#{tool_name}: #{error.message}"}
70
+ end
71
+
72
+ private
73
+
74
+ # Extracts content from an MCP tool response (JSON-RPC envelope).
75
+ # Checks the `isError` flag and routes accordingly.
76
+ #
77
+ # @param response [Hash] full JSON-RPC response from MCP client
78
+ # @return [String] concatenated text content
79
+ # @return [Hash] with :error key if the response indicates an error
80
+ def normalize_response(response)
81
+ result = response["result"] || response
82
+ error = result["isError"]
83
+
84
+ text = extract_text(result)
85
+ error ? {error: "#{tool_name}: #{text}"} : text
86
+ end
87
+
88
+ # Extracts human-readable text from MCP content blocks.
89
+ # MCP responses contain an array of typed content blocks.
90
+ #
91
+ # @param result [Hash] MCP result containing "content" array
92
+ # @return [String] concatenated text from all content blocks
93
+ def extract_text(result)
94
+ content = result["content"]
95
+
96
+ return result.to_json unless content
97
+
98
+ case content
99
+ when Array
100
+ content.filter_map { |block|
101
+ case block["type"]
102
+ when "text" then block["text"]
103
+ when "image" then "[image: #{block["mimeType"]}]"
104
+ else block.to_json
105
+ end
106
+ }.join("\n")
107
+ when String
108
+ content
109
+ else
110
+ content.to_json
111
+ end
112
+ end
113
+ end
114
+ end
data/lib/tools/read.rb CHANGED
@@ -4,7 +4,7 @@ module Tools
4
4
  # Reads file contents with smart truncation and offset/limit paging.
5
5
  # Returns plain text without line numbers, normalized to LF line endings.
6
6
  #
7
- # Truncation limits: `MAX_LINES` lines or `MAX_BYTES` bytes, whichever
7
+ # Truncation limits: `Anima::Settings.max_read_lines` lines or `Anima::Settings.max_read_bytes` bytes, whichever
8
8
  # hits first. When truncated, appends a continuation hint with the next
9
9
  # offset value so the agent can page through large files.
10
10
  #
@@ -16,9 +16,6 @@ module Tools
16
16
  # tool.execute("path" => "large.log", "offset" => 2001, "limit" => 500)
17
17
  # # => "line 2001 content\n..."
18
18
  class Read < Base
19
- MAX_LINES = 2_000
20
- MAX_BYTES = 50_000
21
-
22
19
  def self.tool_name = "read"
23
20
 
24
21
  def self.description = "Read file contents. Returns plain text with smart truncation. Use offset/limit to page through large files."
@@ -29,7 +26,7 @@ module Tools
29
26
  properties: {
30
27
  path: {type: "string", description: "Absolute or relative file path (relative resolved against working directory)"},
31
28
  offset: {type: "integer", description: "1-indexed line number to start from (default: 1)"},
32
- limit: {type: "integer", description: "Maximum number of lines to read (default: 2000, also limited by #{MAX_BYTES} byte cap)"}
29
+ limit: {type: "integer", description: "Maximum lines to read (subject to line and byte caps from config)"}
33
30
  },
34
31
  required: ["path"]
35
32
  }
@@ -61,7 +58,7 @@ module Tools
61
58
  path = input["path"].to_s.strip
62
59
  offset = [input["offset"].to_i, 1].max
63
60
  raw_limit = input["limit"]
64
- limit = raw_limit ? [raw_limit.to_i, 1].max : MAX_LINES
61
+ limit = raw_limit ? [raw_limit.to_i, 1].max : Anima::Settings.max_read_lines
65
62
  [path, offset, limit]
66
63
  end
67
64
 
@@ -81,15 +78,15 @@ module Tools
81
78
 
82
79
  # Reads the file, normalizes line endings, and applies truncation limits.
83
80
  # Two limits are enforced as first-hit-wins: line count and byte size.
84
- # A single line exceeding `MAX_BYTES` is rejected outright (likely minified).
85
- # Files larger than `MAX_READ_SIZE` are rejected to avoid memory exhaustion.
86
- MAX_READ_SIZE = 10 * 1024 * 1024 # 10 MB
81
+ # A single line exceeding `Anima::Settings.max_read_bytes` is rejected outright (likely minified).
82
+ # Files larger than max_file_size are rejected to avoid memory exhaustion.
87
83
 
88
84
  def read_file(path, offset, limit)
89
85
  file_size = File.size(path)
90
- if file_size > MAX_READ_SIZE
86
+ max_size = Anima::Settings.max_file_size
87
+ if file_size > max_size
91
88
  return {error: "File is #{file_size} bytes (#{file_size / 1_048_576} MB). " \
92
- "Max readable size is #{MAX_READ_SIZE / 1_048_576} MB. " \
89
+ "Max readable size is #{max_size / 1_048_576} MB. " \
93
90
  "Use bash tool with: head -n #{offset + limit} #{path} | tail -n +#{offset}"}
94
91
  end
95
92
 
@@ -99,7 +96,7 @@ module Tools
99
96
  start_index = offset - 1
100
97
  return "[File has #{lines.size} lines. Offset #{offset} is beyond end of file.]" if start_index >= lines.size
101
98
 
102
- window = lines[start_index, [limit, MAX_LINES].min]
99
+ window = lines[start_index, [limit, Anima::Settings.max_read_lines].min]
103
100
 
104
101
  error = check_oversized_lines(window, offset, path)
105
102
  return error if error
@@ -112,11 +109,12 @@ module Tools
112
109
  end
113
110
 
114
111
  def check_oversized_lines(window, offset, path)
115
- index = window.index { |line| line.bytesize > MAX_BYTES }
112
+ max_bytes = Anima::Settings.max_read_bytes
113
+ index = window.index { |line| line.bytesize > max_bytes }
116
114
  return unless index
117
115
 
118
116
  line_num = offset + index
119
- {error: "Line #{line_num} exceeds #{MAX_BYTES} bytes (likely minified). " \
117
+ {error: "Line #{line_num} exceeds #{max_bytes} bytes (likely minified). " \
120
118
  "Use bash tool with: sed -n '#{line_num}p' #{path}"}
121
119
  end
122
120
 
@@ -131,15 +129,16 @@ module Tools
131
129
  end
132
130
  end
133
131
 
134
- # Accumulates lines until `MAX_BYTES` would be exceeded.
132
+ # Accumulates lines until the byte cap would be exceeded.
135
133
  # @return [Array(String, Integer)] accumulated text and number of lines included
136
134
  def accumulate_lines(window)
135
+ max_bytes = Anima::Settings.max_read_bytes
137
136
  output = +""
138
137
  bytes = 0
139
138
  count = 0
140
139
 
141
140
  window.each_with_index do |line, index|
142
- break if bytes + line.bytesize > MAX_BYTES && index > 0
141
+ break if bytes + line.bytesize > max_bytes && index > 0
143
142
 
144
143
  output << line
145
144
  bytes += line.bytesize
@@ -4,16 +4,17 @@ module Tools
4
4
  class UnknownToolError < StandardError; end
5
5
 
6
6
  # Manages tool registration, schema export, and dispatch.
7
- # Tools are registered by class and looked up by name at execution time.
8
- # An optional context hash is passed to each tool's constructor, allowing
9
- # shared dependencies (e.g. a {ShellSession}) to reach tools that need them.
7
+ # Accepts both tool classes (e.g. {Tools::Base} subclasses) and tool
8
+ # instances (e.g. {Tools::McpTool}) via duck typing. Classes are
9
+ # instantiated with the registry's context on each execution; instances
10
+ # are called directly since they carry their own state.
10
11
  #
11
12
  # @example
12
13
  # registry = Tools::Registry.new(context: {shell_session: my_shell})
13
14
  # registry.register(Tools::Bash)
14
15
  # registry.execute("bash", {"command" => "ls"})
15
16
  class Registry
16
- # @return [Hash{String => Class}] registered tool classes keyed by name
17
+ # @return [Hash{String => Class, Object}] registered tools keyed by name
17
18
  attr_reader :tools
18
19
 
19
20
  # @param context [Hash] keyword arguments forwarded to every tool constructor
@@ -22,11 +23,11 @@ module Tools
22
23
  @context = context
23
24
  end
24
25
 
25
- # Register a tool class. The class must respond to .tool_name.
26
- # @param tool_class [Class<Tools::Base>] the tool class to register
26
+ # Register a tool class or instance. Must respond to +tool_name+ and +schema+.
27
+ # @param tool [Class<Tools::Base>, #tool_name] tool class or duck-typed instance
27
28
  # @return [void]
28
- def register(tool_class)
29
- @tools[tool_class.tool_name] = tool_class
29
+ def register(tool)
30
+ @tools[tool.tool_name] = tool
30
31
  end
31
32
 
32
33
  # @return [Array<Hash>] schema array for the Anthropic tools API parameter
@@ -34,16 +35,17 @@ module Tools
34
35
  @tools.values.map(&:schema)
35
36
  end
36
37
 
37
- # Instantiate and execute a tool by name. The registry's context is
38
- # forwarded to the tool constructor as keyword arguments.
38
+ # Execute a tool by name. Classes are instantiated with the registry's
39
+ # context; instances are called directly.
39
40
  #
40
41
  # @param name [String] registered tool name
41
42
  # @param input [Hash] tool input parameters
42
43
  # @return [String, Hash] tool execution result
43
44
  # @raise [UnknownToolError] if no tool is registered with the given name
44
45
  def execute(name, input)
45
- tool_class = @tools.fetch(name) { raise UnknownToolError, "Unknown tool: #{name}" }
46
- tool_class.new(**@context).execute(input)
46
+ tool = @tools.fetch(name) { raise UnknownToolError, "Unknown tool: #{name}" }
47
+ instance = tool.is_a?(Class) ? tool.new(**@context) : tool
48
+ instance.execute(input)
47
49
  end
48
50
 
49
51
  # @param name [String] tool name to check
@@ -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