rubyn-code 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +269 -467
  3. data/db/migrations/009_create_teams.sql +6 -6
  4. data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
  5. data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
  6. data/exe/rubyn-code +1 -1
  7. data/lib/rubyn_code/agent/RUBYN.md +17 -0
  8. data/lib/rubyn_code/agent/conversation.rb +68 -19
  9. data/lib/rubyn_code/agent/loop.rb +312 -54
  10. data/lib/rubyn_code/agent/loop_detector.rb +6 -6
  11. data/lib/rubyn_code/auth/RUBYN.md +19 -0
  12. data/lib/rubyn_code/auth/oauth.rb +40 -35
  13. data/lib/rubyn_code/auth/server.rb +16 -12
  14. data/lib/rubyn_code/auth/token_store.rb +22 -22
  15. data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
  16. data/lib/rubyn_code/autonomous/daemon.rb +115 -79
  17. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
  18. data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
  19. data/lib/rubyn_code/background/RUBYN.md +13 -0
  20. data/lib/rubyn_code/background/notifier.rb +0 -2
  21. data/lib/rubyn_code/background/worker.rb +60 -15
  22. data/lib/rubyn_code/cli/RUBYN.md +30 -0
  23. data/lib/rubyn_code/cli/app.rb +85 -9
  24. data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
  25. data/lib/rubyn_code/cli/commands/base.rb +53 -0
  26. data/lib/rubyn_code/cli/commands/budget.rb +24 -0
  27. data/lib/rubyn_code/cli/commands/clear.rb +16 -0
  28. data/lib/rubyn_code/cli/commands/compact.rb +21 -0
  29. data/lib/rubyn_code/cli/commands/context.rb +44 -0
  30. data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
  31. data/lib/rubyn_code/cli/commands/cost.rb +23 -0
  32. data/lib/rubyn_code/cli/commands/diff.rb +30 -0
  33. data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
  34. data/lib/rubyn_code/cli/commands/help.rb +41 -0
  35. data/lib/rubyn_code/cli/commands/model.rb +37 -0
  36. data/lib/rubyn_code/cli/commands/plan.rb +22 -0
  37. data/lib/rubyn_code/cli/commands/quit.rb +17 -0
  38. data/lib/rubyn_code/cli/commands/registry.rb +64 -0
  39. data/lib/rubyn_code/cli/commands/resume.rb +51 -0
  40. data/lib/rubyn_code/cli/commands/review.rb +26 -0
  41. data/lib/rubyn_code/cli/commands/skill.rb +32 -0
  42. data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
  43. data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
  44. data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
  45. data/lib/rubyn_code/cli/commands/undo.rb +17 -0
  46. data/lib/rubyn_code/cli/commands/version.rb +16 -0
  47. data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
  48. data/lib/rubyn_code/cli/input_handler.rb +20 -23
  49. data/lib/rubyn_code/cli/renderer.rb +25 -27
  50. data/lib/rubyn_code/cli/repl.rb +161 -194
  51. data/lib/rubyn_code/cli/setup.rb +117 -0
  52. data/lib/rubyn_code/cli/spinner.rb +40 -40
  53. data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
  54. data/lib/rubyn_code/cli/version_check.rb +94 -0
  55. data/lib/rubyn_code/config/RUBYN.md +14 -0
  56. data/lib/rubyn_code/config/defaults.rb +28 -19
  57. data/lib/rubyn_code/config/project_config.rb +7 -9
  58. data/lib/rubyn_code/config/settings.rb +3 -3
  59. data/lib/rubyn_code/context/RUBYN.md +20 -0
  60. data/lib/rubyn_code/context/auto_compact.rb +7 -7
  61. data/lib/rubyn_code/context/compactor.rb +2 -2
  62. data/lib/rubyn_code/context/context_collapse.rb +45 -0
  63. data/lib/rubyn_code/context/manager.rb +20 -3
  64. data/lib/rubyn_code/context/manual_compact.rb +7 -7
  65. data/lib/rubyn_code/context/micro_compact.rb +12 -12
  66. data/lib/rubyn_code/db/RUBYN.md +40 -0
  67. data/lib/rubyn_code/db/connection.rb +13 -13
  68. data/lib/rubyn_code/db/migrator.rb +67 -27
  69. data/lib/rubyn_code/db/schema.rb +6 -6
  70. data/lib/rubyn_code/debug.rb +74 -0
  71. data/lib/rubyn_code/hooks/RUBYN.md +17 -0
  72. data/lib/rubyn_code/hooks/built_in.rb +9 -9
  73. data/lib/rubyn_code/hooks/registry.rb +5 -5
  74. data/lib/rubyn_code/hooks/runner.rb +1 -1
  75. data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
  76. data/lib/rubyn_code/learning/RUBYN.md +16 -0
  77. data/lib/rubyn_code/learning/extractor.rb +22 -22
  78. data/lib/rubyn_code/learning/injector.rb +17 -18
  79. data/lib/rubyn_code/learning/instinct.rb +18 -14
  80. data/lib/rubyn_code/llm/RUBYN.md +15 -0
  81. data/lib/rubyn_code/llm/client.rb +121 -55
  82. data/lib/rubyn_code/llm/message_builder.rb +19 -15
  83. data/lib/rubyn_code/llm/streaming.rb +80 -50
  84. data/lib/rubyn_code/mcp/RUBYN.md +21 -0
  85. data/lib/rubyn_code/mcp/client.rb +25 -24
  86. data/lib/rubyn_code/mcp/config.rb +7 -7
  87. data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
  88. data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
  90. data/lib/rubyn_code/memory/RUBYN.md +17 -0
  91. data/lib/rubyn_code/memory/models.rb +3 -3
  92. data/lib/rubyn_code/memory/search.rb +17 -17
  93. data/lib/rubyn_code/memory/session_persistence.rb +49 -34
  94. data/lib/rubyn_code/memory/store.rb +17 -17
  95. data/lib/rubyn_code/observability/RUBYN.md +19 -0
  96. data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
  97. data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
  98. data/lib/rubyn_code/observability/token_counter.rb +1 -1
  99. data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
  100. data/lib/rubyn_code/output/RUBYN.md +11 -0
  101. data/lib/rubyn_code/output/diff_renderer.rb +6 -6
  102. data/lib/rubyn_code/output/formatter.rb +4 -4
  103. data/lib/rubyn_code/permissions/RUBYN.md +17 -0
  104. data/lib/rubyn_code/permissions/prompter.rb +8 -8
  105. data/lib/rubyn_code/protocols/RUBYN.md +14 -0
  106. data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
  107. data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
  108. data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
  109. data/lib/rubyn_code/skills/RUBYN.md +19 -0
  110. data/lib/rubyn_code/skills/catalog.rb +7 -7
  111. data/lib/rubyn_code/skills/document.rb +15 -15
  112. data/lib/rubyn_code/skills/loader.rb +6 -8
  113. data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
  114. data/lib/rubyn_code/sub_agents/runner.rb +15 -15
  115. data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
  116. data/lib/rubyn_code/tasks/RUBYN.md +13 -0
  117. data/lib/rubyn_code/tasks/dag.rb +12 -16
  118. data/lib/rubyn_code/tasks/manager.rb +24 -24
  119. data/lib/rubyn_code/tasks/models.rb +4 -4
  120. data/lib/rubyn_code/teams/RUBYN.md +14 -0
  121. data/lib/rubyn_code/teams/mailbox.rb +38 -18
  122. data/lib/rubyn_code/teams/manager.rb +19 -19
  123. data/lib/rubyn_code/teams/teammate.rb +3 -4
  124. data/lib/rubyn_code/tools/RUBYN.md +38 -0
  125. data/lib/rubyn_code/tools/background_run.rb +9 -11
  126. data/lib/rubyn_code/tools/base.rb +54 -3
  127. data/lib/rubyn_code/tools/bash.rb +16 -34
  128. data/lib/rubyn_code/tools/bundle_add.rb +10 -12
  129. data/lib/rubyn_code/tools/bundle_install.rb +9 -11
  130. data/lib/rubyn_code/tools/compact.rb +10 -9
  131. data/lib/rubyn_code/tools/db_migrate.rb +17 -15
  132. data/lib/rubyn_code/tools/edit_file.rb +12 -12
  133. data/lib/rubyn_code/tools/executor.rb +9 -4
  134. data/lib/rubyn_code/tools/git_commit.rb +29 -34
  135. data/lib/rubyn_code/tools/git_diff.rb +17 -18
  136. data/lib/rubyn_code/tools/git_log.rb +17 -19
  137. data/lib/rubyn_code/tools/git_status.rb +18 -20
  138. data/lib/rubyn_code/tools/glob.rb +7 -9
  139. data/lib/rubyn_code/tools/grep.rb +11 -9
  140. data/lib/rubyn_code/tools/load_skill.rb +7 -7
  141. data/lib/rubyn_code/tools/memory_search.rb +13 -12
  142. data/lib/rubyn_code/tools/memory_write.rb +14 -12
  143. data/lib/rubyn_code/tools/rails_generate.rb +16 -16
  144. data/lib/rubyn_code/tools/read_file.rb +8 -7
  145. data/lib/rubyn_code/tools/read_inbox.rb +5 -5
  146. data/lib/rubyn_code/tools/registry.rb +2 -2
  147. data/lib/rubyn_code/tools/review_pr.rb +55 -55
  148. data/lib/rubyn_code/tools/run_specs.rb +20 -19
  149. data/lib/rubyn_code/tools/schema.rb +9 -11
  150. data/lib/rubyn_code/tools/send_message.rb +10 -10
  151. data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
  152. data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
  153. data/lib/rubyn_code/tools/task.rb +28 -28
  154. data/lib/rubyn_code/tools/web_fetch.rb +46 -31
  155. data/lib/rubyn_code/tools/web_search.rb +64 -66
  156. data/lib/rubyn_code/tools/write_file.rb +7 -6
  157. data/lib/rubyn_code/version.rb +1 -1
  158. data/lib/rubyn_code.rb +136 -105
  159. metadata +94 -21
