roast-ai 1.1.0 → 1.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/docs/write-comments.md +1 -1
  3. data/.rubocop.yml +10 -1
  4. data/Gemfile.lock +125 -20
  5. data/README.md +70 -5
  6. data/examples/custom_logging.rb +4 -2
  7. data/examples/demo/Gemfile.lock +17 -15
  8. data/examples/plugin-gem-example/Gemfile.lock +16 -16
  9. data/examples/simple_agent.rb +1 -1
  10. data/examples/simple_chat.rb +1 -1
  11. data/examples/targets_and_params.rb +12 -0
  12. data/internal/documentation/{doc-comments-external.md → comments/doc-comments-external.md} +5 -3
  13. data/internal/documentation/{doc-comments.md → comments/doc-comments.md} +2 -1
  14. data/internal/rubocop/cop/roast/no_test_class_nesting.rb +126 -0
  15. data/internal/rubocop/rubocop-roast.yml +6 -0
  16. data/internal/workflows/maintenance/branch_docs_impact.rb +97 -0
  17. data/internal/workflows/maintenance/deprecated_models_docs_updater.rb +78 -0
  18. data/lib/roast/cog/config.rb +1 -1
  19. data/lib/roast/cog_input_manager.rb +30 -51
  20. data/lib/roast/cogs/agent/config.rb +61 -5
  21. data/lib/roast/cogs/agent/providers/claude/claude_invocation.rb +7 -3
  22. data/lib/roast/cogs/agent/providers/claude/messages/result_message.rb +1 -1
  23. data/lib/roast/cogs/agent/providers/claude/tool_result.rb +344 -4
  24. data/lib/roast/cogs/agent/providers/claude/tool_use.rb +356 -1
  25. data/lib/roast/cogs/agent/providers/pi/messages/tool_call_message.rb +218 -18
  26. data/lib/roast/cogs/agent/providers/pi/messages/tool_result_message.rb +208 -13
  27. data/lib/roast/cogs/agent/providers/pi/pi_invocation.rb +4 -2
  28. data/lib/roast/cogs/agent.rb +3 -2
  29. data/lib/roast/cogs/chat/config.rb +81 -4
  30. data/lib/roast/cogs/chat.rb +90 -10
  31. data/lib/roast/config_manager.rb +29 -2
  32. data/lib/roast/event.rb +1 -0
  33. data/lib/roast/event_monitor.rb +35 -3
  34. data/lib/roast/log.rb +21 -0
  35. data/lib/roast/log_formatter.rb +9 -7
  36. data/lib/roast/version.rb +1 -1
  37. data/lib/roast/workflow.rb +10 -7
  38. data/lib/roast/workflow_param_accessors.rb +53 -0
  39. data/roast-ai.gemspec +1 -1
  40. data/sorbet/rbi/shims/lib/roast/cog/config.rbi +251 -0
  41. data/sorbet/rbi/shims/lib/roast/config_context.rbi +5 -1
  42. data/tutorial/01_your_first_workflow/README.md +10 -5
  43. data/tutorial/01_your_first_workflow/configured_chat.rb +1 -1
  44. data/tutorial/02_chaining_cogs/README.md +25 -4
  45. data/tutorial/02_chaining_cogs/code_review.rb +2 -2
  46. data/tutorial/02_chaining_cogs/session_resumption.rb +2 -1
  47. data/tutorial/03_targets_and_params/README.md +11 -2
  48. data/tutorial/04_configuration_options/README.md +21 -2
  49. data/tutorial/08_iterative_workflows/README.md +1 -1
  50. data/tutorial/README.md +2 -2
  51. metadata +14 -8
  52. /data/internal/documentation/{architectural-notes.md → comments/architectural-notes.md} +0 -0
  53. /data/internal/documentation/{doc-comments-internal.md → comments/doc-comments-internal.md} +0 -0
@@ -70,7 +70,8 @@ These files provide the primary interface between users and Roast. The documenta
70
70
  # Configure the cog to use a specified provider when invoking an agent
