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
@@ -20,6 +20,14 @@ module Roast
20
20
  #
21
21
  # For tasks requiring local filesystem access or locally-configured tools, use the `agent` cog instead.
22
22
  class Chat < Cog
23
+ # Raised when the LLM response hits the max token limit, indicating the output was
24
+ # truncated and should not be considered usable.
25
+ class MaxTokensExceededError < Cog::CogError; end
26
+
27
+ # Anthropic always sends a max_tokens value in the request payload with this fallback
28
+ # when the model metadata doesn't specify one (see ruby_llm's Anthropic::Chat#build_base_payload).
29
+ ANTHROPIC_DEFAULT_MAX_TOKENS = 4096
30
+
23
31
  # The configuration object for this chat cog instance
24
32
  #
25
33
  #: Roast::Cogs::Chat::Config
@@ -42,36 +50,100 @@ module Roast
42
50
  chat.messages[num_existing_messages..].each do |message|
43
51
  case message.role
44
52
  when :user
45
- puts "[USER PROMPT] #{message.content}" if config.show_prompt?
53
+ Event << { block: { header: "USER PROMPT", content: message.content } } if config.show_prompt?
46
54
  when :assistant
47
- puts "[LLM RESPONSE] #{message.content}" if config.show_response?
55
+ Event << { block: { header: "LLM RESPONSE", content: message.content } } if config.show_response?
48
56
  else
49
57
  # No other message types are expected, but let's show them if they do appear
50
58
  # but only the user has requested some form of output
51
- puts "[UNKNOWN] #{message.content}" if config.show_prompt? || config.show_response?
59
+ Event << { block: { header: "UNKNOWN", content: message.content } } if config.show_prompt? || config.show_response?
52
60
  end
53
61
  end
54
62
  if config.show_stats?
55
63
  temperature = chat.instance_variable_get(:@temperature)
56
- puts "[LLM STATS]"
57
- puts "\tModel: #{response.model_id}"
58
- puts "\tTemperature: #{format("%0.2f", temperature)}" if temperature
59
- puts "\tInput Tokens: #{response.input_tokens}"
60
- puts "\tOutput Tokens: #{response.output_tokens}"
64
+ lines = ["Model: #{response.model_id}"]
65
+ lines << "Temperature: #{format("%0.2f", temperature)}" if temperature
66
+ lines << "Input Tokens: #{response.input_tokens}"
67
+ lines << "Output Tokens: #{response.output_tokens}"
68
+ Event << { block: { header: "LLM STATS", content: lines.join("\n") } }
61
69
  end
62
70
 
71
+ verify_response_not_truncated!(response)
72
+
63
73
  Output.new(Session.from_chat(chat), response.content)
64
74
  end
65
75
 
66
76
  private
67
77
 
78
+ # Verify that the LLM response was not truncated by hitting the max token limit.
79
+ #
80
+ # ruby_llm does not expose a stop_reason/finish_reason from the provider response, so we
81
+ # detect truncation heuristically: if the output token count equals (or exceeds) the
82
+ # effective max token limit, the content was almost certainly cut off mid-generation.
83
+ #
84
+ # The effective limit is derived from the ruby_llm model registry, which is populated
85
+ # from models.dev and provider APIs. This lookup is decoupled from the chat instance's
86
+ # `assume_model_exists` setting — even when the chat call itself skips registry
87
+ # verification (the default), we still consult the registry for token-limit metadata.
88
+ #
89
+ # For Anthropic, the provider always sends max_tokens with a fallback of 4096 in the
90
+ # request payload, so if the model is not in the registry we replicate that fallback.
91
+ # For other providers, if the model is not in the registry, the check is skipped because
92
+ # we have no ceiling to compare against.
93
+ #
94
+ # Note: the `>=` boundary can produce false positives when output hits the limit exactly
95
+ # on a natural boundary. This is an unavoidable trade-off of the heuristic approach until
96
+ # ruby_llm exposes finish_reason.
97
+ #
98
+ #: (RubyLLM::Message) -> void
99
+ def verify_response_not_truncated!(response)
100
+ max_tokens = effective_max_tokens
101
+ return unless max_tokens
102
+ return unless response.output_tokens
103
+
104
+ if response.output_tokens >= max_tokens
105
+ raise MaxTokensExceededError,
106
+ "LLM response from #{response.model_id} was truncated at the max token limit " \
107
+ "(output: #{response.output_tokens} tokens, limit: #{max_tokens} tokens). " \
108
+ "The response content is likely incomplete and should not be used."
109
+ end
110
+ end
111
+
112
+ # Determine the effective max token limit for the chat request.
113
+ #
114
+ # Queries the ruby_llm model registry directly, independent of the chat instance's
115
+ # `assume_model_exists` setting. This ensures the token limit is available even when
116
+ # the chat call itself uses the default permissive mode.
117
+ #
118
+ # Returns nil if the model is not in the registry and the provider doesn't have a
119
+ # known default.
120
+ #
121
+ #: () -> Integer?
122
+ def effective_max_tokens
123
+ RubyLLM.models.find(config.valid_model, config.valid_provider!)&.max_tokens
124
+ rescue RubyLLM::ModelNotFoundError
125
+ # Anthropic always sends max_tokens with a 4096 fallback in the request payload
126
+ ANTHROPIC_DEFAULT_MAX_TOKENS if config.valid_provider! == :anthropic
127
+ end
128
+
68
129
  # Get a RubyLLM context configured for this chat cog