@@ -4,17 +4,17 @@ module RubynCode
4
4
  module Tools
5
5
  module Schema
6
6
  TYPE_MAP = {
7
- string: "string",
8
- integer: "integer",
9
- number: "number",
10
- boolean: "boolean",
11
- array: "array",
12
- object: "object"
7
+ string: 'string',
8
+ integer: 'integer',
9
+ number: 'number',
10
+ boolean: 'boolean',
11
+ array: 'array',
12
+ object: 'object'
13
13
  }.freeze
14
14
 
15
15
  class << self
16
16
  def build(params_hash)
17
- return { type: "object", properties: {}, required: [] } if params_hash.empty?
17
+ return { type: 'object', properties: {}, required: [] } if params_hash.empty?
18
18
 
19
19
  properties = {}
20
20
  required = []
@@ -28,7 +28,7 @@ module RubynCode
28
28
  end
29
29
 
30
30
  schema = {
31
- type: "object",
31
+ type: 'object',
32
32
  properties: properties
33
33
  }
34
34
  schema[:required] = required unless required.empty?
@@ -47,9 +47,7 @@ module RubynCode
47
47
  prop[:default] = spec[:default] if spec.key?(:default)
48
48
  prop[:enum] = spec[:enum] if spec[:enum]
49
49
 
