rubyn-code 0.5.0 → 0.7.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 +182 -11
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +89 -3
- data/lib/rubyn_code/agent/llm_caller.rb +2 -2
- data/lib/rubyn_code/agent/loop.rb +49 -9
- data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
- data/lib/rubyn_code/agent/tool_processor.rb +3 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/checkpoint/hook.rb +26 -0
- data/lib/rubyn_code/checkpoint/manager.rb +109 -0
- data/lib/rubyn_code/chisel/debt.rb +65 -0
- data/lib/rubyn_code/chisel/inspection.rb +93 -0
- data/lib/rubyn_code/chisel.rb +127 -0
- data/lib/rubyn_code/cli/app.rb +2 -2
- data/lib/rubyn_code/cli/commands/agents.rb +31 -0
- data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
- data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
- data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
- data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
- data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
- data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
- data/lib/rubyn_code/cli/commands/context.rb +3 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
- data/lib/rubyn_code/cli/commands/goal.rb +87 -0
- data/lib/rubyn_code/cli/commands/learning.rb +62 -0
- data/lib/rubyn_code/cli/commands/loop.rb +58 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
- data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/loop_runner.rb +98 -0
- data/lib/rubyn_code/cli/mention_expander.rb +92 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +37 -14
- data/lib/rubyn_code/cli/repl_commands.rb +77 -2
- data/lib/rubyn_code/cli/repl_setup.rb +9 -1
- data/lib/rubyn_code/cli/setup.rb +13 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
- data/lib/rubyn_code/cli/version_check.rb +10 -3
- data/lib/rubyn_code/config/defaults.rb +13 -1
- data/lib/rubyn_code/config/schema.json +4 -0
- data/lib/rubyn_code/config/settings.rb +17 -2
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/debug.rb +11 -5
- data/lib/rubyn_code/goal/evaluator.rb +95 -0
- data/lib/rubyn_code/hooks/event_map.rb +56 -0
- data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
- data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
- data/lib/rubyn_code/hooks/response.rb +83 -0
- data/lib/rubyn_code/hooks/runner.rb +61 -3
- data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
- data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +143 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers.rb +17 -2
- data/lib/rubyn_code/ide/protocol.rb +15 -0
- data/lib/rubyn_code/ide/server.rb +39 -1
- data/lib/rubyn_code/index/codebase_index.rb +39 -1
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
- data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
- data/lib/rubyn_code/llm/model_router.rb +2 -2
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
- data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
- data/lib/rubyn_code/megaplan/interview_session.rb +250 -0
- data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
- data/lib/rubyn_code/memory/search.rb +9 -5
- data/lib/rubyn_code/memory/session_persistence.rb +159 -21
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
- data/lib/rubyn_code/skills/registry_client.rb +4 -3
- data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
- data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
- data/lib/rubyn_code/teams/agent_registry.rb +120 -0
- data/lib/rubyn_code/teams/mailbox.rb +99 -10
- data/lib/rubyn_code/teams/manager.rb +83 -5
- data/lib/rubyn_code/teams/teammate.rb +5 -1
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/executor.rb +5 -3
- data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
- data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
- data/lib/rubyn_code/tools/web_fetch.rb +1 -1
- data/lib/rubyn_code/tools/web_search.rb +4 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +53 -2
- data/skills/megaplan/megaplan.md +156 -0
- data/skills/rubyn_self_test.md +322 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +49 -4
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Teams
|
|
5
|
+
# Discovery service for active agents in the system.
|
|
6
|
+
#
|
|
7
|
+
# Provides a unified view of all agents (main loop, sub-agents, teammates)
|
|
8
|
+
# with status, lineage, and messaging capabilities.
|
|
9
|
+
class AgentRegistry
|
|
10
|
+
# @param manager [Manager] the teammate manager
|
|
11
|
+
# @param mailbox [Mailbox] the team mailbox
|
|
12
|
+
def initialize(manager:, mailbox:)
|
|
13
|
+
@manager = manager
|
|
14
|
+
@mailbox = mailbox
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns a snapshot of all registered agents with their status
|
|
18
|
+
# and unread message counts.
|
|
19
|
+
#
|
|
20
|
+
# @return [Array<Hash>] agent snapshots
|
|
21
|
+
def snapshot
|
|
22
|
+
@manager.list.map { |t| agent_snapshot(t) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns only active agents.
|
|
26
|
+
#
|
|
27
|
+
# @return [Array<Hash>] active agent snapshots
|
|
28
|
+
def active
|
|
29
|
+
@manager.active_teammates.map { |t| agent_snapshot(t) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns the full agent tree starting from all root agents.
|
|
33
|
+
#
|
|
34
|
+
# @return [Array<Hash>] nested tree structures
|
|
35
|
+
def forest
|
|
36
|
+
@manager.roots.map { |root| build_display_tree(root) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns lineage (ancestors) for a given agent.
|
|
40
|
+
#
|
|
41
|
+
# @param agent_id [String] the agent's ID
|
|
42
|
+
# @return [Array<Teammate>] ordered from root to immediate parent
|
|
43
|
+
def lineage(agent_id)
|
|
44
|
+
ancestors = []
|
|
45
|
+
current = @manager.find_by_id(agent_id)
|
|
46
|
+
return ancestors unless current
|
|
47
|
+
|
|
48
|
+
while current&.parent_agent_id
|
|
49
|
+
parent = @manager.find_by_id(current.parent_agent_id)
|
|
50
|
+
break unless parent
|
|
51
|
+
|
|
52
|
+
ancestors.unshift(parent)
|
|
53
|
+
current = parent
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
ancestors
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns a formatted status report of all agents.
|
|
60
|
+
#
|
|
61
|
+
# @return [String] human-readable status report
|
|
62
|
+
def status_report
|
|
63
|
+
agents = snapshot
|
|
64
|
+
return 'No agents registered.' if agents.empty?
|
|
65
|
+
|
|
66
|
+
lines = ['Agent Registry Status:', '']
|
|
67
|
+
agents.each do |agent|
|
|
68
|
+
icon = status_icon(agent[:status])
|
|
69
|
+
parent_info = agent[:parent_agent_id] ? " (child of #{agent[:parent_agent_id][0, 8]})" : ' (root)'
|
|
70
|
+
lines << " #{icon} #{agent[:name]} [#{agent[:role]}] — #{agent[:status]}#{parent_info}"
|
|
71
|
+
lines << " Unread: #{agent[:unread_count]}" if agent[:unread_count].positive?
|
|
72
|
+
end
|
|
73
|
+
lines.join("\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Builds a snapshot hash for a single agent.
|
|
79
|
+
#
|
|
80
|
+
# @param teammate [Teammate]
|
|
81
|
+
# @return [Hash]
|
|
82
|
+
def agent_snapshot(teammate)
|
|
83
|
+
{
|
|
84
|
+
id: teammate.id,
|
|
85
|
+
name: teammate.name,
|
|
86
|
+
role: teammate.role,
|
|
87
|
+
status: teammate.status,
|
|
88
|
+
parent_agent_id: teammate.parent_agent_id,
|
|
89
|
+
unread_count: @mailbox.unread_count(teammate.name),
|
|
90
|
+
created_at: teammate.created_at
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Recursively builds a display tree with snapshot data.
|
|
95
|
+
#
|
|
96
|
+
# @param agent [Teammate]
|
|
97
|
+
# @return [Hash]
|
|
98
|
+
def build_display_tree(agent)
|
|
99
|
+
children = @manager.children_of(agent.id)
|
|
100
|
+
{
|
|
101
|
+
**agent_snapshot(agent),
|
|
102
|
+
children: children.map { |child| build_display_tree(child) }
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Returns a status icon for display.
|
|
107
|
+
#
|
|
108
|
+
# @param status [String]
|
|
109
|
+
# @return [String]
|
|
110
|
+
def status_icon(status)
|
|
111
|
+
case status
|
|
112
|
+
when 'active' then '🟢'
|
|
113
|
+
when 'idle' then '🟡'
|
|
114
|
+
when 'offline' then '⚫'
|
|
115
|
+
else '❓'
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -9,6 +9,8 @@ module RubynCode
|
|
|
9
9
|
#
|
|
10
10
|
# Messages are stored in the `mailbox_messages` table with structured
|
|
11
11
|
# JSON content. Each message tracks read/unread state per recipient.
|
|
12
|
+
# Supports structured data payloads and correlation IDs for
|
|
13
|
+
# request/response tracking.
|
|
12
14
|
class Mailbox
|
|
13
15
|
# @param db [DB::Connection] the database connection
|
|
14
16
|
def initialize(db)
|
|
@@ -22,8 +24,11 @@ module RubynCode
|
|
|
22
24
|
# @param to [String] recipient agent name
|
|
23
25
|
# @param content [String] message body
|
|
24
26
|
# @param message_type [String] type of message (default: "message")
|
|
27
|
+
# @param correlation_id [String, nil] optional correlation ID for request/response pairing
|
|
28
|
+
# @param data [Hash, nil] optional structured data payload
|
|
25
29
|
# @return [String] the message id
|
|
26
|
-
|
|
30
|
+
# rubocop:disable Metrics/ParameterLists
|
|
31
|
+
def send(from:, to:, content:, message_type: 'message', correlation_id: nil, data: nil)
|
|
27
32
|
id = SecureRandom.uuid
|
|
28
33
|
now = Time.now.utc.iso8601
|
|
29
34
|
|
|
@@ -36,25 +41,54 @@ module RubynCode
|
|
|
36
41
|
timestamp: now
|
|
37
42
|
})
|
|
38
43
|
|
|
44
|
+
data_json = data ? JSON.generate(data) : nil
|
|
45
|
+
|
|
39
46
|
@db.execute(
|
|
40
47
|
<<~SQL,
|
|
41
|
-
INSERT INTO mailbox_messages (id, sender, recipient, message_type, payload, read, created_at)
|
|
42
|
-
VALUES (?, ?, ?, ?, ?, 0, ?)
|
|
48
|
+
INSERT INTO mailbox_messages (id, sender, recipient, message_type, payload, correlation_id, data, read, created_at)
|
|
49
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
|
|
43
50
|
SQL
|
|
44
|
-
[id, from, to, message_type, payload, now]
|
|
51
|
+
[id, from, to, message_type, payload, correlation_id, data_json, now]
|
|
45
52
|
)
|
|
46
53
|
|
|
47
54
|
id
|
|
48
55
|
end
|
|
56
|
+
# rubocop:enable Metrics/ParameterLists
|
|
57
|
+
|
|
58
|
+
# Sends a structured message with typed data payload.
|
|
59
|
+
# Convenience wrapper around #send for machine-to-machine communication.
|
|
60
|
+
#
|
|
61
|
+
# @param from [String] sender agent name
|
|
62
|
+
# @param to [String] recipient agent name
|
|
63
|
+
# @param type [String] message type (e.g. 'task', 'result', 'error')
|
|
64
|
+
# @param data [Hash] structured data payload
|
|
65
|
+
# @param content [String] human-readable summary (default: auto-generated)
|
|
66
|
+
# @param correlation_id [String, nil] optional correlation ID
|
|
67
|
+
# @return [String] the message id
|
|
68
|
+
# rubocop:disable Metrics/ParameterLists
|
|
69
|
+
def send_structured(from:, to:, type:, data:, content: nil, correlation_id: nil)
|
|
70
|
+
content ||= "#{type}: #{data.inspect}"[0, 200]
|
|
71
|
+
correlation_id ||= SecureRandom.uuid
|
|
72
|
+
|
|
73
|
+
send(
|
|
74
|
+
from: from,
|
|
75
|
+
to: to,
|
|
76
|
+
content: content,
|
|
77
|
+
message_type: type,
|
|
78
|
+
correlation_id: correlation_id,
|
|
79
|
+
data: data
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
# rubocop:enable Metrics/ParameterLists
|
|
49
83
|
|
|
50
84
|
# Reads all unread messages for the given agent and marks them as read.
|
|
51
85
|
#
|
|
52
86
|
# @param name [String] the recipient agent name
|
|
53
|
-
# @return [Array<Hash>] parsed message hashes
|
|
87
|
+
# @return [Array<Hash>] parsed message hashes with optional :data key
|
|
54
88
|
def read_inbox(name)
|
|
55
89
|
rows = @db.query(
|
|
56
90
|
<<~SQL,
|
|
57
|
-
SELECT id, payload FROM mailbox_messages
|
|
91
|
+
SELECT id, payload, correlation_id, data FROM mailbox_messages
|
|
58
92
|
WHERE recipient = ? AND read = 0
|
|
59
93
|
ORDER BY created_at ASC
|
|
60
94
|
SQL
|
|
@@ -64,7 +98,7 @@ module RubynCode
|
|
|
64
98
|
return [] if rows.empty?
|
|
65
99
|
|
|
66
100
|
ids = rows.map { |r| r['id'] }
|
|
67
|
-
messages = rows.map { |r|
|
|
101
|
+
messages = rows.map { |r| parse_message_row(r) }
|
|
68
102
|
|
|
69
103
|
# Mark all fetched messages as read in a single statement
|
|
70
104
|
placeholders = ids.map { '?' }.join(', ')
|
|
@@ -76,6 +110,24 @@ module RubynCode
|
|
|
76
110
|
messages
|
|
77
111
|
end
|
|
78
112
|
|
|
113
|
+
# Finds all messages matching a correlation ID.
|
|
114
|
+
# Useful for tracking request/response chains.
|
|
115
|
+
#
|
|
116
|
+
# @param correlation_id [String] the correlation ID to search for
|
|
117
|
+
# @return [Array<Hash>] matched messages ordered by creation time
|
|
118
|
+
def find_by_correlation_id(correlation_id)
|
|
119
|
+
rows = @db.query(
|
|
120
|
+
<<~SQL,
|
|
121
|
+
SELECT id, payload, correlation_id, data FROM mailbox_messages
|
|
122
|
+
WHERE correlation_id = ?
|
|
123
|
+
ORDER BY created_at ASC
|
|
124
|
+
SQL
|
|
125
|
+
[correlation_id]
|
|
126
|
+
).to_a
|
|
127
|
+
|
|
128
|
+
rows.map { |r| parse_message_row(r) }
|
|
129
|
+
end
|
|
130
|
+
|
|
79
131
|
# Broadcasts a message from one agent to all other agents.
|
|
80
132
|
#
|
|
81
133
|
# @param from [String] sender agent name
|
|
@@ -98,14 +150,14 @@ module RubynCode
|
|
|
98
150
|
def pending_for(name)
|
|
99
151
|
rows = @db.query(
|
|
100
152
|
<<~SQL,
|
|
101
|
-
SELECT payload FROM mailbox_messages
|
|
153
|
+
SELECT id, payload, correlation_id, data FROM mailbox_messages
|
|
102
154
|
WHERE recipient = ? AND read = 0
|
|
103
155
|
ORDER BY created_at ASC
|
|
104
156
|
SQL
|
|
105
157
|
[name]
|
|
106
158
|
).to_a
|
|
107
159
|
|
|
108
|
-
rows.map { |r|
|
|
160
|
+
rows.map { |r| parse_message_row(r) }
|
|
109
161
|
end
|
|
110
162
|
|
|
111
163
|
# Returns the count of unread messages for the given agent.
|
|
@@ -122,8 +174,22 @@ module RubynCode
|
|
|
122
174
|
|
|
123
175
|
private
|
|
124
176
|
|
|
177
|
+
# Parses a message row into a hash, merging structured data if present.
|
|
178
|
+
#
|
|
179
|
+
# @param row [Hash] database row
|
|
180
|
+
# @return [Hash] parsed message with optional :data and :correlation_id keys
|
|
181
|
+
def parse_message_row(row)
|
|
182
|
+
msg = JSON.parse(row['payload'], symbolize_names: true)
|
|
183
|
+
msg[:correlation_id] = row['correlation_id'] if row['correlation_id']
|
|
184
|
+
msg[:data] = JSON.parse(row['data'], symbolize_names: true) if row['data']
|
|
185
|
+
msg
|
|
186
|
+
rescue JSON::ParserError
|
|
187
|
+
{ content: row['payload'].to_s, parse_error: true }
|
|
188
|
+
end
|
|
189
|
+
|
|
125
190
|
# Creates the mailbox_messages table if it does not already exist.
|
|
126
|
-
# Schema must stay in sync with db/migrations/009_create_teams.sql
|
|
191
|
+
# Schema must stay in sync with db/migrations/009_create_teams.sql
|
|
192
|
+
# and 014_multi_agent_upgrade.rb.
|
|
127
193
|
def ensure_table!
|
|
128
194
|
@db.execute(<<~SQL)
|
|
129
195
|
CREATE TABLE IF NOT EXISTS mailbox_messages (
|
|
@@ -133,15 +199,38 @@ module RubynCode
|
|
|
133
199
|
message_type TEXT NOT NULL DEFAULT 'message'
|
|
134
200
|
CHECK(message_type IN ('message','task','result','error','broadcast','shutdown_request','shutdown_response','status_change')),
|
|
135
201
|
payload TEXT NOT NULL,
|
|
202
|
+
correlation_id TEXT,
|
|
203
|
+
data TEXT,
|
|
136
204
|
read INTEGER NOT NULL DEFAULT 0,
|
|
137
205
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
138
206
|
)
|
|
139
207
|
SQL
|
|
140
208
|
|
|
209
|
+
# Self-heal: add the columns the original migration lacked so
|
|
210
|
+
# that test databases bootstrapped before the multi-agent
|
|
211
|
+
# upgrade don't end up with a half-built table. Each ALTER
|
|
212
|
+
# is a no-op if the column already exists (SQLite returns an
|
|
213
|
+
# error which we swallow).
|
|
214
|
+
add_mailbox_column_if_missing('correlation_id')
|
|
215
|
+
add_mailbox_column_if_missing('data')
|
|
216
|
+
|
|
141
217
|
@db.execute(<<~SQL)
|
|
142
218
|
CREATE INDEX IF NOT EXISTS idx_mailbox_recipient_read
|
|
143
219
|
ON mailbox_messages (recipient, read)
|
|
144
220
|
SQL
|
|
221
|
+
|
|
222
|
+
@db.execute(<<~SQL)
|
|
223
|
+
CREATE INDEX IF NOT EXISTS idx_mailbox_correlation
|
|
224
|
+
ON mailbox_messages (correlation_id)
|
|
225
|
+
SQL
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# @return [Boolean] true if the column was added
|
|
229
|
+
def add_mailbox_column_if_missing(column)
|
|
230
|
+
@db.execute("ALTER TABLE mailbox_messages ADD COLUMN #{column} TEXT")
|
|
231
|
+
true
|
|
232
|
+
rescue SQLite3::SQLException
|
|
233
|
+
false
|
|
145
234
|
end
|
|
146
235
|
end
|
|
147
236
|
end
|
|
@@ -9,7 +9,7 @@ module RubynCode
|
|
|
9
9
|
# CRUD manager for teammates backed by SQLite.
|
|
10
10
|
#
|
|
11
11
|
# Provides lifecycle management for agent teammates: spawning,
|
|
12
|
-
# listing, status updates, and
|
|
12
|
+
# listing, status updates, removal, and parent-child discovery.
|
|
13
13
|
class Manager
|
|
14
14
|
# @param db [DB::Connection] the database connection
|
|
15
15
|
# @param mailbox [Mailbox] the team mailbox for inter-agent messaging
|
|
@@ -25,9 +25,10 @@ module RubynCode
|
|
|
25
25
|
# @param role [String] the teammate's role description
|
|
26
26
|
# @param persona [String, nil] optional persona prompt
|
|
27
27
|
# @param model [String, nil] optional LLM model override
|
|
28
|
+
# @param parent_agent_id [String, nil] ID of the parent agent that spawned this teammate
|
|
28
29
|
# @return [Teammate] the newly created teammate
|
|
29
30
|
# @raise [Error] if a teammate with the given name already exists
|
|
30
|
-
def spawn(name:, role:, persona: nil, model: nil)
|
|
31
|
+
def spawn(name:, role:, persona: nil, model: nil, parent_agent_id: nil)
|
|
31
32
|
existing = get(name)
|
|
32
33
|
raise Error, "Teammate '#{name}' already exists" if existing
|
|
33
34
|
|
|
@@ -37,10 +38,10 @@ module RubynCode
|
|
|
37
38
|
|
|
38
39
|
@db.execute(
|
|
39
40
|
<<~SQL,
|
|
40
|
-
INSERT INTO teammates (id, name, role, persona, model, status, metadata, created_at)
|
|
41
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
41
|
+
INSERT INTO teammates (id, name, role, persona, model, status, parent_agent_id, metadata, created_at)
|
|
42
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
42
43
|
SQL
|
|
43
|
-
[id, name, role, persona, model, 'idle', metadata_json, now]
|
|
44
|
+
[id, name, role, persona, model, 'idle', parent_agent_id, metadata_json, now]
|
|
44
45
|
)
|
|
45
46
|
|
|
46
47
|
Teammate.new(
|
|
@@ -50,6 +51,7 @@ module RubynCode
|
|
|
50
51
|
persona: persona,
|
|
51
52
|
model: model,
|
|
52
53
|
status: 'idle',
|
|
54
|
+
parent_agent_id: parent_agent_id,
|
|
53
55
|
metadata: {},
|
|
54
56
|
created_at: now
|
|
55
57
|
)
|
|
@@ -74,6 +76,51 @@ module RubynCode
|
|
|
74
76
|
row_to_teammate(rows.first)
|
|
75
77
|
end
|
|
76
78
|
|
|
79
|
+
# Finds a teammate by ID.
|
|
80
|
+
#
|
|
81
|
+
# @param id [String]
|
|
82
|
+
# @return [Teammate, nil]
|
|
83
|
+
def find_by_id(id)
|
|
84
|
+
rows = @db.query('SELECT * FROM teammates WHERE id = ? LIMIT 1', [id]).to_a
|
|
85
|
+
return nil if rows.empty?
|
|
86
|
+
|
|
87
|
+
row_to_teammate(rows.first)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns all direct children of the given parent agent.
|
|
91
|
+
#
|
|
92
|
+
# @param parent_id [String] the parent agent's ID
|
|
93
|
+
# @return [Array<Teammate>]
|
|
94
|
+
def children_of(parent_id)
|
|
95
|
+
rows = @db.query(
|
|
96
|
+
'SELECT * FROM teammates WHERE parent_agent_id = ? ORDER BY created_at ASC',
|
|
97
|
+
[parent_id]
|
|
98
|
+
).to_a
|
|
99
|
+
rows.map { |row| row_to_teammate(row) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns all root agents (those with no parent).
|
|
103
|
+
#
|
|
104
|
+
# @return [Array<Teammate>]
|
|
105
|
+
def roots
|
|
106
|
+
rows = @db.query(
|
|
107
|
+
'SELECT * FROM teammates WHERE parent_agent_id IS NULL ORDER BY created_at ASC'
|
|
108
|
+
).to_a
|
|
109
|
+
rows.map { |row| row_to_teammate(row) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Builds a full agent tree from a root agent ID.
|
|
113
|
+
# Returns a nested hash: { agent: Teammate, children: [{ agent:, children: }, ...] }
|
|
114
|
+
#
|
|
115
|
+
# @param root_id [String] the root agent's ID
|
|
116
|
+
# @return [Hash, nil] nested tree structure or nil if root not found
|
|
117
|
+
def agent_tree(root_id)
|
|
118
|
+
root = find_by_id(root_id)
|
|
119
|
+
return nil unless root
|
|
120
|
+
|
|
121
|
+
build_tree_node(root)
|
|
122
|
+
end
|
|
123
|
+
|
|
77
124
|
# Updates a teammate's status.
|
|
78
125
|
#
|
|
79
126
|
# @param name [String]
|
|
@@ -120,6 +167,18 @@ module RubynCode
|
|
|
120
167
|
|
|
121
168
|
private
|
|
122
169
|
|
|
170
|
+
# Recursively builds a tree node for an agent and its children.
|
|
171
|
+
#
|
|
172
|
+
# @param agent [Teammate]
|
|
173
|
+
# @return [Hash]
|
|
174
|
+
def build_tree_node(agent)
|
|
175
|
+
kids = children_of(agent.id)
|
|
176
|
+
{
|
|
177
|
+
agent: agent,
|
|
178
|
+
children: kids.map { |child| build_tree_node(child) }
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
123
182
|
# Converts a database row hash to a Teammate value object.
|
|
124
183
|
#
|
|
125
184
|
# @param row [Hash]
|
|
@@ -134,6 +193,7 @@ module RubynCode
|
|
|
134
193
|
persona: row['persona'],
|
|
135
194
|
model: row['model'],
|
|
136
195
|
status: row['status'],
|
|
196
|
+
parent_agent_id: row['parent_agent_id'],
|
|
137
197
|
metadata: metadata,
|
|
138
198
|
created_at: row['created_at']
|
|
139
199
|
)
|
|
@@ -161,14 +221,32 @@ module RubynCode
|
|
|
161
221
|
persona TEXT,
|
|
162
222
|
model TEXT,
|
|
163
223
|
status TEXT NOT NULL DEFAULT 'idle',
|
|
224
|
+
parent_agent_id TEXT,
|
|
164
225
|
metadata TEXT NOT NULL DEFAULT '{}',
|
|
165
226
|
created_at TEXT NOT NULL
|
|
166
227
|
)
|
|
167
228
|
SQL
|
|
168
229
|
|
|
230
|
+
# Self-heal: add columns that later migrations added to legacy
|
|
231
|
+
# tables. Same pattern as Mailbox#ensure_table!.
|
|
232
|
+
add_teammate_column_if_missing('parent_agent_id')
|
|
233
|
+
add_teammate_column_if_missing('metadata')
|
|
234
|
+
|
|
169
235
|
@db.execute(<<~SQL)
|
|
170
236
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_teammates_name ON teammates (name)
|
|
171
237
|
SQL
|
|
238
|
+
|
|
239
|
+
@db.execute(<<~SQL)
|
|
240
|
+
CREATE INDEX IF NOT EXISTS idx_teammates_parent ON teammates (parent_agent_id)
|
|
241
|
+
SQL
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# @return [Boolean] true if the column was added
|
|
245
|
+
def add_teammate_column_if_missing(column)
|
|
246
|
+
@db.execute("ALTER TABLE teammates ADD COLUMN #{column} TEXT")
|
|
247
|
+
true
|
|
248
|
+
rescue SQLite3::SQLException
|
|
249
|
+
false
|
|
172
250
|
end
|
|
173
251
|
end
|
|
174
252
|
end
|
|
@@ -8,7 +8,7 @@ module RubynCode
|
|
|
8
8
|
VALID_STATUSES = %w[idle active offline].freeze
|
|
9
9
|
|
|
10
10
|
Teammate = Data.define(
|
|
11
|
-
:id, :name, :role, :persona, :model, :status, :metadata, :created_at
|
|
11
|
+
:id, :name, :role, :persona, :model, :status, :parent_agent_id, :metadata, :created_at
|
|
12
12
|
) do
|
|
13
13
|
# @return [Boolean]
|
|
14
14
|
def idle? = status == 'idle'
|
|
@@ -19,6 +19,9 @@ module RubynCode
|
|
|
19
19
|
# @return [Boolean]
|
|
20
20
|
def offline? = status == 'offline'
|
|
21
21
|
|
|
22
|
+
# @return [Boolean] true if this teammate was not spawned by another agent
|
|
23
|
+
def root? = parent_agent_id.nil?
|
|
24
|
+
|
|
22
25
|
# @return [Hash]
|
|
23
26
|
def to_h
|
|
24
27
|
{
|
|
@@ -28,6 +31,7 @@ module RubynCode
|
|
|
28
31
|
persona: persona,
|
|
29
32
|
model: model,
|
|
30
33
|
status: status,
|
|
34
|
+
parent_agent_id: parent_agent_id,
|
|
31
35
|
metadata: metadata,
|
|
32
36
|
created_at: created_at
|
|
33
37
|
}
|
|
@@ -20,10 +20,24 @@ module RubynCode
|
|
|
20
20
|
}.freeze
|
|
21
21
|
RISK_LEVEL = :read # Never needs approval — it IS the approval mechanism
|
|
22
22
|
|
|
23
|
+
# The user may take a while to answer, so allow far longer than the
|
|
24
|
+
# default RPC timeout before giving up on the IDE round-trip.
|
|
25
|
+
IDE_ASK_TIMEOUT = 300 # seconds
|
|
26
|
+
|
|
23
27
|
attr_writer :prompt_callback
|
|
24
28
|
|
|
29
|
+
def initialize(project_root:, ide_client: nil)
|
|
30
|
+
super(project_root: project_root)
|
|
31
|
+
@ide_client = ide_client
|
|
32
|
+
end
|
|
33
|
+
|
|
25
34
|
def execute(question:)
|
|
26
|
-
if @
|
|
35
|
+
if @ide_client
|
|
36
|
+
# IDE mode: round-trip the question through the extension's UI.
|
|
37
|
+
response = @ide_client.request('ide/askUser', { question: question }, timeout: IDE_ASK_TIMEOUT)
|
|
38
|
+
answer = response && response['answer']
|
|
39
|
+
answer.nil? || answer.empty? ? '[no response]' : answer
|
|
40
|
+
elsif @prompt_callback
|
|
27
41
|
@prompt_callback.call(question)
|
|
28
42
|
elsif $stdin.respond_to?(:tty?) && $stdin.tty?
|
|
29
43
|
# Interactive fallback: prompt on stdin
|
|
@@ -56,8 +56,10 @@ module RubynCode
|
|
|
56
56
|
|
|
57
57
|
def build_tool(tool_name)
|
|
58
58
|
tool_class = Registry.get(tool_name)
|
|
59
|
-
# IDE-aware tools accept an ide_client parameter.
|
|
60
|
-
|
|
59
|
+
# IDE-aware tools accept an ide_client parameter. Inspect #initialize,
|
|
60
|
+
# not .method(:new) — the latter reports Class#new's own [[:rest]]
|
|
61
|
+
# signature and never forwards to the constructor.
|
|
62
|
+
if @ide_client && tool_class.instance_method(:initialize).parameters.any? { |_, name| name == :ide_client }
|
|
61
63
|
tool = tool_class.new(project_root: project_root, ide_client: @ide_client)
|
|
62
64
|
else
|
|
63
65
|
tool = tool_class.new(project_root: project_root)
|
|
@@ -140,7 +142,7 @@ module RubynCode
|
|
|
140
142
|
path = resolve_cache_path(params)
|
|
141
143
|
return unless path&.end_with?('.rb')
|
|
142
144
|
|
|
143
|
-
@codebase_index.
|
|
145
|
+
@codebase_index.update_file!(path)
|
|
144
146
|
rescue StandardError => e
|
|
145
147
|
RubynCode::Debug.warn("CodebaseIndex incremental update failed: #{e.message}")
|
|
146
148
|
end
|