roast-ai 1.0.2 → 1.2.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/docs/write-comments.md +1 -1
  3. data/.rubocop.yml +12 -1
  4. data/Gemfile +2 -2
  5. data/Gemfile.lock +149 -34
  6. data/README.md +56 -3
  7. data/examples/agent_with_multiple_prompts.rb +27 -0
  8. data/examples/custom_logging.rb +4 -2
  9. data/examples/demo/Gemfile.lock +49 -15
  10. data/examples/plugin-gem-example/Gemfile.lock +19 -15
  11. data/examples/simple_chat.rb +1 -1
  12. data/examples/simple_pi_agent.rb +18 -0
  13. data/internal/rubocop/cop/roast/no_test_class_nesting.rb +126 -0
  14. data/internal/rubocop/rubocop-roast.yml +6 -0
  15. data/internal/workflows/maintenance/branch_docs_impact.rb +97 -0
  16. data/internal/workflows/maintenance/deprecated_models_docs_updater.rb +78 -0
  17. data/lib/roast/cog/config.rb +1 -1
  18. data/lib/roast/cog/output.rb +2 -1
  19. data/lib/roast/cog/registry.rb +3 -3
  20. data/lib/roast/cog_input_manager.rb +28 -7
  21. data/lib/roast/cogs/agent/config.rb +2 -2
  22. data/lib/roast/cogs/agent/input.rb +20 -22
  23. data/lib/roast/cogs/agent/providers/claude/claude_invocation.rb +13 -5
  24. data/lib/roast/cogs/agent/providers/claude/messages/result_message.rb +1 -1
  25. data/lib/roast/cogs/agent/providers/claude/tool_result.rb +344 -4
  26. data/lib/roast/cogs/agent/providers/claude/tool_use.rb +356 -1
  27. data/lib/roast/cogs/agent/providers/claude.rb +16 -3
  28. data/lib/roast/cogs/agent/providers/pi/messages/tool_call_message.rb +60 -0
  29. data/lib/roast/cogs/agent/providers/pi/messages/tool_result_message.rb +57 -0
  30. data/lib/roast/cogs/agent/providers/pi/pi_invocation.rb +352 -0
  31. data/lib/roast/cogs/agent/providers/pi.rb +41 -0
  32. data/lib/roast/cogs/agent/stats.rb +29 -0
  33. data/lib/roast/cogs/agent/usage.rb +22 -0
  34. data/lib/roast/cogs/agent.rb +5 -6
  35. data/lib/roast/cogs/chat/config.rb +28 -2
  36. data/lib/roast/cogs/chat.rb +82 -10
  37. data/lib/roast/event.rb +1 -0
  38. data/lib/roast/event_monitor.rb +35 -3
  39. data/lib/roast/log.rb +21 -0
  40. data/lib/roast/log_formatter.rb +9 -7
  41. data/lib/roast/version.rb +1 -1
  42. data/lib/roast.rb +1 -3
  43. data/roast-ai.gemspec +2 -1
  44. data/sorbet/rbi/gems/activesupport@8.0.2.rbi +549 -383
  45. data/sorbet/rbi/gems/addressable@2.8.7.rbi +46 -44
  46. data/sorbet/rbi/gems/ast@2.4.3.rbi +7 -6
  47. data/sorbet/rbi/gems/async@2.34.0.rbi +21 -3
  48. data/sorbet/rbi/gems/benchmark@0.4.1.rbi +7 -7
  49. data/sorbet/rbi/gems/bigdecimal@3.2.2.rbi +198 -1
  50. data/sorbet/rbi/gems/concurrent-ruby@1.3.5.rbi +405 -328
  51. data/sorbet/rbi/gems/console@1.34.2.rbi +2 -2
  52. data/sorbet/rbi/gems/docile@1.4.1.rbi +30 -30
  53. data/sorbet/rbi/gems/drb@2.2.3.rbi +25 -25
  54. data/sorbet/rbi/gems/erubi@1.13.1.rbi +2 -0
  55. data/sorbet/rbi/gems/faraday-net_http@3.4.2.rbi +2 -77
  56. data/sorbet/rbi/gems/faraday-retry@2.3.2.rbi +2 -57
  57. data/sorbet/rbi/gems/faraday@2.14.1.rbi +382 -75
  58. data/sorbet/rbi/gems/guard-compat@1.2.1.rbi +1 -110
  59. data/sorbet/rbi/gems/guard-minitest@2.4.6.rbi +0 -139
  60. data/sorbet/rbi/gems/guard@2.19.1.rbi +38 -38
  61. data/sorbet/rbi/gems/hashdiff@1.2.0.rbi +3 -3
  62. data/sorbet/rbi/gems/i18n@1.14.7.rbi +53 -29
  63. data/sorbet/rbi/gems/io-event@1.14.0.rbi +67 -10
  64. data/sorbet/rbi/gems/json@2.18.1.rbi +227 -5
  65. data/sorbet/rbi/gems/lint_roller@1.1.0.rbi +83 -0
  66. data/sorbet/rbi/gems/listen@3.9.0.rbi +7 -7
  67. data/sorbet/rbi/gems/logger@1.7.0.rbi +3 -3
  68. data/sorbet/rbi/gems/lumberjack@1.2.10.rbi +21 -21
  69. data/sorbet/rbi/gems/marcel@1.1.0.rbi +1 -1
  70. data/sorbet/rbi/gems/minitest-rg@5.3.0.rbi +0 -96
  71. data/sorbet/rbi/gems/minitest@5.25.5.rbi +1 -16
  72. data/sorbet/rbi/gems/net-http@0.9.1.rbi +27 -19
  73. data/sorbet/rbi/gems/netrc@0.11.0.rbi +18 -0
  74. data/sorbet/rbi/gems/notiffany@0.1.3.rbi +20 -20
  75. data/sorbet/rbi/gems/ostruct@0.6.2.rbi +149 -15
  76. data/sorbet/rbi/gems/parser@3.3.8.0.rbi +141 -139
  77. data/sorbet/rbi/gems/prism@1.4.0.rbi +922 -864
  78. data/sorbet/rbi/gems/public_suffix@6.0.2.rbi +56 -35
  79. data/sorbet/rbi/gems/racc@1.8.1.rbi +10 -2
  80. data/sorbet/rbi/gems/rainbow@3.1.1.rbi +12 -12
  81. data/sorbet/rbi/gems/rake@13.3.0.rbi +219 -318
  82. data/sorbet/rbi/gems/{rbi@0.3.6.rbi → rbi@0.3.9.rbi} +612 -2267
  83. data/sorbet/rbi/gems/{rbs@3.9.4.rbi → rbs@4.0.0.dev.5.rbi} +2013 -680
  84. data/sorbet/rbi/gems/regexp_parser@2.10.0.rbi +151 -113
  85. data/sorbet/rbi/gems/require-hooks@0.2.3.rbi +110 -0
  86. data/sorbet/rbi/gems/rexml@3.4.2.rbi +24 -51
  87. data/sorbet/rbi/gems/rubocop-ast@1.45.1.rbi +506 -815
  88. data/sorbet/rbi/gems/rubocop-sorbet@0.10.5.rbi +16 -16
  89. data/sorbet/rbi/gems/rubocop@1.77.0.rbi +2692 -2327
  90. data/sorbet/rbi/gems/ruby-progressbar@1.13.0.rbi +8 -8
  91. data/sorbet/rbi/gems/ruby_llm@1.8.2.rbi +38 -23
  92. data/sorbet/rbi/gems/securerandom@0.4.1.rbi +1 -1
  93. data/sorbet/rbi/gems/simplecov-html@0.13.2.rbi +2 -131
  94. data/sorbet/rbi/gems/simplecov@0.22.0.rbi +28 -127
  95. data/sorbet/rbi/gems/{spoom@1.6.3.rbi → spoom@1.7.11.rbi} +1139 -2246
  96. data/sorbet/rbi/gems/sqlite3@2.9.0.rbi +91 -1
  97. data/sorbet/rbi/gems/{tapioca@0.16.11.rbi → tapioca@0.17.10.rbi} +721 -835
  98. data/sorbet/rbi/gems/thor@1.4.0.rbi +53 -53
  99. data/sorbet/rbi/gems/tsort@0.2.0.rbi +393 -0
  100. data/sorbet/rbi/gems/type_toolkit@0.0.5.rbi +49 -0
  101. data/sorbet/rbi/gems/tzinfo@2.0.6.rbi +144 -143
  102. data/sorbet/rbi/gems/uri@1.1.1.rbi +7 -7
  103. data/sorbet/rbi/gems/vcr@6.3.1.rbi +53 -36
  104. data/sorbet/rbi/gems/webmock@3.25.1.rbi +38 -13
  105. data/sorbet/rbi/gems/zeitwerk@2.7.3.rbi +39 -272
  106. data/sorbet/rbi/shims/lib/roast/execution_context.rbi +3 -3
  107. data/tutorial/01_your_first_workflow/README.md +9 -5
  108. data/tutorial/01_your_first_workflow/configured_chat.rb +1 -1
  109. data/tutorial/02_chaining_cogs/README.md +2 -2
  110. data/tutorial/02_chaining_cogs/code_review.rb +1 -1
  111. data/tutorial/02_chaining_cogs/session_resumption.rb +1 -1
  112. data/tutorial/03_targets_and_params/README.md +1 -1
  113. data/tutorial/04_configuration_options/README.md +2 -2
  114. data/tutorial/08_iterative_workflows/README.md +1 -1
  115. data/tutorial/README.md +1 -1
  116. metadata +39 -17
  117. data/docs/AGENT_STEPS.md +0 -288
  118. data/docs/INSTRUMENTATION.md +0 -243
  119. data/docs/ITERATION_SYNTAX.md +0 -147
  120. data/docs/VALIDATION.md +0 -178
  121. data/lib/roast/nil_assertions.rb +0 -23
  122. /data/internal/documentation/{architectural-notes.md → comments/architectural-notes.md} +0 -0
  123. /data/internal/documentation/{doc-comments-external.md → comments/doc-comments-external.md} +0 -0
  124. /data/internal/documentation/{doc-comments-internal.md → comments/doc-comments-internal.md} +0 -0
  125. /data/internal/documentation/{doc-comments.md → comments/doc-comments.md} +0 -0
