ruby_llm-agents 0.2.4 → 0.3.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +413 -0
  3. data/app/channels/ruby_llm/agents/executions_channel.rb +24 -1
  4. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +81 -0
  5. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +51 -0
  6. data/app/controllers/ruby_llm/agents/agents_controller.rb +228 -59
  7. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +167 -12
  8. data/app/controllers/ruby_llm/agents/executions_controller.rb +189 -31
  9. data/app/controllers/ruby_llm/agents/settings_controller.rb +20 -0
  10. data/app/helpers/ruby_llm/agents/application_helper.rb +307 -7
  11. data/app/models/ruby_llm/agents/execution/analytics.rb +224 -20
  12. data/app/models/ruby_llm/agents/execution/metrics.rb +41 -25
  13. data/app/models/ruby_llm/agents/execution/scopes.rb +234 -14
  14. data/app/models/ruby_llm/agents/execution.rb +259 -16
  15. data/app/services/ruby_llm/agents/agent_registry.rb +49 -12
  16. data/app/views/layouts/rubyllm/agents/application.html.erb +351 -85
  17. data/app/views/rubyllm/agents/agents/_version_comparison.html.erb +186 -0
  18. data/app/views/rubyllm/agents/agents/show.html.erb +233 -10
  19. data/app/views/rubyllm/agents/dashboard/_action_center.html.erb +62 -0
  20. data/app/views/rubyllm/agents/dashboard/_alerts_feed.html.erb +62 -0
  21. data/app/views/rubyllm/agents/dashboard/_breaker_strip.html.erb +47 -0
  22. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +165 -0
  23. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +10 -0
  24. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +71 -0
  25. data/app/views/rubyllm/agents/dashboard/index.html.erb +215 -109
  26. data/app/views/rubyllm/agents/executions/_filters.html.erb +152 -155
  27. data/app/views/rubyllm/agents/executions/_list.html.erb +103 -12
  28. data/app/views/rubyllm/agents/executions/dry_run.html.erb +149 -0
  29. data/app/views/rubyllm/agents/executions/index.html.erb +17 -72
  30. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +16 -2
  31. data/app/views/rubyllm/agents/executions/show.html.erb +693 -14
  32. data/app/views/rubyllm/agents/settings/show.html.erb +369 -0
  33. data/app/views/rubyllm/agents/shared/_filter_dropdown.html.erb +121 -0
  34. data/app/views/rubyllm/agents/shared/_select_dropdown.html.erb +85 -0
  35. data/config/routes.rb +7 -0
  36. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +27 -0
  37. data/lib/generators/ruby_llm_agents/templates/add_caching_migration.rb.tt +23 -0
  38. data/lib/generators/ruby_llm_agents/templates/add_finish_reason_migration.rb.tt +19 -0
  39. data/lib/generators/ruby_llm_agents/templates/add_routing_migration.rb.tt +19 -0
  40. data/lib/generators/ruby_llm_agents/templates/add_streaming_migration.rb.tt +8 -0
  41. data/lib/generators/ruby_llm_agents/templates/add_tracing_migration.rb.tt +34 -0
  42. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +66 -4
  43. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +53 -6
  44. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +143 -8
  45. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +38 -1
  46. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +78 -0
  47. data/lib/ruby_llm/agents/alert_manager.rb +207 -0
  48. data/lib/ruby_llm/agents/attempt_tracker.rb +295 -0
  49. data/lib/ruby_llm/agents/base.rb +597 -112
  50. data/lib/ruby_llm/agents/budget_tracker.rb +360 -0
  51. data/lib/ruby_llm/agents/circuit_breaker.rb +197 -0
  52. data/lib/ruby_llm/agents/configuration.rb +279 -1
  53. data/lib/ruby_llm/agents/engine.rb +58 -6
  54. data/lib/ruby_llm/agents/execution_logger_job.rb +17 -6
  55. data/lib/ruby_llm/agents/inflections.rb +13 -2
  56. data/lib/ruby_llm/agents/instrumentation.rb +538 -87
  57. data/lib/ruby_llm/agents/redactor.rb +130 -0
  58. data/lib/ruby_llm/agents/reliability.rb +185 -0
  59. data/lib/ruby_llm/agents/version.rb +3 -1
  60. data/lib/ruby_llm/agents.rb +52 -0
  61. metadata +41 -2
  62. data/app/controllers/ruby_llm/agents/application_controller.rb +0 -37