71
71
  #
72
72
  # The provider is the source of the agent tool itself.
73
- # If no provider is specified, Anthropic Claude Code (`:claude`) will be used as the default provider.
73
+ # If no provider is specified, Roast uses the provider named by the `ROAST_DEFAULT_AGENT_PROVIDER`
74
+ # environment variable, or Pi (`:pi`) when that variable is unset.
74
75
  #
75
76
  # A provider must be properly installed on your system in order for Roast to be able to use it.
76
77
  #
@@ -0,0 +1,126 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module RuboCop
5
+ module Cop
6
+ module Roast
7
+ # Prevents nesting classes or modules inside reopened (non-test) class
8
+ # definitions in test files.
9
+ #
10
+ # When a class is reopened in a test file (e.g., `class Agent < Cog`) and
11
+ # contains nested class or module definitions, IDE test runners like
12
+ # RubyMine fail to discover test suites. Use `::` scoping instead.
13
+ #
14
+ # The cop walks the entire subtree of the offending class, so deeply
15
+ # nested structures (e.g., `class Agent < Cog; module Providers; ...`)
16
+ # are caught even when the nested definitions are not direct children.
17
+ #
18
+ # Classes nested inside test classes are exempt — helper stubs and
19
+ # fixtures defined inside a test suite are perfectly fine.
20
+ #
21
+ # @example Bad — reopened class with nested module
22
+ # class Agent < Cog
23
+ # module Providers
24
+ # class Claude::MessageTest < ActiveSupport::TestCase
25
+ # # ...
26
+ # end
27
+ # end
28
+ # end
29
+ #
30
+ # @example Bad — reopened class with nested test class
31
+ # class Agent < Cog
32
+ # class ConfigTest < ActiveSupport::TestCase
33
+ # # ...
34
+ # end
35
+ # end
36
+ #
37
+ # @example Good — :: scoping, no class reopening
38
+ # module Agent::Providers
39
+ # class Claude::MessageTest < ActiveSupport::TestCase
40
+ # # ...
41
+ # end
42
+ # end
43
+ #
44
+ # @example Good — :: scoped test class
45
+ # class Agent::ConfigTest < ActiveSupport::TestCase
46
+ # # ...
47
+ # end
48
+ #
49
+ # @example Good — helper class inside a test class
50
+ # class Agent::OutputTest < ActiveSupport::TestCase
51
+ # class FakeAdapter
52
+ # def call; end
53
+ # end
54
+ # end
55
+ #
56
+ class NoTestClassNesting < Base
57
+ MSG = "Do not nest classes or modules inside reopened class `%<parent>s` in test files. " \
58
+ "Use `::` scoping instead (e.g., `class %<parent>s::Nested` or `module %<parent>s::Nested`)."
59
+
60
+ # @!method test_base_class?(node)
61
+ def_node_matcher :test_base_class?, <<~PATTERN
62
+ {
63
+ (const (const {nil? cbase} :ActiveSupport) :TestCase)
64
+ (const (const {nil? cbase} :Minitest) :Test)
65
+ (const {nil? cbase} :Minitest)
66
+ }
67
+ PATTERN
68
+
69
+ def on_class(node)
70
+ # Test classes are allowed to contain nested definitions (helpers, stubs)
71
+ return if test_class?(node)
72
+
73
+ # Classes nested inside a test class are helpers — leave them alone
74
+ return if inside_test_class?(node)
75
+
76
+ # Flag if this non-test class contains any nested class or module
77
+ return unless contains_nested_definitions?(node)
78
+
79
+ message = format(MSG, parent: node.identifier.const_name)
80
+ add_offense(node.loc.keyword.join(node.identifier.source_range), message: message)
81
+ end
82
+
83
+ private
84
+
85
+ def test_class?(node)
86
+ node.parent_class && test_base_class?(node.parent_class)
87
+ end
88
+
89
+ # Returns true if any ancestor of +node+ is a test class.
90
+ def inside_test_class?(node)
91
+ current = node.parent
92
+ while current
93
+ return true if current.class_type? && test_class?(current)
94
+
95
+ current = current.parent
96
+ end
97
+ false
98
+ end
99
+
100
+ # Returns true if +node+ contains any nested class or module definition
101
+ # at any depth, excluding those sheltered inside an intermediate test class.
102
+ def contains_nested_definitions?(node)
103
+ node.each_descendant(:class, :module) do |descendant|
104
+ next if sheltered_by_test_class?(descendant, node)
105
+
106
+ return true
107
+ end
108
+ false
109
+ end
110
+
111
+ # Returns true if there is a test class between +descendant+ and +stop_at+
112
+ # in the ancestor chain — meaning the descendant is a helper inside a test
113
+ # class and should not count as a problematic nested definition.
114
+ def sheltered_by_test_class?(descendant, stop_at)
115
+ current = descendant.parent
116
+ while current && current != stop_at
117
+ return true if current.class_type? && test_class?(current)
118
+
119
+ current = current.parent
120
+ end
121
+ false
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,6 @@
1
+ Roast/NoTestClassNesting:
2
+ Description: "Prevents nesting test classes inside reopened production classes. Use :: scoping instead."
3
+ Enabled: true
4
+ VersionAdded: "0.1.0"
5
+ Include:
6
+ - "test/**/*.rb"
@@ -0,0 +1,97 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ #: self as Roast::Workflow
5
+
6
+ # Gets the committed diff of the current branch vs origin/main, then analyzes it for potential
7
+ # documentation impacts, and optionally applies fixes. This is meant to be run as a pre-merge check, to
8
+ # catch any potential documentation issues before they get merged into main.
9
+ #
10
+ # Accepts a `--fix` flag to apply any suggested fixes in place. Otherwise, it just reports the analysis
11
+ # and recommended fixes without applying them.
12
+
13
+ config do
14
+ agent do
15
+ provider :claude
16
+ model "claude-opus-4-7"
17
+ quiet!
18
+ end
19
+ chat do
20
+ provider :openai
21
+ model "gpt-5"
22
+ quiet!
23
+ end
24
+ end
25
+
26
+ execute do
27
+ cmd(:diff) do
28
+ merge_base = %x(git merge-base origin/main HEAD).strip
29
+ fail!("could not determine merge-base with origin/main — run `git fetch origin main` first") if merge_base.empty?
30
+ "git diff #{merge_base} HEAD"
31
+ end
32
+
33
+ agent(:analyzer) do
34
+ skip! if cmd!(:diff).text.strip.empty?
35
+ fail!("diff too large (#{cmd!(:diff).text.bytesize} bytes) to analyze — narrow the branch or exclude generated files") if cmd!(:diff).text.bytesize > 500_000
36
+ <<~PROMPT
37
+ You are checking whether a git diff makes any existing documentation stale.
38
+
39
+ Rules:
40
+ - Use ONLY the diff below. Do not run any commands.
41
+ - Be thorough about finding real issues — do not be conservative.
42
+ - But do NOT speculate about docs you cannot see in the diff.
43
+ - No markdown headers, no preamble, no caveats, no "limitations" sections.
44
+
45
+ Output format — pick exactly one:
46
+
47
+ If nothing in the diff affects existing docs, output a single line:
48
+ No documentation impact.
49
+
50
+ Otherwise, output one block per affected doc, separated by blank lines:
51
+ <doc/path.md>
52
+ Stale because: <one sentence>
53
+ Fix: <one sentence>
54
+
55
+ --- DIFF START ---
56
+ #{cmd!(:diff).text}
57
+ --- DIFF END ---
58
+ PROMPT
59
+ end
60
+
61
+ chat(:report) do
62
+ skip! if cmd!(:diff).text.strip.empty?
63
+ <<~PROMPT
64
+ Based on the following analysis, summarize the impact of the changes in this branch on the project's documentation.
65
+ Highlight any significant improvements or regressions, and provide recommendations for any additional documentation updates that may be necessary.
66
+ Do not suggest updates that are not needed.
67
+ Do not be verbose. Be super concise.
68
+
69
+ Analysis:
70
+ #{agent!(:analyzer).response}
71
+ PROMPT
72
+ end
73
+
74
+ agent(:fixer) do
75
+ skip! unless arg?(:fix)
76
+ skip! if cmd!(:diff).text.strip.empty?
77
+ skip! if agent!(:analyzer).response.strip == "No documentation impact."
78
+ <<~PROMPT
79
+ Apply the documentation fixes suggested in the analysis below. Edit the
80
+ affected files in place. Do not modify any code files; docs only.
81
+
82
+ #{agent!(:analyzer).response}
83
+ PROMPT
84
+ end
85
+
86
+ ruby(:output) do
87
+ if cmd!(:diff).text.strip.empty?
88
+ puts "No changes vs origin/main — nothing to analyze."
89
+ else
90
+ files = cmd!(:diff).out.scan(%r{^diff --git a/.+ b/(.+)$}).flatten
91
+ puts "Files considered (#{files.size}):"
92
+ files.each { |f| puts " #{f}" }
93
+ puts "ANALYSIS:\n#{chat!(:report).response}"
94
+ puts(agent?(:fixer) ? "Fixes applied." : "(Next time, run with `-- fix` to auto-apply suggested edits)")
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,78 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ #: self as Roast::Workflow
5
+
6
+ # Looks at the Anthropic (https://platform.claude.com/docs/en/about-claude/model-deprecations) and
7
+ # OpenAI (https://developers.openai.com/api/docs/deprecations) model-deprecation pages and updates all
8
+ # docs, doc comments and code references to deprecated/retired models to reflect those changes. This is
9
+ # meant to be run periodically to keep all the references to models up to date.
10
+
11
+ config do
12
+ agent do
13
+ provider :claude
14
+ model "claude-opus-4-8"
15
+ quiet!
16
+ end
17
+ end
18
+
19
+ execute do
20
+ agent(:model_verifier) do
21
+ <<~PROMPT
22
+ Check both of these pages for deprecated/retired models and their suggested replacements:
23
+ - Anthropic (Claude): https://platform.claude.com/docs/en/about-claude/model-deprecations
24
+ - OpenAI: https://developers.openai.com/api/docs/deprecations
25
+
26
+ Include every deprecated or retired model that has a recommended replacement, from both pages.
27
+ Only include models — ignore deprecated API endpoints, tools, or other non-model features.
28
+
29
+ Output ONLY a JSON object of exactly this shape, with no surrounding prose:
30
+ {"outdated": [{"model": "<outdated_model>", "replacement": "<replacement_model>"}]}
31
+
32
+ If there are none, output {"outdated": []}.
33
+ PROMPT
34
+ end
35
+
36
+ agent(:model_finder) do
37
+ <<~PROMPT
38
+ You are a code search engine. Search through the codebase and documentation for any references to
39
+ the outdated models below, including test files, and list every place where they are mentioned.
40
+
41
+ The input is a JSON object of the form:
42
+ {"outdated": [{"model": "<outdated_model>", "replacement": "<replacement_model>"}]}
43
+
44
+ INPUT:
45
+ #{agent!(:model_verifier).response}
46
+
47
+ Output ONLY a JSON object of exactly this shape, with no surrounding prose, carrying the
48
+ replacement through for each model you find a reference to:
49
+ {"references": [{"model": "<outdated_model>", "replacement": "<replacement_model>", "locations": ["<file_path>:<line_number>"]}]}
50
+
51
+ If you find no references, output {"references": []}.
52
+ PROMPT
53
+ end
54
+
55
+ agent(:updater) do |my|
56
+ finder = agent!(:model_finder)
57
+ skip! if finder.json![:references].blank?
58
+ my.session = finder.session
59
+ <<~PROMPT
60
+ For each reference you found of an outdated model, update the reference to use the suggested replacement model instead. Make sure to update all types of references, including documentation, doc comments and code references. Output the list of updated references in the following format:
61
+ <outdated_model_1> -> <replacement_model_1>:
62
+ - <file_path>:<line_number>
63
+ <outdated_model_2> -> <replacement_model_2>:
64
+ - <file_path>:<line_number>
65
+ ...
66
+ PROMPT
67
+ end
68
+
69
+ ruby(:output) do
70
+ if agent?(:updater)
71
+ puts "[OUTDATED MODELS & REPLACEMENTS]\n #{agent!(:model_verifier).response}"
72
+ puts "[REFERENCES IN CODEBASE]\n #{agent!(:model_finder).response}"
73
+ puts "[UPDATED REFERENCES]\n #{agent!(:updater).response}"
74
+ else
75
+ puts "No references to outdated models found — nothing to update."
76
+ end
77
+ end
78
+ end
@@ -230,7 +230,7 @@ module Roast
230
230
  #
