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.
- checksums.yaml +4 -4
- data/README.md +269 -467
- data/db/migrations/009_create_teams.sql +6 -6
- data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
- data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
- data/exe/rubyn-code +1 -1
- data/lib/rubyn_code/agent/RUBYN.md +17 -0
- data/lib/rubyn_code/agent/conversation.rb +68 -19
- data/lib/rubyn_code/agent/loop.rb +312 -54
- data/lib/rubyn_code/agent/loop_detector.rb +6 -6
- data/lib/rubyn_code/auth/RUBYN.md +19 -0
- data/lib/rubyn_code/auth/oauth.rb +40 -35
- data/lib/rubyn_code/auth/server.rb +16 -12
- data/lib/rubyn_code/auth/token_store.rb +22 -22
- data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
- data/lib/rubyn_code/autonomous/daemon.rb +115 -79
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
- data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
- data/lib/rubyn_code/background/RUBYN.md +13 -0
- data/lib/rubyn_code/background/notifier.rb +0 -2
- data/lib/rubyn_code/background/worker.rb +60 -15
- data/lib/rubyn_code/cli/RUBYN.md +30 -0
- data/lib/rubyn_code/cli/app.rb +85 -9
- data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
- data/lib/rubyn_code/cli/commands/base.rb +53 -0
- data/lib/rubyn_code/cli/commands/budget.rb +24 -0
- data/lib/rubyn_code/cli/commands/clear.rb +16 -0
- data/lib/rubyn_code/cli/commands/compact.rb +21 -0
- data/lib/rubyn_code/cli/commands/context.rb +44 -0
- data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
- data/lib/rubyn_code/cli/commands/cost.rb +23 -0
- data/lib/rubyn_code/cli/commands/diff.rb +30 -0
- data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
- data/lib/rubyn_code/cli/commands/help.rb +41 -0
- data/lib/rubyn_code/cli/commands/model.rb +37 -0
- data/lib/rubyn_code/cli/commands/plan.rb +22 -0
- data/lib/rubyn_code/cli/commands/quit.rb +17 -0
- data/lib/rubyn_code/cli/commands/registry.rb +64 -0
- data/lib/rubyn_code/cli/commands/resume.rb +51 -0
- data/lib/rubyn_code/cli/commands/review.rb +26 -0
- data/lib/rubyn_code/cli/commands/skill.rb +32 -0
- data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
- data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
- data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
- data/lib/rubyn_code/cli/commands/undo.rb +17 -0
- data/lib/rubyn_code/cli/commands/version.rb +16 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
- data/lib/rubyn_code/cli/input_handler.rb +20 -23
- data/lib/rubyn_code/cli/renderer.rb +25 -27
- data/lib/rubyn_code/cli/repl.rb +161 -194
- data/lib/rubyn_code/cli/setup.rb +117 -0
- data/lib/rubyn_code/cli/spinner.rb +40 -40
- data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
- data/lib/rubyn_code/cli/version_check.rb +94 -0
- data/lib/rubyn_code/config/RUBYN.md +14 -0
- data/lib/rubyn_code/config/defaults.rb +28 -19
- data/lib/rubyn_code/config/project_config.rb +7 -9
- data/lib/rubyn_code/config/settings.rb +3 -3
- data/lib/rubyn_code/context/RUBYN.md +20 -0
- data/lib/rubyn_code/context/auto_compact.rb +7 -7
- data/lib/rubyn_code/context/compactor.rb +2 -2
- data/lib/rubyn_code/context/context_collapse.rb +45 -0
- data/lib/rubyn_code/context/manager.rb +20 -3
- data/lib/rubyn_code/context/manual_compact.rb +7 -7
- data/lib/rubyn_code/context/micro_compact.rb +12 -12
- data/lib/rubyn_code/db/RUBYN.md +40 -0
- data/lib/rubyn_code/db/connection.rb +13 -13
- data/lib/rubyn_code/db/migrator.rb +67 -27
- data/lib/rubyn_code/db/schema.rb +6 -6
- data/lib/rubyn_code/debug.rb +74 -0
- data/lib/rubyn_code/hooks/RUBYN.md +17 -0
- data/lib/rubyn_code/hooks/built_in.rb +9 -9
- data/lib/rubyn_code/hooks/registry.rb +5 -5
- data/lib/rubyn_code/hooks/runner.rb +1 -1
- data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
- data/lib/rubyn_code/learning/RUBYN.md +16 -0
- data/lib/rubyn_code/learning/extractor.rb +22 -22
- data/lib/rubyn_code/learning/injector.rb +17 -18
- data/lib/rubyn_code/learning/instinct.rb +18 -14
- data/lib/rubyn_code/llm/RUBYN.md +15 -0
- data/lib/rubyn_code/llm/client.rb +121 -55
- data/lib/rubyn_code/llm/message_builder.rb +19 -15
- data/lib/rubyn_code/llm/streaming.rb +80 -50
- data/lib/rubyn_code/mcp/RUBYN.md +21 -0
- data/lib/rubyn_code/mcp/client.rb +25 -24
- data/lib/rubyn_code/mcp/config.rb +7 -7
- data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
- data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
- data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
- data/lib/rubyn_code/memory/RUBYN.md +17 -0
- data/lib/rubyn_code/memory/models.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +17 -17
- data/lib/rubyn_code/memory/session_persistence.rb +49 -34
- data/lib/rubyn_code/memory/store.rb +17 -17
- data/lib/rubyn_code/observability/RUBYN.md +19 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
- data/lib/rubyn_code/observability/token_counter.rb +1 -1
- data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
- data/lib/rubyn_code/output/RUBYN.md +11 -0
- data/lib/rubyn_code/output/diff_renderer.rb +6 -6
- data/lib/rubyn_code/output/formatter.rb +4 -4
- data/lib/rubyn_code/permissions/RUBYN.md +17 -0
- data/lib/rubyn_code/permissions/prompter.rb +8 -8
- data/lib/rubyn_code/protocols/RUBYN.md +14 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
- data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
- data/lib/rubyn_code/skills/RUBYN.md +19 -0
- data/lib/rubyn_code/skills/catalog.rb +7 -7
- data/lib/rubyn_code/skills/document.rb +15 -15
- data/lib/rubyn_code/skills/loader.rb +6 -8
- data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
- data/lib/rubyn_code/sub_agents/runner.rb +15 -15
- data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
- data/lib/rubyn_code/tasks/RUBYN.md +13 -0
- data/lib/rubyn_code/tasks/dag.rb +12 -16
- data/lib/rubyn_code/tasks/manager.rb +24 -24
- data/lib/rubyn_code/tasks/models.rb +4 -4
- data/lib/rubyn_code/teams/RUBYN.md +14 -0
- data/lib/rubyn_code/teams/mailbox.rb +38 -18
- data/lib/rubyn_code/teams/manager.rb +19 -19
- data/lib/rubyn_code/teams/teammate.rb +3 -4
- data/lib/rubyn_code/tools/RUBYN.md +38 -0
- data/lib/rubyn_code/tools/background_run.rb +9 -11
- data/lib/rubyn_code/tools/base.rb +54 -3
- data/lib/rubyn_code/tools/bash.rb +16 -34
- data/lib/rubyn_code/tools/bundle_add.rb +10 -12
- data/lib/rubyn_code/tools/bundle_install.rb +9 -11
- data/lib/rubyn_code/tools/compact.rb +10 -9
- data/lib/rubyn_code/tools/db_migrate.rb +17 -15
- data/lib/rubyn_code/tools/edit_file.rb +12 -12
- data/lib/rubyn_code/tools/executor.rb +9 -4
- data/lib/rubyn_code/tools/git_commit.rb +29 -34
- data/lib/rubyn_code/tools/git_diff.rb +17 -18
- data/lib/rubyn_code/tools/git_log.rb +17 -19
- data/lib/rubyn_code/tools/git_status.rb +18 -20
- data/lib/rubyn_code/tools/glob.rb +7 -9
- data/lib/rubyn_code/tools/grep.rb +11 -9
- data/lib/rubyn_code/tools/load_skill.rb +7 -7
- data/lib/rubyn_code/tools/memory_search.rb +13 -12
- data/lib/rubyn_code/tools/memory_write.rb +14 -12
- data/lib/rubyn_code/tools/rails_generate.rb +16 -16
- data/lib/rubyn_code/tools/read_file.rb +8 -7
- data/lib/rubyn_code/tools/read_inbox.rb +5 -5
- data/lib/rubyn_code/tools/registry.rb +2 -2
- data/lib/rubyn_code/tools/review_pr.rb +55 -55
- data/lib/rubyn_code/tools/run_specs.rb +20 -19
- data/lib/rubyn_code/tools/schema.rb +9 -11
- data/lib/rubyn_code/tools/send_message.rb +10 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
- data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
- data/lib/rubyn_code/tools/task.rb +28 -28
- data/lib/rubyn_code/tools/web_fetch.rb +46 -31
- data/lib/rubyn_code/tools/web_search.rb +64 -66
- data/lib/rubyn_code/tools/write_file.rb +7 -6
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +136 -105
- metadata +94 -21
|
@@ -4,17 +4,17 @@ module RubynCode
|
|
|
4
4
|
module Tools
|
|
5
5
|
module Schema
|
|
6
6
|
TYPE_MAP = {
|
|
7
|
-
string:
|
|
8
|
-
integer:
|
|
9
|
-
number:
|
|
10
|
-
boolean:
|
|
11
|
-
array:
|
|
12
|
-
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:
|
|
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:
|
|
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
|
|
4
|
-
require_relative
|
|
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 =
|
|
11
|
-
DESCRIPTION =
|
|
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:
|
|
14
|
-
content: { type: :string, required: true, description:
|
|
15
|
-
message_type: { type: :string, required: false, default:
|
|
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:
|
|
37
|
-
raise Error,
|
|
38
|
-
raise Error,
|
|
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
|
|
4
|
-
require_relative
|
|
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 =
|
|
10
|
-
DESCRIPTION =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ==
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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 =
|
|
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(
|
|
148
|
-
|
|
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
|
|
4
|
-
require_relative
|
|
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 =
|
|
10
|
-
DESCRIPTION =
|
|
11
|
-
|
|
12
|
-
|
|
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:
|
|
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:
|
|
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,
|
|
38
|
-
raise Error,
|
|
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
|
-
|
|
62
|
-
|
|
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 ==
|
|
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 ==
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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 ==
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
4
|
-
require_relative
|
|
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 =
|
|
10
|
-
DESCRIPTION =
|
|
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:
|
|
14
|
+
description: 'Action to perform: create, update, complete, list, get'
|
|
15
15
|
},
|
|
16
16
|
title: {
|
|
17
17
|
type: :string, required: false,
|
|
18
|
-
description:
|
|
18
|
+
description: 'Task title (required for create)'
|
|
19
19
|
},
|
|
20
20
|
description: {
|
|
21
21
|
type: :string, required: false,
|
|
22
|
-
description:
|
|
22
|
+
description: 'Task description'
|
|
23
23
|
},
|
|
24
24
|
task_id: {
|
|
25
25
|
type: :string, required: false,
|
|
26
|
-
description:
|
|
26
|
+
description: 'Task ID (required for update, complete, get)'
|
|
27
27
|
},
|
|
28
28
|
status: {
|
|
29
29
|
type: :string, required: false,
|
|
30
|
-
description:
|
|
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:
|
|
34
|
+
description: 'Session ID for scoping tasks'
|
|
35
35
|
},
|
|
36
36
|
priority: {
|
|
37
37
|
type: :integer, required: false,
|
|
38
|
-
description:
|
|
38
|
+
description: 'Task priority (higher = more important)'
|
|
39
39
|
},
|
|
40
40
|
blocked_by: {
|
|
41
41
|
type: :array, required: false,
|
|
42
|
-
description:
|
|
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:
|
|
46
|
+
description: 'Result text (for complete)'
|
|
47
47
|
},
|
|
48
48
|
owner: {
|
|
49
49
|
type: :string, required: false,
|
|
50
|
-
description:
|
|
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
|
|
61
|
-
when
|
|
62
|
-
when
|
|
63
|
-
when
|
|
64
|
-
when
|
|
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,
|
|
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:
|
|
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,
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
|
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,
|
|
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
|
|
4
|
-
require_relative
|
|
5
|
-
require_relative
|
|
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 =
|
|
11
|
-
DESCRIPTION =
|
|
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:
|
|
14
|
-
max_length: { type: :integer, required: false, default: 10_000,
|
|
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#{
|
|
31
|
+
header = "Content from: #{url}\n#{'=' * 60}\n\n"
|
|
31
32
|
available = max_length - header.length
|
|
32
|
-
content = text.length > available
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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[
|
|
50
|
-
f.headers[
|
|
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
|
-
|
|
57
|
-
raise Error, "
|
|
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
|
|
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!(
|
|
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!(
|
|
88
|
-
text.gsub!(
|
|
89
|
-
text.gsub!(
|
|
90
|
-
text.gsub!(
|
|
91
|
-
text.gsub!(
|
|
92
|
-
text.gsub!(
|
|
93
|
-
text.gsub!(/&#(\d+);/) { [
|
|
102
|
+
text.gsub!('&', '&')
|
|
103
|
+
text.gsub!('<', '<')
|
|
104
|
+
text.gsub!('>', '>')
|
|
105
|
+
text.gsub!('"', '"')
|
|
106
|
+
text.gsub!(''', "'")
|
|
107
|
+
text.gsub!(' ', ' ')
|
|
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
|