rubyn-code 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Protocols
|
|
5
|
+
# Handles SIGINT (Ctrl-C) gracefully with a two-stage interrupt protocol.
|
|
6
|
+
#
|
|
7
|
+
# First Ctrl-C sets the interrupted flag so the current LLM call can
|
|
8
|
+
# check and abort gracefully. A second Ctrl-C within 2 seconds forces
|
|
9
|
+
# an immediate exit.
|
|
10
|
+
module InterruptHandler
|
|
11
|
+
@interrupted = false
|
|
12
|
+
@last_interrupt_at = nil
|
|
13
|
+
@callbacks = []
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Installs the SIGINT trap with the two-stage interrupt protocol.
|
|
18
|
+
#
|
|
19
|
+
# @return [void]
|
|
20
|
+
def setup!
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
@interrupted = false
|
|
23
|
+
@last_interrupt_at = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
trap("INT") do
|
|
27
|
+
handle_interrupt
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns whether the interrupted flag is currently set.
|
|
32
|
+
#
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def interrupted?
|
|
35
|
+
@mutex.synchronize { @interrupted }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Clears the interrupted flag and resets the last interrupt timestamp.
|
|
39
|
+
#
|
|
40
|
+
# @return [void]
|
|
41
|
+
def reset!
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
@interrupted = false
|
|
44
|
+
@last_interrupt_at = nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Registers a callback to be invoked on the first interrupt.
|
|
49
|
+
# Callbacks are executed in registration order.
|
|
50
|
+
#
|
|
51
|
+
# @yield the block to run on interrupt
|
|
52
|
+
# @return [void]
|
|
53
|
+
def on_interrupt(&block)
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
@callbacks << block
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Clears all registered callbacks. Intended for test cleanup.
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
62
|
+
def clear_callbacks!
|
|
63
|
+
@mutex.synchronize { @callbacks.clear }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Core interrupt handler logic. Called from the SIGINT trap.
|
|
69
|
+
# Signal handlers must be reentrant-safe and avoid complex operations.
|
|
70
|
+
def handle_interrupt
|
|
71
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
72
|
+
|
|
73
|
+
if @last_interrupt_at && (now - @last_interrupt_at) < 2.0
|
|
74
|
+
# Second Ctrl-C within 2 seconds: force exit
|
|
75
|
+
$stderr.write("\nForce exiting...\n")
|
|
76
|
+
exit!(1)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@last_interrupt_at = now
|
|
80
|
+
@interrupted = true
|
|
81
|
+
|
|
82
|
+
$stderr.write("\nInterrupted. Press Ctrl-C again within 2s to force exit.\n")
|
|
83
|
+
|
|
84
|
+
# Fire callbacks outside the mutex to avoid deadlock in signal context.
|
|
85
|
+
# We read the callbacks array directly since signal handlers should be fast.
|
|
86
|
+
@callbacks.each do |callback|
|
|
87
|
+
callback.call
|
|
88
|
+
rescue StandardError
|
|
89
|
+
# Swallow errors in signal handlers to avoid crashing
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-prompt"
|
|
4
|
+
require "tty-reader"
|
|
5
|
+
require "pastel"
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module Protocols
|
|
9
|
+
# Presents a plan to the user for approval before executing significant changes.
|
|
10
|
+
#
|
|
11
|
+
# This protocol ensures that destructive or wide-reaching operations are
|
|
12
|
+
# reviewed by a human before proceeding.
|
|
13
|
+
module PlanApproval
|
|
14
|
+
APPROVED = :approved
|
|
15
|
+
REJECTED = :rejected
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Displays a plan and asks the user to approve or reject it.
|
|
19
|
+
#
|
|
20
|
+
# @param plan_text [String] the plan description to display
|
|
21
|
+
# @param prompt [String, nil] optional custom prompt message
|
|
22
|
+
# @return [Symbol] :approved or :rejected
|
|
23
|
+
def request(plan_text, prompt: nil)
|
|
24
|
+
pastel = Pastel.new
|
|
25
|
+
tty = build_prompt
|
|
26
|
+
|
|
27
|
+
$stdout.puts
|
|
28
|
+
$stdout.puts pastel.cyan.bold("Proposed Plan")
|
|
29
|
+
$stdout.puts pastel.cyan("=" * 60)
|
|
30
|
+
$stdout.puts plan_text
|
|
31
|
+
$stdout.puts pastel.cyan("=" * 60)
|
|
32
|
+
$stdout.puts
|
|
33
|
+
|
|
34
|
+
if prompt
|
|
35
|
+
$stdout.puts pastel.yellow(prompt)
|
|
36
|
+
$stdout.puts
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
approved = tty.yes?(
|
|
40
|
+
pastel.yellow.bold("Do you approve this plan?"),
|
|
41
|
+
default: false
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if approved
|
|
45
|
+
$stdout.puts pastel.green("Plan approved.")
|
|
46
|
+
APPROVED
|
|
47
|
+
else
|
|
48
|
+
$stdout.puts pastel.red("Plan rejected.")
|
|
49
|
+
REJECTED
|
|
50
|
+
end
|
|
51
|
+
rescue TTY::Reader::InputInterrupt
|
|
52
|
+
$stdout.puts pastel.red("\nPlan rejected (interrupted).")
|
|
53
|
+
REJECTED
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Builds a TTY::Prompt instance configured for non-destructive interrupt handling.
|
|
59
|
+
#
|
|
60
|
+
# @return [TTY::Prompt]
|
|
61
|
+
def build_prompt
|
|
62
|
+
TTY::Prompt.new(interrupt: :noop)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Protocols
|
|
5
|
+
# Graceful shutdown protocol for agent teammates.
|
|
6
|
+
#
|
|
7
|
+
# Implements a cooperative handshake where a coordinator requests
|
|
8
|
+
# shutdown and the target agent acknowledges after saving state.
|
|
9
|
+
module ShutdownHandshake
|
|
10
|
+
# Default timeout in seconds when waiting for a shutdown response.
|
|
11
|
+
TIMEOUT = 10
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Initiates a shutdown request to a teammate and waits for acknowledgement.
|
|
15
|
+
#
|
|
16
|
+
# @param mailbox [Teams::Mailbox] the team mailbox
|
|
17
|
+
# @param from [String] the requesting agent name
|
|
18
|
+
# @param to [String] the target agent name to shut down
|
|
19
|
+
# @param timeout [Integer] seconds to wait for response (default: 10)
|
|
20
|
+
# @return [Symbol] :acknowledged or :timeout
|
|
21
|
+
def initiate(mailbox:, from:, to:, timeout: TIMEOUT)
|
|
22
|
+
mailbox.send(
|
|
23
|
+
from: from,
|
|
24
|
+
to: to,
|
|
25
|
+
content: "shutdown_request",
|
|
26
|
+
message_type: "shutdown_request"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
deadline = Time.now + timeout
|
|
30
|
+
|
|
31
|
+
loop do
|
|
32
|
+
messages = mailbox.read_inbox(from)
|
|
33
|
+
response = messages.find do |msg|
|
|
34
|
+
msg[:from] == to && msg[:message_type] == "shutdown_response"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
return :acknowledged if response
|
|
38
|
+
|
|
39
|
+
return :timeout if Time.now >= deadline
|
|
40
|
+
|
|
41
|
+
sleep(0.25)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Sends a shutdown response (approval or denial) from the target agent.
|
|
46
|
+
#
|
|
47
|
+
# @param mailbox [Teams::Mailbox] the team mailbox
|
|
48
|
+
# @param from [String] the responding agent name
|
|
49
|
+
# @param to [String] the agent that requested the shutdown
|
|
50
|
+
# @param approve [Boolean] whether to approve the shutdown (default: true)
|
|
51
|
+
# @return [String] the message id
|
|
52
|
+
def respond(mailbox:, from:, to:, approve: true)
|
|
53
|
+
content = approve ? "shutdown_approved" : "shutdown_denied"
|
|
54
|
+
|
|
55
|
+
mailbox.send(
|
|
56
|
+
from: from,
|
|
57
|
+
to: to,
|
|
58
|
+
content: content,
|
|
59
|
+
message_type: "shutdown_response"
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Performs a full graceful shutdown for an agent: saves state,
|
|
64
|
+
# sends acknowledgement, and sets status to offline.
|
|
65
|
+
#
|
|
66
|
+
# @param agent_name [String] the agent being shut down
|
|
67
|
+
# @param mailbox [Teams::Mailbox] the team mailbox
|
|
68
|
+
# @param session_persistence [#save_session] persistence layer for saving session state
|
|
69
|
+
# @param conversation [Agent::Conversation] the agent's conversation to persist
|
|
70
|
+
# @param requester [String, nil] the agent that requested shutdown (for acknowledgement)
|
|
71
|
+
# @return [void]
|
|
72
|
+
def graceful_shutdown(agent_name, mailbox:, session_persistence:, conversation:, requester: nil)
|
|
73
|
+
# Step 1: Save current session state
|
|
74
|
+
save_state(agent_name, session_persistence, conversation)
|
|
75
|
+
|
|
76
|
+
# Step 2: Send shutdown acknowledgement if there is a requester
|
|
77
|
+
if requester
|
|
78
|
+
respond(mailbox: mailbox, from: agent_name, to: requester, approve: true)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Step 3: Broadcast offline status to all listeners
|
|
82
|
+
mailbox.send(
|
|
83
|
+
from: agent_name,
|
|
84
|
+
to: "_system",
|
|
85
|
+
content: "#{agent_name} is now offline",
|
|
86
|
+
message_type: "status_change"
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Saves the agent's session state via the persistence layer.
|
|
93
|
+
#
|
|
94
|
+
# @param agent_name [String]
|
|
95
|
+
# @param session_persistence [#save_session]
|
|
96
|
+
# @param conversation [Agent::Conversation]
|
|
97
|
+
# @return [void]
|
|
98
|
+
def save_state(agent_name, session_persistence, conversation)
|
|
99
|
+
session_persistence.save_session(
|
|
100
|
+
agent_name: agent_name,
|
|
101
|
+
messages: conversation.messages
|
|
102
|
+
)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
$stderr.puts "[ShutdownHandshake] Warning: failed to save state for '#{agent_name}': #{e.message}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Skills
|
|
5
|
+
class Catalog
|
|
6
|
+
SKILL_GLOB = "**/*.md"
|
|
7
|
+
|
|
8
|
+
attr_reader :skills_dirs
|
|
9
|
+
|
|
10
|
+
def initialize(skills_dirs)
|
|
11
|
+
@skills_dirs = Array(skills_dirs)
|
|
12
|
+
@index = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def descriptions
|
|
16
|
+
entries = available
|
|
17
|
+
return "" if entries.empty?
|
|
18
|
+
|
|
19
|
+
entries.map { |entry| "- /#{entry[:name]}: #{entry[:description]}" }.join("\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def available
|
|
23
|
+
build_index unless @index
|
|
24
|
+
@index
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def find(name)
|
|
28
|
+
entry = available.find { |e| e[:name] == name.to_s }
|
|
29
|
+
entry&.fetch(:path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def build_index
|
|
35
|
+
@index = []
|
|
36
|
+
|
|
37
|
+
skills_dirs.each do |dir|
|
|
38
|
+
next unless File.directory?(dir)
|
|
39
|
+
|
|
40
|
+
Dir.glob(File.join(dir, SKILL_GLOB)).sort.each do |path|
|
|
41
|
+
entry = extract_metadata(path)
|
|
42
|
+
@index << entry if entry
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@index.uniq! { |e| e[:name] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def extract_metadata(path)
|
|
50
|
+
header = File.read(path, 1024, encoding: "UTF-8")
|
|
51
|
+
.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
|
|
52
|
+
doc = Document.parse(header, filename: path)
|
|
53
|
+
|
|
54
|
+
name = if doc.name.empty? || doc.name == "unknown"
|
|
55
|
+
File.basename(path, ".md")
|
|
56
|
+
else
|
|
57
|
+
doc.name
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
name: name,
|
|
62
|
+
description: doc.description,
|
|
63
|
+
path: File.expand_path(path)
|
|
64
|
+
}
|
|
65
|
+
rescue StandardError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Skills
|
|
7
|
+
class Document
|
|
8
|
+
FRONTMATTER_PATTERN = /\A---\s*\n(.+?\n)---\s*\n(.*)\z/m
|
|
9
|
+
|
|
10
|
+
attr_reader :name, :description, :tags, :body
|
|
11
|
+
|
|
12
|
+
def initialize(name:, description:, tags:, body:)
|
|
13
|
+
@name = name
|
|
14
|
+
@description = description
|
|
15
|
+
@tags = tags
|
|
16
|
+
@body = body
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def parse(content, filename: nil)
|
|
21
|
+
match = FRONTMATTER_PATTERN.match(content)
|
|
22
|
+
|
|
23
|
+
if match
|
|
24
|
+
frontmatter = YAML.safe_load(match[1], permitted_classes: [Symbol]) || {}
|
|
25
|
+
body = match[2].to_s.strip
|
|
26
|
+
|
|
27
|
+
new(
|
|
28
|
+
name: frontmatter["name"].to_s,
|
|
29
|
+
description: frontmatter["description"].to_s,
|
|
30
|
+
tags: Array(frontmatter["tags"]),
|
|
31
|
+
body: body
|
|
32
|
+
)
|
|
33
|
+
else
|
|
34
|
+
body = content.to_s.strip
|
|
35
|
+
title = extract_title(body)
|
|
36
|
+
derived_name = filename ? File.basename(filename, ".*").tr("_", "-") : title_to_name(title)
|
|
37
|
+
tags = derive_tags(derived_name, body)
|
|
38
|
+
|
|
39
|
+
new(
|
|
40
|
+
name: derived_name,
|
|
41
|
+
description: title,
|
|
42
|
+
tags: tags,
|
|
43
|
+
body: body
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_file(path)
|
|
49
|
+
raise Error, "Skill file not found: #{path}" unless File.exist?(path)
|
|
50
|
+
raise Error, "Not a file: #{path}" unless File.file?(path)
|
|
51
|
+
|
|
52
|
+
content = File.read(path, encoding: "UTF-8")
|
|
53
|
+
parse(content, filename: path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def extract_title(body)
|
|
59
|
+
first_line = body.lines.first&.strip || ""
|
|
60
|
+
first_line.start_with?("#") ? first_line.sub(/^#+\s*/, "") : first_line[0..80]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def title_to_name(title)
|
|
64
|
+
title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")[0..40]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def derive_tags(name, body)
|
|
68
|
+
tags = []
|
|
69
|
+
tags << "ruby" if body.match?(/\bruby\b/i) || name.include?("ruby")
|
|
70
|
+
tags << "rails" if body.match?(/\brails\b/i) || name.include?("rails")
|
|
71
|
+
tags << "rspec" if body.match?(/\brspec\b/i) || name.include?("rspec")
|
|
72
|
+
tags << "testing" if body.match?(/\b(test|spec|minitest)\b/i)
|
|
73
|
+
tags << "patterns" if body.match?(/\b(pattern|design|solid)\b/i)
|
|
74
|
+
tags << "refactoring" if body.match?(/\brefactor/i)
|
|
75
|
+
tags.uniq
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Skills
|
|
5
|
+
class Loader
|
|
6
|
+
attr_reader :catalog
|
|
7
|
+
|
|
8
|
+
def initialize(catalog)
|
|
9
|
+
@catalog = catalog
|
|
10
|
+
@loaded = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def load(name)
|
|
14
|
+
name = name.to_s
|
|
15
|
+
|
|
16
|
+
if @loaded.key?(name)
|
|
17
|
+
return @loaded[name]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
path = catalog.find(name)
|
|
21
|
+
raise Error, "Skill not found: #{name}" unless path
|
|
22
|
+
|
|
23
|
+
doc = Document.parse_file(path)
|
|
24
|
+
content = format_skill(doc)
|
|
25
|
+
|
|
26
|
+
@loaded[name] = content
|
|
27
|
+
content
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def loaded
|
|
31
|
+
@loaded.keys
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def descriptions_for_prompt
|
|
35
|
+
catalog.descriptions
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def format_skill(doc)
|
|
41
|
+
parts = []
|
|
42
|
+
parts << "<skill name=\"#{escape_xml(doc.name)}\">"
|
|
43
|
+
parts << doc.body unless doc.body.empty?
|
|
44
|
+
parts << "</skill>"
|
|
45
|
+
parts.join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def escape_xml(text)
|
|
49
|
+
text.to_s
|
|
50
|
+
.gsub("&", "&")
|
|
51
|
+
.gsub("<", "<")
|
|
52
|
+
.gsub(">", ">")
|
|
53
|
+
.gsub("\"", """)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module SubAgents
|
|
5
|
+
class Runner
|
|
6
|
+
AGENT_TOOL_SETS = {
|
|
7
|
+
explore: %w[read_file glob grep bash].freeze,
|
|
8
|
+
general: nil # resolved at runtime to exclude sub-agent tools
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
SUB_AGENT_TOOLS = %w[sub_agent spawn_agent].freeze
|
|
12
|
+
MAX_ITERATIONS_HARD_LIMIT = 50
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def call(prompt:, llm_client:, project_root:, agent_type: :explore, max_iterations: 30)
|
|
16
|
+
new(
|
|
17
|
+
prompt: prompt,
|
|
18
|
+
llm_client: llm_client,
|
|
19
|
+
project_root: project_root,
|
|
20
|
+
agent_type: agent_type,
|
|
21
|
+
max_iterations: max_iterations
|
|
22
|
+
).run
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(prompt:, llm_client:, project_root:, agent_type:, max_iterations:)
|
|
27
|
+
@prompt = prompt
|
|
28
|
+
@llm_client = llm_client
|
|
29
|
+
@project_root = File.expand_path(project_root)
|
|
30
|
+
@agent_type = agent_type.to_sym
|
|
31
|
+
@max_iterations = [max_iterations.to_i, MAX_ITERATIONS_HARD_LIMIT].min
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run
|
|
35
|
+
conversation = build_conversation
|
|
36
|
+
executor = build_executor
|
|
37
|
+
tool_defs = build_tool_definitions
|
|
38
|
+
|
|
39
|
+
iteration = 0
|
|
40
|
+
final_text = ""
|
|
41
|
+
|
|
42
|
+
loop do
|
|
43
|
+
break if iteration >= @max_iterations
|
|
44
|
+
|
|
45
|
+
response = request_llm(conversation, tool_defs)
|
|
46
|
+
iteration += 1
|
|
47
|
+
|
|
48
|
+
text_content = extract_text(response)
|
|
49
|
+
tool_calls = extract_tool_calls(response)
|
|
50
|
+
|
|
51
|
+
if tool_calls.empty?
|
|
52
|
+
final_text = text_content
|
|
53
|
+
break
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
conversation << { role: "assistant", content: response }
|
|
57
|
+
|
|
58
|
+
tool_results = execute_tools(executor, tool_calls)
|
|
59
|
+
conversation << { role: "user", content: tool_results }
|
|
60
|
+
|
|
61
|
+
final_text = text_content unless text_content.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Summarizer.call(final_text)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def build_conversation
|
|
70
|
+
[
|
|
71
|
+
{ role: "user", content: @prompt }
|
|
72
|
+
]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_executor
|
|
76
|
+
Tools::Executor.new(project_root: @project_root)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_tool_definitions
|
|
80
|
+
allowed = allowed_tool_names
|
|
81
|
+
Tools::Registry.all
|
|
82
|
+
.select { |t| allowed.include?(t.tool_name) }
|
|
83
|
+
.map(&:to_schema)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def allowed_tool_names
|
|
87
|
+
preset = AGENT_TOOL_SETS[@agent_type]
|
|
88
|
+
|
|
89
|
+
if preset
|
|
90
|
+
# Only include tools that are actually registered
|
|
91
|
+
registered = Tools::Registry.tool_names
|
|
92
|
+
preset & registered
|
|
93
|
+
else
|
|
94
|
+
# :general — all registered tools minus sub-agent spawning tools
|
|
95
|
+
Tools::Registry.tool_names - SUB_AGENT_TOOLS
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def request_llm(conversation, tool_defs)
|
|
100
|
+
@llm_client.chat(
|
|
101
|
+
messages: conversation,
|
|
102
|
+
tools: tool_defs
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def extract_text(response)
|
|
107
|
+
case response
|
|
108
|
+
when String
|
|
109
|
+
response
|
|
110
|
+
when Hash
|
|
111
|
+
content = response[:content] || response["content"]
|
|
112
|
+
extract_text_from_content(content)
|
|
113
|
+
when Array
|
|
114
|
+
extract_text_from_content(response)
|
|
115
|
+
else
|
|
116
|
+
response.to_s
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def extract_text_from_content(content)
|
|
121
|
+
return content.to_s unless content.is_a?(Array)
|
|
122
|
+
|
|
123
|
+
content
|
|
124
|
+
.select { |block| block_type(block) == "text" }
|
|
125
|
+
.map { |block| block[:text] || block["text"] }
|
|
126
|
+
.compact
|
|
127
|
+
.join("\n")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def extract_tool_calls(response)
|
|
131
|
+
content = case response
|
|
132
|
+
when Hash then response[:content] || response["content"]
|
|
133
|
+
when Array then response
|
|
134
|
+
else return []
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
return [] unless content.is_a?(Array)
|
|
138
|
+
|
|
139
|
+
content.select { |block| block_type(block) == "tool_use" }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def block_type(block)
|
|
143
|
+
(block[:type] || block["type"]).to_s
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def execute_tools(executor, tool_calls)
|
|
147
|
+
tool_calls.map do |call|
|
|
148
|
+
tool_name = call[:name] || call["name"]
|
|
149
|
+
tool_input = call[:input] || call["input"] || {}
|
|
150
|
+
tool_id = call[:id] || call["id"]
|
|
151
|
+
|
|
152
|
+
# Prevent recursive sub-agent spawning
|
|
153
|
+
result = if SUB_AGENT_TOOLS.include?(tool_name)
|
|
154
|
+
"Error: Sub-agents cannot spawn other sub-agents."
|
|
155
|
+
else
|
|
156
|
+
executor.execute(tool_name, tool_input)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
{
|
|
160
|
+
type: "tool_result",
|
|
161
|
+
tool_use_id: tool_id,
|
|
162
|
+
content: result.to_s
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module SubAgents
|
|
5
|
+
module Summarizer
|
|
6
|
+
DEFAULT_MAX_LENGTH = 2000
|
|
7
|
+
TRUNCATION_SUFFIX = "\n\n[... output truncated ...]"
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def call(text, max_length: DEFAULT_MAX_LENGTH)
|
|
11
|
+
return "" if text.nil? || text.empty?
|
|
12
|
+
|
|
13
|
+
text = text.to_s.strip
|
|
14
|
+
|
|
15
|
+
return text if text.length <= max_length
|
|
16
|
+
|
|
17
|
+
truncate_with_context(text, max_length)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def truncate_with_context(text, max_length)
|
|
23
|
+
usable = max_length - TRUNCATION_SUFFIX.length
|
|
24
|
+
return text[0, max_length] if usable <= 0
|
|
25
|
+
|
|
26
|
+
# Keep the beginning (context setup) and end (final result) of the output.
|
|
27
|
+
# The end usually contains the most relevant conclusion.
|
|
28
|
+
head_size = (usable * 0.4).to_i
|
|
29
|
+
tail_size = usable - head_size
|
|
30
|
+
|
|
31
|
+
head = text[0, head_size]
|
|
32
|
+
tail = text[-tail_size, tail_size]
|
|
33
|
+
|
|
34
|
+
# Trim to nearest newline boundaries when possible to avoid mid-line cuts.
|
|
35
|
+
head = trim_to_last_newline(head)
|
|
36
|
+
tail = trim_to_first_newline(tail)
|
|
37
|
+
|
|
38
|
+
"#{head}#{TRUNCATION_SUFFIX}\n\n#{tail}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def trim_to_last_newline(text)
|
|
42
|
+
last_nl = text.rindex("\n")
|
|
43
|
+
return text unless last_nl && last_nl > (text.length * 0.5)
|
|
44
|
+
|
|
45
|
+
text[0..last_nl]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def trim_to_first_newline(text)
|
|
49
|
+
first_nl = text.index("\n")
|
|
50
|
+
return text unless first_nl && first_nl < (text.length * 0.3)
|
|
51
|
+
|
|
52
|
+
text[(first_nl + 1)..]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|