@@ -1,11 +1,12 @@
1
1
  PATH
2
2
  remote: ../..
3
3
  specs:
4
- roast-ai (1.0.2)
4
+ roast-ai (1.2.0)
5
5
  activesupport (~> 8.0)
6
6
  async (>= 2.34)
7
7
  rainbow (>= 3.0.0)
8
- ruby_llm (>= 1.8)
8
+ ruby_llm (>= 1.13)
9
+ type_toolkit (>= 0.0.5)
9
10
  zeitwerk (>= 2.6)
10
11
 
11
12
  PATH
@@ -17,7 +18,7 @@ PATH
17
18
  GEM
18
19
  remote: https://rubygems.org/
19
20
  specs:
20
- activesupport (8.0.3)
21
+ activesupport (8.0.4.1)
21
22
  base64
22
23
  benchmark (>= 0.3)
23
24
  bigdecimal
@@ -26,7 +27,7 @@ GEM
26
27
  drb
27
28
  i18n (>= 1.6, < 2)
28
29
  logger (>= 1.4.2)
29
- minitest (>= 5.1)
30
+ minitest (>= 5.1, < 6)
30
31
  securerandom (>= 0.3)
31
32
  tzinfo (~> 2.0, >= 2.0.5)
32
33
  uri (>= 0.13.1)
