brute 2.0.6 → 3.0.1

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 (89) 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/message_transport/anthropic.rb +143 -0
  6. data/lib/brute/message_transport/llm.rb +94 -0
  7. data/lib/brute/message_transport/openai.rb +113 -0
  8. data/lib/brute/message_transport/ruby_llm.rb +78 -0
  9. data/lib/brute/message_transport.rb +133 -0
  10. data/lib/brute/messages.rb +120 -0
  11. data/lib/brute/middleware/001_otel_span.rb +4 -2
  12. data/lib/brute/middleware/002_session_log.rb +100 -0
  13. data/lib/brute/middleware/004_summarize.rb +21 -19
  14. data/lib/brute/middleware/006_loop.rb +158 -0
  15. data/lib/brute/middleware/010_max_iterations.rb +9 -7
  16. data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
  17. data/lib/brute/middleware/020_system_prompt.rb +6 -4
  18. data/lib/brute/middleware/040_compaction_check.rb +5 -3
  19. data/lib/brute/middleware/060_questions.rb +3 -1
  20. data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +60 -46
  21. data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
  22. data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
  23. data/lib/brute/middleware/event_handler.rb +3 -1
  24. data/lib/brute/middleware/user_queue.rb +3 -1
  25. data/lib/brute/prompts/autonomy.rb +3 -0
  26. data/lib/brute/prompts/base.rb +3 -0
  27. data/lib/brute/prompts/build_switch.rb +3 -1
  28. data/lib/brute/prompts/code_references.rb +3 -0
  29. data/lib/brute/prompts/code_style.rb +3 -0
  30. data/lib/brute/prompts/conventions.rb +3 -0
  31. data/lib/brute/prompts/doing_tasks.rb +3 -0
  32. data/lib/brute/prompts/editing_approach.rb +3 -0
  33. data/lib/brute/prompts/editing_constraints.rb +3 -0
  34. data/lib/brute/prompts/environment.rb +3 -1
  35. data/lib/brute/prompts/frontend_tasks.rb +3 -0
  36. data/lib/brute/prompts/git_safety.rb +3 -0
  37. data/lib/brute/prompts/identity.rb +3 -1
  38. data/lib/brute/prompts/instructions.rb +3 -1
  39. data/lib/brute/prompts/max_steps.rb +3 -1
  40. data/lib/brute/prompts/objectivity.rb +3 -0
  41. data/lib/brute/prompts/plan_reminder.rb +3 -1
  42. data/lib/brute/prompts/proactiveness.rb +3 -0
  43. data/lib/brute/prompts/security_and_safety.rb +3 -0
  44. data/lib/brute/prompts/skills.rb +6 -2
  45. data/lib/brute/prompts/task_management.rb +3 -0
  46. data/lib/brute/prompts/tone_and_style.rb +3 -0
  47. data/lib/brute/prompts/tool_usage.rb +3 -0
  48. data/lib/brute/prompts.rb +5 -0
  49. data/lib/brute/rack/adapter.rb +237 -0
  50. data/lib/brute/skill.rb +192 -15
  51. data/lib/brute/system_prompt.rb +3 -1
  52. data/lib/brute/tool.rb +92 -75
  53. data/lib/brute/tools/adapter.rb +258 -0
  54. data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
  55. data/lib/brute/tools/fs/snapshot_store.rb +41 -0
  56. data/lib/brute/tools/fs_patch.rb +6 -4
  57. data/lib/brute/tools/fs_read.rb +5 -3
  58. data/lib/brute/tools/fs_remove.rb +3 -3
  59. data/lib/brute/tools/fs_search.rb +4 -2
  60. data/lib/brute/tools/fs_undo.rb +3 -3
  61. data/lib/brute/tools/fs_write.rb +6 -4
  62. data/lib/brute/tools/net_fetch.rb +1 -1
  63. data/lib/brute/tools/question.rb +1 -1
  64. data/lib/brute/tools/shell.rb +4 -2
  65. data/lib/brute/tools/skill_load.rb +156 -0
  66. data/lib/brute/tools/sub_agent.rb +101 -0
  67. data/lib/brute/tools/todo_list/store.rb +36 -0
  68. data/lib/brute/tools/todo_read.rb +2 -2
  69. data/lib/brute/tools/todo_write.rb +2 -2
  70. data/lib/brute/tools.rb +2 -1
  71. data/lib/brute/truncation.rb +3 -1
  72. data/lib/brute/turn/agent_pipeline.rb +169 -0
  73. data/lib/brute/turn/pipeline.rb +95 -0
  74. data/lib/brute/turn/tool_pipeline.rb +96 -0
  75. data/lib/brute/utils/diff.rb +3 -1
  76. data/lib/brute/version.rb +1 -1
  77. data/lib/brute.rb +45 -71
  78. data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
  79. data/lib/{brute → brute_cli}/providers/shell_response.rb +9 -15
  80. metadata +52 -43
  81. data/lib/brute/agent.rb +0 -82
  82. data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
  83. data/lib/brute/middleware/100_llm_call.rb +0 -63
  84. data/lib/brute/pipeline.rb +0 -97
  85. data/lib/brute/queue/file_mutation_queue.rb +0 -102
  86. data/lib/brute/session.rb +0 -51
  87. data/lib/brute/store/snapshot_store.rb +0 -36
  88. data/lib/brute/store/todo_store.rb +0 -30
  89. data/lib/brute/sub_agent.rb +0 -106