231
231
  #: () -> bool
232
232
  def abort_on_failure?
233
- !!@values[:abort_on_failure]
233
+ @values.fetch(:abort_on_failure, true)
234
234
  end
235
235
 
236
236
  # Configure the cog to run external commands in the specified working directory
@@ -4,6 +4,8 @@
4
4
  module Roast
5
5
  # Context in which an individual cog block within the `execute` block of a workflow is evaluated
6
6
  class CogInputManager
7
+ include WorkflowParamAccessors
8
+
7
9
  class CogOutputAccessError < Roast::Error; end
8
10
 
9
11
  class CogDoesNotExistError < CogOutputAccessError; end
@@ -104,50 +106,6 @@ module Roast
104
106
  end
105
107
  end
106
108
 
107
- #: () -> String
108
- def target!
109
- raise ArgumentError, "expected exactly one target" unless @workflow_context.params.targets.length == 1
110
-
111
- @workflow_context.params.targets.first #: as String
112
- end
113
-
114
- #: () -> Array[String]
115
- def targets
116
- @workflow_context.params.targets.dup
117
- end
118
-
119
- #: (Symbol) -> bool
120
- def arg?(value)
121
- @workflow_context.params.args.include?(value)
122
- end
123
-
124
- #: () -> Array[Symbol]
125
- def args
126
- @workflow_context.params.args.dup
127
- end
128
-
129
- #: (Symbol) -> String?
130
- def kwarg(key)
131
- @workflow_context.params.kwargs[key]
132
- end
133
-
134
- #: (Symbol) -> String
135
- def kwarg!(key)
136
- raise ArgumentError, "expected keyword argument '#{key}' to be present" unless @workflow_context.params.kwargs.include?(key)
137
-
138
- @workflow_context.params.kwargs[key] #: as String
139
- end
140
-
141
- #: (Symbol) -> bool
142
- def kwarg?(key)
143
- @workflow_context.params.kwargs.include?(key)
144
- end
145
-
146
- #: () -> Hash[Symbol, String]
147
- def kwargs
148
- @workflow_context.params.kwargs.dup
149
- end
150
-
151
109
  #: () -> Pathname