@@ -38,19 +39,19 @@ GEM
38
39
  metrics (~> 0.12)
39
40
  traces (~> 0.18)
40
41
  base64 (0.3.0)
41
- benchmark (0.4.1)
42
- bigdecimal (3.3.1)
43
- concurrent-ruby (1.3.5)
44
- connection_pool (2.5.4)
42
+ benchmark (0.5.0)
43
+ bigdecimal (4.1.2)
44
+ concurrent-ruby (1.3.7)
45
+ connection_pool (3.0.2)
45
46
  console (1.34.3)
46
47
  fiber-annotation
47
48
  fiber-local (~> 1.1)
48
49
  json
49
50
  date (3.4.1)
50
51
  drb (2.2.3)
51
- erb (5.1.1)
52
+ erb (6.0.1.1)
52
53
  event_stream_parser (1.0.0)
53
- faraday (2.14.1)
54
+ faraday (2.14.2)
54
55
  faraday-net_http (>= 2.0, < 3.5)
55
56
  json
56
57
  logger
@@ -64,7 +65,7 @@ GEM
64
65
  fiber-local (1.1.0)
65
66
  fiber-storage
66
67
  fiber-storage (1.0.1)
67
- i18n (1.14.7)
68
+ i18n (1.14.8)
68
69
  concurrent-ruby (~> 1.0)
