rubyn-code 0.2.2 → 0.3.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. metadata +53 -1
@@ -7,9 +7,10 @@ module RubynCode
7
7
  module Tools
8
8
  class SpawnAgent < Base
9
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
- "works independently, and returns only a summary. Use 'explore' type for research/reading, " \
12
- "'worker' type for writing code/files. The sub-agent shares the filesystem but not your conversation."
10
+ DESCRIPTION = 'Spawn an isolated sub-agent to handle a task. The sub-agent gets its own ' \
11
+ "fresh context, works independently, and returns only a summary. Use 'explore' " \
12
+ "type for research/reading, 'worker' type for writing code/files. The sub-agent " \
13
+ 'shares the filesystem but not your conversation.'
13
14
  PARAMETERS = {
14
15
  prompt: {
15
16
  type: :string,
@@ -18,7 +19,7 @@ module RubynCode
18
19
  },
19
20
  agent_type: {
20
21
  type: :string,
21
- description: "Type of agent: 'explore' (read-only tools) or 'worker' (full write access). Default: explore",
22
+ description: "Type of agent: 'explore' (read-only) or 'worker' (full write access). Default: explore",
22
23
  required: false,
23
24
  enum: %w[explore worker]
24
25
  }
@@ -36,113 +37,121 @@ module RubynCode
36
37
  callback.call(:started, "Spawning #{type} agent...")
37
38
 
38
39
  tools = tools_for_type(type)
39
-
40
- result, hit_limit = run_sub_agent(prompt: prompt, tools: tools, type: type, callback: callback)
40
+ result, hit_limit = run_sub_agent(
41
+ prompt: prompt, tools: tools, type: type, callback: callback
42
+ )
41
43
 
42
44
  callback.call(:done, "Agent finished (#{@tool_count} tool calls).")
43
45
 
44
46
  summary = RubynCode::SubAgents::Summarizer.call(result, max_length: 3000)
47
+ format_agent_result(type, summary, hit_limit)
48
+ end
49
+
50
+ private
45
51
 
52
+ def format_agent_result(type, summary, hit_limit)
46
53
  if hit_limit
47
54
  "## 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}"
55
+ 'The sub-agent ran out of turns before finishing. Here is what it accomplished so far:' \
56
+ "\n\n#{summary}"
49
57
  else
50
58
  "## Sub-Agent Result (#{type})\n\n#{summary}"
51
59
  end
52
60
  end
53
61
 
54
- private
62
+ def max_iterations_for(type)
63
+ if type == :explore
64
+ Config::Defaults::MAX_EXPLORE_AGENT_ITERATIONS
65
+ else
66
+ Config::Defaults::MAX_SUB_AGENT_ITERATIONS
67
+ end
68
+ end
55
69
 
56
70
  # Returns [result_text, hit_limit] tuple
57
71
  def run_sub_agent(prompt:, tools:, type:, callback:)
58
72
  conversation = RubynCode::Agent::Conversation.new
59
73
  conversation.add_user_message(prompt)
60
74
 
61
- max_iterations = if type == :explore
62
- Config::Defaults::MAX_EXPLORE_AGENT_ITERATIONS
63
- else
64
- Config::Defaults::MAX_SUB_AGENT_ITERATIONS
65
- end
75
+ max_iterations = max_iterations_for(type)
66
76
  iteration = 0
67
77
  last_text = nil
68
78
 
69
79
  loop do
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
87
-
88
- response = @llm_client.chat(
89
- messages: conversation.to_api_format,
90
- tools: tools,
91
- system: sub_agent_system_prompt(type)
92
- )
80
+ return finish_at_limit(conversation, type, last_text) if iteration >= max_iterations
93
81
 
94
- content = response.respond_to?(:content) ? Array(response.content) : []
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?
82
+ last_text, done = process_iteration(
83
+ conversation, tools, type, callback, last_text
84
+ )
85
+ return [last_text || '', false] if done
100
86
 
101
- if tool_calls.empty?
102
- conversation.add_assistant_message(content)
103
- return [last_text || '', false]
104
- end
87
+ iteration += 1
88
+ end
89
+ end
105
90
 
106
- # Add assistant message with tool calls
107
- conversation.add_assistant_message(content)
91
+ def finish_at_limit(conversation, type, last_text)
92
+ conversation.add_user_message(
93
+ 'You have reached your turn limit. Summarize everything you found or ' \
94
+ 'accomplished so far. Be thorough — this is your last chance to report back.'
95
+ )
96
+ response = @llm_client.chat(
97
+ messages: conversation.to_api_format,
98
+ tools: [],
99
+ system: sub_agent_system_prompt(type)
100
+ )
101
+ summary = extract_text(response)
102
+ [summary.empty? ? (last_text || '') : summary, true]
103
+ end
108
104
 
109
- # Execute each tool call
110
- tool_calls.each do |tc|
111
- name = tc.respond_to?(:name) ? tc.name : tc[:name]
112
- input = tc.respond_to?(:input) ? tc.input : tc[:input]
113
- id = tc.respond_to?(:id) ? tc.id : tc[:id]
105
+ def process_iteration(conversation, tools, type, callback, last_text)
106
+ response = @llm_client.chat(
107
+ messages: conversation.to_api_format,
108
+ tools: tools,
109
+ system: sub_agent_system_prompt(type)
110
+ )
114
111
 
115
- @tool_count += 1
116
- callback.call(:tool, name.to_s)
112
+ content = response_content(response)
113
+ tool_calls = content.select { |b| block_type?(b, 'tool_use') }
114
+ text_blocks = content.select { |b| block_type?(b, 'text') }
115
+ last_text = text_blocks.map(&:text).join("\n") unless text_blocks.empty?
117
116
 
118
- begin
119
- tool_class = RubynCode::Tools::Registry.get(name)
117
+ conversation.add_assistant_message(content)
118
+ return [last_text, true] if tool_calls.empty?
120
119
 
121
- # Block recursive spawning
122
- if %w[spawn_agent].include?(name)
123
- conversation.add_tool_result(id, name, 'Error: Sub-agents cannot spawn other agents.', is_error: true)
124
- next
125
- end
120
+ execute_sub_agent_tools(tool_calls, conversation, type, callback)
121
+ [last_text, false]
122
+ end
126
123
 
127
- # Block write tools for explore agents
128
- if type == :explore && tool_class.risk_level != :read
129
- conversation.add_tool_result(id, name, 'Error: Explore agents can only use read-only tools.',
130
- is_error: true)
131
- next
132
- end
124
+ def execute_sub_agent_tools(tool_calls, conversation, type, callback)
125
+ tool_calls.each do |tc|
126
+ name, input, id = extract_tool_call(tc)
127
+ @tool_count += 1
128
+ callback.call(:tool, name.to_s)
133
129
 
134
- tool = tool_class.new(project_root: project_root)
135
- result = tool.execute(**input.transform_keys(&:to_sym))
136
- truncated = tool.truncate(result.to_s)
130
+ run_single_tool(name, input, id, conversation, type)
131
+ end
132
+ end
137
133
 
138
- conversation.add_tool_result(id, name, truncated)
139
- rescue StandardError => e
140
- conversation.add_tool_result(id, name, "Error: #{e.message}", is_error: true)
141
- end
142
- end
134
+ def run_single_tool(name, input, id, conversation, type)
135
+ if %w[spawn_agent].include?(name)
136
+ conversation.add_tool_result(
137
+ id, name, 'Error: Sub-agents cannot spawn other agents.', is_error: true
138
+ )
139
+ return
140
+ end
143
141
 
144
- iteration += 1
142
+ tool_class = RubynCode::Tools::Registry.get(name)
143
+ if type == :explore && tool_class.risk_level != :read
144
+ conversation.add_tool_result(
145
+ id, name, 'Error: Explore agents can only use read-only tools.', is_error: true
146
+ )
147
+ return
145
148
  end
149
+
150
+ tool = tool_class.new(project_root: project_root)
151
+ result = tool.execute(**input.transform_keys(&:to_sym))
152
+ conversation.add_tool_result(id, name, tool.truncate(result.to_s))
153
+ rescue StandardError => e
154
+ conversation.add_tool_result(id, name, "Error: #{e.message}", is_error: true)
146
155
  end
147
156
 
148
157
  def tools_for_type(type)
@@ -150,28 +159,50 @@ module RubynCode
150
159
  blocked = %w[spawn_agent send_message read_inbox compact memory_write]
151
160
 
152
161
  if type == :explore
153
- # Read-only tools
154
162
  read_tools = %w[read_file glob grep bash load_skill memory_search]
155
163
  all_tools.select { |t| read_tools.include?(t[:name]) }
156
164
  else
157
- # Worker gets everything except agent-spawning and team tools
158
165
  all_tools.reject { |t| blocked.include?(t[:name]) }
159
166
  end
160
167
  end
161
168
 
162
169
  def sub_agent_system_prompt(type)
163
- base = 'You are a Rubyn sub-agent. Complete your task efficiently and return a clear summary of what you found or did.'
170
+ base = 'You are a Rubyn sub-agent. Complete your task efficiently and ' \
171
+ 'return a clear summary of what you found or did.'
164
172
 
165
173
  case type
166
174
  when :explore
167
- "#{base}\nYou have read-only access. Search, read files, and analyze. Do NOT attempt to write or modify anything."
175
+ "#{base}\nYou have read-only access. Search, read files, and analyze. " \
176
+ 'Do NOT attempt to write or modify anything.'
168
177
  when :worker
169
- "#{base}\nYou have full read/write access. Make the changes needed, run tests if appropriate, and report what you did."
178
+ "#{base}\nYou have full read/write access. Make the changes needed, " \
179
+ 'run tests if appropriate, and report what you did.'
170
180
  else
171
181
  base
172
182
  end
173
183
  end
174
184
 
185
+ def extract_text(response)
186
+ content = response_content(response)
187
+ text_blocks = content.select { |b| block_type?(b, 'text') }
188
+ text_blocks.map(&:text).join("\n")
189
+ end
190
+
191
+ def response_content(response)
192
+ response.respond_to?(:content) ? Array(response.content) : []
193
+ end
194
+
195
+ def block_type?(block, type)
196
+ block.respond_to?(:type) && block.type == type
197
+ end
198
+
199
+ def extract_tool_call(tool_call)
200
+ name = tool_call.respond_to?(:name) ? tool_call.name : tool_call[:name]
201
+ input = tool_call.respond_to?(:input) ? tool_call.input : tool_call[:input]
202
+ call_id = tool_call.respond_to?(:id) ? tool_call.id : tool_call[:id]
203
+ [name, input, call_id]
204
+ end
205
+
175
206
  def default_status(_type, message)
176
207
  RubynCode::Debug.agent("sub-agent: #{message}")
177
208
  end
@@ -43,7 +43,6 @@ module RubynCode
43
43
  teammate = manager.spawn(name: name, role: role)
44
44
  callback.call(:started, "Spawning teammate '#{name}' as #{role}...")
45
45
 
46
- # Spawn a background thread running the teammate agent
47
46
  Thread.new do
48
47
  run_teammate_agent(teammate, prompt, mailbox, callback)
49
48
  end
@@ -57,48 +56,63 @@ module RubynCode
57
56
  conversation = Agent::Conversation.new
58
57
  conversation.add_user_message(initial_prompt)
59
58
 
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.'
63
-
59
+ system_prompt = build_system_prompt(teammate)
64
60
  tools = tools_for_teammate
65
61
  max_iterations = Config::Defaults::MAX_SUB_AGENT_ITERATIONS
66
62
 
67
63
  max_iterations.times do
68
- response = @llm_client.chat(
69
- messages: conversation.to_api_format,
70
- tools: tools,
71
- system: system_prompt
64
+ done = process_teammate_iteration(
65
+ conversation, tools, system_prompt, teammate, mailbox, callback
72
66
  )
73
-
74
- content = response.respond_to?(:content) ? Array(response.content) : []
75
- tool_calls = content.select { |b| b.respond_to?(:type) && b.type == 'tool_use' }
76
-
77
- if tool_calls.empty?
78
- text = content.select { |b| b.respond_to?(:type) && b.type == 'text' }
79
- .map(&:text).join("\n")
80
- conversation.add_assistant_message(content)
81
- callback.call(:done, "Teammate '#{teammate.name}' finished initial task.")
82
-
83
- # Send result back to main agent inbox
84
- mailbox.send(from: teammate.name, to: 'rubyn', content: text)
85
-
86
- # Now loop waiting for new messages
87
- poll_inbox(teammate, conversation, tools, system_prompt, mailbox, callback)
88
- return
89
- end
90
-
91
- conversation.add_assistant_message(content)
92
- execute_tool_calls(tool_calls, conversation, callback)
67
+ return if done
93
68
  end
94
69
 
95
70
  callback.call(:done, "Teammate '#{teammate.name}' reached iteration limit.")
96
71
  rescue StandardError => e
97
72
  callback.call(:done, "Teammate '#{teammate.name}' error: #{e.message}")
98
- RubynCode::Debug.agent("Teammate #{teammate.name} error: #{e.class}: #{e.message}")
73
+ RubynCode::Debug.agent(
74
+ "Teammate #{teammate.name} error: #{e.class}: #{e.message}"
75
+ )
99
76
  end
100
77
 
101
- def poll_inbox(teammate, conversation, tools, system_prompt, mailbox, _callback)
78
+ def build_system_prompt(teammate)
79
+ "You are #{teammate.name}, a #{teammate.role} teammate agent. " \
80
+ 'Complete tasks efficiently. Use tools when needed. ' \
81
+ 'When done, provide a clear summary of what you accomplished.'
82
+ end
83
+
84
+ # rubocop:disable Metrics/ParameterLists
85
+ def process_teammate_iteration(conversation, tools, system_prompt, teammate, mailbox, callback) # rubocop:disable Naming/PredicateMethod -- returns boolean but is an action method, not a predicate
86
+ response = @llm_client.chat(
87
+ messages: conversation.to_api_format,
88
+ tools: tools,
89
+ system: system_prompt
90
+ )
91
+
92
+ content = response_content(response)
93
+ tool_calls = content.select { |b| block_type?(b, 'tool_use') }
94
+
95
+ if tool_calls.empty?
96
+ finish_teammate_task(content, conversation, teammate, mailbox, callback)
97
+ poll_inbox(teammate, conversation, tools, system_prompt, mailbox)
98
+ return true
99
+ end
100
+
101
+ conversation.add_assistant_message(content)
102
+ execute_tool_calls(tool_calls, conversation, callback)
103
+ false
104
+ end
105
+ # rubocop:enable Metrics/ParameterLists
106
+
107
+ def finish_teammate_task(content, conversation, teammate, mailbox, callback)
108
+ text = content.select { |b| block_type?(b, 'text') }
109
+ .map(&:text).join("\n")
110
+ conversation.add_assistant_message(content)
111
+ callback.call(:done, "Teammate '#{teammate.name}' finished initial task.")
112
+ mailbox.send(from: teammate.name, to: 'rubyn', content: text)
113
+ end
114
+
115
+ def poll_inbox(teammate, conversation, tools, system_prompt, mailbox)
102
116
  loop do
103
117
  sleep Config::Defaults::POLL_INTERVAL
104
118
 
@@ -106,50 +120,64 @@ module RubynCode
106
120
  next if messages.empty?
107
121
 
108
122
  messages.each do |msg|
109
- conversation.add_user_message(msg[:content])
110
-
111
- response = @llm_client.chat(
112
- messages: conversation.to_api_format,
113
- tools: tools,
114
- system: system_prompt
123
+ handle_inbox_message(
124
+ msg, conversation, tools, system_prompt, teammate, mailbox
115
125
  )
116
-
117
- content = response.respond_to?(:content) ? Array(response.content) : []
118
- conversation.add_assistant_message(content)
119
-
120
- text = content.select { |b| b.respond_to?(:type) && b.type == 'text' }
121
- .map(&:text).join("\n")
122
- mailbox.send(from: teammate.name, to: msg[:from], content: text) unless text.empty?
123
126
  end
124
127
  end
125
128
  rescue StandardError => e
126
- RubynCode::Debug.agent("Teammate #{teammate.name} poll error: #{e.message}")
129
+ RubynCode::Debug.agent(
130
+ "Teammate #{teammate.name} poll error: #{e.message}"
131
+ )
127
132
  end
128
133
 
134
+ # rubocop:disable Metrics/ParameterLists
135
+ def handle_inbox_message(msg, conversation, tools, system_prompt, teammate, mailbox)
136
+ conversation.add_user_message(msg[:content])
137
+
138
+ response = @llm_client.chat(
139
+ messages: conversation.to_api_format,
140
+ tools: tools,
141
+ system: system_prompt
142
+ )
143
+
144
+ content = response_content(response)
145
+ conversation.add_assistant_message(content)
146
+
147
+ text = content.select { |b| block_type?(b, 'text') }
148
+ .map(&:text).join("\n")
149
+ return if text.empty?
150
+
151
+ mailbox.send(from: teammate.name, to: msg[:from], content: text)
152
+ end
153
+ # rubocop:enable Metrics/ParameterLists
154
+
129
155
  def execute_tool_calls(tool_calls, conversation, callback)
130
156
  tool_calls.each do |tc|
131
- name = tc.respond_to?(:name) ? tc.name : tc[:name]
132
- input = tc.respond_to?(:input) ? tc.input : tc[:input]
133
- id = tc.respond_to?(:id) ? tc.id : tc[:id]
134
-
157
+ name, input, id = extract_tool_call(tc)
135
158
  callback.call(:tool, " [teammate] > #{name}")
136
159
 
137
- begin
138
- # Block recursive spawning
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)
141
- next
142
- end
143
-
144
- tool_class = Registry.get(name)
145
- tool = tool_class.new(project_root: project_root)
146
- result = tool.execute(**input.transform_keys(&:to_sym))
147
- truncated = tool.truncate(result.to_s)
148
- conversation.add_tool_result(id, name, truncated)
149
- rescue StandardError => e
150
- conversation.add_tool_result(id, name, "Error: #{e.message}", is_error: true)
151
- end
160
+ run_single_tool(name, input, id, conversation)
161
+ end
162
+ end
163
+
164
+ def run_single_tool(name, input, id, conversation)
165
+ if %w[spawn_agent spawn_teammate].include?(name)
166
+ conversation.add_tool_result(
167
+ id, name, 'Error: Teammates cannot spawn other agents.',
168
+ is_error: true
169
+ )
170
+ return
152
171
  end
172
+
173
+ tool_class = Registry.get(name)
174
+ tool = tool_class.new(project_root: project_root)
175
+ result = tool.execute(**input.transform_keys(&:to_sym))
176
+ conversation.add_tool_result(id, name, tool.truncate(result.to_s))
177
+ rescue StandardError => e
178
+ conversation.add_tool_result(
179
+ id, name, "Error: #{e.message}", is_error: true
180
+ )
153
181
  end
154
182
 
155
183
  def tools_for_teammate
@@ -158,6 +186,21 @@ module RubynCode
158
186
  all_tools.reject { |t| blocked.include?(t[:name]) }
159
187
  end
160
188
 
189
+ def response_content(response)
190
+ response.respond_to?(:content) ? Array(response.content) : []
191
+ end
192
+
193
+ def block_type?(block, type)
194
+ block.respond_to?(:type) && block.type == type
195
+ end
196
+
197
+ def extract_tool_call(tool_call)
198
+ name = tool_call.respond_to?(:name) ? tool_call.name : tool_call[:name]
199
+ input = tool_call.respond_to?(:input) ? tool_call.input : tool_call[:input]
200
+ call_id = tool_call.respond_to?(:id) ? tool_call.id : tool_call[:id]
201
+ [name, input, call_id]
202
+ end
203
+
161
204
  def default_status(_type, message)
162
205
  RubynCode::Debug.agent("spawn_teammate: #{message}")
163
206
  end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Tools
5
+ # Parses raw test framework output (RSpec/Minitest) into compact summaries.
6
+ # Passing suites compress to a single line; failures preserve enough context
7
+ # to diagnose the issue without the full verbose output.
8
+ module SpecOutputParser
9
+ MAX_FAILURE_LINES = 15
10
+ MAX_FAILURES = 10
11
+
12
+ class << self
13
+ # Parse raw spec output into a compact summary.
14
+ #
15
+ # @param raw [String] raw test framework output
16
+ # @return [String] compressed summary
17
+ def parse(raw)
18
+ return '(no output)' if raw.nil? || raw.strip.empty?
19
+
20
+ if rspec_output?(raw)
21
+ parse_rspec(raw)
22
+ elsif minitest_output?(raw)
23
+ parse_minitest(raw)
24
+ else
25
+ raw
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def rspec_output?(raw)
32
+ raw.include?('example') && (raw.include?('failure') || raw.include?('pending'))
33
+ end
34
+
35
+ def minitest_output?(raw)
36
+ raw.include?('assertions') || raw.include?('runs,')
37
+ end
38
+
39
+ def parse_rspec(raw)
40
+ summary = extract_rspec_summary(raw)
41
+ return summary if summary && !raw.include?('FAILED') && raw.match?(/0 failures/)
42
+
43
+ failures = extract_rspec_failures(raw)
44
+ parts = []
45
+ parts.concat(format_failures(failures))
46
+ parts << summary if summary
47
+ parts.empty? ? raw : parts.join("\n")
48
+ end
49
+
50
+ def extract_rspec_summary(raw)
51
+ raw.lines.reverse_each do |line|
52
+ return line.strip if line.match?(/\d+ examples?.*\d+ failures?/)
53
+ end
54
+ nil
55
+ end
56
+
57
+ def extract_rspec_failures(raw)
58
+ failures = []
59
+ current = nil
60
+
61
+ raw.each_line do |line|
62
+ if line.match?(/^\s+\d+\)\s/)
63
+ failures << current if current
64
+ current = { header: line.strip, body: [] }
65
+ elsif current
66
+ current[:body] << line.rstrip if current[:body].size < MAX_FAILURE_LINES
67
+ end
68
+ end
69
+
70
+ failures << current if current
71
+ failures.first(MAX_FAILURES)
72
+ end
73
+
74
+ def parse_minitest(raw)
75
+ summary = extract_minitest_summary(raw)
76
+ return summary if summary && raw.match?(/0 failures/)
77
+
78
+ failures = extract_minitest_failures(raw)
79
+ parts = []
80
+ parts.concat(format_failures(failures))
81
+ parts << summary if summary
82
+ parts.empty? ? raw : parts.join("\n")
83
+ end
84
+
85
+ def extract_minitest_summary(raw)
86
+ raw.lines.reverse_each do |line|
87
+ return line.strip if line.match?(/\d+ runs?,\s*\d+ assertions?/)
88
+ end
89
+ nil
90
+ end
91
+
92
+ def extract_minitest_failures(raw)
93
+ failures = []
94
+ current = nil
95
+
96
+ raw.each_line do |line|
97
+ if line.match?(/^\s+\d+\)\s(Failure|Error):/)
98
+ failures << current if current
99
+ current = { header: line.strip, body: [] }
100
+ elsif current
101
+ current[:body] << line.rstrip if current[:body].size < MAX_FAILURE_LINES
102
+ end
103
+ end
104
+
105
+ failures << current if current
106
+ failures.first(MAX_FAILURES)
107
+ end
108
+
109
+ def format_failures(failures)
110
+ failures.map do |f|
111
+ body = f[:body].reject(&:empty?).first(MAX_FAILURE_LINES)
112
+ "#{f[:header]}\n#{body.join("\n")}"
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -7,7 +7,8 @@ module RubynCode
7
7
  module Tools