152
110
  def tmpdir
153
111
  Pathname.new(@workflow_context.tmpdir).realpath
@@ -159,7 +117,7 @@ module Roast
159
117
  # Supports both relative shorthand paths like "greeting" and full absolute paths.
160
118
  #
161
119
  # @param path [String, Pathname] The template path to resolve. Can be:
162
- # - Shorthand name: "greeting" -> searches for prompts/greeting.md.erb
120
+ # - Shorthand name: "greeting" -> searches for prompts/greeting.md.erb or templates/greeting.md.erb
163
121
  # - With extension: "template.erb" -> searches for template.erb
164
122
  # - Absolute path: "/full/path/to/template.erb" -> uses as-is
165
123
  # @param args [Hash] Template variables for ERB interpolation
@@ -175,13 +133,14 @@ module Roast
175
133
  # 1. Absolute path as-is (if absolute)
176
134
  # 2-4. Workflow directory: path, path.erb, path.md.erb
177
135
  # 5-7. Workflow directory prompts/: prompts/path, prompts/path.erb, prompts/path.md.erb
178
- # 8-10. Current directory: path, path.erb, path.md.erb
179
- # 11-13. Current directory prompts/: prompts/path, prompts/path.erb, prompts/path.md.erb
136
+ # 8-10. Workflow directory templates/: templates/path, templates/path.erb, templates/path.md.erb
137
+ # 11-13. Current directory: path, path.erb, path.md.erb
138
+ # 14-16. Current directory prompts/: prompts/path, prompts/path.erb, prompts/path.md.erb
139
+ # 17-19. Current directory templates/: templates/path, templates/path.erb, templates/path.md.erb
140
+ # 20-22. Tilde-expanded path: path, path.erb, path.md.erb
180
141
  #