69
70
  io-console (0.8.1)
70
71
  io-event (1.14.2)
@@ -72,7 +73,7 @@ GEM
72
73
  pp (>= 0.6.0)
73
74
  rdoc (>= 4.0.0)
74
75
  reline (>= 0.4.2)
75
- json (2.18.1)
76
+ json (2.19.5)
76
77
  language_server-protocol (3.17.0.5)
77
78
  lint_roller (1.1.0)
78
79
  logger (1.7.0)
@@ -118,21 +119,24 @@ GEM
118
119
  parser (>= 3.3.7.2)
119
120
  prism (~> 1.4)
120
121
  ruby-progressbar (1.13.0)
121
- ruby_llm (1.11.0)
122
+ ruby_llm (1.15.0)
122
123
  base64
123
124
  event_stream_parser (~> 1)
124
125
  faraday (>= 1.10.0)
125
126
  faraday-multipart (>= 1)
126
127
  faraday-net_http (>= 1)
127
128
  faraday-retry (>= 1)
128
- marcel (~> 1.0)
129
- ruby_llm-schema (~> 0.2.1)
129
+ marcel (~> 1)
130
+ ruby_llm-schema (~> 0)
130
131
  zeitwerk (~> 2)
131
132
  ruby_llm-schema (0.2.5)
132
133
  securerandom (0.4.1)
133
134
  stringio (3.1.7)
134
135
  traces (0.18.2)
135
136
  tsort (0.2.0)
137
+ type_toolkit (0.0.5)
138
+ lint_roller
139
+ rubocop (>= 1.72.0)
136
140
  tzinfo (2.0.6)
137
141
  concurrent-ruby (~> 1.0)
138
142
  unicode-display_width (3.2.0)
@@ -21,6 +21,6 @@ execute do
21
21
  # Ask a question with a template prompt. You can pass variables to it as you would an ERB template
22
22
  chat { template("examples/prompts/simple_prompt.md.erb", { lake_answer: chat!(:lake).response }) }
23
23
 
24
- # Shorthand template syntax - searches prompts/ directory automatically
24
+ # Shorthand template syntax - searches prompts/ and templates/ directories automatically
25
25
  chat { template("simple_prompt", { lake_answer: chat!(:lake).response }) }
26
26
  end
@@ -0,0 +1,18 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ #: self as Roast::Workflow
5
+
6
+ config do
7
+ agent do
8
+ provider :pi
9
+ model "anthropic/claude-haiku-4-5-20251001"
10
+ append_system_prompt "Always respond in haiku form"
11
+ show_prompt!
12
+ dump_raw_agent_messages_to "tmp/pi-messages.log"
13
+ end
14
+ end
15
+
16
+ execute do
17
+ agent { "What is the world's largest lake?" }
18
+ end
@@ -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
@@ -241,8 +241,9 @@ module Roast
241
241
  lines.reverse.each do |line|
242
242
  # Look for numbers with various separators, formats, and currency symbols (very permissive)
243
243
  # Matches: 123, 1,234, 1_234, 1 234, 1.23, -1.23, 1.23e10, 1.23e-10
244
+ # Reversed so that the last number on the line is tried first (matching the bottom-up line order)
244
245
  matches = line.scan(/-?[\d\s$¢£€¥.,_]+(?:[eE][+-]?\d+)?/) #: as Array[String]
245
- candidates.concat(matches.map(&:strip))
246
+ candidates.concat(matches.map(&:strip).reverse)
246
247
  end
247
248
 
248
249
  candidates.compact.uniq
@@ -51,13 +51,13 @@ module Roast
51
51
  #
52
52
  #: (singleton(Roast::Cog)) -> void
53
53
  def use(cog_class)