@@ -2,13 +2,42 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Agents
5
+ # View helpers for the RubyLLM::Agents dashboard
6
+ #
7
+ # Provides formatting utilities for displaying execution data,
8
+ # including number formatting, URL helpers, and JSON syntax highlighting.
9
+ #
10
+ # @api public
5
11
  module ApplicationHelper
6
12
  include Chartkick::Helper if defined?(Chartkick)
7
13
 
14
+ # Returns the URL helpers for the engine's routes
15
+ #
16
+ # Use this to generate paths and URLs within the dashboard views.
17
+ #
18
+ # @return [Module] URL helpers module with path/url methods
19
+ # @example Generate execution path
20
+ # ruby_llm_agents.execution_path(execution)
21
+ # @example Generate agents index URL
22
+ # ruby_llm_agents.agents_url
8
23
  def ruby_llm_agents
9
24
  RubyLLM::Agents::Engine.routes.url_helpers
10
25
  end
11
26
 
27
+ # Formats large numbers with human-readable suffixes (K, M, B)
28
+ #
29
+ # @param number [Numeric, nil] The number to format
30
+ # @param prefix [String, nil] Optional prefix (e.g., "$" for currency)
31
+ # @param precision [Integer] Decimal places to show (default: 1)
32
+ # @return [String] Formatted number with suffix
33
+ # @example Basic usage
34
+ # number_to_human_short(1234567) #=> "1.2M"
35
+ # @example With currency prefix
36
+ # number_to_human_short(1500, prefix: "$") #=> "$1.5K"
37
+ # @example With custom precision
38
+ # number_to_human_short(1234567, precision: 2) #=> "1.23M"
39
+ # @example Small numbers
40
+ # number_to_human_short(0.00123, precision: 1) #=> "0.0012"
12
41
  def number_to_human_short(number, prefix: nil, precision: 1)
13
42
  return "#{prefix}0" if number.nil? || number.zero?
14
43
 
@@ -28,6 +57,67 @@ module RubyLLM
28
57
  "#{prefix}#{formatted}"
29
58
  end
30
59
 
60
+ # Renders an enabled/disabled badge
61
+ #
62
+ # @param enabled [Boolean] Whether the feature is enabled
63
+ # @return [ActiveSupport::SafeBuffer] HTML badge element
64
+ def render_enabled_badge(enabled)
65
+ if enabled
66
+ '<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300">Enabled</span>'.html_safe
67
+ else
68
+ '<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Disabled</span>'.html_safe
69
+ end
70
+ end
71
+
72
+ # Renders a configured/not configured badge
73
+ #
74
+ # @param configured [Boolean] Whether the setting is configured
75
+ # @return [ActiveSupport::SafeBuffer] HTML badge element
76
+ def render_configured_badge(configured)
77
+ if configured
78
+ '<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300">Configured</span>'.html_safe
79
+ else
80
+ '<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Not configured</span>'.html_safe
81
+ end
82
+ end
83
+
84
+ # Redacts sensitive data from an object for display
85
+ #
86
+ # Uses the configured redaction rules to mask sensitive fields
87
+ # and patterns in the data.
88
+ #
89
+ # @param obj [Object] The object to redact (Hash, Array, or primitive)
90
+ # @return [Object] The redacted object
91
+ # @example
92
+ # redact_for_display({ password: "secret", name: "John" })
93
+ # #=> { password: "[REDACTED]", name: "John" }
94
+ def redact_for_display(obj)
95
+ Redactor.redact(obj)
96
+ end
97
+
98
+ # Syntax-highlights a redacted Ruby object as pretty-printed JSON
99
+ #
100
+ # Combines redaction and highlighting in one call.
101
+ #
102
+ # @param obj [Object] Any JSON-serializable Ruby object
103
+ # @return [ActiveSupport::SafeBuffer] HTML-safe highlighted redacted JSON
104
+ def highlight_json_redacted(obj)
105
+ return "" if obj.nil?
106
+
107
+ redacted = redact_for_display(obj)
108
+ highlight_json(redacted)
109
+ end
110
+
111
+ # Syntax-highlights a Ruby object as pretty-printed JSON
112
+ #
113
+ # Converts the object to JSON and applies color highlighting
114
+ # using Tailwind CSS classes.
115
+ #
116
+ # @param obj [Object] Any JSON-serializable Ruby object
117
+ # @return [ActiveSupport::SafeBuffer] HTML-safe highlighted JSON string
118
+ # @see #highlight_json_string
119
+ # @example
120
+ # highlight_json({ name: "test", count: 42 })
31
121
  def highlight_json(obj)