181
142
  #: (String | Pathname, ?Hash) -> String
182
143
  def template(path, args = {})
183
- # NOTE: Pathname does not expand ~ for home directory automatically.
184
- # This is tracked in issue https://github.com/Shopify/roast/issues/663.
185
144
  path = Pathname.new(path) unless path.is_a?(Pathname)
186
145
 
187
146
  # Priority stack of places to look for a matching file
@@ -201,17 +160,37 @@ module Roast
201
160
  candidate_paths << workflow_dir / "prompts" / "#{path}.erb"
202
161
  candidate_paths << workflow_dir / "prompts" / "#{path}.md.erb"
203
162
 
204
- # 8-10. Relative to current working directory
163
+ # 8-10. Relative to workflow directory templates folder
164
+ candidate_paths << workflow_dir / "templates" / path
165
+ candidate_paths << workflow_dir / "templates" / "#{path}.erb"
166
+ candidate_paths << workflow_dir / "templates" / "#{path}.md.erb"
167
+
168
+ # 11-13. Relative to current working directory
205
169
  pwd = Pathname.pwd
206
170
  candidate_paths << pwd / path
207
171
  candidate_paths << pwd / "#{path}.erb"
208
172
  candidate_paths << pwd / "#{path}.md.erb"
209
173
 
