brute 2.0.5 → 3.0.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/events/handler.rb +3 -1
  3. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  4. data/lib/brute/events/terminal_output_handler.rb +3 -1
  5. data/lib/brute/messages.rb +47 -0
  6. data/lib/brute/middleware/001_otel_span.rb +4 -2
  7. data/lib/brute/middleware/002_session_log.rb +100 -0
  8. data/lib/brute/middleware/004_summarize.rb +14 -12
  9. data/lib/brute/middleware/006_loop.rb +158 -0
  10. data/lib/brute/middleware/010_max_iterations.rb +8 -6
  11. data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
  12. data/lib/brute/middleware/020_system_prompt.rb +5 -3
  13. data/lib/brute/middleware/040_compaction_check.rb +3 -1
  14. data/lib/brute/middleware/060_questions.rb +3 -1
  15. data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +31 -20
  16. data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
  17. data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
  18. data/lib/brute/middleware/event_handler.rb +3 -1
  19. data/lib/brute/middleware/user_queue.rb +3 -1
  20. data/lib/brute/prompts/autonomy.rb +3 -0
  21. data/lib/brute/prompts/base.rb +3 -0
  22. data/lib/brute/prompts/build_switch.rb +3 -1
  23. data/lib/brute/prompts/code_references.rb +3 -0
  24. data/lib/brute/prompts/code_style.rb +3 -0
  25. data/lib/brute/prompts/conventions.rb +3 -0
  26. data/lib/brute/prompts/doing_tasks.rb +3 -0
  27. data/lib/brute/prompts/editing_approach.rb +3 -0
  28. data/lib/brute/prompts/editing_constraints.rb +3 -0
  29. data/lib/brute/prompts/environment.rb +3 -1
  30. data/lib/brute/prompts/frontend_tasks.rb +3 -0
  31. data/lib/brute/prompts/git_safety.rb +3 -0
  32. data/lib/brute/prompts/identity.rb +3 -1
  33. data/lib/brute/prompts/instructions.rb +3 -1
  34. data/lib/brute/prompts/max_steps.rb +3 -1
  35. data/lib/brute/prompts/objectivity.rb +3 -0
  36. data/lib/brute/prompts/plan_reminder.rb +3 -1
  37. data/lib/brute/prompts/proactiveness.rb +3 -0
  38. data/lib/brute/prompts/security_and_safety.rb +3 -0
  39. data/lib/brute/prompts/skills.rb +6 -2
  40. data/lib/brute/prompts/task_management.rb +3 -0
  41. data/lib/brute/prompts/tone_and_style.rb +3 -0
  42. data/lib/brute/prompts/tool_usage.rb +3 -0
  43. data/lib/brute/prompts.rb +5 -0
  44. data/lib/brute/rack/adapter.rb +237 -0
  45. data/lib/brute/skill.rb +192 -15
  46. data/lib/brute/system_prompt.rb +3 -1
  47. data/lib/brute/tools/adapter.rb +296 -0
  48. data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
  49. data/lib/brute/tools/fs/snapshot_store.rb +41 -0
  50. data/lib/brute/tools/fs_patch.rb +5 -3
  51. data/lib/brute/tools/fs_read.rb +4 -2
  52. data/lib/brute/tools/fs_remove.rb +2 -2
  53. data/lib/brute/tools/fs_search.rb +3 -1
  54. data/lib/brute/tools/fs_undo.rb +2 -2
  55. data/lib/brute/tools/fs_write.rb +5 -3
  56. data/lib/brute/tools/shell.rb +3 -1
  57. data/lib/brute/tools/skill_load.rb +156 -0
  58. data/lib/brute/tools/sub_agent.rb +118 -0
  59. data/lib/brute/tools/todo_list/store.rb +36 -0
  60. data/lib/brute/tools/todo_read.rb +1 -1
  61. data/lib/brute/tools/todo_write.rb +1 -1
  62. data/lib/brute/tools.rb +2 -1
  63. data/lib/brute/truncation.rb +3 -1
  64. data/lib/brute/turn/agent_pipeline.rb +168 -0
  65. data/lib/brute/turn/pipeline.rb +95 -0
  66. data/lib/brute/turn/tool_pipeline.rb +106 -0
  67. data/lib/brute/utils/diff.rb +3 -1
  68. data/lib/brute/version.rb +1 -1
  69. data/lib/brute.rb +46 -71
  70. data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
  71. data/lib/{brute → brute_cli}/providers/shell_response.rb +3 -5
  72. data/lib/ruby_llm/message_transport.rb +117 -0
  73. metadata +32 -28
  74. data/lib/brute/agent.rb +0 -82
  75. data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
  76. data/lib/brute/middleware/100_llm_call.rb +0 -63
  77. data/lib/brute/pipeline.rb +0 -97
  78. data/lib/brute/queue/file_mutation_queue.rb +0 -102
  79. data/lib/brute/session.rb +0 -52
  80. data/lib/brute/store/snapshot_store.rb +0 -36
  81. data/lib/brute/store/todo_store.rb +0 -30
  82. data/lib/brute/sub_agent.rb +0 -106
  83. data/lib/brute/tool.rb +0 -107
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Tools
8
+ # @namespace
9
+ module FS
10
+ # Per-path stack of file snapshots used by fs_write, fs_patch, fs_remove
11
+ # to enable undo. Each call to .save pushes the current content (or
12
+ # :did_not_exist for new files). .pop retrieves the most recent snapshot.
13
+ module SnapshotStore
14
+ @snapshots = Hash.new { |h, k| h[k] = [] }
15
+ @mutex = Mutex.new
16
+
17
+ class << self
18
+ # Push the current content of +path+ onto the snapshot stack.
19
+ # If the file doesn't exist yet, records +:did_not_exist+.
20
+ def save(path)
21
+ key = File.expand_path(path)
22
+ content = File.exist?(key) ? File.read(key) : :did_not_exist
23
+ @mutex.synchronize { @snapshots[key].push(content) }
24
+ end
25
+
26
+ # Pop and return the most recent snapshot for +path+, or +nil+
27
+ # if there is no history.
28
+ def pop(path)
29
+ key = File.expand_path(path)
30
+ @mutex.synchronize { @snapshots[key].pop }
31
+ end
32
+
33
+ # Clear all snapshots. Used in tests and session resets.
34
+ def clear!
35
+ @mutex.synchronize { @snapshots.clear }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -19,13 +19,13 @@ module Brute
19
19
 