54
- reg = create_registration(cog_class)
55
- cogs[reg.first] = reg.second
54
+ name, klass = create_registration(cog_class)
55
+ cogs[name] = klass
56
56
  end
57
57
 
58
58
  private
59
59
 
60
- #: (singleton(Roast::Cog)) -> Array(Symbol, singleton(Cog))
60
+ #: (singleton(Roast::Cog)) -> [Symbol, singleton(Cog)]
61
61
  def create_registration(cog_class)
62
62
  cog_class_name = cog_class.name
63
63
  raise CouldNotDeriveCogNameError if cog_class_name.nil?
@@ -159,7 +159,7 @@ module Roast
159
159
  # Supports both relative shorthand paths like "greeting" and full absolute paths.
160
160
  #
161
161
  # @param path [String, Pathname] The template path to resolve. Can be:
162
- # - Shorthand name: "greeting" -> searches for prompts/greeting.md.erb
162
+ # - Shorthand name: "greeting" -> searches for prompts/greeting.md.erb or templates/greeting.md.erb
163
163
  # - With extension: "template.erb" -> searches for template.erb
164
164
  # - Absolute path: "/full/path/to/template.erb" -> uses as-is
165
165
  # @param args [Hash] Template variables for ERB interpolation
@@ -175,13 +175,14 @@ module Roast
175
175
  # 1. Absolute path as-is (if absolute)
176
176
  # 2-4. Workflow directory: path, path.erb, path.md.erb
177
177
  # 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
178
+ # 8-10. Workflow directory templates/: templates/path, templates/path.erb, templates/path.md.erb
179
+ # 11-13. Current directory: path, path.erb, path.md.erb
180
+ # 14-16. Current directory prompts/: prompts/path, prompts/path.erb, prompts/path.md.erb
181
+ # 17-19. Current directory templates/: templates/path, templates/path.erb, templates/path.md.erb
182
+ # 20-22. Tilde-expanded path: path, path.erb, path.md.erb
180
183
  #
181
184
  #: (String | Pathname, ?Hash) -> String
182
185
  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
186
  path = Pathname.new(path) unless path.is_a?(Pathname)
186
187
 
187
188
  # Priority stack of places to look for a matching file
@@ -201,17 +202,37 @@ module Roast
201
202
  candidate_paths << workflow_dir / "prompts" / "#{path}.erb"
202
203
  candidate_paths << workflow_dir / "prompts" / "#{path}.md.erb"
203
204
 
204
- # 8-10. Relative to current working directory
205
+ # 8-10. Relative to workflow directory templates folder
206
+ candidate_paths << workflow_dir / "templates" / path
207
+ candidate_paths << workflow_dir / "templates" / "#{path}.erb"
208
+ candidate_paths << workflow_dir / "templates" / "#{path}.md.erb"
209
+
210
+ # 11-13. Relative to current working directory
205
211
  pwd = Pathname.pwd
206
212
  candidate_paths << pwd / path
207
213
  candidate_paths << pwd / "#{path}.erb"
208
214
  candidate_paths << pwd / "#{path}.md.erb"
209
215
 
210
- # 11-13. Relative to current working directory prompts folder
216
+ # 14-16. Relative to current working directory prompts folder
211
217
  candidate_paths << pwd / "prompts" / path
212
218
  candidate_paths << pwd / "prompts" / "#{path}.erb"
213
219
  candidate_paths << pwd / "prompts" / "#{path}.md.erb"
214
220
 
221
+ # 17-19. Relative to current working directory templates folder
222
+ candidate_paths << pwd / "templates" / path
223
+ candidate_paths << pwd / "templates" / "#{path}.erb"
224
+ candidate_paths << pwd / "templates" / "#{path}.md.erb"
225
+
226
+ # 20-22. Tilde expanded path
227
+ begin
228
+ expanded_path = Pathname.new(File.expand_path(path))
229
+ candidate_paths << expanded_path
230
+ candidate_paths << Pathname.new(File.expand_path("#{expanded_path}.erb"))
231
+ candidate_paths << Pathname.new(File.expand_path("#{expanded_path}.md.erb"))
232
+ rescue ArgumentError
233
+ # File.expand_path raises when expanding ~something/foo (assuming "something" is not a real user).
234
+ # Nothing to do here, falls back to other candidate paths without tilde expansion.
235
+ end
215
236
  # Use the first path that exists