210
- # 11-13. Relative to current working directory prompts folder
174
+ # 14-16. Relative to current working directory prompts folder
211
175
  candidate_paths << pwd / "prompts" / path
212
176
  candidate_paths << pwd / "prompts" / "#{path}.erb"
213
177
  candidate_paths << pwd / "prompts" / "#{path}.md.erb"
214
178
 
179
+ # 17-19. Relative to current working directory templates folder
180
+ candidate_paths << pwd / "templates" / path
181
+ candidate_paths << pwd / "templates" / "#{path}.erb"
182
+ candidate_paths << pwd / "templates" / "#{path}.md.erb"
183
+
184
+ # 20-22. Tilde expanded path
185
+ begin
186
+ expanded_path = Pathname.new(File.expand_path(path))
187
+ candidate_paths << expanded_path
188
+ candidate_paths << Pathname.new(File.expand_path("#{expanded_path}.erb"))
189
+ candidate_paths << Pathname.new(File.expand_path("#{expanded_path}.md.erb"))
190
+ rescue ArgumentError
191
+ # File.expand_path raises when expanding ~something/foo (assuming "something" is not a real user).
192
+ # Nothing to do here, falls back to other candidate paths without tilde expansion.
193
+ end
215
194
  # Use the first path that exists
216
195
  resolved_path = candidate_paths.find(&:exist?)
217
196
 
@@ -5,12 +5,21 @@ module Roast
5
5
  module Cogs