20
20
  def execute(file_path:, old_string:, new_string:, replace_all: false)
21
21
  path = File.expand_path(file_path)
22
- Brute::Queue::FileMutationQueue.serialize(path) do
22
+ Brute::Tools::FS::FileMutationQueue.serialize(path) do
23
23
  raise "File not found: #{path}" unless File.exist?(path)
24
24
 
25
25
  original = File.read(path)
26
26
  raise "old_string not found in #{path}" unless original.include?(old_string)
27
27
 
28
- Brute::Store::SnapshotStore.save(path)
28
+ Brute::Tools::FS::SnapshotStore.save(path)
29
29
 
30
30
  updated = if replace_all
31
31
  original.gsub(old_string, new_string)
@@ -43,7 +43,9 @@ module Brute
43
43
  end
44
44
  end
45
45
 
46
- test do
46
+ __END__
47
+
48
+ describe "brute/tools/fs_patch" do
47
49
  require "tmpdir"
48
50
 
49
51
  it "replaces old_string with new_string" do
@@ -26,7 +26,7 @@ module Brute
26
26
  # 7. File-not-found suggestions — on miss, scan the parent directory for
27
27
  # similar names and suggest "Did you mean...?" candidates.
28
28
  # 8. Return a plain string instead of a Hash — avoids the .to_s repr