69
130
  #
70
131
  #: () -> RubyLLM::Context
71
132
  def ruby_llm_context
72
133
  @ruby_llm_context ||= RubyLLM.context do |context|
73
- context.openai_api_key = config.valid_api_key!
74
- context.openai_api_base = config.valid_base_url
134
+ case config.valid_provider!
135
+ when :openai
136
+ context.openai_api_key = config.valid_api_key!
137
+ context.openai_api_base = config.valid_base_url
138
+ when :anthropic
139
+ context.anthropic_api_key = config.valid_api_key!
140
+ context.anthropic_api_base = config.valid_base_url
141
+ when :perplexity
142
+ context.perplexity_api_key = config.valid_api_key!
143
+ when :gemini
144
+ context.gemini_api_key = config.valid_api_key!
145
+ context.gemini_api_base = config.valid_base_url
146
+ end
75
147
  end
76
148
  end
77
149
  end
data/lib/roast/event.rb CHANGED
@@ -24,6 +24,7 @@ module Roast
24
24
  :end,
25
25
  :stdout,
26
26
  :stderr,
27
+ :block,
27
28
  ].freeze #: Array[Symbol]
28
29
 
29
30
  #: Array[TaskContext::PathElement]
@@ -6,6 +6,8 @@ module Roast
6
6
  extend self
7
7
  include Kernel
8
8
 
9
+ BLOCK_SEPARATOR = ("─" * 40).freeze #: String
10
+
9
11
  class EventMonitorError < StandardError; end
10
12
 
11
13
  class EventMonitorAlreadyStartedError < EventMonitorError; end
@@ -116,17 +118,47 @@ module Roast
116
118
 
117
119
  #: (Event) -> void
118
120
  def handle_log_event(event)
119
- Roast::Log.logger.add(event.log_severity, "#{format_path(event)} #{event.log_message}")
121
+ path = format_path(event)
122
+ event.log_message.lines.each_with_index do |line, idx|
123
+ path_prefix = idx.zero? ? path : "·" * path.length
124
+ Roast::Log.logger.add(event.log_severity, "#{path_prefix} #{line.rstrip}")
125
+ end
120
126
  end
121
127
 
122
128
  #: (Event) -> void
123
129
  def handle_stderr_event(event)
124
- Roast::Log.logger.warn { "#{format_path(event)} ❯❯ #{event[:stderr]}" }
130
+ path = format_path(event)
131
+ event[:stderr].lines.each_with_index do |line, idx|
132
+ path_prefix = idx.zero? ? "#{path} ❯❯" : "#{"·" * path.length} ❙❙"
133
+ Roast::Log.logger.warn do
134
+ Roast::Log::Message.new(
135
+ type: :stderr,
136
+ text: "#{path_prefix} #{line.rstrip}",
137
+ )
138
+ end
139
+ end
125
140
  end
126
141
 
127
142
  #: (Event) -> void
128
143
  def handle_stdout_event(event)