6
6
  class Agent < Cog
7
7
  class Config < Cog::Config
8
- VALID_PROVIDERS = [:claude, :pi].freeze #: Array[Symbol]
8
+ VALID_PROVIDERS = [:pi, :claude].freeze #: Array[Symbol]
9
+
10
+ # Environment variable that overrides the built-in default agent provider.
11
+ #
12
+ # When an agent cog does not explicitly configure a provider, Roast uses the provider named by
13
+ # this variable, falling back to the built-in default (`VALID_PROVIDERS.first`, i.e. `:pi`) when it
14
+ # is unset or blank. The value is normalized (surrounding whitespace stripped, then downcased)
15
+ # before lookup, and an explicit `provider` configured on the cog always takes precedence over it.
16
+ DEFAULT_PROVIDER_ENV_VAR = "ROAST_DEFAULT_AGENT_PROVIDER" #: String
9
17
 
10
18
  # Configure the cog to use a specified provider when invoking an agent
11
19
  #
12
20
  # The provider is the source of the agent tool itself.
13
- # If no provider is specified, Anthropic Claude Code (`:claude`) will be used as the default provider.
21
+ # If no provider is specified, Roast uses the provider named by the `ROAST_DEFAULT_AGENT_PROVIDER`
22
+ # environment variable, or Pi (`:pi`) when that variable is unset.
14
23
  #
15
24
  # A provider must be properly installed on your system in order for Roast to be able to use it.
16
25
  #
@@ -24,7 +33,8 @@ module Roast
24
33
 
25
34
  # Configure the cog to use the default provider when invoking an agent
26
35
  #
27
- # The default provider used by Roast is Anthropic Claude Code (`:claude`).
36
+ # The default provider is the one named by the `ROAST_DEFAULT_AGENT_PROVIDER` environment variable,
37
+ # or Pi (`:pi`) when that variable is unset.
28
38
  #
29
39
  # The provider must be properly installed on your system in order for Roast to be able to use it.
30
40
  #
@@ -38,6 +48,10 @@ module Roast
38
48
 
39
49
  # Get the validated provider name that the cog is configured to use when invoking an agent
40
50
  #
51
+ # The provider is resolved in order of precedence: the provider explicitly configured on the cog,
52
+ # then the `ROAST_DEFAULT_AGENT_PROVIDER` environment variable (normalized by stripping surrounding
53
+ # whitespace and downcasing), then the built-in default (`VALID_PROVIDERS.first`, i.e. `:pi`).
54
+ #
41
55
  # Note: this method will return the name of a valid provider or raise an `InvalidConfigError`.
42
56
  # It will __not__, however, validate that the agent is properly installed on your system.
43
57
  # If the agent is not properly installed, you will likely experience a failure when Roast attempts to
@@ -49,9 +63,10 @@ module Roast
49
63
  #
50
64
  #: () -> Symbol
51
65
  def valid_provider!
52
- provider = @values[:provider] || VALID_PROVIDERS.first
66
+ env_default = ENV[DEFAULT_PROVIDER_ENV_VAR].presence&.strip&.downcase&.to_sym
67
+ provider = @values[:provider] || env_default || VALID_PROVIDERS.first
53
68
  unless VALID_PROVIDERS.include?(provider)
54
- raise ArgumentError, "'#{provider}' is not a valid provider. Available providers include: #{VALID_PROVIDERS.join(", ")}"
69
+ raise InvalidConfigError, "'#{provider}' is not a valid provider. Available providers include: #{VALID_PROVIDERS.join(", ")}"
55
70
  end
56
71
 
57
72
  provider
@@ -131,6 +146,47 @@ module Roast
131
146
  @values[:model].presence
132
147
  end
133
148
 