29
- # bloat when ToolCall coerces the result for the LLM message.
29
+ # bloat when ToolPipeline coerces the result for the LLM message.
30
30
  #
31
31
  class FSRead < RubyLLM::Tool
32
32
  description "Read the contents of a file. Returns file content with line numbers. " \
@@ -146,7 +146,9 @@ module Brute
146
146
  end
147
147
  end
148
148
 
149
- test do
149
+ __END__
150
+
151
+ describe "brute/tools/fs_read" do
150
152
  require "tmpdir"
151
153
 
152
154
  it "reads a file without error" do
@@ -16,10 +16,10 @@ module Brute
16
16
 
17
17
  def execute(path:)
18
18
  target = File.expand_path(path)
19
- Brute::Queue::FileMutationQueue.serialize(target) do
19
+ Brute::Tools::FS::FileMutationQueue.serialize(target) do
20
20
  raise "Path not found: #{target}" unless File.exist?(target)
21
21
 
22
- Brute::Store::SnapshotStore.save(target) if File.file?(target)
22
+ Brute::Tools::FS::SnapshotStore.save(target) if File.file?(target)
23
23
 
24
24
  if File.directory?(target)
25
25
  Dir.rmdir(target)
@@ -62,7 +62,9 @@ module Brute
62
62
  end
63
63
  end
64
64
 
65
- test do
65
+ __END__
66
+
67
+ describe "brute/tools/fs_search" do
66
68
  require "tmpdir"
67
69
 
68
70
  it "searches the current directory without error" do
@@ -16,8 +16,8 @@ module Brute
16
16
 
17
17
  def execute(path:)
18
18
  target = File.expand_path(path)
19
- Brute::Queue::FileMutationQueue.serialize(target) do
20
- snapshot = Brute::Store::SnapshotStore.pop(target)
19
+ Brute::Tools::FS::FileMutationQueue.serialize(target) do
20
+ snapshot = Brute::Tools::FS::SnapshotStore.pop(target)
21
21
  raise "No undo history available for: #{target}" unless snapshot
22
22
 
23
23
  if snapshot == :did_not_exist
@@ -18,9 +18,9 @@ module Brute
18
18
 
19
19
  def execute(file_path:, content:)
20
20
  path = File.expand_path(file_path)
21
- Brute::Queue::FileMutationQueue.serialize(path) do
21
+ Brute::Tools::FS::FileMutationQueue.serialize(path) do
22
22
  old_content = File.exist?(path) ? File.read(path) : ''
23
- Brute::Store::SnapshotStore.save(path)
23
+ Brute::Tools::FS::SnapshotStore.save(path)
24
24
  FileUtils.mkdir_p(File.dirname(path))
25
25
  File.write(path, content)
26
26
  diff = Brute::Diff.unified(old_content, content)
@@ -31,7 +31,9 @@ module Brute
31
31
  end
32
32
  end
33
33
 
34
- test do
34
+ __END__
35
+
36
+ describe "brute/tools/fs_write" do
35
37
  require "tmpdir"
36
38
 
37
39
  it "writes content to a new file" do
@@ -60,7 +60,9 @@ module Brute
60
60
  end
61
61
  end
62
62
 
63
- test do
63
+ __END__
64
+
65
+ describe "brute/tools/shell" do
64
66
  #it "runs a command without error" do
65
67
  # result = Brute::Tools::Shell.new.call(command: "echo hello")