@@ -21,7 +21,7 @@ module Brute
21
21
  # 5. Return a plain string instead of a Hash.
22
22
  # 6. Align output cap with universal truncation (2000 lines / 50 KB).
23
23
  #
24
- class FSSearch < RubyLLM::Tool
24
+ class FSSearch < Brute::Tool
25
25
  description "Search file contents using ripgrep (regex), or find files by glob pattern. " \
26
26
  "Returns matching lines with file paths and line numbers."
27
27
 
@@ -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
@@ -6,7 +6,7 @@ require "brute/tools"
6
6
 
7
7
  module Brute
8
8
  module Tools
9
- class FSUndo < RubyLLM::Tool
9
+ class FSUndo < Brute::Tool
10
10
  description "Undo the last write or patch operation on a file, restoring it to " \
11
11
  "its previous state."
12
12
 
@@ -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
@@ -7,7 +7,7 @@ require 'fileutils'
7
7
 
8
8
  module Brute
9
9
  module Tools
10
- class FSWrite < RubyLLM::Tool
10
+ class FSWrite < Brute::Tool
11
11
  description "Write content to a file. Creates parent directories if they don't exist. " \
12
12
  'Use this for creating new files or completely replacing file contents.'
13
13
 
@@ -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
@@ -9,7 +9,7 @@ require "uri"
9
9
 
10
10
  module Brute
11
11
  module Tools
12
- class NetFetch < RubyLLM::Tool
12
+ class NetFetch < Brute::Tool
13
13
  description "Fetch content from a URL. Returns the response body as text."
14
14
 
15
15
  param :url, type: 'string', desc: "The URL to fetch", required: true
@@ -6,7 +6,7 @@ require "brute/tools"
6
6
 
7
7
  module Brute
8
8
  module Tools
9
- class Question < RubyLLM::Tool
9
+ class Question < Brute::Tool
10
10
  description "Ask the user questions during execution. Use this to gather preferences, " \
11
11
  "clarify ambiguous instructions, get decisions on implementation choices, or " \
12
12
  "offer choices about direction. Users can always select \"Other\" to provide " \
@@ -22,7 +22,7 @@ module Brute
22
22
  # the LLM (defaults to 5 minutes).
23
23
  # 5. Return a plain string instead of a Hash.
24
24
  #
25
- class Shell < RubyLLM::Tool
25
+ class Shell < Brute::Tool
26
26
  description "Execute a shell command and return stdout, stderr, and exit code. " \
27
27
  "Use for git operations, running tests, installing packages, etc."
28
28
 
