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.
- checksums.yaml +4 -4
- data/.reek.yml +27 -1
- data/CHANGELOG.md +19 -0
- data/README.md +213 -43
- data/agents/codebase-analyzer.md +88 -0
- data/agents/codebase-pattern-finder.md +83 -0
- data/agents/documentation-researcher.md +59 -0
- data/agents/thoughts-analyzer.md +102 -0
- data/agents/web-search-researcher.md +71 -0
- data/anima-core.gemspec +3 -0
- data/app/channels/session_channel.rb +195 -45
- data/app/decorators/user_message_decorator.rb +16 -5
- data/app/jobs/agent_request_job.rb +55 -2
- data/app/jobs/analytical_brain_job.rb +33 -0
- data/app/jobs/count_event_tokens_job.rb +15 -4
- data/app/models/concerns/event/broadcasting.rb +81 -0
- data/app/models/event.rb +20 -1
- data/app/models/goal.rb +91 -0
- data/app/models/session.rb +366 -21
- data/config/application.rb +2 -0
- data/config/initializers/event_subscribers.rb +0 -1
- data/config/routes.rb +0 -6
- data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
- data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
- data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
- data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
- data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
- data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
- data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
- data/db/migrate/20260315140843_create_goals.rb +16 -0
- data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
- data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
- data/lib/agent_loop.rb +65 -6
- data/lib/agents/definition.rb +116 -0
- data/lib/agents/registry.rb +106 -0
- data/lib/analytical_brain/runner.rb +276 -0
- data/lib/analytical_brain/tools/activate_skill.rb +52 -0
- data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
- data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
- data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
- data/lib/analytical_brain/tools/finish_goal.rb +62 -0
- data/lib/analytical_brain/tools/read_workflow.rb +58 -0
- data/lib/analytical_brain/tools/rename_session.rb +63 -0
- data/lib/analytical_brain/tools/set_goal.rb +60 -0
- data/lib/analytical_brain/tools/update_goal.rb +60 -0
- data/lib/analytical_brain.rb +23 -0
- data/lib/anima/cli/mcp/secrets.rb +76 -0
- data/lib/anima/cli/mcp.rb +197 -0
- data/lib/anima/cli.rb +5 -40
- data/lib/anima/installer.rb +168 -0
- data/lib/anima/settings.rb +226 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +9 -0
- data/lib/credential_store.rb +103 -0
- data/lib/environment_probe.rb +232 -0
- data/lib/events/subscribers/persister.rb +1 -0
- data/lib/events/user_message.rb +17 -0
- data/lib/llm/client.rb +29 -10
- data/lib/mcp/client_manager.rb +86 -0
- data/lib/mcp/config.rb +213 -0
- data/lib/mcp/health_check.rb +77 -0
- data/lib/mcp/secrets.rb +73 -0
- data/lib/mcp/stdio_transport.rb +206 -0
- data/lib/providers/anthropic.rb +11 -20
- data/lib/shell_session.rb +11 -10
- data/lib/skills/definition.rb +97 -0
- data/lib/skills/registry.rb +105 -0
- data/lib/tools/edit.rb +226 -0
- data/lib/tools/mcp_tool.rb +114 -0
- data/lib/tools/read.rb +151 -0
- data/lib/tools/registry.rb +14 -12
- data/lib/tools/request_feature.rb +121 -0
- data/lib/tools/return_result.rb +81 -0
- data/lib/tools/spawn_specialist.rb +109 -0
- data/lib/tools/spawn_subagent.rb +111 -0
- data/lib/tools/subagent_prompts.rb +12 -0
- data/lib/tools/web_get.rb +8 -9
- data/lib/tools/write.rb +86 -0
- data/lib/tui/app.rb +985 -26
- data/lib/tui/cable_client.rb +69 -31
- data/lib/tui/message_store.rb +103 -8
- data/lib/tui/screens/chat.rb +293 -45
- data/lib/workflows/definition.rb +97 -0
- data/lib/workflows/registry.rb +89 -0
- data/skills/activerecord/SKILL.md +255 -0
- data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
- data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
- data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
- data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
- data/skills/activerecord/examples/associations/self_referential.rb +302 -0
- data/skills/activerecord/examples/associations/through_associations.rb +203 -0
- data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
- data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
- data/skills/activerecord/examples/basics/inheritance.rb +377 -0
- data/skills/activerecord/examples/basics/type_casting.rb +317 -0
- data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
- data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
- data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
- data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
- data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
- data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
- data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
- data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
- data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
- data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
- data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
- data/skills/activerecord/examples/querying/optimization.rb +275 -0
- data/skills/activerecord/examples/querying/scopes.rb +260 -0
- data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
- data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
- data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
- data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
- data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
- data/skills/activerecord/references/associations.md +709 -0
- data/skills/activerecord/references/basics.md +622 -0
- data/skills/activerecord/references/callbacks.md +738 -0
- data/skills/activerecord/references/migrations.md +657 -0
- data/skills/activerecord/references/querying.md +655 -0
- data/skills/activerecord/references/validations.md +596 -0
- data/skills/dragonruby/SKILL.md +250 -0
- data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
- data/skills/dragonruby/examples/audio/background_music.rb +29 -0
- data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
- data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
- data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
- data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
- data/skills/dragonruby/examples/core/hello_world.rb +24 -0
- data/skills/dragonruby/examples/core/labels.rb +22 -0
- data/skills/dragonruby/examples/core/sprites.rb +35 -0
- data/skills/dragonruby/examples/core/state_management.rb +29 -0
- data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
- data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
- data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
- data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
- data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
- data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
- data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
- data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
- data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
- data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
- data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
- data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
- data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
- data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
- data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
- data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
- data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
- data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
- data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
- data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
- data/skills/dragonruby/examples/input/controller_input.rb +28 -0
- data/skills/dragonruby/examples/input/directional_input.rb +24 -0
- data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
- data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
- data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
- data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
- data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
- data/skills/dragonruby/examples/rendering/labels.rb +32 -0
- data/skills/dragonruby/examples/rendering/layering.rb +51 -0
- data/skills/dragonruby/examples/rendering/solids.rb +61 -0
- data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
- data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
- data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
- data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
- data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
- data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
- data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
- data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
- data/skills/dragonruby/references/audio.md +396 -0
- data/skills/dragonruby/references/core.md +385 -0
- data/skills/dragonruby/references/distribution.md +434 -0
- data/skills/dragonruby/references/entities.md +516 -0
- data/skills/dragonruby/references/game-logic/persistence.md +386 -0
- data/skills/dragonruby/references/game-logic/state.md +389 -0
- data/skills/dragonruby/references/input.md +414 -0
- data/skills/dragonruby/references/rendering/animation.md +467 -0
- data/skills/dragonruby/references/rendering/primitives.md +403 -0
- data/skills/dragonruby/references/scenes.md +443 -0
- data/skills/draper-decorators/SKILL.md +344 -0
- data/skills/draper-decorators/examples/application_decorator.rb +61 -0
- data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
- data/skills/draper-decorators/examples/model_decorator.rb +152 -0
- data/skills/draper-decorators/references/anti-patterns.md +640 -0
- data/skills/draper-decorators/references/patterns.md +507 -0
- data/skills/draper-decorators/references/testing.md +559 -0
- data/skills/gh-issue.md +182 -0
- data/skills/mcp-server/SKILL.md +177 -0
- data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
- data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
- data/skills/mcp-server/examples/http_client.rb +48 -0
- data/skills/mcp-server/examples/http_server.rb +97 -0
- data/skills/mcp-server/examples/rails_integration.rb +88 -0
- data/skills/mcp-server/examples/stdio_server.rb +108 -0
- data/skills/mcp-server/examples/streaming_client.rb +95 -0
- data/skills/mcp-server/references/gotchas.md +183 -0
- data/skills/mcp-server/references/prompts.md +98 -0
- data/skills/mcp-server/references/resources.md +53 -0
- data/skills/mcp-server/references/server.md +140 -0
- data/skills/mcp-server/references/tools.md +146 -0
- data/skills/mcp-server/references/transport.md +104 -0
- data/skills/ratatui-ruby/SKILL.md +315 -0
- data/skills/ratatui-ruby/references/core-concepts.md +340 -0
- data/skills/ratatui-ruby/references/events.md +387 -0
- data/skills/ratatui-ruby/references/frameworks.md +522 -0
- data/skills/ratatui-ruby/references/layout.md +423 -0
- data/skills/ratatui-ruby/references/styling.md +268 -0
- data/skills/ratatui-ruby/references/testing.md +433 -0
- data/skills/ratatui-ruby/references/widgets.md +532 -0
- data/skills/rspec/SKILL.md +340 -0
- data/skills/rspec/examples/core/basic_structure.rb +69 -0
- data/skills/rspec/examples/core/configuration.rb +126 -0
- data/skills/rspec/examples/core/hooks.rb +126 -0
- data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
- data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
- data/skills/rspec/examples/core/shared_examples.rb +145 -0
- data/skills/rspec/examples/factory_bot/associations.rb +314 -0
- data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
- data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
- data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
- data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
- data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
- data/skills/rspec/examples/factory_bot/traits.rb +293 -0
- data/skills/rspec/examples/factory_bot/transients.rb +229 -0
- data/skills/rspec/examples/matchers/change.rb +115 -0
- data/skills/rspec/examples/matchers/collections.rb +154 -0
- data/skills/rspec/examples/matchers/comparisons.rb +79 -0
- data/skills/rspec/examples/matchers/composing.rb +155 -0
- data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
- data/skills/rspec/examples/matchers/equality.rb +58 -0
- data/skills/rspec/examples/matchers/errors.rb +136 -0
- data/skills/rspec/examples/matchers/output.rb +103 -0
- data/skills/rspec/examples/matchers/predicates.rb +87 -0
- data/skills/rspec/examples/matchers/truthiness.rb +101 -0
- data/skills/rspec/examples/matchers/types.rb +82 -0
- data/skills/rspec/examples/matchers/yield.rb +147 -0
- data/skills/rspec/examples/mocks/any_instance.rb +172 -0
- data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
- data/skills/rspec/examples/mocks/constants.rb +177 -0
- data/skills/rspec/examples/mocks/doubles.rb +139 -0
- data/skills/rspec/examples/mocks/expectations.rb +137 -0
- data/skills/rspec/examples/mocks/message_chains.rb +173 -0
- data/skills/rspec/examples/mocks/ordering.rb +144 -0
- data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
- data/skills/rspec/examples/mocks/responses.rb +223 -0
- data/skills/rspec/examples/mocks/spies.rb +149 -0
- data/skills/rspec/examples/mocks/stubbing.rb +133 -0
- data/skills/rspec/examples/rails/channels.rb +250 -0
- data/skills/rspec/examples/rails/controller_specs.rb +302 -0
- data/skills/rspec/examples/rails/helper_specs.rb +245 -0
- data/skills/rspec/examples/rails/job_specs.rb +256 -0
- data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
- data/skills/rspec/examples/rails/matchers.rb +374 -0
- data/skills/rspec/examples/rails/model_specs.rb +193 -0
- data/skills/rspec/examples/rails/request_specs.rb +275 -0
- data/skills/rspec/examples/rails/routing_specs.rb +276 -0
- data/skills/rspec/examples/rails/system_specs.rb +294 -0
- data/skills/rspec/examples/rails/transactions.rb +254 -0
- data/skills/rspec/examples/rails/view_specs.rb +252 -0
- data/skills/rspec/references/core.md +816 -0
- data/skills/rspec/references/factory_bot.md +641 -0
- data/skills/rspec/references/matchers.md +516 -0
- data/skills/rspec/references/mocks.md +381 -0
- data/skills/rspec/references/rails.md +528 -0
- data/templates/soul.md +40 -0
- data/workflows/commit.md +45 -0
- data/workflows/create_handoff.md +98 -0
- data/workflows/create_note.md +82 -0
- data/workflows/create_plan.md +457 -0
- data/workflows/decompose_ticket.md +109 -0
- data/workflows/feature.md +91 -0
- data/workflows/implement_plan.md +87 -0
- data/workflows/iterate_plan.md +247 -0
- data/workflows/research_codebase.md +210 -0
- data/workflows/resume_handoff.md +217 -0
- data/workflows/review_pr.md +320 -0
- data/workflows/thoughts_init.md +71 -0
- data/workflows/validate_plan.md +166 -0
- metadata +290 -3
- data/app/controllers/api/sessions_controller.rb +0 -25
- 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 {
|
|
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:
|
|
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 #{
|
|
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
|
-
|
|
54
|
+
max_bytes = Anima::Settings.max_web_response_bytes
|
|
55
|
+
return body if body.bytesize <= max_bytes
|
|
57
56
|
|
|
58
|
-
body.byteslice(0,
|
|
59
|
-
"\n\n[Truncated: response exceeded #{
|
|
57
|
+
body.byteslice(0, max_bytes) +
|
|
58
|
+
"\n\n[Truncated: response exceeded #{max_bytes} bytes]"
|
|
60
59
|
end
|
|
61
60
|
end
|
|
62
61
|
end
|
data/lib/tools/write.rb
ADDED
|
@@ -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
|