66
68
  # result.strip.should =~ /hello/
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "fileutils"
5
+
6
+ require "bundler/setup"
7
+ require "brute"
8
+ require "brute/tools"
9
+
10
+ module Brute
11
+ module Tools
12
+ # The `skill` tool — stages 2 (activation) and 3 (execution) of the Agent
13
+ # Skills progressive-disclosure lifecycle (https://agentskills.io).
14
+ #
15
+ # Stage 1 (discovery) happens in the system prompt: Brute::Prompts::Skills
16
+ # lists each skill's name + description only (~100 tokens each). When a task
17
+ # matches, the model calls this tool with the skill name to pull the full
18
+ # SKILL.md body into the conversation (stage 2).
19
+ #
20
+ # The output also reports the skill's base directory and a capped listing of
21
+ # bundled files. That is stage 3: skills bundle scripts/, references/, and
22
+ # assets/ that the model runs or reads *through the agent's existing tools*
23
+ # (`shell`, `read`) by relative path. There is no separate skill runtime.
24
+ #
25
+ # The tool must scan the same directories as Prompts::Skills, or it would
26
+ # advertise skills it cannot load. Both default to Dir.pwd; agents that point
27
+ # the prompt at a custom root (e.g. ctx.merge(cwd: __dir__)) should build the
28
+ # tool with the matching cwd: Brute::Tools::SkillLoad.new(cwd: __dir__).
29
+ #
30
+ # Reference: opencode's tool/skill.ts.
31
+ class SkillLoad < RubyLLM::Tool
32
+ description "Load a specialized skill when the task at hand matches one of the " \
33
+ "available skills listed in the system context. This injects the skill's " \
34
+ "full instructions into the conversation, plus the skill's base directory " \
35
+ "and a listing of bundled files (scripts, references, assets) that you can " \
36
+ "run or read by relative path using your existing tools. The name must match " \
37
+ "one of the available skills."
38
+
39
+ param :name, type: "string", desc: "The name of the skill from the available skills list", required: true
40
+
41
+ FILE_LIMIT = 10
42
+
43
+ def name; "skill"; end
44
+
45
+ def initialize(cwd: Dir.pwd)
46
+ super()
47
+ @cwd = cwd
48
+ end
49
+
50
+ def execute(name:)
51
+ skill = Brute::Skill.get(name, cwd: @cwd)
52
+ return unknown_skill(name) unless skill
53
+
54
+ directory = File.dirname(skill.location)
55
+ files = bundled_files(directory)
56
+
57
+ render(skill, directory, files)
58
+ end
59
+
60
+ private
61
+
62
+ def unknown_skill(name)
63
+ available = Brute::Skill.all(cwd: @cwd).map(&:name)
64
+ listing = available.empty? ? "(none)" : available.join(", ")
65
+ "Error: unknown skill #{name.inspect}. Available skills: #{listing}"
66
+ end
67
+
68
+ def bundled_files(directory)
69
+ Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH)
70
+ .select { |p| File.file?(p) }
71
+ .reject { |p| File.basename(p) == Brute::Skill::FILENAME }
72
+ .map { |p| Pathname.new(p).relative_path_from(Pathname.new(directory)).to_s }
73
+ .sort
74
+ .first(FILE_LIMIT)
75
+ end
76
+
77
+ def render(skill, directory, files)
78
+ lines = [
79
+ "<skill name=\"#{skill.name}\">",
80
+ "# Skill: #{skill.name}",
81
+ "",
82
+ skill.content,
83
+ "",
84
+ "Base directory for this skill: #{directory}",
85
+ "Relative paths in this skill (e.g. scripts/, references/, assets/) are relative to " \
86
+ "this base directory. Use your existing tools (read, shell) to access them.",
87
+ ]
88
+
89
+ unless files.empty?
90
+ lines << ""
91
+ lines << "Bundled files (sampled, up to #{FILE_LIMIT}):"
92
+ files.each { |f| lines << " #{f}" }
93
+ end
94
+
95
+ lines << "</skill>"
96
+ lines.join("\n")
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ __END__
103
+
104
+ describe "brute/tools/skill_load" do
105
+ require "tmpdir"
106
+
107
+ def write_skill(root, name, body: "Do the thing.", frontmatter: nil)
108
+ dir = File.join(root, ".brute", "skills", name)
109
+ FileUtils.mkdir_p(dir)
110
+ frontmatter ||= "name: #{name}\ndescription: A skill that does #{name}"
111
+ File.write(File.join(dir, "SKILL.md"), "---\n#{frontmatter}\n---\n\n#{body}\n")
112
+ dir
113
+ end
114
+
115
+ it "returns the skill body and base directory" do
116
+ Dir.mktmpdir do |root|
117
+ dir = write_skill(root, "debugging", body: "Step 1. Reproduce.")
118
+ out = Brute::Tools::SkillLoad.new(cwd: root).call(name: "debugging")
119
+ out.should =~ /Step 1\. Reproduce\./
120
+ out.should =~ /Base directory for this skill: #{Regexp.escape(dir)}/
121
+ end
122
+ end
123
+
124
+ it "lists bundled files but not SKILL.md itself" do
125
+ Dir.mktmpdir do |root|
126
+ dir = write_skill(root, "deploy")
127
+ FileUtils.mkdir_p(File.join(dir, "scripts"))
128
+ File.write(File.join(dir, "scripts", "run.sh"), "echo hi")
129
+ out = Brute::Tools::SkillLoad.new(cwd: root).call(name: "deploy")
130
+ out.should =~ %r{scripts/run\.sh}
131
+ out.should.not =~ /SKILL\.md/
132
+ end
133
+ end
134
+
135
+ it "caps bundled files at FILE_LIMIT" do
136
+ Dir.mktmpdir do |root|
137
+ dir = write_skill(root, "many")
138
+ 15.times { |i| File.write(File.join(dir, "f#{i}.txt"), "x") }
139
+ out = Brute::Tools::SkillLoad.new(cwd: root).call(name: "many")
140
+ out.scan(/^ f\d+\.txt$/).size.should.be <= Brute::Tools::SkillLoad::FILE_LIMIT
141
+ end
142
+ end
143
+
144
+ it "returns a tool-error string (not an exception) for an unknown skill" do
145
+ Dir.mktmpdir do |root|
146
+ write_skill(root, "alpha")
147
+ out = Brute::Tools::SkillLoad.new(cwd: root).call(name: "missing")
148
+ out.should =~ /unknown skill/
149
+ out.should =~ /alpha/
150
+ end
151
+ end
152
+
153
+ it "defaults cwd to Dir.pwd" do
154
+ Brute::Tools::SkillLoad.new.should.be.kind_of(Brute::Tools::SkillLoad)
155
+ end
156
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require 'brute/turn/agent_pipeline'
6
+
7
+ module Brute
8
+ module Tools
9
+ # A SubAgent is an Agent that exposes a tool-shaped facade so it can
10
+ # be dropped into another agent's tools list. The parent agent hands it
11
+ # to the LLM as a regular tool; when invoked, the SubAgent runs its own
12
+ # pipeline against a fresh Session built from the tool arguments, then
13
+ # returns the final assistant message as the tool result.
14
+ #
15
+ # Usage:
16
+ #
17
+ # researcher = Brute::Tools::SubAgent.new(
18
+ # name: "research",
19
+ # description: "Delegate a research task to a read-only sub-agent.",
20
+ # ) do
21
+ # use Brute::Middleware::SystemPrompt
22
+ # use Brute::Middleware::Loop::ToolResult
23
+ # use Brute::Middleware::MaxIterations, max_iterations: 10
24
+ # use Brute::Middleware::ToolPipeline, tools: [Brute::Tools::FSRead, Brute::Tools::FSSearch]
25
+ # run ->(env) do
26
+ # ctx = RubyLLM.context { |c| c.ollama_api_base = ENV["OLLAMA_API_BASE"] }
27
+ # model, provider = RubyLLM::Models.resolve(
28
+ # "llama3.2", provider: :ollama, assume_exists: true, config: ctx.config)
29
+ # response = provider.complete(env[:messages],
30
+ # tools: Brute.rubyllm_tools(env[:tools]),
31
+ # temperature: 0.7,
32
+ # model: model)
33
+ # RubyLLM::MessageTransport.new(response).wrap_each { |m| env[:messages] << m }
34
+ # end
35
+ # end
36
+ #
37
+ # # The SubAgent IS a tool — hand it to a parent agent's ToolPipeline:
38
+ # main_agent = Brute.agent do
39
+ # use Brute::Middleware::ToolPipeline, tools: [Brute::Tools::FSRead, researcher]
40
+ # run ->(env) { ... }
41
+ # end
42
+ # main_agent.start("delegate some research")
43
+ #
44
+ class SubAgent < Brute::Turn::AgentPipeline
45
+ DEFAULT_PARAMS = {
46
+ task: { type: "string", desc: "A clear, detailed description of the task", required: true },
47
+ }.freeze
48
+
49
+ attr_reader :sub_agent_name, :description, :params
50
+
51
+ def initialize(name:, description:, params: DEFAULT_PARAMS, &block)
52
+ @sub_agent_name = name.to_s
53
+ @description = description
54
+ @params = params
55
+ super(&block)
56
+ end
57
+
58
+ # Tool-shaped entry point. Builds a session from arguments, runs the
59
+ # agent loop, returns the last assistant message as a string.
60
+ def execute(arguments)
61
+ session = build_session(arguments)
62
+ start(session)
63
+ extract_result(session)
64
+ end
65
+
66
+ # Adapter so the parent agent's completion middleware (and ruby_llm)
67
+ # sees this as a regular tool. ToolPipeline middleware should call
68
+ # `to_ruby_llm` when building the tools hash if a tool responds to it.
69
+ def to_ruby_llm
70
+ sub = self
71
+ Class.new(::RubyLLM::Tool) do
72
+ description sub.description
73
+ sub.params.each { |k, opts| param k, **opts }
74
+ define_method(:name) { sub.sub_agent_name }
75
+ define_method(:execute) { |**args| sub.execute(args) }
76
+ end.new
77
+ end
78
+
79
+ # Lets ToolPipeline treat SubAgents the same as RubyLLM::Tool instances
80
+ # without checking respond_to? everywhere.
81
+ def name
82
+ @sub_agent_name
83
+ end
84
+
85
+ private
86
+
87
+ def build_session(arguments)
88
+ task = arguments[:task] || arguments["task"]
89
+ Brute.log.tap { |s| s.user(task) }
90
+ end
91
+
92
+ def extract_result(session)
93
+ last = session.reverse_each.find do |m|
94
+ m.role == :assistant && m.content.is_a?(String) && !m.content.empty?
95
+ end
96
+ last&.content || "(sub-agent completed but produced no text response)"
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ __END__
103
+
104
+ describe "brute/tools/sub_agent" do
105
+ it "exposes a name matching the sub-agent identifier" do
106
+ sa = Brute::Tools::SubAgent.new(name: "research", description: "test") do
107
+ run ->(env) { env[:messages].assistant("done") }
108
+ end
109
+ sa.name.should == "research"
110
+ end
111
+
112
+ it "execute returns the last assistant message" do
113
+ sa = Brute::Tools::SubAgent.new(name: "research", description: "test") do
114
+ run ->(env) { env[:messages].assistant("result text") }
115
+ end
116
+ sa.execute(task: "do something").should == "result text"
117
+ end
118
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Tools
8
+ # @namespace
9
+ module TodoList
10
+ # In-memory todo list storage. The agent uses this to track multi-step
11
+ # tasks via the todo_read / todo_write tools. The list is replaced
12
+ # wholesale on each todo_write call.
13
+ module Store
14
+ @items = []
15
+ @mutex = Mutex.new
16
+
17
+ class << self
18
+ # Replace the entire todo list.
19
+ def replace(items)
20
+ @mutex.synchronize { @items = items.dup }
21
+ end
22
+
23
+ # Return all current items.
24
+ def all
25
+ @mutex.synchronize { @items.dup }
26
+ end
27
+
28
+ # Clear all items.
29
+ def clear!
30
+ @mutex.synchronize { @items.clear }
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -13,7 +13,7 @@ module Brute
13
13
  def name; "todo_read"; end