@@ -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 < Brute::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,101 @@
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
+ # # The LLM call, written with your library of choice. Convert
27
+ # # env[:messages] to its format, call it, and append the response
28
+ # # back as Brute::Message values (the MessageTransport pattern —
29
+ # # see examples/ruby_llm.rb, examples/openai.rb, ...).
30
+ # end
31
+ # end
32
+ #
33
+ # # The SubAgent IS a tool — hand it to a parent agent's ToolPipeline:
34
+ # main_agent = Brute.agent do
35
+ # use Brute::Middleware::ToolPipeline, tools: [Brute::Tools::FSRead, researcher]
36
+ # run ->(env) { ... }
37
+ # end
38
+ # main_agent.start("delegate some research")
39
+ #
40
+ class SubAgent < Brute::Turn::AgentPipeline
41
+ DEFAULT_PARAMS = {
42
+ task: { type: "string", desc: "A clear, detailed description of the task", required: true },
43
+ }.freeze
44
+
45
+ attr_reader :sub_agent_name, :description, :params
46
+
47
+ def initialize(name:, description:, params: DEFAULT_PARAMS, &block)
48
+ @sub_agent_name = name.to_s
49
+ @description = description
50
+ @params = params
51
+ super(&block)
52
+ end
53
+
54
+ # Tool-shaped entry point. Builds a session from arguments, runs the
55
+ # agent loop, returns the last assistant message as a string.
56
+ def execute(arguments)
57
+ session = build_session(arguments)
58
+ start(session)
59
+ extract_result(session)
60
+ end
61
+
62
+ # Lets ToolPipeline treat SubAgents the same as any other tool
63
+ # without checking respond_to? everywhere.
64
+ def name
65
+ @sub_agent_name
66
+ end
67
+
68
+ private
69
+
70
+ def build_session(arguments)
71
+ task = arguments[:task] || arguments["task"]
72
+ Brute.log.tap { |s| s.user(task) }
73
+ end
74
+
75
+ def extract_result(session)
76
+ last = session.reverse_each.find do |m|
77
+ m.role == :assistant && m.content.is_a?(String) && !m.content.empty?
78
+ end
79
+ last&.content || "(sub-agent completed but produced no text response)"
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ __END__
86
+
87
+ describe "brute/tools/sub_agent" do
88
+ it "exposes a name matching the sub-agent identifier" do
89
+ sa = Brute::Tools::SubAgent.new(name: "research", description: "test") do
90
+ run ->(env) { env[:messages].assistant("done") }
91
+ end
92
+ sa.name.should == "research"
93
+ end
94
+
95
+ it "execute returns the last assistant message" do
96
+ sa = Brute::Tools::SubAgent.new(name: "research", description: "test") do
97
+ run ->(env) { env[:messages].assistant("result text") }
98
+ end
99
+ sa.execute(task: "do something").should == "result text"
100
+ end
101
+ 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
@@ -6,14 +6,14 @@ require "brute/tools"
6
6
 
7
7
  module Brute
8
8
  module Tools
9
- class TodoRead < RubyLLM::Tool
9
+ class TodoRead < Brute::Tool
10
10
  description "Read the current todo list to check task status and progress."
11
11
  param :_placeholder, type: 'string', desc: "Unused, pass any value", required: false
12
12
 
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
@@ -6,7 +6,7 @@ require "brute/tools"
6
6
 
7
7
  module Brute
8
8
  module Tools
9
- class TodoWrite < RubyLLM::Tool
9
+ class TodoWrite < Brute::Tool
10
10
  description "Create or update the todo list. Send the complete list each time — " \
11
11
  "this replaces the existing list entirely."
12
12
 