32
122
  return "" if obj.nil?
33
123
 
@@ -35,9 +125,209 @@ module RubyLLM
35
125
  highlight_json_string(json_string)
36
126
  end
37
127
 
128
+ # Renders an SVG sparkline chart from trend data
129
+ #
130
+ # Creates a simple polyline SVG for inline trend visualization.
131
+ # Used in version comparison to show historical performance.
132
+ #
133
+ # @param trend_data [Array<Hash>] Array of daily data points
134
+ # @param metric_key [Symbol] The metric to chart (:count, :success_rate, :avg_cost, etc.)
135
+ # @param color_class [String] Tailwind color class for the line
136
+ # @return [ActiveSupport::SafeBuffer] SVG sparkline element
137
+ def render_sparkline(trend_data, metric_key, color_class: "text-blue-500")
138
+ return "".html_safe if trend_data.blank? || trend_data.length < 2
139
+
140
+ values = trend_data.map { |d| d[metric_key].to_f || 0 }
141
+ max_val = values.max || 1
142
+ min_val = values.min || 0
143
+ range = max_val - min_val
144
+ range = 1 if range.zero?
145
+
146
+ # Generate SVG polyline points
147
+ points = values.each_with_index.map do |val, i|
148
+ x = (i.to_f / (values.length - 1)) * 100
149
+ y = 28 - ((val - min_val) / range * 24) + 2 # 2px padding top/bottom
150
+ "#{x.round(2)},#{y.round(2)}"
151
+ end.join(" ")
152
+
153
+ content_tag(:svg, class: "w-full h-8", viewBox: "0 0 100 30", preserveAspectRatio: "none") do
154
+ content_tag(:polyline, nil,
155
+ points: points,
156
+ fill: "none",
157
+ stroke: "currentColor",
158
+ "stroke-width": "2",
159
+ "stroke-linecap": "round",
160
+ "stroke-linejoin": "round",
161
+ class: color_class
162
+ )
163
+ end
164
+ end
165
+
166
+ # Renders a comparison badge based on change percentage
167
+ #
168
+ # Determines if a metric change is significant and returns an appropriate
169
+ # badge indicating improvement, regression, or stability.
170
+ #
171
+ # @param change_pct [Float] Percentage change between versions
172
+ # @param metric_type [Symbol] Type of metric (:success_rate, :cost, :tokens, :duration, :count)
173
+ # @return [ActiveSupport::SafeBuffer] HTML badge element
174
+ def comparison_badge(change_pct, metric_type)
175
+ threshold = case metric_type
176
+ when :success_rate then 5
177
+ when :cost, :tokens then 15
178
+ when :duration then 20
179
+ when :count then 25
180
+ else 10
181
+ end
182
+
183
+ # Determine what "improvement" means for this metric
184
+ # For cost/tokens/duration: negative change is good (lower is better)
185
+ # For success_rate/count: positive change is good (higher is better)
186
+ is_improvement = case metric_type
187
+ when :success_rate, :count then change_pct > threshold
188
+ when :cost, :tokens, :duration then change_pct < -threshold
189
+ else false
190
+ end
191
+
192
+ is_regression = case metric_type
193
+ when :success_rate, :count then change_pct < -threshold
194
+ when :cost, :tokens, :duration then change_pct > threshold
195
+ else false
196
+ end
197
+
198
+ if is_improvement
199
+ content_tag(:span, class: "inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/50 rounded-full") do
200
+ safe_join([
201
+ content_tag(:svg, class: "w-3 h-3", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
202
+ content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M5 10l7-7m0 0l7 7m-7-7v18")
203
+ end,
204
+ "Improved"
205
+ ])
206
+ end
207
+ elsif is_regression
208
+ content_tag(:span, class: "inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-900/50 rounded-full") do
209
+ safe_join([
210
+ content_tag(:svg, class: "w-3 h-3", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
211
+ content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M19 14l-7 7m0 0l-7-7m7 7V3")
212
+ end,
213
+ "Regressed"
214
+ ])
215
+ end
216
+ else
217
+ content_tag(:span, class: "inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-full") do
218
+ safe_join([
219
+ content_tag(:svg, class: "w-3 h-3", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
220
+ content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M5 12h14")
221
+ end,
222
+ "Stable"
223
+ ])
224
+ end
225
+ end
226
+ end
227
+
228
+ # Returns the appropriate row background class based on change significance
229
+ #
230
+ # @param change_pct [Float] Percentage change
231
+ # @param metric_type [Symbol] Type of metric
232
+ # @return [String] Tailwind CSS classes for row background
233
+ def comparison_row_class(change_pct, metric_type)
234
+ threshold = case metric_type
235
+ when :success_rate then 5
236
+ when :cost, :tokens then 15
237
+ when :duration then 20
238
+ when :count then 25
239
+ else 10
240
+ end
241
+
242
+ is_improvement = case metric_type
243
+ when :success_rate, :count then change_pct > threshold
244
+ when :cost, :tokens, :duration then change_pct < -threshold
245
+ else false
246
+ end
247
+
248
+ is_regression = case metric_type
249
+ when :success_rate, :count then change_pct < -threshold
250
+ when :cost, :tokens, :duration then change_pct > threshold
251
+ else false
252
+ end
253
+
254
+ if is_improvement
255
+ "bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800"
256
+ elsif is_regression
257
+ "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
258
+ else
259
+ "bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-700"
260
+ end
261
+ end
262
+
263
+ # Generates an overall comparison summary based on multiple metrics
264
+ #
265
+ # @param metrics [Array<Hash>] Array of metric comparison results
266
+ # @return [ActiveSupport::SafeBuffer] HTML summary banner
267
+ def comparison_summary_badge(improvements_count, regressions_count, v2_label)
268
+ if improvements_count >= 3 && regressions_count == 0
269
+ content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/50 rounded-lg") do
270
+ safe_join([
271
+ content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
272
+ content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z")
273
+ end,
274
+ "v#{v2_label} shows overall improvement"
275
+ ])
276
+ end
277
+ elsif regressions_count >= 3 && improvements_count == 0
278
+ content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-900/50 rounded-lg") do
279
+ safe_join([
280
+ content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
281
+ content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z")
282
+ end,
283
+ "v#{v2_label} shows overall regression"
284
+ ])
285
+ end
286
+ elsif improvements_count > 0 || regressions_count > 0
287
+ content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-amber-700 dark:text-amber-300 bg-amber-100 dark:bg-amber-900/50 rounded-lg") do
288
+ safe_join([
289
+ content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
290
+ content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4")
291
+ end,
292
+ "v#{v2_label} shows mixed results"
293
+ ])
294
+ end
295
+ else
296
+ content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-lg") do
297
+ safe_join([
298
+ content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
299
+ content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M5 12h14")
300
+ end,
301
+ "No significant changes"
302
+ ])
303
+ end
304
+ end
305
+ end
306
+
307
+ # Syntax-highlights a JSON string with Tailwind CSS colors
308
+ #
309
+ # Tokenizes the JSON and wraps each token type in a span with
310
+ # appropriate color classes:
311
+ # - Purple (text-purple-600): Object keys
312
+ # - Green (text-green-600): String values
313
+ # - Blue (text-blue-600): Numbers
314
+ # - Amber (text-amber-600): Booleans (true/false)
315
+ # - Gray (text-gray-400): null values
316
+ #
317
+ # The tokenizer uses a character-by-character approach:
318
+ # 1. Identifies token type by first character
319
+ # 2. Parses complete token (handling escapes in strings)
320
+ # 3. Determines if strings are keys (followed by colon)
321
+ # 4. Wraps each token in appropriate span
322
+ #
323
+ # @param json_string [String] A valid JSON string
324
+ # @return [ActiveSupport::SafeBuffer] HTML-safe highlighted output
325
+ # @api private
38
326
  def highlight_json_string(json_string)
39
327
  return "" if json_string.blank?
40
328
 
329
+ # Phase 1: Tokenization
330
+ # Convert JSON string into array of typed tokens for later rendering
41
331
  tokens = []
42
332
  i = 0
43
333
  chars = json_string.chars
@@ -47,7 +337,8 @@ module RubyLLM
47
337
 
48
338
  case char
49
339
  when '"'
50
- # Parse string
340
+ # String token: starts with quote, ends with unescaped quote
341
+ # Handles escape sequences like \" and \\
51
342
  str_start = i
52
343
  i += 1
53
344
  while i < chars.length
@@ -62,7 +353,7 @@ module RubyLLM
62
353
  end
63
354
  tokens << { type: :string, value: chars[str_start...i].join }
64
355
  when /[0-9\-]/
65
- # Parse number
356
+ # Number token: starts with digit or minus, continues with digits/decimals/exponents
66
357
  num_start = i
67
358
  i += 1
68
359
  while i < chars.length && chars[i] =~ /[0-9.eE+\-]/
@@ -70,7 +361,7 @@ module RubyLLM
70
361
  end
71
362
  tokens << { type: :number, value: chars[num_start...i].join }
72
363
  when 't'
73
- # true
364
+ # Boolean token: check for "true" keyword
74
365
  if chars[i, 4].join == 'true'
75
366
  tokens << { type: :boolean, value: 'true' }
76
367
  i += 4
@@ -79,7 +370,7 @@ module RubyLLM
79
370
  i += 1
80
371
  end
81
372
  when 'f'
82
- # false
373
+ # Boolean token: check for "false" keyword
83
374
  if chars[i, 5].join == 'false'
84
375
  tokens << { type: :boolean, value: 'false' }
85
376
  i += 5
@@ -88,7 +379,7 @@ module RubyLLM
88
379
  i += 1
89
380
  end
90
381
  when 'n'
91
- # null
382
+ # Null token: check for "null" keyword
92
383
  if chars[i, 4].join == 'null'
93
384
  tokens << { type: :null, value: 'null' }
94
385
  i += 4
@@ -97,20 +388,27 @@ module RubyLLM
97
388
  i += 1
98
389
  end
99
390
  when ':', ',', '{', '}', '[', ']', ' ', "\n", "\t"
391
+ # Punctuation token: structural characters and whitespace
100
392
  tokens << { type: :punct, value: char }
101
393
  i += 1
102
394
  else
395
+ # Fallback for unexpected characters
103
396
  tokens << { type: :text, value: char }
104
397
  i += 1
105
398
  end
106
399
  end
107
400
 
108
- # Build highlighted HTML - detect if string is a key (followed by :)
401
+ # Phase 2: Rendering
402
+ # Convert tokens to HTML with color classes
403
+ # Key detection: strings followed by colon are object keys (purple)
404
+ # Value strings get different color (green)
109
405
  result = []
110
406
  tokens.each_with_index do |token, idx|
111
407
  case token[:type]
112
408
  when :string
113
- # Check if this is a key (next non-whitespace token is :)
409
+ # Key detection algorithm:
410
+ # Look ahead past any whitespace tokens to find next punctuation
411
+ # If next non-whitespace punct is ':', this string is an object key
114
412
  is_key = false
115
413
  (idx + 1...tokens.length).each do |j|
116
414
  if tokens[j][:type] == :punct
@@ -118,8 +416,10 @@ module RubyLLM
118
416
  is_key = true
119
417
  break
120
418
  elsif tokens[j][:value] !~ /\s/
419
+ # Non-whitespace punct that isn't colon - not a key
121
420
  break
122
421
  end
422
+ # Skip whitespace and continue looking
123
423
  else
124
424
  break
125
425
  end