rubyn-code 0.7.0 → 0.9.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 +45 -17
- data/lib/rubyn_code/agent/conversation.rb +11 -1
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
- data/lib/rubyn_code/agent/llm_caller.rb +5 -1
- data/lib/rubyn_code/agent/loop.rb +57 -3
- data/lib/rubyn_code/agent/response_parser.rb +8 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +3 -0
- data/lib/rubyn_code/agent/tool_processor.rb +10 -0
- data/lib/rubyn_code/autonomous/daemon.rb +1 -1
- data/lib/rubyn_code/cli/commands/context.rb +27 -0
- data/lib/rubyn_code/cli/commands/custom_command.rb +44 -2
- data/lib/rubyn_code/cli/commands/custom_loader.rb +36 -5
- data/lib/rubyn_code/cli/commands/effort.rb +47 -0
- data/lib/rubyn_code/cli/commands/export.rb +174 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +32 -8
- data/lib/rubyn_code/cli/commands/resume.rb +97 -26
- data/lib/rubyn_code/cli/commands/think.rb +47 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/mention_expander.rb +19 -0
- data/lib/rubyn_code/cli/repl.rb +31 -1
- data/lib/rubyn_code/cli/repl_commands.rb +1 -1
- data/lib/rubyn_code/cli/repl_setup.rb +8 -6
- data/lib/rubyn_code/config/defaults.rb +2 -1
- data/lib/rubyn_code/config/schema.json +5 -0
- data/lib/rubyn_code/config/settings.rb +4 -2
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +64 -3
- data/lib/rubyn_code/index/prism_extractor.rb +82 -0
- data/lib/rubyn_code/learning/injector.rb +1 -2
- data/lib/rubyn_code/llm/adapters/anthropic.rb +107 -17
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
- data/lib/rubyn_code/llm/adapters/base.rb +2 -1
- data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
- data/lib/rubyn_code/llm/client.rb +16 -3
- data/lib/rubyn_code/llm/image_reader.rb +60 -0
- data/lib/rubyn_code/llm/message_builder.rb +21 -1
- data/lib/rubyn_code/llm/model_router.rb +4 -4
- data/lib/rubyn_code/mcp/discovery.rb +93 -0
- data/lib/rubyn_code/memory/session_persistence.rb +1 -1
- data/lib/rubyn_code/observability/cost_calculator.rb +6 -3
- data/lib/rubyn_code/protocols/RUBYN.md +0 -3
- data/lib/rubyn_code/tasks/models.rb +0 -16
- data/lib/rubyn_code/teams/teammate.rb +0 -15
- data/lib/rubyn_code/tools/RUBYN.md +3 -3
- data/lib/rubyn_code/tools/bash.rb +3 -3
- data/lib/rubyn_code/tools/code_graph.rb +134 -0
- data/lib/rubyn_code/tools/executor.rb +6 -1
- data/lib/rubyn_code/tools/phone_a_friend.rb +135 -0
- data/lib/rubyn_code/tools/todo_store.rb +55 -0
- data/lib/rubyn_code/tools/todo_write.rb +88 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +14 -8
- data/skills/rubyn_self_test.md +140 -0
- metadata +11 -7
- data/lib/rubyn_code/context/context_budget.rb +0 -183
- data/lib/rubyn_code/context/schema_filter.rb +0 -64
- data/lib/rubyn_code/learning/shortcut.rb +0 -95
- data/lib/rubyn_code/llm/adapters/token_caching.rb +0 -54
- data/lib/rubyn_code/llm/streaming.rb +0 -10
- data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
|
@@ -6,6 +6,14 @@ module RubynCode
|
|
|
6
6
|
def type = 'text'
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
+
ThinkingBlock = Data.define(:text) do
|
|
10
|
+
def type = 'thinking'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
ImageBlock = Data.define(:media_type, :data) do
|
|
14
|
+
def type = 'image'
|
|
15
|
+
end
|
|
16
|
+
|
|
9
17
|
ToolUseBlock = Data.define(:id, :name, :input) do
|
|
10
18
|
def type = 'tool_use'
|
|
11
19
|
end
|
|
@@ -24,7 +32,11 @@ module RubynCode
|
|
|
24
32
|
end
|
|
25
33
|
end
|
|
26
34
|
|
|
27
|
-
Response = Data.define(:id, :content, :stop_reason, :usage) do
|
|
35
|
+
Response = Data.define(:id, :content, :stop_reason, :stop_details, :usage) do
|
|
36
|
+
def initialize(id:, content:, stop_reason:, usage:, stop_details: nil)
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
|
|
28
40
|
def text
|
|
29
41
|
content.select { |b| b.type == 'text' }.map(&:text).join
|
|
30
42
|
end
|
|
@@ -36,6 +48,10 @@ module RubynCode
|
|
|
36
48
|
def tool_use?
|
|
37
49
|
stop_reason == 'tool_use'
|
|
38
50
|
end
|
|
51
|
+
|
|
52
|
+
def refusal?
|
|
53
|
+
stop_reason == 'refusal'
|
|
54
|
+
end
|
|
39
55
|
end
|
|
40
56
|
|
|
41
57
|
class MessageBuilder
|
|
@@ -102,6 +118,10 @@ module RubynCode
|
|
|
102
118
|
case block
|
|
103
119
|
when TextBlock
|
|
104
120
|
{ type: 'text', text: block.text }
|
|
121
|
+
when ThinkingBlock
|
|
122
|
+
{ type: 'thinking', text: block.text }
|
|
123
|
+
when ImageBlock
|
|
124
|
+
{ type: 'image', source: { type: 'base64', media_type: block.media_type, data: block.data } }
|
|
105
125
|
when ToolUseBlock
|
|
106
126
|
{ type: 'tool_use', id: block.id, name: block.name, input: block.input }
|
|
107
127
|
when ToolResultBlock
|
|
@@ -13,8 +13,8 @@ module RubynCode
|
|
|
13
13
|
# env_key: ANTHROPIC_API_KEY
|
|
14
14
|
# models:
|
|
15
15
|
# cheap: claude-haiku-4-5
|
|
16
|
-
# mid: claude-sonnet-
|
|
17
|
-
# top: claude-opus-
|
|
16
|
+
# mid: claude-sonnet-5
|
|
17
|
+
# top: claude-opus-5
|
|
18
18
|
# openai:
|
|
19
19
|
# env_key: OPENAI_API_KEY
|
|
20
20
|
# models:
|
|
@@ -46,11 +46,11 @@ module RubynCode
|
|
|
46
46
|
%w[openai gpt-5.4-nano]
|
|
47
47
|
].freeze,
|
|
48
48
|
mid: [
|
|
49
|
-
%w[anthropic claude-sonnet-
|
|
49
|
+
%w[anthropic claude-sonnet-5],
|
|
50
50
|
%w[openai gpt-5.4-mini]
|
|
51
51
|
].freeze,
|
|
52
52
|
top: [
|
|
53
|
-
%w[anthropic claude-opus-
|
|
53
|
+
%w[anthropic claude-opus-5],
|
|
54
54
|
%w[openai gpt-5.4]
|
|
55
55
|
].freeze
|
|
56
56
|
}.freeze
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'rubyn_code/mcp/config'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module MCP
|
|
8
|
+
# Discovers MCP server definitions from a project's `.mcp.json` file
|
|
9
|
+
# (the Claude Code–style layout at the project root) and merges them
|
|
10
|
+
# with the user-level `.rubyn-code/mcp.json` definitions parsed by
|
|
11
|
+
# `MCP::Config`.
|
|
12
|
+
#
|
|
13
|
+
# Project-level entries are tagged `source: :project` so /mcp can
|
|
14
|
+
# distinguish them from user-level entries.
|
|
15
|
+
module Discovery
|
|
16
|
+
PROJECT_CONFIG_FILENAME = '.mcp.json'
|
|
17
|
+
|
|
18
|
+
Entry = Data.define(:name, :command, :args, :env, :url, :source)
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param project_root [String, nil] project root (nil => nothing discovered)
|
|
23
|
+
# @return [Array<Entry>]
|
|
24
|
+
def discover(project_root)
|
|
25
|
+
user = MCP::Config.load(project_root.to_s).map { |cfg| to_entry(cfg, :user) }
|
|
26
|
+
proj = load_project(project_root)
|
|
27
|
+
user + proj
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param project_root [String, nil]
|
|
31
|
+
# @return [Array<Entry>]
|
|
32
|
+
def load_project(project_root)
|
|
33
|
+
return [] if project_root.to_s.empty?
|
|
34
|
+
|
|
35
|
+
path = File.join(project_root, PROJECT_CONFIG_FILENAME)
|
|
36
|
+
return [] unless File.exist?(path)
|
|
37
|
+
|
|
38
|
+
data = JSON.parse(File.read(path))
|
|
39
|
+
Array(data['mcpServers']).filter_map do |name, server_def|
|
|
40
|
+
build_entry(name, server_def, :project)
|
|
41
|
+
end
|
|
42
|
+
rescue JSON::ParserError => e
|
|
43
|
+
RubynCode::Debug.warn("[MCP::Discovery] Failed to parse #{path}: #{e.message}")
|
|
44
|
+
[]
|
|
45
|
+
rescue SystemCallError => e
|
|
46
|
+
RubynCode::Debug.warn("[MCP::Discovery] Could not read #{path}: #{e.message}")
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Array<Entry>] entries that the local runner can start right now
|
|
51
|
+
def stdio_servers(entries)
|
|
52
|
+
entries.reject { |e| e.command.to_s.empty? }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Array<Entry>] entries that need a network protocol (deferred)
|
|
56
|
+
def remote_servers(entries)
|
|
57
|
+
entries.select { |e| e.command.to_s.empty? && !e.url.to_s.empty? }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_entry(cfg, source)
|
|
61
|
+
Entry.new(
|
|
62
|
+
name: cfg[:name],
|
|
63
|
+
command: cfg[:command],
|
|
64
|
+
args: cfg[:args],
|
|
65
|
+
env: cfg[:env],
|
|
66
|
+
url: cfg[:url],
|
|
67
|
+
source: source
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_entry(name, server_def, source)
|
|
72
|
+
return nil unless server_def.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
command = server_def['command'].to_s
|
|
75
|
+
url = server_def['url'].to_s
|
|
76
|
+
|
|
77
|
+
if command.empty? && url.empty?
|
|
78
|
+
RubynCode::Debug.warn("[MCP::Discovery] Skipping #{name}: no command or url")
|
|
79
|
+
return nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Entry.new(
|
|
83
|
+
name: name,
|
|
84
|
+
command: command,
|
|
85
|
+
args: Array(server_def['args']),
|
|
86
|
+
env: server_def['env'].is_a?(Hash) ? server_def['env'].transform_keys(&:to_s) : {},
|
|
87
|
+
url: url,
|
|
88
|
+
source: source
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -303,7 +303,7 @@ module RubynCode
|
|
|
303
303
|
CREATE TABLE IF NOT EXISTS messages (
|
|
304
304
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
305
305
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
306
|
-
role TEXT NOT NULL CHECK(role IN ('system','user','assistant')),
|
|
306
|
+
role TEXT NOT NULL CHECK(role IN ('system','user','assistant','tool','function')),
|
|
307
307
|
content TEXT,
|
|
308
308
|
tool_calls TEXT,
|
|
309
309
|
tool_use_id TEXT,
|
|
@@ -9,12 +9,15 @@ module RubynCode
|
|
|
9
9
|
module CostCalculator
|
|
10
10
|
# Per-million-token rates: { model_prefix => [input_rate, output_rate] }
|
|
11
11
|
PRICING = {
|
|
12
|
-
# Anthropic — Claude 5 / 4.8 / 4.6
|
|
12
|
+
# Anthropic — Claude 5 / 4.8 / 4.7 / 4.6
|
|
13
13
|
'claude-fable-5' => [10.00, 50.00],
|
|
14
|
+
'claude-opus-5' => [5.00, 25.00],
|
|
14
15
|
'claude-opus-4-8' => [5.00, 25.00],
|
|
15
|
-
'claude-
|
|
16
|
+
'claude-opus-4-7' => [5.00, 25.00],
|
|
17
|
+
'claude-opus-4-6' => [5.00, 25.00],
|
|
18
|
+
'claude-sonnet-5' => [3.00, 15.00],
|
|
16
19
|
'claude-sonnet-4-6' => [3.00, 15.00],
|
|
17
|
-
'claude-
|
|
20
|
+
'claude-haiku-4-5' => [1.00, 5.00],
|
|
18
21
|
# OpenAI — GPT-5.4
|
|
19
22
|
'gpt-5.4' => [2.50, 10.00],
|
|
20
23
|
'gpt-5.4-mini' => [0.15, 0.60],
|
|
@@ -7,8 +7,5 @@ Safety and coordination protocols for agent lifecycle.
|
|
|
7
7
|
- **`ShutdownHandshake`** — Graceful shutdown. Waits for the current tool call to complete,
|
|
8
8
|
saves conversation state, and cleans up resources.
|
|
9
9
|
|
|
10
|
-
- **`PlanApproval`** — When the agent proposes a multi-step plan, this prompts the user
|
|
11
|
-
for approval before execution. Shows the plan, waits for yes/no/edit.
|
|
12
|
-
|
|
13
10
|
- **`InterruptHandler`** — Traps SIGINT (Ctrl+C). First interrupt cancels the current
|
|
14
11
|
operation. Second interrupt within 2 seconds triggers shutdown.
|
|
@@ -11,22 +11,6 @@ module RubynCode
|
|
|
11
11
|
def completed? = status == 'completed'
|
|
12
12
|
def blocked? = status == 'blocked'
|
|
13
13
|
def failed? = status == 'failed'
|
|
14
|
-
|
|
15
|
-
def to_h
|
|
16
|
-
{
|
|
17
|
-
id: id,
|
|
18
|
-
session_id: session_id,
|
|
19
|
-
title: title,
|
|
20
|
-
description: description,
|
|
21
|
-
status: status,
|
|
22
|
-
priority: priority,
|
|
23
|
-
owner: owner,
|
|
24
|
-
result: result,
|
|
25
|
-
metadata: metadata,
|
|
26
|
-
created_at: created_at,
|
|
27
|
-
updated_at: updated_at
|
|
28
|
-
}
|
|
29
|
-
end
|
|
30
14
|
end
|
|
31
15
|
end
|
|
32
16
|
end
|
|
@@ -21,21 +21,6 @@ module RubynCode
|
|
|
21
21
|
|
|
22
22
|
# @return [Boolean] true if this teammate was not spawned by another agent
|
|
23
23
|
def root? = parent_agent_id.nil?
|
|
24
|
-
|
|
25
|
-
# @return [Hash]
|
|
26
|
-
def to_h
|
|
27
|
-
{
|
|
28
|
-
id: id,
|
|
29
|
-
name: name,
|
|
30
|
-
role: role,
|
|
31
|
-
persona: persona,
|
|
32
|
-
model: model,
|
|
33
|
-
status: status,
|
|
34
|
-
parent_agent_id: parent_agent_id,
|
|
35
|
-
metadata: metadata,
|
|
36
|
-
created_at: created_at
|
|
37
|
-
}
|
|
38
|
-
end
|
|
39
24
|
end
|
|
40
25
|
end
|
|
41
26
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Layer 2: Tools
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
34 built-in tools that Claude can invoke. The extensibility surface of the system.
|
|
4
4
|
|
|
5
5
|
## Core Classes
|
|
6
6
|
|
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
| Category | Tools |
|
|
22
22
|
|----------|-------|
|
|
23
|
-
| File I/O | `read_file`, `write_file`, `edit_file`, `glob`, `grep` |
|
|
23
|
+
| File I/O | `read_file`, `write_file`, `edit_file`, `glob`, `grep`, `code_graph` |
|
|
24
24
|
| Shell | `bash`, `background_run` |
|
|
25
25
|
| Rails | `rails_generate`, `db_migrate`, `run_specs`, `bundle_install`, `bundle_add` |
|
|
26
26
|
| Git | `git_commit`, `git_diff`, `git_log`, `git_status` |
|
|
27
27
|
| Web | `web_search`, `web_fetch` |
|
|
28
28
|
| Memory | `memory_search`, `memory_write` |
|
|
29
|
-
| Agents | `spawn_agent`, `spawn_teammate`, `send_message`, `read_inbox` |
|
|
29
|
+
| Agents | `spawn_agent`, `spawn_teammate`, `send_message`, `read_inbox`, `phone_a_friend` |
|
|
30
30
|
| Meta | `compact`, `load_skill`, `task`, `review_pr` |
|
|
31
31
|
|
|
32
32
|
## Adding a Tool
|
|
@@ -43,9 +43,9 @@ module RubynCode
|
|
|
43
43
|
env = ENV.to_h.dup
|
|
44
44
|
|
|
45
45
|
env.each_key do |key|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
# String#[] instead of #include?: Style/ArrayIntersect misfires on
|
|
47
|
+
# the any?+include? shape even though key is a String.
|
|
48
|
+
env[key] = '[SCRUBBED]' if Config::Defaults::SCRUB_ENV_VARS.any? { |sensitive| key.upcase[sensitive] }
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
env
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'registry'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
# Codegraph-style exploration over the persistent codebase index: one
|
|
9
|
+
# call answers "where is X and how is it wired?" with the verbatim
|
|
10
|
+
# line-numbered source of matching symbols plus their callers, callees,
|
|
11
|
+
# and affected files — replacing a grep + read_file loop.
|
|
12
|
+
class CodeGraph < Base
|
|
13
|
+
TOOL_NAME = 'code_graph'
|
|
14
|
+
DESCRIPTION = 'Explores the codebase knowledge graph. Given symbol names or keywords, returns the ' \
|
|
15
|
+
'matching definitions with verbatim line-numbered source, the methods that call them, ' \
|
|
16
|
+
'the methods they call, and the affected files (including specs). Prefer this over ' \
|
|
17
|
+
'grep/read_file when locating or understanding code — one call replaces a search loop.'
|
|
18
|
+
PARAMETERS = {
|
|
19
|
+
query: { type: :string, required: true,
|
|
20
|
+
description: 'Symbol names or keywords, e.g. "build_request_body" or "task budget"' },
|
|
21
|
+
max_symbols: { type: :integer, required: false, default: 5,
|
|
22
|
+
description: 'Maximum matching symbols to expand (default 5)' }
|
|
23
|
+
}.freeze
|
|
24
|
+
RISK_LEVEL = :read
|
|
25
|
+
REQUIRES_CONFIRMATION = false
|
|
26
|
+
|
|
27
|
+
MAX_SOURCE_LINES = 60
|
|
28
|
+
MAX_RELATED = 15
|
|
29
|
+
MATCHABLE_TYPES = %w[class module model controller service concern method].freeze
|
|
30
|
+
|
|
31
|
+
def self.summarize(output, args)
|
|
32
|
+
query = args['query'] || args[:query] || ''
|
|
33
|
+
count = output.to_s.scan(/^## /).size
|
|
34
|
+
"code_graph #{query} (#{count} symbols)"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute(query:, max_symbols: 5)
|
|
38
|
+
index = Index::CodebaseIndex.new(project_root: project_root)
|
|
39
|
+
index.load_or_build!
|
|
40
|
+
|
|
41
|
+
matches = ranked_matches(index, query, max_symbols)
|
|
42
|
+
return "No symbols matching '#{query}' in the codebase index." if matches.empty?
|
|
43
|
+
|
|
44
|
+
sections = matches.map { |node| render_symbol(index, node) }
|
|
45
|
+
truncate(sections.join("\n\n"))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def ranked_matches(index, query, limit)
|
|
51
|
+
terms = query.to_s.downcase.split(/\W+/).reject(&:empty?)
|
|
52
|
+
return [] if terms.empty?
|
|
53
|
+
|
|
54
|
+
scored = index.nodes.filter_map do |node|
|
|
55
|
+
next unless MATCHABLE_TYPES.include?(node['type'])
|
|
56
|
+
|
|
57
|
+
score = score_node(node, terms)
|
|
58
|
+
[score, node] if score.positive?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
scored.sort_by { |score, node| [-score, node['file'].to_s] }
|
|
62
|
+
.map(&:last)
|
|
63
|
+
.uniq { |n| [n['name'], n['file'], n['line']] }
|
|
64
|
+
.first(limit)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def score_node(node, terms)
|
|
68
|
+
name = node['name'].to_s.downcase
|
|
69
|
+
file = node['file'].to_s.downcase
|
|
70
|
+
score = terms.sum do |term|
|
|
71
|
+
if name == term then 100
|
|
72
|
+
elsif name.include?(term) then 40
|
|
73
|
+
elsif file.include?(term) then 10
|
|
74
|
+
else 0
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
# A name hitting every query term beats a single exact-term match.
|
|
78
|
+
score += 50 if terms.size > 1 && terms.all? { |t| name.include?(t) }
|
|
79
|
+
score
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def render_symbol(index, node)
|
|
83
|
+
header = [node['owner'], node['name']].reject { |p| p.nil? || p.empty? }.join('#')
|
|
84
|
+
lines = ["## #{header} (#{node['file']}:#{node['line']})"]
|
|
85
|
+
lines << source_slice(node)
|
|
86
|
+
append_call_info(lines, index, node) if node['type'] == 'method'
|
|
87
|
+
append_affected_files(lines, index, node)
|
|
88
|
+
lines.join("\n")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def source_slice(node)
|
|
92
|
+
path = File.join(project_root, node['file'].to_s)
|
|
93
|
+
return '(source unavailable)' unless File.file?(path)
|
|
94
|
+
|
|
95
|
+
start = [node['line'].to_i, 1].max
|
|
96
|
+
finish = (node['end_line'] || (start + MAX_SOURCE_LINES)).to_i
|
|
97
|
+
truncated = finish > start + MAX_SOURCE_LINES
|
|
98
|
+
finish = start + MAX_SOURCE_LINES if truncated
|
|
99
|
+
|
|
100
|
+
slice = File.readlines(path)[(start - 1)..(finish - 1)] || []
|
|
101
|
+
rendered = slice.each_with_index.map { |line, i| "#{(start + i).to_s.rjust(5)}| #{line.rstrip}" }
|
|
102
|
+
rendered << " ...| (truncated at #{MAX_SOURCE_LINES} lines)" if truncated
|
|
103
|
+
rendered.join("\n")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def append_call_info(lines, index, node)
|
|
107
|
+
callers = call_edges(index).select { |e| e['to'] == node['name'] }
|
|
108
|
+
callees = call_edges(index).select { |e| e['from'] == node['file'] && e['from_method'] == node['name'] }
|
|
109
|
+
|
|
110
|
+
unless callers.empty?
|
|
111
|
+
described = callers.first(MAX_RELATED).map { |e| "#{e['from_method']} (#{e['from']}:#{e['line']})" }
|
|
112
|
+
lines << "Called by: #{described.uniq.join(', ')}"
|
|
113
|
+
end
|
|
114
|
+
return if callees.empty?
|
|
115
|
+
|
|
116
|
+
lines << "Calls: #{callees.map { |e| e['to'] }.uniq.first(MAX_RELATED).join(', ')}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def append_affected_files(lines, index, node)
|
|
120
|
+
caller_files = call_edges(index).select { |e| e['to'] == node['name'] }.map { |e| e['from'] }
|
|
121
|
+
spec_edges = index.edges.select { |e| e['relationship'] == 'tests' && e['to'] == node['file'] }
|
|
122
|
+
spec_files = spec_edges.map { |e| e['from'] }
|
|
123
|
+
affected = (caller_files + spec_files).uniq - [node['file']]
|
|
124
|
+
lines << "Affected files: #{affected.first(MAX_RELATED).join(', ')}" unless affected.empty?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def call_edges(index)
|
|
128
|
+
@call_edges ||= index.edges.select { |e| e['relationship'] == 'calls' }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
Registry.register(CodeGraph)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -5,7 +5,7 @@ module RubynCode
|
|
|
5
5
|
class Executor
|
|
6
6
|
attr_reader :project_root, :output_compressor, :file_cache
|
|
7
7
|
attr_accessor :llm_client, :background_worker, :on_agent_status, :db, :ask_user_callback,
|
|
8
|
-
:codebase_index, :ide_client
|
|
8
|
+
:codebase_index, :ide_client, :todo_store
|
|
9
9
|
|
|
10
10
|
def initialize(project_root:, ide_client: nil)
|
|
11
11
|
@project_root = File.expand_path(project_root)
|
|
@@ -64,6 +64,9 @@ module RubynCode
|
|
|
64
64
|
else
|
|
65
65
|
tool = tool_class.new(project_root: project_root)
|
|
66
66
|
end
|
|
67
|
+
if @todo_store && tool_class.instance_method(:initialize).parameters.any? { |_, name| name == :store }
|
|
68
|
+
tool.instance_variable_set(:@store, @todo_store)
|
|
69
|
+
end
|
|
67
70
|
inject_dependencies(tool, tool_name)
|
|
68
71
|
tool
|
|
69
72
|
end
|
|
@@ -82,6 +85,8 @@ module RubynCode
|
|
|
82
85
|
when 'spawn_agent', 'spawn_teammate'
|
|
83
86
|
inject_agent_deps(tool)
|
|
84
87
|
tool.db = @db if tool_name == 'spawn_teammate' && tool.respond_to?(:db=)
|
|
88
|
+
when 'phone_a_friend'
|
|
89
|
+
tool.llm_client = @llm_client if tool.respond_to?(:llm_client=)
|
|
85
90
|
when 'background_run'
|
|
86
91
|
tool.background_worker = @background_worker if tool.respond_to?(:background_worker=)
|
|
87
92
|
when 'ask_user'
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'registry'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
# Ask a different model for a second opinion. The "friend" is chosen to
|
|
9
|
+
# maximize perspective diversity: prefer the top-tier model of another
|
|
10
|
+
# configured provider (a genuinely different model family), and fall back
|
|
11
|
+
# to the active provider's top-tier model when no other provider has an
|
|
12
|
+
# API key available. One-shot, no tools, returns plain text.
|
|
13
|
+
class PhoneAFriend < Base
|
|
14
|
+
TOOL_NAME = 'phone_a_friend'
|
|
15
|
+
DESCRIPTION = 'Ask a different model for a second opinion when you are stuck, ' \
|
|
16
|
+
'weighing two approaches, or want your reasoning sanity-checked. ' \
|
|
17
|
+
'Sends one question (plus optional context) to another model — ' \
|
|
18
|
+
'a different provider when one is configured, otherwise the ' \
|
|
19
|
+
'top-tier model of the current provider — and returns its answer. ' \
|
|
20
|
+
'The friend has no tools and sees nothing except what you send.'
|
|
21
|
+
PARAMETERS = {
|
|
22
|
+
question: {
|
|
23
|
+
type: :string,
|
|
24
|
+
required: true,
|
|
25
|
+
description: 'The question to ask. Be specific about what kind of answer you ' \
|
|
26
|
+
'need (a decision, a review of reasoning, an alternative approach).'
|
|
27
|
+
},
|
|
28
|
+
context: {
|
|
29
|
+
type: :string,
|
|
30
|
+
required: false,
|
|
31
|
+
description: 'Relevant code, error output, or background. The friend sees ' \
|
|
32
|
+
'only this — include everything needed to answer well.'
|
|
33
|
+
}
|
|
34
|
+
}.freeze
|
|
35
|
+
RISK_LEVEL = :external
|
|
36
|
+
|
|
37
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
38
|
+
You are giving a second opinion to another AI coding agent that is working
|
|
39
|
+
inside a project and has hit a question it wants an outside perspective on.
|
|
40
|
+
You cannot see the project — only what the agent sent you. Answer directly
|
|
41
|
+
and concretely: commit to a recommendation, explain the key reason, and
|
|
42
|
+
flag anything important the agent may have missed. If the provided context
|
|
43
|
+
is insufficient to answer well, say exactly what is missing.
|
|
44
|
+
PROMPT
|
|
45
|
+
|
|
46
|
+
# Injected by the Executor (active client) and overridable in tests.
|
|
47
|
+
attr_writer :llm_client, :client_factory
|
|
48
|
+
|
|
49
|
+
def execute(question:, context: nil)
|
|
50
|
+
return 'phone_a_friend: no LLM client available.' unless @llm_client
|
|
51
|
+
|
|
52
|
+
provider, model = pick_friend
|
|
53
|
+
response = call_friend(provider, model, build_message(question, context))
|
|
54
|
+
answer = extract_text(response)
|
|
55
|
+
return "phone_a_friend: #{provider}/#{model} returned no text." if answer.empty?
|
|
56
|
+
|
|
57
|
+
"## Second Opinion — #{provider}/#{model}\n\n#{answer}"
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
friend = provider ? "#{provider}/#{model}" : 'friend'
|
|
60
|
+
"phone_a_friend: call to #{friend} failed: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.summarize(_output, _args)
|
|
64
|
+
'asked another model for a second opinion'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Returns [provider, model]. Prefers another provider whose API key is
|
|
70
|
+
# present; otherwise escalates to the active provider's top tier.
|
|
71
|
+
def pick_friend
|
|
72
|
+
active = @llm_client.provider_name
|
|
73
|
+
other = (known_providers - [active]).find { |p| key_present?(p) }
|
|
74
|
+
provider = other || active
|
|
75
|
+
[provider, top_model_for(provider)]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def call_friend(provider, model, message)
|
|
79
|
+
client = provider == @llm_client.provider_name ? @llm_client : friend_client(provider, model)
|
|
80
|
+
client.chat(
|
|
81
|
+
messages: [{ role: 'user', content: message }],
|
|
82
|
+
tools: nil,
|
|
83
|
+
system: SYSTEM_PROMPT,
|
|
84
|
+
model: model
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def friend_client(provider, model)
|
|
89
|
+
factory = @client_factory || ->(prov, mod) { LLM::Client.new(provider: prov, model: mod) }
|
|
90
|
+
factory.call(provider, model)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_message(question, context)
|
|
94
|
+
return question if context.to_s.empty?
|
|
95
|
+
|
|
96
|
+
"#{question}\n\n## Context\n\n#{context}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def known_providers
|
|
100
|
+
configured = settings.to_h['providers']
|
|
101
|
+
names = configured.is_a?(Hash) ? configured.keys : []
|
|
102
|
+
(names + Config::Settings::DEFAULT_PROVIDER_MODELS.keys).uniq
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def key_present?(provider)
|
|
106
|
+
env_key = provider_setting(provider, 'env_key')
|
|
107
|
+
env_key ? !ENV[env_key].to_s.empty? : false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def top_model_for(provider)
|
|
111
|
+
config_models = provider_setting(provider, 'models')
|
|
112
|
+
model = config_models['top'] || config_models.values.last if config_models.is_a?(Hash)
|
|
113
|
+
model || @llm_client.model
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Reads a provider key from config.yml, falling back to the built-in
|
|
117
|
+
# provider defaults for anthropic/openai.
|
|
118
|
+
def provider_setting(provider, key)
|
|
119
|
+
value = settings.provider_config(provider)&.fetch(key, nil)
|
|
120
|
+
value || Config::Settings::DEFAULT_PROVIDER_MODELS.dig(provider, key)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def settings
|
|
124
|
+
@settings ||= Config::Settings.new
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def extract_text(response)
|
|
128
|
+
content = response.respond_to?(:content) ? Array(response.content) : []
|
|
129
|
+
content.select { |b| b.respond_to?(:type) && b.type == 'text' }.map(&:text).join("\n")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
Registry.register(PhoneAFriend)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Tools
|
|
5
|
+
# Shared, thread-safe checklist store. The Agent::Loop owns one instance and
|
|
6
|
+
# exposes it to (a) the renderer so the user sees what's in flight, and
|
|
7
|
+
# (b) the TodoWrite tool so the model can mutate it.
|
|
8
|
+
class TodoStore
|
|
9
|
+
include MonitorMixin
|
|
10
|
+
|
|
11
|
+
Item = Data.define(:content, :status, :active_form)
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
super
|
|
15
|
+
@items = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def replace(items)
|
|
19
|
+
synchronize do
|
|
20
|
+
@items = items.map do |item|
|
|
21
|
+
Item.new(
|
|
22
|
+
content: item['content'] || item[:content],
|
|
23
|
+
status: item['status'] || item[:status],
|
|
24
|
+
active_form: (item['active_form'] || item[:active_form]).to_s
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def current
|
|
31
|
+
synchronize { @items.map(&:to_h) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def clear
|
|
35
|
+
synchronize { @items = [] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def empty?
|
|
39
|
+
current.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render
|
|
43
|
+
current.map do |item|
|
|
44
|
+
mark =
|
|
45
|
+
case item[:status]
|
|
46
|
+
when 'completed' then '☑'
|
|
47
|
+
when 'in_progress' then '[~]'
|
|
48
|
+
else '[ ]'
|
|
49
|
+
end
|
|
50
|
+
"#{mark} #{item[:content]}"
|
|
51
|
+
end.join("\n")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|