@@ -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,169 @@
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 ::Brute::Message then Brute.log(input)
61
+ when ::Hash then Brute.log(Brute::Message.new(**input.transform_keys(&:to_sym)))
62
+ when ::Array then input.extend(Brute::Messages)
63
+ else Brute.log(input)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ __END__
71
+
72
+ describe "brute/turn/agent_pipeline" do
73
+ require "brute/messages"
74
+
75
+ # A top-level stub middleware the eval'd scripts can reference.
76
+ AgentStubMW = Class.new do
77
+ def initialize(app); @app = app; end
78
+ def call(env); env[:metadata][:mw] = true; @app.call(env); end
79
+ end unless defined?(AgentStubMW)
80
+
81
+ it "starts a turn and returns the env with the log in :messages" do
82
+ agent = Brute.agent.run(->(env) { env[:messages].assistant("hello") })
83
+ agent.should.be.kind_of?(Brute::Turn::AgentPipeline)
84
+
85
+ env = agent.start("hi")
86
+ env[:messages].map(&:role).should == [:user, :assistant]
87
+ env[:messages].last.content.should == "hello"
88
+ end
89
+
90
+ it "coerces a String input into a user message" do
91
+ captured = nil
92
+ Brute.agent.run(->(env) { captured = env[:messages].dup }).start("a question")
93
+ captured.first.role.should == :user
94
+ captured.first.content.should == "a question"
95
+ end
96
+
97
+ it "shapes env with no config (that lives in the run proc)" do
98
+ captured = nil
99
+ Brute.agent.run(->(env) { captured = env }).start
100
+ captured[:current_iteration].should == 1
101
+ captured.key?(:provider).should.be.false
102
+ captured.key?(:tools).should.be.false
103
+ end
104
+
105
+ it "is its own builder — .use and .run both return the AgentPipeline" do
106
+ agent = Brute.agent.use(AgentStubMW)
107
+ agent.should.be.kind_of?(Brute::Turn::AgentPipeline)
108
+
109
+ agent.run { |env| env[:messages].assistant("done") }.should.equal?(agent)
110
+
111
+ env = agent.start("go")
112
+ env[:metadata][:mw].should.be.true
113
+ env[:messages].last.content.should == "done"
114
+ end
115
+
116
+ it "parses a ru-style string into a runnable Agent" do
117
+ agent = Brute::Turn::AgentPipeline.new_from_string('run ->(env) { env[:messages].assistant("from ru") }', "(test)")
118
+ agent.should.be.kind_of?(Brute::Turn::AgentPipeline)
119
+ agent.start("hi")[:messages].last.content.should == "from ru"
120
+ end
121
+
122
+ it "use in a ru string wraps the terminal" do
123
+ script = <<~RUBY
124
+ use AgentStubMW
125
+ run ->(env) { env[:messages].assistant("ok") }
126
+ RUBY
127
+ Brute::Turn::AgentPipeline.new_from_string(script, "(test)").start("go")[:metadata][:mw].should.be.true
128
+ end
129
+
130
+ it "builds inline from a block passed to Brute.agent" do
131
+ agent = Brute.agent do
132
+ use AgentStubMW
133
+ run ->(env) { env[:messages].assistant("done") }
134
+ end
135
+ agent.start("go")[:messages].last.content.should == "done"
136
+ end
137
+
138
+ it "map chains and to_app taps generate_map to build a (retargeted) URLMap" do
139
+ agent = Brute.agent
140
+ .map("/weather", "Get the weather in the following location $ARGUMENTS")
141
+ .run(->(prompt) { prompt })
142
+
143
+ agent.should.be.kind_of?(Brute::Turn::AgentPipeline) # map returns self
144
+ agent.build.should.be.kind_of?(::Rack::URLMap) # to_app tapped generate_map (super)
145
+ end
146
+
147
+ it "swaps a /command prompt for its template ($ARGUMENTS) and calls the app" do
148
+ got = nil
149
+ agent = Brute.agent
150
+ .map("/weather", "Get the weather in the following location $ARGUMENTS")
151
+ .map("/echo") { "you said: $ARGUMENTS" }
152
+ .run(->(prompt) { got = prompt })
153
+
154
+ agent.call("/weather London")
155
+ got.should == "Get the weather in the following location London"
156
+ agent.call("/echo hi there")
157
+ got.should == "you said: hi there"
158
+ end
159
+
160
+ it "passes non-commands through untouched (and normalizes the slash)" do
161
+ got = nil
162
+ agent = Brute.agent.map("weather", "W $ARGUMENTS").run(->(prompt) { got = prompt })
163
+
164
+ agent.call("just a question")
165
+ got.should == "just a question"
166
+ agent.call("/weather NYC") # registered as "weather", normalized to "/weather"
167
+ got.should == "W NYC"
168
+ end
169
+ end