rubyn-code 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/index/codebase_index.rb +245 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +55 -252
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +9 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- metadata +53 -1
|
@@ -15,10 +15,10 @@ module RubynCode
|
|
|
15
15
|
ALWAYS_ALLOW = %w[
|
|
16
16
|
read_file glob grep git_status git_diff git_log
|
|
17
17
|
memory_search memory_write load_skill compact
|
|
18
|
-
task web_search web_fetch
|
|
18
|
+
task web_search web_fetch ask_user
|
|
19
19
|
].to_set.freeze
|
|
20
20
|
|
|
21
|
-
def self.check(tool_name:,
|
|
21
|
+
def self.check(tool_name:, tier:, deny_list:, tool_input: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
22
22
|
return :deny if deny_list.blocks?(tool_name)
|
|
23
23
|
return :allow if ALWAYS_ALLOW.include?(tool_name)
|
|
24
24
|
|
|
@@ -26,17 +26,15 @@ module RubynCode
|
|
|
26
26
|
|
|
27
27
|
return :ask if risk == :destructive
|
|
28
28
|
|
|
29
|
+
resolve_by_tier(tier, risk)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.resolve_by_tier(tier, risk)
|
|
29
33
|
case tier
|
|
30
|
-
when Tier::ASK_ALWAYS
|
|
31
|
-
|
|
32
|
-
when Tier::
|
|
33
|
-
|
|
34
|
-
when Tier::AUTONOMOUS
|
|
35
|
-
risk == :external ? :ask : :allow
|
|
36
|
-
when Tier::UNRESTRICTED
|
|
37
|
-
:allow
|
|
38
|
-
else
|
|
39
|
-
:ask
|
|
34
|
+
when Tier::ASK_ALWAYS, nil then :ask
|
|
35
|
+
when Tier::ALLOW_READ then risk == :read ? :allow : :ask
|
|
36
|
+
when Tier::AUTONOMOUS then risk == :external ? :ask : :allow
|
|
37
|
+
when Tier::UNRESTRICTED then :allow
|
|
40
38
|
end
|
|
41
39
|
end
|
|
42
40
|
|
|
@@ -53,7 +51,7 @@ module RubynCode
|
|
|
53
51
|
:unknown
|
|
54
52
|
end
|
|
55
53
|
|
|
56
|
-
private_class_method :resolve_risk
|
|
54
|
+
private_class_method :resolve_risk, :resolve_by_tier
|
|
57
55
|
end
|
|
58
56
|
end
|
|
59
57
|
end
|
|
@@ -33,21 +33,20 @@ module RubynCode
|
|
|
33
33
|
# @param tool_input [Hash]
|
|
34
34
|
# @return [Boolean] true if the user approved
|
|
35
35
|
def self.confirm_destructive(tool_name, tool_input)
|
|
36
|
-
prompt = build_prompt
|
|
37
36
|
pastel = Pastel.new
|
|
37
|
+
display_destructive_warning(pastel, tool_name, tool_input)
|
|
38
|
+
|
|
39
|
+
answer = build_prompt.ask(pastel.red.bold('Type "yes" to confirm this destructive action:'))
|
|
40
|
+
answer&.strip&.downcase == 'yes'
|
|
41
|
+
rescue TTY::Prompt::Reader::InputInterrupt
|
|
42
|
+
false
|
|
43
|
+
end
|
|
38
44
|
|
|
45
|
+
def self.display_destructive_warning(pastel, tool_name, tool_input)
|
|
39
46
|
$stdout.puts pastel.red.bold('WARNING: Destructive operation requested')
|
|
40
47
|
$stdout.puts pastel.red('=' * 50)
|
|
41
48
|
display_tool_summary(pastel, tool_name, tool_input)
|
|
42
49
|
$stdout.puts pastel.red('=' * 50)
|
|
43
|
-
|
|
44
|
-
answer = prompt.ask(
|
|
45
|
-
pastel.red.bold('Type "yes" to confirm this destructive action:')
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
answer&.strip&.downcase == 'yes'
|
|
49
|
-
rescue TTY::Prompt::Reader::InputInterrupt
|
|
50
|
-
false
|
|
51
50
|
end
|
|
52
51
|
|
|
53
52
|
# @api private
|
|
@@ -22,39 +22,44 @@ module RubynCode
|
|
|
22
22
|
# @return [Symbol] :approved or :rejected
|
|
23
23
|
def request(plan_text, prompt: nil)
|
|
24
24
|
pastel = Pastel.new
|
|
25
|
-
|
|
25
|
+
display_plan(pastel, plan_text, prompt)
|
|
26
26
|
|
|
27
|
+
approved = build_prompt.yes?(
|
|
28
|
+
pastel.yellow.bold('Do you approve this plan?'),
|
|
29
|
+
default: false
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
approved ? approve(pastel) : reject(pastel)
|
|
33
|
+
rescue TTY::Reader::InputInterrupt
|
|
34
|
+
$stdout.puts pastel.red("\nPlan rejected (interrupted).")
|
|
35
|
+
REJECTED
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def display_plan(pastel, plan_text, prompt)
|
|
27
41
|
$stdout.puts
|
|
28
42
|
$stdout.puts pastel.cyan.bold('Proposed Plan')
|
|
29
43
|
$stdout.puts pastel.cyan('=' * 60)
|
|
30
44
|
$stdout.puts plan_text
|
|
31
45
|
$stdout.puts pastel.cyan('=' * 60)
|
|
32
46
|
$stdout.puts
|
|
47
|
+
return unless prompt
|
|
33
48
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
end
|
|
49
|
+
$stdout.puts pastel.yellow(prompt)
|
|
50
|
+
$stdout.puts
|
|
51
|
+
end
|
|
38
52
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
def approve(pastel)
|
|
54
|
+
$stdout.puts pastel.green('Plan approved.')
|
|
55
|
+
APPROVED
|
|
56
|
+
end
|
|
43
57
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
APPROVED
|
|
47
|
-
else
|
|
48
|
-
$stdout.puts pastel.red('Plan rejected.')
|
|
49
|
-
REJECTED
|
|
50
|
-
end
|
|
51
|
-
rescue TTY::Reader::InputInterrupt
|
|
52
|
-
$stdout.puts pastel.red("\nPlan rejected (interrupted).")
|
|
58
|
+
def reject(pastel)
|
|
59
|
+
$stdout.puts pastel.red('Plan rejected.')
|
|
53
60
|
REJECTED
|
|
54
61
|
end
|
|
55
62
|
|
|
56
|
-
private
|
|
57
|
-
|
|
58
63
|
# Builds a TTY::Prompt instance configured for non-destructive interrupt handling.
|
|
59
64
|
#
|
|
60
65
|
# @return [TTY::Prompt]
|
|
@@ -19,30 +19,30 @@ module RubynCode
|
|
|
19
19
|
class << self
|
|
20
20
|
def parse(content, filename: nil)
|
|
21
21
|
match = FRONTMATTER_PATTERN.match(content)
|
|
22
|
+
match ? parse_with_frontmatter(match) : parse_without_frontmatter(content, filename)
|
|
23
|
+
end
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
def parse_with_frontmatter(match)
|
|
26
|
+
frontmatter = YAML.safe_load(match[1], permitted_classes: [Symbol]) || {}
|
|
27
|
+
new(
|
|
28
|
+
name: frontmatter['name'].to_s,
|
|
29
|
+
description: frontmatter['description'].to_s,
|
|
30
|
+
tags: Array(frontmatter['tags']),
|
|
31
|
+
body: match[2].to_s.strip
|
|
32
|
+
)
|
|
33
|
+
end
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
body: body
|
|
32
|
-
)
|
|
33
|
-
else
|
|
34
|
-
body = content.to_s.strip
|
|
35
|
-
title = extract_title(body)
|
|
36
|
-
derived_name = filename ? File.basename(filename, '.*').tr('_', '-') : title_to_name(title)
|
|
37
|
-
tags = derive_tags(derived_name, body)
|
|
35
|
+
def parse_without_frontmatter(content, filename)
|
|
36
|
+
body = content.to_s.strip
|
|
37
|
+
title = extract_title(body)
|
|
38
|
+
derived_name = filename ? File.basename(filename, '.*').tr('_', '-') : title_to_name(title)
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
end
|
|
40
|
+
new(
|
|
41
|
+
name: derived_name,
|
|
42
|
+
description: title,
|
|
43
|
+
tags: derive_tags(derived_name, body),
|
|
44
|
+
body: body
|
|
45
|
+
)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def parse_file(path)
|
|
@@ -53,6 +53,15 @@ module RubynCode
|
|
|
53
53
|
parse(content, filename: path)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
TAG_RULES = [
|
|
57
|
+
['ruby', /\bruby\b/i],
|
|
58
|
+
['rails', /\brails\b/i],
|
|
59
|
+
['rspec', /\brspec\b/i],
|
|
60
|
+
['testing', /\b(?:test|spec|minitest)\b/i],
|
|
61
|
+
['patterns', /\b(?:pattern|design|solid)\b/i],
|
|
62
|
+
['refactoring', /\brefactor/i]
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
56
65
|
private
|
|
57
66
|
|
|
58
67
|
def extract_title(body)
|
|
@@ -65,14 +74,9 @@ module RubynCode
|
|
|
65
74
|
end
|
|
66
75
|
|
|
67
76
|
def derive_tags(name, body)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
tags << 'rspec' if body.match?(/\brspec\b/i) || name.include?('rspec')
|
|
72
|
-
tags << 'testing' if body.match?(/\b(test|spec|minitest)\b/i)
|
|
73
|
-
tags << 'patterns' if body.match?(/\b(pattern|design|solid)\b/i)
|
|
74
|
-
tags << 'refactoring' if body.match?(/\brefactor/i)
|
|
75
|
-
tags.uniq
|
|
77
|
+
TAG_RULES.each_with_object([]) do |(tag, pattern), tags|
|
|
78
|
+
tags << tag if body.match?(pattern) || name.include?(tag)
|
|
79
|
+
end.uniq
|
|
76
80
|
end
|
|
77
81
|
end
|
|
78
82
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Skills
|
|
5
|
+
# Manages skill TTL (time-to-live) and size caps. Skills injected into
|
|
6
|
+
# context are tracked with a turn counter; once a skill exceeds its
|
|
7
|
+
# TTL it is marked for ejection during the next compaction pass.
|
|
8
|
+
class TtlManager
|
|
9
|
+
DEFAULT_TTL = 5 # turns
|
|
10
|
+
MAX_SKILL_TOKENS = 800 # tokens
|
|
11
|
+
CHARS_PER_TOKEN = 4
|
|
12
|
+
|
|
13
|
+
Entry = Data.define(:name, :loaded_at_turn, :ttl, :token_count, :last_referenced_turn) do
|
|
14
|
+
def expired?(current_turn)
|
|
15
|
+
current_turn - last_referenced_turn > ttl
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :entries
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@entries = {}
|
|
23
|
+
@current_turn = 0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Advance the turn counter. Call this once per agent loop iteration.
|
|
27
|
+
def tick!
|
|
28
|
+
@current_turn += 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Register a loaded skill with optional TTL override.
|
|
32
|
+
#
|
|
33
|
+
# @param name [String] skill name
|
|
34
|
+
# @param content [String] skill content
|
|
35
|
+
# @param ttl [Integer] turns before expiry (default 5)
|
|
36
|
+
# @return [String] content, possibly truncated to size cap
|
|
37
|
+
def register(name, content, ttl: DEFAULT_TTL)
|
|
38
|
+
truncated = enforce_size_cap(content)
|
|
39
|
+
token_count = estimate_tokens(truncated)
|
|
40
|
+
|
|
41
|
+
@entries[name] = Entry.new(
|
|
42
|
+
name: name,
|
|
43
|
+
loaded_at_turn: @current_turn,
|
|
44
|
+
ttl: ttl,
|
|
45
|
+
token_count: token_count,
|
|
46
|
+
last_referenced_turn: @current_turn
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
truncated
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Mark a skill as recently referenced (resets its TTL countdown).
|
|
53
|
+
def touch(name)
|
|
54
|
+
return unless @entries.key?(name)
|
|
55
|
+
|
|
56
|
+
@entries[name] = @entries[name].with(last_referenced_turn: @current_turn)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns names of skills that have exceeded their TTL.
|
|
60
|
+
def expired_skills
|
|
61
|
+
@entries.select { |_, entry| entry.expired?(@current_turn) }.keys
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Remove expired skills and return their names.
|
|
65
|
+
def eject_expired!
|
|
66
|
+
expired = expired_skills
|
|
67
|
+
expired.each { |name| @entries.delete(name) }
|
|
68
|
+
expired
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns total tokens used by currently loaded skills.
|
|
72
|
+
def total_tokens
|
|
73
|
+
@entries.values.sum(&:token_count)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns stats for the analytics dashboard.
|
|
77
|
+
def stats
|
|
78
|
+
{
|
|
79
|
+
loaded_skills: @entries.size,
|
|
80
|
+
total_tokens: total_tokens,
|
|
81
|
+
expired: expired_skills.size,
|
|
82
|
+
current_turn: @current_turn
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def enforce_size_cap(content)
|
|
89
|
+
max_chars = MAX_SKILL_TOKENS * CHARS_PER_TOKEN
|
|
90
|
+
return content if content.length <= max_chars
|
|
91
|
+
|
|
92
|
+
content[0, max_chars] + "\n... [skill truncated to #{MAX_SKILL_TOKENS} tokens]"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def estimate_tokens(text)
|
|
96
|
+
(text.bytesize.to_f / CHARS_PER_TOKEN).ceil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -32,40 +32,35 @@ module RubynCode
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def run
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
tool_defs = build_tool_definitions
|
|
35
|
+
state = { conversation: build_conversation, executor: build_executor,
|
|
36
|
+
tool_defs: build_tool_definitions, final_text: '' }
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
break if iteration >= @max_iterations
|
|
44
|
-
|
|
45
|
-
response = request_llm(conversation, tool_defs)
|
|
46
|
-
iteration += 1
|
|
47
|
-
|
|
48
|
-
text_content = extract_text(response)
|
|
49
|
-
tool_calls = extract_tool_calls(response)
|
|
38
|
+
@max_iterations.times do
|
|
39
|
+
result = run_single_iteration(state)
|
|
40
|
+
break if result == :done
|
|
41
|
+
end
|
|
50
42
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
break
|
|
54
|
-
end
|
|
43
|
+
Summarizer.call(state[:final_text])
|
|
44
|
+
end
|
|
55
45
|
|
|
56
|
-
|
|
46
|
+
private
|
|
57
47
|
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
def run_single_iteration(state)
|
|
49
|
+
response = request_llm(state[:conversation], state[:tool_defs])
|
|
50
|
+
text_content = extract_text(response)
|
|
51
|
+
tool_calls = extract_tool_calls(response)
|
|
60
52
|
|
|
61
|
-
|
|
53
|
+
if tool_calls.empty?
|
|
54
|
+
state[:final_text] = text_content
|
|
55
|
+
return :done
|
|
62
56
|
end
|
|
63
57
|
|
|
64
|
-
|
|
58
|
+
state[:conversation] << { role: 'assistant', content: response }
|
|
59
|
+
state[:conversation] << { role: 'user', content: execute_tools(state[:executor], tool_calls) }
|
|
60
|
+
state[:final_text] = text_content unless text_content.empty?
|
|
61
|
+
:continue
|
|
65
62
|
end
|
|
66
63
|
|
|
67
|
-
private
|
|
68
|
-
|
|
69
64
|
def build_conversation
|
|
70
65
|
[
|
|
71
66
|
{ role: 'user', content: @prompt }
|
data/lib/rubyn_code/tasks/dag.rb
CHANGED
|
@@ -113,30 +113,35 @@ module RubynCode
|
|
|
113
113
|
# @return [Array<String>]
|
|
114
114
|
# @raise [RuntimeError] if the graph contains a cycle
|
|
115
115
|
def topological_sort
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
all_nodes = collect_all_nodes
|
|
117
|
+
in_degree = compute_in_degrees(all_nodes)
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
deps.each do |dep_id|
|
|
122
|
-
all_nodes.add(dep_id)
|
|
123
|
-
in_degree[dep_id] # touch to initialize
|
|
124
|
-
in_degree[task_id] += 1 # task_id depends on dep_id, so task_id has higher in-degree
|
|
125
|
-
end
|
|
126
|
-
end
|
|
119
|
+
sorted = kahn_sort(all_nodes, in_degree)
|
|
120
|
+
raise 'Cycle detected in task dependency graph' if sorted.size != all_nodes.size
|
|
127
121
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
all_nodes.each { |n| in_degree_corrected[n] = 0 }
|
|
122
|
+
sorted
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
133
126
|
|
|
127
|
+
def collect_all_nodes
|
|
128
|
+
nodes = Set.new
|
|
134
129
|
@forward.each do |task_id, deps|
|
|
135
|
-
|
|
136
|
-
|
|
130
|
+
nodes.add(task_id)
|
|
131
|
+
deps.each { |dep_id| nodes.add(dep_id) }
|
|
137
132
|
end
|
|
133
|
+
nodes
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def compute_in_degrees(all_nodes)
|
|
137
|
+
in_degree = Hash.new(0)
|
|
138
|
+
all_nodes.each { |n| in_degree[n] = 0 }
|
|
139
|
+
@forward.each { |task_id, deps| in_degree[task_id] += deps.size }
|
|
140
|
+
in_degree
|
|
141
|
+
end
|
|
138
142
|
|
|
139
|
-
|
|
143
|
+
def kahn_sort(all_nodes, in_degree)
|
|
144
|
+
queue = all_nodes.select { |n| in_degree[n].zero? }
|
|
140
145
|
sorted = []
|
|
141
146
|
|
|
142
147
|
until queue.empty?
|
|
@@ -144,18 +149,14 @@ module RubynCode
|
|
|
144
149
|
sorted << node
|
|
145
150
|
|
|
146
151
|
@reverse[node].each do |dependent|
|
|
147
|
-
|
|
148
|
-
queue << dependent if
|
|
152
|
+
in_degree[dependent] -= 1
|
|
153
|
+
queue << dependent if in_degree[dependent].zero?
|
|
149
154
|
end
|
|
150
155
|
end
|
|
151
156
|
|
|
152
|
-
raise 'Cycle detected in task dependency graph' if sorted.size != all_nodes.size
|
|
153
|
-
|
|
154
157
|
sorted
|
|
155
158
|
end
|
|
156
159
|
|
|
157
|
-
private
|
|
158
|
-
|
|
159
160
|
def ensure_table
|
|
160
161
|
@db.execute(<<~SQL)
|
|
161
162
|
CREATE TABLE IF NOT EXISTS task_dependencies (
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'registry'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
class AskUser < Base
|
|
9
|
+
TOOL_NAME = 'ask_user'
|
|
10
|
+
DESCRIPTION = 'Ask the user a question and wait for their response. ' \
|
|
11
|
+
'Use this when you need clarification, want to confirm a plan before executing, ' \
|
|
12
|
+
'or are stuck and need guidance. The question is displayed and the user\'s answer ' \
|
|
13
|
+
'is returned as the tool result.'
|
|
14
|
+
PARAMETERS = {
|
|
15
|
+
question: {
|
|
16
|
+
type: :string,
|
|
17
|
+
description: 'The question to ask the user',
|
|
18
|
+
required: true
|
|
19
|
+
}
|
|
20
|
+
}.freeze
|
|
21
|
+
RISK_LEVEL = :read # Never needs approval — it IS the approval mechanism
|
|
22
|
+
|
|
23
|
+
attr_writer :prompt_callback
|
|
24
|
+
|
|
25
|
+
def execute(question:)
|
|
26
|
+
if @prompt_callback
|
|
27
|
+
@prompt_callback.call(question)
|
|
28
|
+
elsif $stdin.respond_to?(:tty?) && $stdin.tty?
|
|
29
|
+
# Interactive fallback: prompt on stdin
|
|
30
|
+
$stdout.puts
|
|
31
|
+
$stdout.puts " #{question}"
|
|
32
|
+
$stdout.print ' > '
|
|
33
|
+
$stdout.flush
|
|
34
|
+
$stdin.gets&.strip || '[no response]'
|
|
35
|
+
else
|
|
36
|
+
# Non-interactive (piped input, -p mode, daemon) — can't ask
|
|
37
|
+
'[non-interactive session — cannot ask user. Make your best judgment and proceed.]'
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Registry.register(AskUser)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -30,7 +30,8 @@ module RubynCode
|
|
|
30
30
|
return 'Error: Background worker not available. Use bash tool instead.' unless @background_worker
|
|
31
31
|
|
|
32
32
|
job_id = @background_worker.run(command, timeout: timeout)
|
|
33
|
-
"Background job started: #{job_id}\nCommand: #{command}\
|
|
33
|
+
"Background job started: #{job_id}\nCommand: #{command}\n" \
|
|
34
|
+
"Timeout: #{timeout}s\nResults will appear automatically when complete."
|
|
34
35
|
end
|
|
35
36
|
end
|
|
36
37
|
|
|
@@ -57,7 +57,8 @@ module RubynCode
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
unless expanded.start_with?(project_root)
|
|
60
|
-
raise PermissionDeniedError,
|
|
60
|
+
raise PermissionDeniedError,
|
|
61
|
+
"Path traversal denied: #{path} resolves outside project root"
|
|
61
62
|
end
|
|
62
63
|
|
|
63
64
|
expanded
|
|
@@ -67,7 +68,8 @@ module RubynCode
|
|
|
67
68
|
return output if output.nil? || output.length <= max
|
|
68
69
|
|
|
69
70
|
half = max / 2
|
|
70
|
-
"
|
|
71
|
+
middle = "\n\n... [truncated #{output.length - max} characters] ...\n\n"
|
|
72
|
+
"#{output[0, half]}#{middle}#{output[-half, half]}"
|
|
71
73
|
end
|
|
72
74
|
|
|
73
75
|
private
|
|
@@ -82,34 +84,30 @@ module RubynCode
|
|
|
82
84
|
stdout = +''
|
|
83
85
|
stderr = +''
|
|
84
86
|
|
|
85
|
-
out_reader = Thread.new
|
|
86
|
-
|
|
87
|
-
rescue StandardError
|
|
88
|
-
nil
|
|
89
|
-
end
|
|
90
|
-
err_reader = Thread.new do
|
|
91
|
-
stderr << stderr_io.read
|
|
92
|
-
rescue StandardError
|
|
93
|
-
nil
|
|
94
|
-
end
|
|
87
|
+
out_reader = Thread.new { stdout << stdout_io.read rescue nil } # rubocop:disable Style/RescueModifier
|
|
88
|
+
err_reader = Thread.new { stderr << stderr_io.read rescue nil } # rubocop:disable Style/RescueModifier
|
|
95
89
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
sleep 0.1
|
|
105
|
-
begin
|
|
106
|
-
Process.kill('KILL', wait_thr.pid)
|
|
107
|
-
rescue StandardError
|
|
108
|
-
nil
|
|
109
|
-
end
|
|
110
|
-
wait_thr.join(5)
|
|
111
|
-
end
|
|
90
|
+
wait_for_process(wait_thr, timeout)
|
|
91
|
+
finalize_readers(out_reader, err_reader, stdout_io, stderr_io)
|
|
92
|
+
|
|
93
|
+
[stdout, stderr, wait_thr.value]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def wait_for_process(wait_thr, timeout)
|
|
97
|
+
return if wait_thr.join(timeout)
|
|
112
98
|
|
|
99
|
+
kill_process(wait_thr.pid)
|
|
100
|
+
wait_thr.join(5)
|
|
101
|
+
raise Error, "Command timed out after #{timeout}s"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def kill_process(pid)
|
|
105
|
+
Process.kill('TERM', pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
106
|
+
sleep 0.1
|
|
107
|
+
Process.kill('KILL', pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def finalize_readers(out_reader, err_reader, stdout_io, stderr_io)
|
|
113
111
|
out_reader.join(5)
|
|
114
112
|
err_reader.join(5)
|
|
115
113
|
[stdout_io, stderr_io].each do |io|
|
|
@@ -117,10 +115,6 @@ module RubynCode
|
|
|
117
115
|
rescue StandardError
|
|
118
116
|
nil
|
|
119
117
|
end
|
|
120
|
-
|
|
121
|
-
raise Error, "Command timed out after #{timeout}s" if timed_out
|
|
122
|
-
|
|
123
|
-
[stdout, stderr, wait_thr.value]
|
|
124
118
|
end
|
|
125
119
|
|
|
126
120
|
def read_file_safely(path)
|
|
@@ -9,7 +9,8 @@ module RubynCode
|
|
|
9
9
|
module Tools
|
|
10
10
|
class Bash < Base
|
|
11
11
|
TOOL_NAME = 'bash'
|
|
12
|
-
DESCRIPTION = 'Runs a shell command in the project directory. Blocks dangerous patterns
|
|
12
|
+
DESCRIPTION = 'Runs a shell command in the project directory. Blocks dangerous patterns ' \
|
|
13
|
+
'and scrubs sensitive environment variables.'
|
|
13
14
|
PARAMETERS = {
|
|
14
15
|
command: { type: :string, required: true, description: 'The shell command to execute' },
|
|
15
16
|
timeout: { type: :integer, required: false, default: 120, description: 'Timeout in seconds (default: 120)' }
|