14
14
 
15
15
  def execute(_placeholder: nil)
16
- {todos: Brute::Store::TodoStore.all}
16
+ {todos: Brute::Tools::TodoList::Store.all}
17
17
  end
18
18
  end
19
19
  end
@@ -36,7 +36,7 @@ module Brute
36
36
  t = t.transform_keys(&:to_sym) if t.is_a?(Hash)
37
37
  {id: t[:id], content: t[:content], status: t[:status]}
38
38
  end
39
- Brute::Store::TodoStore.replace(items)
39
+ Brute::Tools::TodoList::Store.replace(items)
40
40
  {success: true, count: items.size}
41
41
  end
42
42
  end
data/lib/brute/tools.rb CHANGED
@@ -20,7 +20,8 @@ module Brute
20
20
  Tools::NetFetch,
21
21
  Tools::TodoWrite,
22
22
  Tools::TodoRead,
23
- Tools::Question
23
+ Tools::Question,
24
+ Tools::SkillLoad
24
25
  ].freeze
25
26
  end
26
27
  end
@@ -124,7 +124,9 @@ module Brute
124
124
  end
125
125
  end
126
126
 
127
- test do
127
+ __END__
128
+
129
+ describe "brute/truncation" do
128
130
  require "tmpdir"
129
131
  require "fileutils"