129
- Roast::Log.logger.info { "#{format_path(event)} ❯ #{event[:stdout]}" }
144
+ path = format_path(event)
145
+ event[:stdout].lines.each_with_index do |line, idx|
146
+ path_prefix = idx.zero? ? "#{path} ❯" : "#{"·" * path.length} ❙"
147
+ Roast::Log.logger.info do
148
+ Roast::Log::Message.new(
149
+ type: :stdout,
150
+ text: "#{path_prefix} #{line.rstrip}",
151
+ )
152
+ end
153
+ end
154
+ end
155
+
156
+ #: (Event) -> void
157
+ def handle_block_event(event)
158
+ block = event[:block]
159
+ header = "[#{block[:header]}]"
160
+ content = block[:content]
161
+ Roast::Log.logger.info { "#{format_path(event)} #{header}↓\n#{BLOCK_SEPARATOR}\n#{content}\n#{BLOCK_SEPARATOR}" }
130
162
  end
131
163
 
132
164
  #: (Event) -> void
data/lib/roast/log.rb CHANGED
@@ -21,6 +21,27 @@ module Roast
21
21
  extend self
22
22
  include Kernel
23
23
 
24
+ # A log message paired with its originating type, so downstream handlers
25
+ # (e.g. the formatter) can render or route it by type.
26
+ class Message
27
+ #: Symbol
28
+ attr_reader :type
29
+
30
+ #: String
31
+ attr_reader :text
32
+
33
+ #: (type: Symbol, text: String) -> void
34
+ def initialize(type:, text:)
35
+ @type = type
36
+ @text = text
37
+ end
38
+
39
+ #: () -> String
40
+ def to_s
41
+ text
42
+ end
43
+ end
44
+
24
45
  LOG_LEVELS = {
25
46
  DEBUG: ::Logger::DEBUG,
26
47
  INFO: ::Logger::INFO,
@@ -15,22 +15,22 @@ module Roast
15
15
  end
16
16
 
17
17
  def call(severity, time, _progname, msg)
18
+ type = msg.is_a?(Roast::Log::Message) ? msg.type : nil
18
19
  line = if @tty
19
20
  format(TTY_FORMAT, severity, msg2str(msg))
20
21
  else
21
22
  format(NON_TTY_FORMAT, severity, time.strftime(DATETIME_FORMAT), severity, msg2str(msg))
22
23
  end
23
- colourize(severity, line)
24
+ colourize(severity, type, line)
24
25
  end
25
26
 
26
27
  private
27
28
 
28
- #: (String, String) -> String
29
- def colourize(severity, line)
30
- if line.include?("❯❯") # standard error lines
31
- @rainbow.wrap(line).yellow
32
- elsif line.include?("❯") # standard output lines
33
- @rainbow.wrap(line)
29
+ #: (String, Symbol?, String) -> String
30
+ def colourize(severity, type, line)
31
+ case type
32
+ when :stderr then @rainbow.wrap(line).yellow
33
+ when :stdout then @rainbow.wrap(line)
34
34
  else
35
35
  case severity
36
36
  when "ERROR", "FATAL" then @rainbow.wrap(line).red
@@ -47,6 +47,8 @@ module Roast
47
47
  msg = case msg
48
48
  when ::String
49
49
  msg.strip
50
+ when Roast::Log::Message
51
+ msg.text.strip
50
52
  else
51
53
  msg
52
54
  end
data/lib/roast/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Roast
5
- VERSION = "1.0.2"
5
+ VERSION = "1.2.0"
6
6
  end
data/lib/roast.rb CHANGED
@@ -34,9 +34,7 @@ require "async"
34
34
  require "async/semaphore"
35
35
  require "rainbow"
36
36
  require "ruby_llm"
37
-
38
- # Require project components that will not get automatically loaded
39
- require "roast/nil_assertions"
37
+ require "type_toolkit"
40
38
 
41
39
  # Autoloading setup
42
40
  require "zeitwerk"
data/roast-ai.gemspec CHANGED
@@ -34,6 +34,7 @@ Gem::Specification.new do |spec|
34
34
  spec.add_dependency("activesupport", "~> 8.0")
35
35
  spec.add_dependency("async", ">= 2.34")
36
36
  spec.add_dependency("rainbow", ">= 3.0.0")
37
- spec.add_dependency("ruby_llm", ">= 1.8")
37
+ spec.add_dependency("ruby_llm", ">= 1.13")
38
+ spec.add_dependency("type_toolkit", ">= 0.0.5")
38
39
  spec.add_dependency("zeitwerk", ">= 2.6")
39
40
  end