50
- if spec[:items]
51
- prop[:items] = build_property(spec[:items])
52
- end
50
+ prop[:items] = build_property(spec[:items]) if spec[:items]
53
51
 
54
52
  prop
55
53
  end
@@ -1,18 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
- require_relative "registry"
3
+ require_relative 'base'
4
+ require_relative 'registry'
5
5
 
6
6
  module RubynCode
7
7
  module Tools
8
8
  # Tool for sending messages to teammates via the team mailbox.
9
9
  class SendMessage < Base
10
- TOOL_NAME = "send_message"
11
- DESCRIPTION = "Sends a message to a teammate. Used for inter-agent communication within a team."
10
+ TOOL_NAME = 'send_message'
11
+ DESCRIPTION = 'Sends a message to a teammate. Used for inter-agent communication within a team.'
12
12
  PARAMETERS = {
13
- to: { type: :string, required: true, description: "Name of the recipient teammate" },
14
- content: { type: :string, required: true, description: "Message content to send" },
15
- message_type: { type: :string, required: false, default: "message",
13
+ to: { type: :string, required: true, description: 'Name of the recipient teammate' },
14
+ content: { type: :string, required: true, description: 'Message content to send' },
15
+ message_type: { type: :string, required: false, default: 'message',
16
16
  description: 'Type of message (default: "message")' }
17
17
  }.freeze
18
18
  RISK_LEVEL = :write
@@ -33,9 +33,9 @@ module RubynCode
33
33
  # @param content [String] message body
34
34
  # @param message_type [String] type of message
35
35
  # @return [String] confirmation with the message id
36
- def execute(to:, content:, message_type: "message")
37
- raise Error, "Recipient name is required" if to.nil? || to.strip.empty?
38
- raise Error, "Message content is required" if content.nil? || content.strip.empty?
36
+ def execute(to:, content:, message_type: 'message')
37
+ raise Error, 'Recipient name is required' if to.nil? || to.strip.empty?
38
+ raise Error, 'Message content is required' if content.nil? || content.strip.empty?
39
39
 
40
40
  message_id = @mailbox.send(
41
41
  from: @sender_name,
@@ -1,19 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
- require_relative "registry"
3
+ require_relative 'base'
4
+ require_relative 'registry'
5
5
 
6
6
  module RubynCode
7
7
  module Tools
8
8
  class SpawnAgent < Base
9
- TOOL_NAME = "spawn_agent"
10
- DESCRIPTION = "Spawn an isolated sub-agent to handle a task. The sub-agent gets its own fresh context, " \
9
+ TOOL_NAME = 'spawn_agent'
10
+ DESCRIPTION = 'Spawn an isolated sub-agent to handle a task. The sub-agent gets its own fresh context, ' \
11
11
  "works independently, and returns only a summary. Use 'explore' type for research/reading, " \
12
12
  "'worker' type for writing code/files. The sub-agent shares the filesystem but not your conversation."
13
13
  PARAMETERS = {
14
14
  prompt: {
15
15
  type: :string,
16
- description: "The task for the sub-agent to perform",
16
+ description: 'The task for the sub-agent to perform',
17
17
  required: true
18
18
  },
19
19
  agent_type: {
@@ -28,7 +28,7 @@ module RubynCode
28
28
  # These get injected by the executor or the REPL
29
29
  attr_writer :llm_client, :on_status
30
30
 
31
- def execute(prompt:, agent_type: "explore")
31
+ def execute(prompt:, agent_type: 'explore')
32
32
  type = agent_type.to_sym
33
33
  callback = @on_status || method(:default_status)
34
34
  @tool_count = 0
@@ -37,25 +37,53 @@ module RubynCode
37
37
 
38
38
  tools = tools_for_type(type)
39
39
 
40
- result = run_sub_agent(prompt: prompt, tools: tools, type: type, callback: callback)
40
+ result, hit_limit = run_sub_agent(prompt: prompt, tools: tools, type: type, callback: callback)
41
41
 
42
42
  callback.call(:done, "Agent finished (#{@tool_count} tool calls).")
43
43
 
44
44
  summary = RubynCode::SubAgents::Summarizer.call(result, max_length: 3000)
45
- "## Sub-Agent Result (#{type})\n\n#{summary}"
45
+
46
+ if hit_limit
47
+ "## Sub-Agent Result (#{type}) — INCOMPLETE (reached #{@tool_count} tool calls)\n\n" \
48
+ "The sub-agent ran out of turns before finishing. Here is what it accomplished so far:\n\n#{summary}"
49
+ else
50
+ "## Sub-Agent Result (#{type})\n\n#{summary}"
51
+ end
46
52
  end
47
53
 
48
54
  private
49
55
 
56
+ # Returns [result_text, hit_limit] tuple
50
57
  def run_sub_agent(prompt:, tools:, type:, callback:)
51
58
  conversation = RubynCode::Agent::Conversation.new
52
59
  conversation.add_user_message(prompt)
53
60
 
54
- max_iterations = type == :explore ? 20 : 30
61
+ max_iterations = if type == :explore
62
+ Config::Defaults::MAX_EXPLORE_AGENT_ITERATIONS
63
+ else
64
+ Config::Defaults::MAX_SUB_AGENT_ITERATIONS
65
+ end
55
66
  iteration = 0
67
+ last_text = nil
56
68
 
57
69
  loop do
58
- break if iteration >= max_iterations
70
+ if iteration >= max_iterations
71
+ # Ask the LLM for a final summary of what it accomplished so far
72
+ conversation.add_user_message(
73
+ 'You have reached your turn limit. Summarize everything you found or accomplished so far. ' \
74
+ 'Be thorough — this is your last chance to report back.'
75
+ )
76
+ response = @llm_client.chat(
77
+ messages: conversation.to_api_format,
78
+ tools: [],
79
+ system: sub_agent_system_prompt(type)
80
+ )
81
+ content = response.respond_to?(:content) ? Array(response.content) : []
82
+ text_blocks = content.select { |b| b.respond_to?(:type) && b.type == 'text' }
83
+ summary = text_blocks.map(&:text).join("\n")
84
+
85
+ return [summary.empty? ? (last_text || '') : summary, true]
86
+ end
59
87
 
60
88
  response = @llm_client.chat(
61
89
  messages: conversation.to_api_format,
@@ -64,14 +92,15 @@ module RubynCode
64
92
  )
65
93
 
66
94
  content = response.respond_to?(:content) ? Array(response.content) : []
67
- tool_calls = content.select { |b| b.respond_to?(:type) && b.type == "tool_use" }
95
+ tool_calls = content.select { |b| b.respond_to?(:type) && b.type == 'tool_use' }
96
+
97
+ # Track the latest text output for partial results
98
+ text_blocks = content.select { |b| b.respond_to?(:type) && b.type == 'text' }
99
+ last_text = text_blocks.map(&:text).join("\n") unless text_blocks.empty?
68
100
 
69
101
  if tool_calls.empty?
70
- # Final text response
71
- text = content.select { |b| b.respond_to?(:type) && b.type == "text" }
72
- .map(&:text).join("\n")
73
102
  conversation.add_assistant_message(content)
74
- return text
103
+ return [last_text || '', false]
75
104
  end
76
105
 
77
106
  # Add assistant message with tool calls
@@ -84,20 +113,21 @@ module RubynCode
84
113
  id = tc.respond_to?(:id) ? tc.id : tc[:id]
85
114
 
86
115
  @tool_count += 1
87
- callback.call(:tool, "#{name}")
116
+ callback.call(:tool, name.to_s)
88
117
 
89
118
  begin
90
119
  tool_class = RubynCode::Tools::Registry.get(name)
91
120
 
92
121
  # Block recursive spawning
93
122
  if %w[spawn_agent].include?(name)
94
- conversation.add_tool_result(id, name, "Error: Sub-agents cannot spawn other agents.", is_error: true)
123
+ conversation.add_tool_result(id, name, 'Error: Sub-agents cannot spawn other agents.', is_error: true)
95
124
  next
96
125
  end
97
126
 
98
127
  # Block write tools for explore agents
99
128
  if type == :explore && tool_class.risk_level != :read
100
- conversation.add_tool_result(id, name, "Error: Explore agents can only use read-only tools.", is_error: true)
129
+ conversation.add_tool_result(id, name, 'Error: Explore agents can only use read-only tools.',
130
+ is_error: true)
101
131
  next
102
132
  end
103
133
 
@@ -113,8 +143,6 @@ module RubynCode
113
143
 
114
144
  iteration += 1
115
145
  end
116
-
117
- "Sub-agent reached iteration limit (#{max_iterations})."
118
146
  end
119
147
 
120
148
  def tools_for_type(type)
@@ -132,7 +160,7 @@ module RubynCode
132
160
  end
133
161
 
134
162
  def sub_agent_system_prompt(type)
135
- base = "You are a Rubyn sub-agent. Complete your task efficiently and return a clear summary of what you found or did."
163
+ base = 'You are a Rubyn sub-agent. Complete your task efficiently and return a clear summary of what you found or did.'
136
164
 
137
165
  case type
138
166
  when :explore
@@ -144,8 +172,8 @@ module RubynCode
144
172
  end
145
173
  end
146
174
 
147
- def default_status(type, message)
148
- $stderr.puts "[sub-agent] #{message}" if ENV["RUBYN_DEBUG"]
175
+ def default_status(_type, message)
176
+ RubynCode::Debug.agent("sub-agent: #{message}")
149
177
  end
150
178
  end
151
179
 
@@ -1,19 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
- require_relative "registry"
3
+ require_relative 'base'
4
+ require_relative 'registry'
5
5
 
6
6
  module RubynCode
7
7
  module Tools
8
8
  class SpawnTeammate < Base
9
- TOOL_NAME = "spawn_teammate"
10
- DESCRIPTION = "Spawn a persistent named teammate agent with a role and an initial task. " \
11
- "The teammate gets its own conversation, processes the initial prompt, and " \
12
- "remains available via the mailbox for further messages."
9
+ TOOL_NAME = 'spawn_teammate'
10
+ DESCRIPTION = 'Spawn a persistent named teammate agent with a role and an initial task. ' \
11
+ 'The teammate gets its own conversation, processes the initial prompt, and ' \
12
+ 'remains available via the mailbox for further messages.'
13
13
  PARAMETERS = {
14
14
  name: {
15
15
  type: :string,
16
- description: "Unique name for the teammate",
16
+ description: 'Unique name for the teammate',
17
17
  required: true
18
18
  },
19
19
  role: {
@@ -23,7 +23,7 @@ module RubynCode
23
23
  },
24
24
  prompt: {
25
25
  type: :string,
26
- description: "Initial task or instruction for the teammate",
26
+ description: 'Initial task or instruction for the teammate',
27
27
  required: true
28
28
  }
29
29
  }.freeze
@@ -34,8 +34,8 @@ module RubynCode
34
34
  def execute(name:, role:, prompt:)
35
35
  callback = @on_status || method(:default_status)
36
36
 
37
- raise Error, "LLM client not available" unless @llm_client
38
- raise Error, "Database not available" unless @db
37
+ raise Error, 'LLM client not available' unless @llm_client
38
+ raise Error, 'Database not available' unless @db
39
39
 
40
40
  mailbox = Teams::Mailbox.new(@db)
41
41
  manager = Teams::Manager.new(@db, mailbox: mailbox)
@@ -58,8 +58,8 @@ module RubynCode
58
58
  conversation.add_user_message(initial_prompt)
59
59
 
60
60
  system_prompt = "You are #{teammate.name}, a #{teammate.role} teammate agent. " \
61
- "Complete tasks efficiently. Use tools when needed. " \
62
- "When done, provide a clear summary of what you accomplished."
61
+ 'Complete tasks efficiently. Use tools when needed. ' \
62
+ 'When done, provide a clear summary of what you accomplished.'
63
63
 
64
64
  tools = tools_for_teammate
65
65
  max_iterations = Config::Defaults::MAX_SUB_AGENT_ITERATIONS
@@ -72,16 +72,16 @@ module RubynCode
72
72
  )
73
73
 
74
74
  content = response.respond_to?(:content) ? Array(response.content) : []
75
- tool_calls = content.select { |b| b.respond_to?(:type) && b.type == "tool_use" }
75
+ tool_calls = content.select { |b| b.respond_to?(:type) && b.type == 'tool_use' }
76
76
 
77
77
  if tool_calls.empty?
78
- text = content.select { |b| b.respond_to?(:type) && b.type == "text" }
78
+ text = content.select { |b| b.respond_to?(:type) && b.type == 'text' }
79
79
  .map(&:text).join("\n")
80
80
  conversation.add_assistant_message(content)
81
81
  callback.call(:done, "Teammate '#{teammate.name}' finished initial task.")
82
82
 
83
83
  # Send result back to main agent inbox
84
- mailbox.send(from: teammate.name, to: "rubyn", content: text)
84
+ mailbox.send(from: teammate.name, to: 'rubyn', content: text)
85
85
 
86
86
  # Now loop waiting for new messages
87
87
  poll_inbox(teammate, conversation, tools, system_prompt, mailbox, callback)
@@ -95,10 +95,10 @@ module RubynCode
95
95
  callback.call(:done, "Teammate '#{teammate.name}' reached iteration limit.")
96
96
  rescue StandardError => e
97
97
  callback.call(:done, "Teammate '#{teammate.name}' error: #{e.message}")
98
- $stderr.puts "[Teammate #{teammate.name}] Error: #{e.class}: #{e.message}" if ENV["RUBYN_DEBUG"]
98
+ RubynCode::Debug.agent("Teammate #{teammate.name} error: #{e.class}: #{e.message}")
99
99
  end
100
100
 
101
- def poll_inbox(teammate, conversation, tools, system_prompt, mailbox, callback)
101
+ def poll_inbox(teammate, conversation, tools, system_prompt, mailbox, _callback)
102
102
  loop do
103
103
  sleep Config::Defaults::POLL_INTERVAL
104
104
 
@@ -117,13 +117,13 @@ module RubynCode
117
117
  content = response.respond_to?(:content) ? Array(response.content) : []
118
118
  conversation.add_assistant_message(content)
119
119
 
120
- text = content.select { |b| b.respond_to?(:type) && b.type == "text" }
120
+ text = content.select { |b| b.respond_to?(:type) && b.type == 'text' }
121
121
  .map(&:text).join("\n")
122
122
  mailbox.send(from: teammate.name, to: msg[:from], content: text) unless text.empty?
123
123
  end
124
124
  end
125
125
  rescue StandardError => e
126
- $stderr.puts "[Teammate #{teammate.name}] Poll error: #{e.message}" if ENV["RUBYN_DEBUG"]
126
+ RubynCode::Debug.agent("Teammate #{teammate.name} poll error: #{e.message}")
127
127
  end
128
128
 
129
129
  def execute_tool_calls(tool_calls, conversation, callback)
@@ -137,7 +137,7 @@ module RubynCode
137
137
  begin
138
138
  # Block recursive spawning
139
139
  if %w[spawn_agent spawn_teammate].include?(name)
140
- conversation.add_tool_result(id, name, "Error: Teammates cannot spawn other agents.", is_error: true)
140
+ conversation.add_tool_result(id, name, 'Error: Teammates cannot spawn other agents.', is_error: true)
141
141
  next
142
142
  end
143
143
 
@@ -159,7 +159,7 @@ module RubynCode
159
159
  end
160
160
 
161
161
  def default_status(_type, message)
162
- $stderr.puts "[spawn_teammate] #{message}" if ENV["RUBYN_DEBUG"]
162
+ RubynCode::Debug.agent("spawn_teammate: #{message}")
163
163
  end
164
164
  end
165
165
 
@@ -1,53 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
- require_relative "registry"
3
+ require_relative 'base'
4
+ require_relative 'registry'
5
5
 
6
6
  module RubynCode
7
7
  module Tools
8
8
  class Task < Base
9
- TOOL_NAME = "task"
10
- DESCRIPTION = "Manage tasks: create, update, complete, list, or get tasks for tracking work items and dependencies."
9
+ TOOL_NAME = 'task'
10
+ DESCRIPTION = 'Manage tasks: create, update, complete, list, or get tasks for tracking work items and dependencies.'
11
11
  PARAMETERS = {
12
12
  action: {
13
13
  type: :string, required: true,
14
- description: "Action to perform: create, update, complete, list, get"
14
+ description: 'Action to perform: create, update, complete, list, get'
15
15
  },
16
16
  title: {
17
17
  type: :string, required: false,
18
- description: "Task title (required for create)"
18
+ description: 'Task title (required for create)'
19
19
  },
20
20
  description: {
21
21
  type: :string, required: false,
22
- description: "Task description"
22
+ description: 'Task description'
23
23
  },
24
24
  task_id: {
25
25
  type: :string, required: false,
26
- description: "Task ID (required for update, complete, get)"
26
+ description: 'Task ID (required for update, complete, get)'
27
27
  },
28
28
  status: {
29
29
  type: :string, required: false,
30
- description: "Filter by status (for list) or set status (for update)"
30
+ description: 'Filter by status (for list) or set status (for update)'
31
31
  },
32
32
  session_id: {
33
33
  type: :string, required: false,
34
- description: "Session ID for scoping tasks"
34
+ description: 'Session ID for scoping tasks'
35
35
  },
36
36
  priority: {
37
37
  type: :integer, required: false,
38
- description: "Task priority (higher = more important)"
38
+ description: 'Task priority (higher = more important)'
39
39
  },
40
40
  blocked_by: {
41
41
  type: :array, required: false,
42
- description: "Array of task IDs this task depends on (for create)"
42
+ description: 'Array of task IDs this task depends on (for create)'
43
43
  },
44
44
  result: {
45
45
  type: :string, required: false,
46
- description: "Result text (for complete)"
46
+ description: 'Result text (for complete)'
47
47
  },
48
48
  owner: {
49
49
  type: :string, required: false,
50
- description: "Owner identifier (for update)"
50
+ description: 'Owner identifier (for update)'
51
51
  }
52
52
  }.freeze
53
53
  RISK_LEVEL = :write
@@ -57,11 +57,11 @@ module RubynCode
57
57
  manager = Tasks::Manager.new(DB::Connection.instance)
58
58
 
59
59
  case action
60
- when "create" then execute_create(manager, **params)
61
- when "update" then execute_update(manager, **params)
62
- when "complete" then execute_complete(manager, **params)
63
- when "list" then execute_list(manager, **params)
64
- when "get" then execute_get(manager, **params)
60
+ when 'create' then execute_create(manager, **params)
61
+ when 'update' then execute_update(manager, **params)
62
+ when 'complete' then execute_complete(manager, **params)
63
+ when 'list' then execute_list(manager, **params)
64
+ when 'get' then execute_get(manager, **params)
65
65
  else
66
66
  raise Error, "Unknown task action: #{action}. Valid actions: create, update, complete, list, get"
67
67
  end
@@ -70,7 +70,7 @@ module RubynCode
70
70
  private
71
71
 
72
72
  def execute_create(manager, title: nil, description: nil, session_id: nil, blocked_by: [], priority: 0, **)
73
- raise Error, "title is required for create" if title.nil? || title.empty?
73
+ raise Error, 'title is required for create' if title.nil? || title.empty?
74
74
 
75
75
  task = manager.create(
76
76
  title: title,
@@ -80,11 +80,11 @@ module RubynCode
80
80
  priority: priority.to_i
81
81
  )
82
82
 
83
- format_task(task, prefix: "Created task")
83
+ format_task(task, prefix: 'Created task')
84
84
  end
85
85
 
86
86
  def execute_update(manager, task_id: nil, **params)
87
- raise Error, "task_id is required for update" if task_id.nil? || task_id.empty?
87
+ raise Error, 'task_id is required for update' if task_id.nil? || task_id.empty?
88
88
 
89
89
  attrs = params.slice(:status, :priority, :owner, :result, :description, :title, :metadata)
90
90
  attrs[:priority] = attrs[:priority].to_i if attrs.key?(:priority)
@@ -92,29 +92,29 @@ module RubynCode
92
92
  task = manager.update(task_id, **attrs)
93
93
  raise Error, "Task not found: #{task_id}" if task.nil?
94
94
 
95
- format_task(task, prefix: "Updated task")
95
+ format_task(task, prefix: 'Updated task')
96
96
  end
97
97
 
98
98
  def execute_complete(manager, task_id: nil, result: nil, **)
99
- raise Error, "task_id is required for complete" if task_id.nil? || task_id.empty?
99
+ raise Error, 'task_id is required for complete' if task_id.nil? || task_id.empty?
100
100
 
101
101
  task = manager.complete(task_id, result: result)
102
102
  raise Error, "Task not found: #{task_id}" if task.nil?
103
103
 
104
- format_task(task, prefix: "Completed task")
104
+ format_task(task, prefix: 'Completed task')
105
105
  end
106
106
 
107
107
  def execute_list(manager, status: nil, session_id: nil, **)
108
108
  tasks = manager.list(status: status, session_id: session_id)
109
109
 
110
- return "No tasks found." if tasks.empty?
110
+ return 'No tasks found.' if tasks.empty?
111
111
 
112
112
  lines = tasks.map { |t| format_task_line(t) }
113
113
  "Found #{tasks.size} task(s):\n\n#{lines.join("\n")}"
114
114
  end
115
115
 
116
116
  def execute_get(manager, task_id: nil, **)
117
- raise Error, "task_id is required for get" if task_id.nil? || task_id.empty?
117
+ raise Error, 'task_id is required for get' if task_id.nil? || task_id.empty?
118
118
 
119
119
  task = manager.get(task_id)
120
120
  raise Error, "Task not found: #{task_id}" if task.nil?
@@ -138,7 +138,7 @@ module RubynCode
138
138
  end
139
139
 
140
140
  def format_task_line(task)
141
- owner_part = task.owner ? " (#{task.owner})" : ""
141
+ owner_part = task.owner ? " (#{task.owner})" : ''
142
142
  "[#{task.status}] #{task.title} (#{task.id[0, 8]}...)#{owner_part} priority=#{task.priority}"
143
143
  end
144
144
  end
@@ -1,17 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "faraday"
4
- require_relative "base"
5
- require_relative "registry"
3
+ require 'faraday'
4
+ require_relative 'base'
5
+ require_relative 'registry'
6
6
 
7
7
  module RubynCode
8
8
  module Tools
9
9
  class WebFetch < Base
10
- TOOL_NAME = "web_fetch"
11
- DESCRIPTION = "Fetch the content of a web page and return it as text. Useful for reading documentation, READMEs, or API docs."
10
+ TOOL_NAME = 'web_fetch'
11
+ DESCRIPTION = 'Fetch the content of a web page and return it as text. Useful for reading documentation, READMEs, or API docs.'
12
12
  PARAMETERS = {
13
- url: { type: :string, required: true, description: "The URL to fetch (must start with http:// or https://)" },
14
- max_length: { type: :integer, required: false, default: 10_000, description: "Maximum number of characters to return (default: 10000)" }
13
+ url: { type: :string, required: true, description: 'The URL to fetch (must start with http:// or https://)' },
14
+ max_length: { type: :integer, required: false, default: 10_000,
15
+ description: 'Maximum number of characters to return (default: 10000)' }
15
16
  }.freeze
16
17
  RISK_LEVEL = :external
17
18
  REQUIRES_CONFIRMATION = true
@@ -27,9 +28,14 @@ module RubynCode
27
28
  if text.strip.empty?
28
29
  "Fetched #{url} but no readable text content was found."
29
30
  else
30
- header = "Content from: #{url}\n#{"=" * 60}\n\n"
31
+ header = "Content from: #{url}\n#{'=' * 60}\n\n"
31
32
  available = max_length - header.length
32
- content = text.length > available ? "#{text[0, available]}\n\n... [truncated at #{max_length} characters]" : text
33
+ content = if text.length > available
34
+ "#{text[0,
35
+ available]}\n\n... [truncated at #{max_length} characters]"
36
+ else
37
+ text
38
+ end
33
39
  "#{header}#{content}"
34
40
  end
35
41
  end
@@ -37,26 +43,35 @@ module RubynCode
37
43
  private
38
44
 
39
45
  def validate_url!(url)
40
- unless url.match?(%r{\Ahttps?://}i)
41
- raise Error, "Invalid URL: must start with http:// or https:// — got: #{url}"
42
- end
46
+ return if url.match?(%r{\Ahttps?://}i)
47
+
48
+ raise Error, "Invalid URL: must start with http:// or https:// — got: #{url}"
43
49
  end
44
50
 
45
- def fetch_page(url)
51
+ MAX_REDIRECTS = 5
52
+
53
+ def fetch_page(url, redirects: 0)
46
54
  conn = Faraday.new do |f|
47
55
  f.options.timeout = 30
48
56
  f.options.open_timeout = 10
49
- f.headers["User-Agent"] = "Mozilla/5.0 (compatible; RubynCode/1.0)"
50
- f.headers["Accept"] = "text/html,application/xhtml+xml,text/plain,*/*"
51
- f.response :follow_redirects, limit: 5
57
+ f.headers['User-Agent'] = 'Mozilla/5.0 (compatible; RubynCode/1.0)'
58
+ f.headers['Accept'] = 'text/html,application/xhtml+xml,text/plain,*/*'
52
59
  end
53
60
 
54
61
  response = conn.get(url)
55
62
 
56
- unless response.success?
57
- raise Error, "HTTP #{response.status} fetching #{url}"
63
+ if [301, 302, 303, 307, 308].include?(response.status)
64
+ raise Error, "Too many redirects fetching #{url}" if redirects >= MAX_REDIRECTS
65
+
66
+ location = response.headers['location']
67
+ raise Error, "Redirect with no Location header from #{url}" unless location
68
+
69
+ location = URI.join(url, location).to_s unless location.start_with?('http')
70
+ return fetch_page(location, redirects: redirects + 1)
58
71
  end
59
72
 
73
+ raise Error, "HTTP #{response.status} fetching #{url}" unless response.success?
74
+
60
75
  response
61
76
  rescue Faraday::TimeoutError
62
77
  raise Error, "Request timed out after 30 seconds fetching #{url}"
@@ -67,37 +82,37 @@ module RubynCode
67
82
  end
68
83
 
69
84
  def html_to_text(html)
70
- return "" if html.nil? || html.empty?
85
+ return '' if html.nil? || html.empty?
71
86
 
72
87
  text = html.dup
73
88
 
74
89
  # Remove script and style blocks entirely
75
- text.gsub!(%r{<script[^>]*>.*?</script>}mi, "")
76
- text.gsub!(%r{<style[^>]*>.*?</style>}mi, "")
90
+ text.gsub!(%r{<script[^>]*>.*?</script>}mi, '')
91
+ text.gsub!(%r{<style[^>]*>.*?</style>}mi, '')
77
92
 
78
93
  # Convert common block elements to newlines
79
94
  text.gsub!(%r{<br\s*/?>}i, "\n")
80
95
  text.gsub!(%r{</(p|div|h[1-6]|li|tr|blockquote|pre)>}i, "\n")
81
- text.gsub!(%r{<(p|div|h[1-6]|li|tr|blockquote|pre)[^>]*>}i, "\n")
96
+ text.gsub!(/<(p|div|h[1-6]|li|tr|blockquote|pre)[^>]*>/i, "\n")
82
97
 
83
98
  # Strip all remaining HTML tags
84
- text.gsub!(/<[^>]*>/, "")
99
+ text.gsub!(/<[^>]*>/, '')
85
100
 
86
101
  # Decode common HTML entities
87
- text.gsub!("&amp;", "&")
88
- text.gsub!("&lt;", "<")
89
- text.gsub!("&gt;", ">")
90
- text.gsub!("&quot;", '"')
91
- text.gsub!("&#39;", "'")
92
- text.gsub!("&nbsp;", " ")
93
- text.gsub!(/&#(\d+);/) { [$1.to_i].pack("U") }
102
+ text.gsub!('&amp;', '&')
103
+ text.gsub!('&lt;', '<')
104
+ text.gsub!('&gt;', '>')
105
+ text.gsub!('&quot;', '"')
106
+ text.gsub!('&#39;', "'")
107
+ text.gsub!('&nbsp;', ' ')
108
+ text.gsub!(/&#(\d+);/) { [::Regexp.last_match(1).to_i].pack('U') }
94
109
 
95
110
  text
96
111
  end
97
112
 
98
113
  def collapse_whitespace(text)
99
114
  # Collapse runs of spaces/tabs on each line, then collapse 3+ newlines into 2
100
- text.gsub(/[^\S\n]+/, " ")
115
+ text.gsub(/[^\S\n]+/, ' ')
101
116
  .gsub(/\n{3,}/, "\n\n")
102
117
  .strip
103
118
  end