149
+ # Configure the cog to stop the agent if it runs longer than the given number of seconds
150
+ #
151
+ # When the limit is reached, the agent process is killed and the cog raises
152
+ # `Roast::CommandRunner::TimeoutError`. By default there is no timeout.
153
+ #
154
+ # #### See Also
155
+ # - `no_timeout!`
156
+ #
157
+ #: (Integer) -> void
158
+ def timeout(seconds)
159
+ @values[:timeout] = seconds
160
+ end
161
+
162
+ # Configure the cog to let the agent run for as long as it takes (the default)
163
+ #
164
+ # #### See Also
165
+ # - `timeout`
166
+ #
167
+ #: () -> void
168
+ def no_timeout!
169
+ @values[:timeout] = nil
170
+ end
171
+
172
+ # Get the validated number of seconds the agent may run before it is stopped
173
+ #
174
+ # Returns `nil` if the agent has no timeout. Raises an `InvalidConfigError` if the configured
175
+ # timeout is not positive.
176
+ #
177
+ # #### See Also
178
+ # - `timeout`
179
+ # - `no_timeout!`
180
+ #
181
+ #: () -> Integer?
182
+ def valid_timeout
183
+ seconds = @values[:timeout]
184
+ return if seconds.nil?
185
+ return seconds if seconds.positive?
186
+
187
+ raise InvalidConfigError, "timeout must be a positive number of seconds, got #{seconds.inspect}"
188
+ end
189
+
134
190
  # Configure the cog with a custom system prompt that will completely replace the agent's
135
191
  # default system prompt every time the agent is invoked
136
192
  #
@@ -56,11 +56,14 @@ module Roast
56
56
  #: (Agent::Config, String, String?, ?fork_session: bool) -> void
57
57
  def initialize(config, prompt, session, fork_session: true)
58
58
  @base_command = config.valid_command #: (String | Array[String])?
59
- @model = config.valid_model #: String?
59
+ # The Claude CLI expects a bare model name, while Pi expects a fully-qualified
60
+ # `anthropic/<model>` id. Strip the prefix so the same model string works for either provider.
61
+ @model = config.valid_model&.delete_prefix("anthropic/") #: String?
60
62
  @append_system_prompt = config.valid_append_system_prompt #: String?
61
63
  @replace_system_prompt = config.valid_replace_system_prompt #: String?
62
64
  @apply_permissions = config.apply_permissions? #: bool
63
65
  @working_directory = config.valid_working_directory #: Pathname?
66
+ @timeout = config.valid_timeout #: Integer?
64
67
  @context = Context.new #: Context
65
68
  @result = Result.new #: Result
66
69
  @raw_dump_file = config.valid_dump_raw_agent_messages_to_path #: Pathname?
@@ -77,17 +80,18 @@ module Roast
77
80
  raise ClaudeAlreadyStartedError if started?
78
81
 
79
82
  @started = true
80
- puts "[USER PROMPT] #{@prompt}" if @show_prompt
83
+ Event << { block: { header: "USER PROMPT", content: @prompt } } if @show_prompt
81
84
  _stdout, stderr, status = CommandRunner.execute(
82
85
  command_line,
83
86
  working_directory: @working_directory,
84
87
  stdin_content: @prompt,
88
+ timeout: @timeout,
85
89
  stdout_handler: lambda { |line| handle_stdout(line) },
86
90
  )
87
91
 
88
92
  if status.success?
89
93
  @completed = true
90
- puts "[AGENT RESPONSE] #{@result.response}" if @show_response
94
+ Event << { block: { header: "AGENT RESPONSE", content: @result.response } } if @show_response
91
95
  else
92
96
  @failed = true
93
97
  @result.success = false
@@ -31,7 +31,7 @@ module Roast
31
31
  @content = hash.delete(:result) || ""
32
32
  @success = hash.delete(:success) || subtype == "success"
33
33
  if hash.delete(:is_error) || subtype == "error"
34
- @content = @content || hash.dig(:error, :message) || "Unknown error"
34
+ @content = @content.presence || hash.dig(:error, :message) || "Unknown error"
35
35
  hash.delete(:error)
36
36
  end
37
37