130
132
 
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/messages"
6
+ require "brute/turn/pipeline"
7
+
8
+ module Brute
9
+ module Turn
10
+
11
+ # agent = Brute.agent # => AgentPipeline
12
+ # .use(Middleware::X) # => same AgentPipeline (.use returns self)
13
+ # .run ->(env) { ... } # => same AgentPipeline (.run returns self)
14
+ #
15
+ # agent.start("what changed?") # => runs the turn, returns env
16
+ #
17
+ class AgentPipeline < Pipeline
18
+
19
+ # Register a slash command:
20
+ #
21
+ # map "/weather", "Get the weather in the following location $ARGUMENTS"
22
+ # map("/weather") { "Get the weather in the following location $ARGUMENTS" }
23
+ #
24
+ def map(command, template = nil, &block)
25
+ command = command.to_s
26
+ command = command.start_with?("/") ? command : "/#{command}"
27
+
28
+ (@map ||= {})[command] = block || proc { template }
29
+ self
30
+ end
31
+
32
+ def generate_map(default_app, mapping)
33
+ super.tap do |routes|
34
+ routes.define_singleton_method(:call) do |prompt|
35
+ name, args = prompt.to_s.strip.split(/\s+/, 2)
36
+ prompt = mapping[name].call.to_s.gsub("$ARGUMENTS", args.to_s) if mapping[name]
37
+ default_app.call(prompt)
38
+ end
39
+ end
40
+ end
41
+
42
+ def start(input = nil, events: NullSink.new)
43
+ env = {
44
+ messages: coerce_messages(input),
45
+ events: events,
46
+ metadata: {},
47
+ current_iteration: 1,
48
+ }
49
+ env.tap do
50
+ build.call(env)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def coerce_messages(input)
57
+ case input
58
+ when nil then Brute.log
59
+ when ::String then Brute.log.tap { |log| log.user(input) }
60
+ when ::RubyLLM::Message then Brute.log(input)
61
+ when ::Array then input.extend(Brute::Messages)
62
+ else Brute.log(input)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ __END__
70
+
71
+ describe "brute/turn/agent_pipeline" do
72
+ require "brute/messages"
73
+
74
+ # A top-level stub middleware the eval'd scripts can reference.
75
+ AgentStubMW = Class.new do
76
+ def initialize(app); @app = app; end
77
+ def call(env); env[:metadata][:mw] = true; @app.call(env); end
78
+ end unless defined?(AgentStubMW)
79
+
80
+ it "starts a turn and returns the env with the log in :messages" do
81
+ agent = Brute.agent.run(->(env) { env[:messages].assistant("hello") })
82
+ agent.should.be.kind_of?(Brute::Turn::AgentPipeline)
83
+
84
+ env = agent.start("hi")
85
+ env[:messages].map(&:role).should == [:user, :assistant]
86
+ env[:messages].last.content.should == "hello"
87
+ end
88
+
89
+ it "coerces a String input into a user message" do
90
+ captured = nil
91
+ Brute.agent.run(->(env) { captured = env[:messages].dup }).start("a question")
92
+ captured.first.role.should == :user
93
+ captured.first.content.should == "a question"
94
+ end
95
+
96
+ it "shapes env with no config (that lives in the run proc)" do
97
+ captured = nil
98
+ Brute.agent.run(->(env) { captured = env }).start
99
+ captured[:current_iteration].should == 1
100
+ captured.key?(:provider).should.be.false
101
+ captured.key?(:tools).should.be.false
102
+ end
103
+
104
+ it "is its own builder — .use and .run both return the AgentPipeline" do
105
+ agent = Brute.agent.use(AgentStubMW)
106
+ agent.should.be.kind_of?(Brute::Turn::AgentPipeline)
107
+
108
+ agent.run { |env| env[:messages].assistant("done") }.should.equal?(agent)
109
+
110
+ env = agent.start("go")
111
+ env[:metadata][:mw].should.be.true
112
+ env[:messages].last.content.should == "done"
113
+ end
114
+
115
+ it "parses a ru-style string into a runnable Agent" do
116
+ agent = Brute::Turn::AgentPipeline.new_from_string('run ->(env) { env[:messages].assistant("from ru") }', "(test)")
117
+ agent.should.be.kind_of?(Brute::Turn::AgentPipeline)
118
+ agent.start("hi")[:messages].last.content.should == "from ru"
119
+ end
120
+
121
+ it "use in a ru string wraps the terminal" do
122
+ script = <<~RUBY
123
+ use AgentStubMW
124
+ run ->(env) { env[:messages].assistant("ok") }
125
+ RUBY
126
+ Brute::Turn::AgentPipeline.new_from_string(script, "(test)").start("go")[:metadata][:mw].should.be.true
127
+ end
128
+
129
+ it "builds inline from a block passed to Brute.agent" do
130
+ agent = Brute.agent do
131
+ use AgentStubMW
132
+ run ->(env) { env[:messages].assistant("done") }
133
+ end
134
+ agent.start("go")[:messages].last.content.should == "done"
135
+ end
136
+
137
+ it "map chains and to_app taps generate_map to build a (retargeted) URLMap" do
138
+ agent = Brute.agent
139
+ .map("/weather", "Get the weather in the following location $ARGUMENTS")
140
+ .run(->(prompt) { prompt })
141
+
142
+ agent.should.be.kind_of?(Brute::Turn::AgentPipeline) # map returns self
143
+ agent.build.should.be.kind_of?(::Rack::URLMap) # to_app tapped generate_map (super)
144
+ end
145
+
146
+ it "swaps a /command prompt for its template ($ARGUMENTS) and calls the app" do
147
+ got = nil
148
+ agent = Brute.agent
149
+ .map("/weather", "Get the weather in the following location $ARGUMENTS")
150
+ .map("/echo") { "you said: $ARGUMENTS" }
151
+ .run(->(prompt) { got = prompt })
152
+
153
+ agent.call("/weather London")
154
+ got.should == "Get the weather in the following location London"
155
+ agent.call("/echo hi there")
156
+ got.should == "you said: hi there"
157
+ end
158
+
159
+ it "passes non-commands through untouched (and normalizes the slash)" do
160
+ got = nil
161
+ agent = Brute.agent.map("weather", "W $ARGUMENTS").run(->(prompt) { got = prompt })
162
+
163
+ agent.call("just a question")
164
+ got.should == "just a question"
165
+ agent.call("/weather NYC") # registered as "weather", normalized to "/weather"
166
+ got.should == "W NYC"
167
+ end
168
+ end