8
8
  class Task < Base
9
9
  TOOL_NAME = 'task'
10
- DESCRIPTION = 'Manage tasks: create, update, complete, list, or get tasks for tracking work items and dependencies.'
10
+ DESCRIPTION = 'Manage tasks: create, update, complete, list, or get tasks ' \
11
+ 'for tracking work items and dependencies.'
11
12
  PARAMETERS = {
12
13
  action: {
13
14
  type: :string, required: true,
@@ -63,21 +64,25 @@ module RubynCode
63
64
  when 'list' then execute_list(manager, **params)
64
65
  when 'get' then execute_get(manager, **params)
65
66
  else
66
- raise Error, "Unknown task action: #{action}. Valid actions: create, update, complete, list, get"
67
+ raise Error,
68
+ "Unknown task action: #{action}. Valid: create, update, complete, list, get"
67
69
  end
68
70
  end
69
71
 
72
+ TASK_OPTIONAL_FIELDS = %i[owner result session_id description].freeze
73
+
70
74
  private
71
75
 
72
- def execute_create(manager, title: nil, description: nil, session_id: nil, blocked_by: [], priority: 0, **)
76
+ def execute_create(manager, **params)
77
+ title = params[:title]
73
78
  raise Error, 'title is required for create' if title.nil? || title.empty?
74
79
 
75
80
  task = manager.create(
76
81
  title: title,
77
- description: description,
78
- session_id: session_id,
79
- blocked_by: Array(blocked_by),
80
- priority: priority.to_i
82
+ description: params[:description],
83
+ session_id: params[:session_id],
84
+ blocked_by: Array(params.fetch(:blocked_by, [])),
85
+ priority: params.fetch(:priority, 0).to_i
81
86
  )
82
87
 
83
88
  format_task(task, prefix: 'Created task')
@@ -124,16 +129,11 @@ module RubynCode
124
129
 
125
130
  def format_task(task, prefix: nil)
126
131
  header = prefix ? "#{prefix}: #{task.title}" : task.title
127
- parts = [
128
- header,
129
- " ID: #{task.id}",
130
- " Status: #{task.status}",
131
- " Priority: #{task.priority}"
132
- ]
133
- parts << " Owner: #{task.owner}" if task.owner
134
- parts << " Result: #{task.result}" if task.result
135
- parts << " Session: #{task.session_id}" if task.session_id
136
- parts << " Description: #{task.description}" if task.description
132
+ parts = [header, " ID: #{task.id}", " Status: #{task.status}", " Priority: #{task.priority}"]
133
+ TASK_OPTIONAL_FIELDS.each do |field|
134
+ value = task.public_send(field)
135
+ parts << " #{field.to_s.capitalize.tr('_', ' ')}: #{value}" if value
136
+ end
137
137
  parts.join("\n")
138
138
  end
139
139