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.
- checksums.yaml +4 -4
- data/README.md +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/index/codebase_index.rb +245 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +55 -252
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +9 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- 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
|
|
11
|
-
"works independently, and returns only a summary. Use 'explore'
|
|
12
|
-
"'worker' type for writing code/files. The sub-agent
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
end
|
|
87
|
+
iteration += 1
|
|
88
|
+
end
|
|
89
|
+
end
|
|
105
90
|
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
117
|
+
conversation.add_assistant_message(content)
|
|
118
|
+
return [last_text, true] if tool_calls.empty?
|
|
120
119
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
next
|
|
125
|
-
end
|
|
120
|
+
execute_sub_agent_tools(tool_calls, conversation, type, callback)
|
|
121
|
+
[last_text, false]
|
|
122
|
+
end
|
|
126
123
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
130
|
+
run_single_tool(name, input, id, conversation, type)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
137
133
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
69
|
-
|
|
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(
|
|
73
|
+
RubynCode::Debug.agent(
|
|
74
|
+
"Teammate #{teammate.name} error: #{e.class}: #{e.message}"
|
|
75
|
+
)
|
|
99
76
|
end
|
|
100
77
|
|
|
101
|
-
def
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
"
|
|
131
|
-
|
|
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
|
|