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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -17
  3. data/lib/rubyn_code/agent/conversation.rb +11 -1
  4. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  5. data/lib/rubyn_code/agent/llm_caller.rb +5 -1
  6. data/lib/rubyn_code/agent/loop.rb +57 -3
  7. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +3 -0
  9. data/lib/rubyn_code/agent/tool_processor.rb +10 -0
  10. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  11. data/lib/rubyn_code/cli/commands/context.rb +27 -0
  12. data/lib/rubyn_code/cli/commands/custom_command.rb +44 -2
  13. data/lib/rubyn_code/cli/commands/custom_loader.rb +36 -5
  14. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  15. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  16. data/lib/rubyn_code/cli/commands/mcp.rb +32 -8
  17. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  18. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  19. data/lib/rubyn_code/cli/first_run.rb +1 -1
  20. data/lib/rubyn_code/cli/mention_expander.rb +19 -0
  21. data/lib/rubyn_code/cli/repl.rb +31 -1
  22. data/lib/rubyn_code/cli/repl_commands.rb +1 -1
  23. data/lib/rubyn_code/cli/repl_setup.rb +8 -6
  24. data/lib/rubyn_code/config/defaults.rb +2 -1
  25. data/lib/rubyn_code/config/schema.json +5 -0
  26. data/lib/rubyn_code/config/settings.rb +4 -2
  27. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  28. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  29. data/lib/rubyn_code/index/codebase_index.rb +64 -3
  30. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  31. data/lib/rubyn_code/learning/injector.rb +1 -2
  32. data/lib/rubyn_code/llm/adapters/anthropic.rb +107 -17
  33. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  34. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  35. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  36. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  37. data/lib/rubyn_code/llm/client.rb +16 -3
  38. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  39. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  40. data/lib/rubyn_code/llm/model_router.rb +4 -4
  41. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  42. data/lib/rubyn_code/memory/session_persistence.rb +1 -1
  43. data/lib/rubyn_code/observability/cost_calculator.rb +6 -3
  44. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  45. data/lib/rubyn_code/tasks/models.rb +0 -16
  46. data/lib/rubyn_code/teams/teammate.rb +0 -15
  47. data/lib/rubyn_code/tools/RUBYN.md +3 -3
  48. data/lib/rubyn_code/tools/bash.rb +3 -3
  49. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  50. data/lib/rubyn_code/tools/executor.rb +6 -1
  51. data/lib/rubyn_code/tools/phone_a_friend.rb +135 -0
  52. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  53. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  54. data/lib/rubyn_code/version.rb +1 -1
  55. data/lib/rubyn_code.rb +14 -8
  56. data/skills/rubyn_self_test.md +140 -0
  57. metadata +11 -7
  58. data/lib/rubyn_code/context/context_budget.rb +0 -183
  59. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  60. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  61. data/lib/rubyn_code/llm/adapters/token_caching.rb +0 -54
  62. data/lib/rubyn_code/llm/streaming.rb +0 -10
  63. 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-4-6
17
- # top: claude-opus-4-8
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-4-6],
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-4-8],
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-haiku-4-5' => [1.00, 5.00],
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-opus-4-6' => [15.00, 75.00],
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
- 32 built-in tools that Claude can invoke. The extensibility surface of the system.
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
- if Config::Defaults::SCRUB_ENV_VARS.any? { |sensitive| key.upcase.include?(sensitive) }
47
- env[key] = '[SCRUBBED]'
48
- end
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