216
237
  resolved_path = candidate_paths.find(&:exist?)
217
238
 
@@ -5,7 +5,7 @@ module Roast
5
5
  module Cogs
6
6
  class Agent < Cog
7
7
  class Config < Cog::Config
8
- VALID_PROVIDERS = [:claude].freeze #: Array[Symbol]
8
+ VALID_PROVIDERS = [:claude, :pi].freeze #: Array[Symbol]
9
9
 
10
10
  # Configure the cog to use a specified provider when invoking an agent
11
11
  #
@@ -51,7 +51,7 @@ module Roast
51
51
  def valid_provider!
52
52
  provider = @values[:provider] || VALID_PROVIDERS.first
53
53
  unless VALID_PROVIDERS.include?(provider)
54
- raise ArgumentError, "'#{provider}' is not a valid provider. Available providers include: #{VALID_PROVIDERS.join(", ")}"
54
+ raise InvalidConfigError, "'#{provider}' is not a valid provider. Available providers include: #{VALID_PROVIDERS.join(", ")}"
55
55
  end
56
56
 
57
57
  provider
@@ -9,10 +9,14 @@ module Roast
9
9
  # The agent cog requires a prompt that will be sent to the agent for processing.
10
10
  # Optionally, a session identifier can be provided to maintain context across multiple invocations.
11
11
  class Input < Cog::Input
12
- # The prompt to send to the agent for processing
12
+ # The prompts to send to the agent for processing
13
13
  #
14
- #: String?
15
- attr_accessor :prompt
14
+ # When multiple prompts are specified, each subsequent prompt is passed to the agent as soon as it completes
15
+ # the previous one, in the same session throughout. This can be useful for helping to ensure the agent produces
16
+ # final outputs in the form you desire after performing a long and complex task.
17
+ #
18
+ #: Array[String]
19
+ attr_accessor :prompts
16
20
 
17
21
  # Optional session identifier for maintaining conversation context
18
22
  #
@@ -28,7 +32,7 @@ module Roast
28
32
  #: () -> void
29
33
  def initialize
30
34
  super
31
- @prompt = nil #: String?
35
+ @prompts = [] #: Array[String]
32
36
  end
33
37
 
34
38
  # Validate that the input has all required parameters
@@ -37,41 +41,35 @@ module Roast
37
41
  #
38
42
  # #### See Also
39
43
  # - `coerce`
40
- # - `valid_prompt!`
41
44
  #
42
45
  #: () -> void
43
46
  def validate!
44
- valid_prompt!
47
+ raise Cog::Input::InvalidInputError, "At least one prompt is required" unless prompts.present?
48
+ raise Cog::Input::InvalidInputError, "Blank prompts are not allowed" if prompts.any?(&:blank?)
45
49
  end
46
50
 
47
51
  # Coerce the input from the return value of the input block
48
52
  #
49
53
  # If the input block returns a String, it will be used as the prompt value.
54
+ # If the input block returns an Array of Strings, the first will be used as the prompt and the
55
+ # rest will be used as finalizers.
50
56
  #
51
57
  # #### See Also
52
58
  # - `validate!`
53
59
  #
54
60
  #: (untyped) -> void
55
61
  def coerce(input_return_value)
56
- if input_return_value.is_a?(String)
57
- self.prompt ||= input_return_value
62
+ case input_return_value
63
+ when String
64
+ self.prompts = [input_return_value]
65
+ when Array
66
+ self.prompts = input_return_value.map(&:to_s)
58
67
  end
59
68
  end
60
69
 
61
- # Get the validated prompt value
62
- #
63
- # Returns the prompt if it is present, otherwise raises an `InvalidInputError`.
64
- #
65
- # #### See Also
66
- # - `prompt`
67
- # - `validate!`
68
- #
69
- #: () -> String
70
- def valid_prompt!
71
- valid_prompt = @prompt
72
- raise Cog::Input::InvalidInputError, "'prompt' is required" unless valid_prompt.present?
73
-
74
- valid_prompt
70
+ #: (String) -> void
71
+ def prompt=(prompt)
72
+ @prompts = [prompt]
75
73
  end
76
74
  end
77
75
  end