smith-agents 0.4.1 → 0.4.3
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +91 -3
- data/README.md +69 -3
- data/UPSTREAM_PROPOSAL.md +19 -0
- data/docs/PATTERNS.md +85 -6
- data/lib/smith/agent/lifecycle.rb +11 -7
- data/lib/smith/agent/registry/introspection.rb +27 -0
- data/lib/smith/agent/registry.rb +22 -17
- data/lib/smith/agent/registry_binding.rb +43 -0
- data/lib/smith/agent.rb +3 -4
- data/lib/smith/doctor/checks/models_registry.rb +25 -14
- data/lib/smith/doctor/checks/persistence_capabilities.rb +37 -29
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/agent_result.rb +26 -0
- data/lib/smith/workflow/branch_env.rb +24 -0
- data/lib/smith/workflow/budget_integration.rb +27 -5
- data/lib/smith/workflow/deterministic_execution.rb +1 -1
- data/lib/smith/workflow/deterministic_step.rb +32 -4
- data/lib/smith/workflow/durability.rb +38 -12
- data/lib/smith/workflow/evaluator_optimizer.rb +18 -10
- data/lib/smith/workflow/event_integration.rb +3 -3
- data/lib/smith/workflow/execution.rb +8 -1
- data/lib/smith/workflow/fanout_execution.rb +121 -0
- data/lib/smith/workflow/graph/contract_helpers.rb +65 -0
- data/lib/smith/workflow/graph/fanout_contract.rb +140 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +98 -0
- data/lib/smith/workflow/graph/optimization_contract.rb +96 -0
- data/lib/smith/workflow/graph/orchestration_contract.rb +83 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +124 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +114 -0
- data/lib/smith/workflow/graph/runtime_readiness.rb +63 -0
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +87 -0
- data/lib/smith/workflow/graph/runtime_readiness_report.rb +65 -0
- data/lib/smith/workflow/graph/targets.rb +5 -0
- data/lib/smith/workflow/graph/transition_diagnostics.rb +7 -0
- data/lib/smith/workflow/graph/transition_snapshot.rb +80 -18
- data/lib/smith/workflow/graph.rb +16 -1
- data/lib/smith/workflow/graph_dsl.rb +4 -0
- data/lib/smith/workflow/guardrail_integration.rb +45 -10
- data/lib/smith/workflow/nested_execution.rb +5 -4
- data/lib/smith/workflow/optimization_state.rb +13 -0
- data/lib/smith/workflow/orchestration_state.rb +13 -0
- data/lib/smith/workflow/orchestrator_worker.rb +5 -16
- data/lib/smith/workflow/parallel/cancellation.rb +9 -0
- data/lib/smith/workflow/parallel/cancellation_signal.rb +21 -0
- data/lib/smith/workflow/parallel.rb +12 -16
- data/lib/smith/workflow/parallel_execution.rb +5 -1
- data/lib/smith/workflow/persistence.rb +37 -6
- data/lib/smith/workflow/retry_execution.rb +52 -0
- data/lib/smith/workflow/router.rb +15 -4
- data/lib/smith/workflow/run_result.rb +54 -0
- data/lib/smith/workflow/transition.rb +260 -24
- data/lib/smith/workflow/usage_entry.rb +30 -0
- data/lib/smith/workflow/worker_execution.rb +13 -0
- data/lib/smith/workflow.rb +40 -136
- data/lib/smith.rb +12 -0
- metadata +24 -1
|
@@ -5,7 +5,8 @@ module Smith
|
|
|
5
5
|
class Transition
|
|
6
6
|
attr_reader :name, :from, :to, :agent_name, :agent_opts, :success_transition, :failure_transition,
|
|
7
7
|
:router_config, :workflow_class, :optimization_config, :orchestrator_config,
|
|
8
|
-
:deterministic_block, :deterministic_kind
|
|
8
|
+
:fanout_config, :retry_config, :deterministic_block, :deterministic_kind,
|
|
9
|
+
:deterministic_routes
|
|
9
10
|
|
|
10
11
|
def initialize(name, from:, to:, &)
|
|
11
12
|
@name = name
|
|
@@ -15,7 +16,7 @@ module Smith
|
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def execute(agent_name, **opts)
|
|
18
|
-
|
|
19
|
+
validate_execute_conflicts!
|
|
19
20
|
|
|
20
21
|
@agent_name = agent_name
|
|
21
22
|
@agent_opts = opts
|
|
@@ -30,18 +31,29 @@ module Smith
|
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
def route(agent_name, routes:, confidence_threshold:, fallback:)
|
|
33
|
-
|
|
34
|
+
validate_route_conflicts!
|
|
34
35
|
|
|
35
|
-
@agent_name = agent_name
|
|
36
|
-
@router_config = {
|
|
36
|
+
@agent_name = normalize_agent_name!(agent_name, "router")
|
|
37
|
+
@router_config = {
|
|
38
|
+
routes: normalize_router_routes!(routes),
|
|
39
|
+
confidence_threshold: normalize_confidence_threshold!(confidence_threshold),
|
|
40
|
+
fallback: normalize_transition_reference!(fallback, "router fallback")
|
|
41
|
+
}
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
def workflow(klass)
|
|
40
45
|
raise WorkflowError, "workflow binding must be a Class" unless klass.is_a?(Class)
|
|
41
46
|
raise WorkflowError, "workflow binding must be a Smith::Workflow subclass" unless klass < Workflow
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
|
|
48
|
+
validate_conflicts!(
|
|
49
|
+
"workflow",
|
|
50
|
+
[
|
|
51
|
+
["execute", @agent_name && !@router_config],
|
|
52
|
+
["route", @router_config],
|
|
53
|
+
["compute/run", @deterministic_block],
|
|
54
|
+
["fan_out", @fanout_config]
|
|
55
|
+
]
|
|
56
|
+
)
|
|
45
57
|
|
|
46
58
|
@workflow_class = klass
|
|
47
59
|
end
|
|
@@ -77,13 +89,33 @@ module Smith
|
|
|
77
89
|
@orchestrator_config = opts
|
|
78
90
|
end
|
|
79
91
|
|
|
92
|
+
def fan_out(branches:)
|
|
93
|
+
validate_fanout_conflicts!
|
|
94
|
+
|
|
95
|
+
@fanout_config = { branches: normalize_fanout_branches!(branches) }
|
|
96
|
+
end
|
|
97
|
+
alias fanout fan_out
|
|
98
|
+
|
|
99
|
+
def retry_on(*error_classes, attempts:, backoff: 0, max_delay: nil, jitter: 0)
|
|
100
|
+
validate_retry_controls!(error_classes, attempts:, backoff:, max_delay:, jitter:)
|
|
101
|
+
|
|
102
|
+
@retry_config = {
|
|
103
|
+
error_classes: error_classes.freeze,
|
|
104
|
+
attempts: attempts,
|
|
105
|
+
backoff: Float(backoff),
|
|
106
|
+
max_delay: max_delay.nil? ? nil : Float(max_delay),
|
|
107
|
+
jitter: Float(jitter)
|
|
108
|
+
}.freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
80
111
|
%i[compute run].each do |method_name|
|
|
81
|
-
define_method(method_name) do
|
|
112
|
+
define_method(method_name) do |routes: nil, &block|
|
|
82
113
|
validate_deterministic_conflicts!
|
|
83
114
|
raise WorkflowError, "#{method_name} requires a block" unless block
|
|
84
115
|
|
|
85
116
|
@deterministic_block = block
|
|
86
117
|
@deterministic_kind = method_name
|
|
118
|
+
@deterministic_routes = normalize_deterministic_routes!(routes)
|
|
87
119
|
end
|
|
88
120
|
end
|
|
89
121
|
|
|
@@ -95,6 +127,10 @@ module Smith
|
|
|
95
127
|
!@orchestrator_config.nil?
|
|
96
128
|
end
|
|
97
129
|
|
|
130
|
+
def fanout?
|
|
131
|
+
!@fanout_config.nil?
|
|
132
|
+
end
|
|
133
|
+
|
|
98
134
|
def optimized?
|
|
99
135
|
!@optimization_config.nil?
|
|
100
136
|
end
|
|
@@ -113,28 +149,228 @@ module Smith
|
|
|
113
149
|
|
|
114
150
|
private
|
|
115
151
|
|
|
152
|
+
def validate_execute_conflicts!
|
|
153
|
+
validate_conflicts!(
|
|
154
|
+
"execute",
|
|
155
|
+
[
|
|
156
|
+
["compute/run", @deterministic_block],
|
|
157
|
+
["fan_out", @fanout_config]
|
|
158
|
+
]
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def validate_route_conflicts!
|
|
163
|
+
validate_conflicts!(
|
|
164
|
+
"route",
|
|
165
|
+
[
|
|
166
|
+
["compute/run", @deterministic_block],
|
|
167
|
+
["fan_out", @fanout_config]
|
|
168
|
+
]
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
116
172
|
def validate_deterministic_conflicts!
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
173
|
+
validate_conflicts!(
|
|
174
|
+
"compute/run",
|
|
175
|
+
[
|
|
176
|
+
["execute", @agent_name && !@router_config],
|
|
177
|
+
["route", @router_config],
|
|
178
|
+
["workflow", @workflow_class],
|
|
179
|
+
["optimize", @optimization_config],
|
|
180
|
+
["orchestrate", @orchestrator_config],
|
|
181
|
+
["fan_out", @fanout_config]
|
|
182
|
+
]
|
|
183
|
+
)
|
|
122
184
|
raise WorkflowError, "transition cannot declare both compute and run" if @deterministic_block
|
|
123
185
|
end
|
|
124
186
|
|
|
125
187
|
def validate_optimize_conflicts!
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
188
|
+
validate_conflicts!(
|
|
189
|
+
"optimize",
|
|
190
|
+
[
|
|
191
|
+
["execute", @agent_name && !@router_config],
|
|
192
|
+
["route", @router_config],
|
|
193
|
+
["workflow", @workflow_class],
|
|
194
|
+
["compute/run", @deterministic_block],
|
|
195
|
+
["fan_out", @fanout_config]
|
|
196
|
+
]
|
|
197
|
+
)
|
|
130
198
|
end
|
|
131
199
|
|
|
132
200
|
def validate_orchestrate_conflicts!
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
201
|
+
validate_conflicts!(
|
|
202
|
+
"orchestrate",
|
|
203
|
+
[
|
|
204
|
+
["execute", @agent_name && !@router_config],
|
|
205
|
+
["route", @router_config],
|
|
206
|
+
["workflow", @workflow_class],
|
|
207
|
+
["optimize", @optimization_config],
|
|
208
|
+
["compute/run", @deterministic_block],
|
|
209
|
+
["fan_out", @fanout_config]
|
|
210
|
+
]
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def validate_fanout_conflicts!
|
|
215
|
+
validate_conflicts!(
|
|
216
|
+
"fan_out",
|
|
217
|
+
[
|
|
218
|
+
["execute", @agent_name && !@router_config],
|
|
219
|
+
["route", @router_config],
|
|
220
|
+
["workflow", @workflow_class],
|
|
221
|
+
["optimize", @optimization_config],
|
|
222
|
+
["orchestrate", @orchestrator_config],
|
|
223
|
+
["compute/run", @deterministic_block]
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def validate_conflicts!(primitive, conflicts)
|
|
229
|
+
conflicts.each do |other, present|
|
|
230
|
+
raise WorkflowError, "transition cannot declare both #{primitive} and #{other}" if present
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def normalize_fanout_branches!(branches)
|
|
235
|
+
raise WorkflowError, "fan_out branches must be a Hash" unless branches.is_a?(Hash)
|
|
236
|
+
raise WorkflowError, "fan_out requires at least one branch" if branches.empty?
|
|
237
|
+
|
|
238
|
+
normalized = branches.each_with_object({}) do |(branch_key, agent_name), map|
|
|
239
|
+
key = normalize_fanout_branch_key!(branch_key)
|
|
240
|
+
agent = normalize_fanout_agent_name!(agent_name, key)
|
|
241
|
+
raise WorkflowError, "fan_out branch #{key.inspect} is duplicated" if map.key?(key)
|
|
242
|
+
|
|
243
|
+
map[key] = agent
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
validate_distinct_fanout_agents!(normalized)
|
|
247
|
+
normalized.freeze
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def normalize_agent_name!(agent_name, label)
|
|
251
|
+
value = agent_name.to_s.strip
|
|
252
|
+
raise WorkflowError, "#{label} agent must not be blank" if value.empty?
|
|
253
|
+
|
|
254
|
+
value.to_sym
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def normalize_router_routes!(routes)
|
|
258
|
+
raise WorkflowError, "router routes must be a Hash" unless routes.is_a?(Hash)
|
|
259
|
+
raise WorkflowError, "router routes must not be empty" if routes.empty?
|
|
260
|
+
|
|
261
|
+
routes.each_with_object({}) do |(route_key, transition_name), map|
|
|
262
|
+
key = normalize_router_route_key!(route_key)
|
|
263
|
+
raise WorkflowError, "router route :#{key} is duplicated" if map.key?(key)
|
|
264
|
+
|
|
265
|
+
map[key] = normalize_transition_reference!(transition_name, "router route :#{key}")
|
|
266
|
+
end.freeze
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def normalize_router_route_key!(route_key)
|
|
270
|
+
value = route_key.to_s.strip
|
|
271
|
+
raise WorkflowError, "router route keys must not be blank" if value.empty?
|
|
272
|
+
|
|
273
|
+
value.to_sym
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def normalize_confidence_threshold!(threshold)
|
|
277
|
+
numeric = Float(threshold)
|
|
278
|
+
return numeric if numeric >= 0.0 && numeric <= 1.0
|
|
279
|
+
|
|
280
|
+
raise WorkflowError, "router confidence_threshold must be a number in 0.0..1.0"
|
|
281
|
+
rescue TypeError, ArgumentError
|
|
282
|
+
raise WorkflowError, "router confidence_threshold must be a number in 0.0..1.0"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def normalize_transition_reference!(transition_name, label)
|
|
286
|
+
case transition_name
|
|
287
|
+
when Symbol
|
|
288
|
+
raise WorkflowError, "#{label} transition must not be blank" if transition_name.to_s.empty?
|
|
289
|
+
|
|
290
|
+
transition_name
|
|
291
|
+
when String
|
|
292
|
+
value = transition_name.strip
|
|
293
|
+
raise WorkflowError, "#{label} transition must not be blank" if value.empty?
|
|
294
|
+
|
|
295
|
+
value.freeze
|
|
296
|
+
else
|
|
297
|
+
raise WorkflowError, "#{label} transition must be String or Symbol"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def normalize_fanout_branch_key!(branch_key)
|
|
302
|
+
key = branch_key.to_s.strip
|
|
303
|
+
raise WorkflowError, "fan_out branch keys must not be blank" if key.empty?
|
|
304
|
+
|
|
305
|
+
key.to_sym
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def normalize_fanout_agent_name!(agent_name, branch_key)
|
|
309
|
+
value = agent_name.to_s.strip
|
|
310
|
+
raise WorkflowError, "fan_out branch #{branch_key.inspect} must declare an agent" if value.empty?
|
|
311
|
+
|
|
312
|
+
value.to_sym
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def validate_distinct_fanout_agents!(branches)
|
|
316
|
+
duplicates = branches.values.tally.select { |_agent, count| count > 1 }.keys
|
|
317
|
+
return if duplicates.empty?
|
|
318
|
+
|
|
319
|
+
raise WorkflowError, "fan_out branch agents must be distinct: #{duplicates.map(&:inspect).join(", ")}"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def validate_retry_controls!(error_classes, attempts:, backoff:, max_delay:, jitter:)
|
|
323
|
+
unless attempts.is_a?(Integer) && attempts.positive?
|
|
324
|
+
raise WorkflowError, "retry_on attempts must be a positive integer"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
error_classes.each do |error_class|
|
|
328
|
+
next if error_class.is_a?(Class) && error_class <= StandardError
|
|
329
|
+
|
|
330
|
+
raise WorkflowError, "retry_on error classes must inherit from StandardError"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
validate_non_negative_numeric!(:backoff, backoff)
|
|
334
|
+
validate_non_negative_numeric!(:jitter, jitter)
|
|
335
|
+
validate_non_negative_numeric!(:max_delay, max_delay) unless max_delay.nil?
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def normalize_deterministic_routes!(routes)
|
|
339
|
+
return nil if routes.nil?
|
|
340
|
+
raise WorkflowError, "deterministic routes must be an Array" unless routes.is_a?(Array)
|
|
341
|
+
raise WorkflowError, "deterministic routes must not be empty" if routes.empty?
|
|
342
|
+
|
|
343
|
+
routes.each_with_object([]) do |route, list|
|
|
344
|
+
name = normalize_deterministic_route!(route)
|
|
345
|
+
raise WorkflowError, "deterministic route #{name.inspect} is duplicated" if list.include?(name)
|
|
346
|
+
|
|
347
|
+
list << name
|
|
348
|
+
end.freeze
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def normalize_deterministic_route!(route)
|
|
352
|
+
case route
|
|
353
|
+
when Symbol
|
|
354
|
+
raise WorkflowError, "deterministic route names must not be blank" if route.to_s.empty?
|
|
355
|
+
|
|
356
|
+
route
|
|
357
|
+
when String
|
|
358
|
+
value = route.strip
|
|
359
|
+
raise WorkflowError, "deterministic route names must not be blank" if value.empty?
|
|
360
|
+
|
|
361
|
+
value.freeze
|
|
362
|
+
else
|
|
363
|
+
raise WorkflowError, "deterministic route names must be String or Symbol"
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def validate_non_negative_numeric!(name, value)
|
|
368
|
+
numeric = Float(value)
|
|
369
|
+
return if numeric >= 0.0
|
|
370
|
+
|
|
371
|
+
raise WorkflowError, "retry_on #{name} must be non-negative"
|
|
372
|
+
rescue TypeError, ArgumentError
|
|
373
|
+
raise WorkflowError, "retry_on #{name} must be numeric"
|
|
138
374
|
end
|
|
139
375
|
|
|
140
376
|
def validate_orchestrate_controls!(opts)
|
|
@@ -177,7 +413,7 @@ module Smith
|
|
|
177
413
|
raise WorkflowError, "optimize max_rounds must be a positive integer"
|
|
178
414
|
end
|
|
179
415
|
|
|
180
|
-
VALID_EXIT_MODES = [
|
|
416
|
+
VALID_EXIT_MODES = %i[raise return_last].freeze
|
|
181
417
|
private_constant :VALID_EXIT_MODES
|
|
182
418
|
|
|
183
419
|
def validate_optimize_exit_modes!(on_exhaustion:, on_converged:, on_threshold:)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# One row per agent provider call. `usage_id` is a UUID generated at
|
|
6
|
+
# recording time and stable across persist/restore so hosts can use it as an
|
|
7
|
+
# idempotency anchor.
|
|
8
|
+
# rubocop:disable Style/RedundantStructKeywordInit
|
|
9
|
+
UsageEntry = Struct.new(
|
|
10
|
+
:usage_id,
|
|
11
|
+
:agent_name,
|
|
12
|
+
:model,
|
|
13
|
+
:input_tokens,
|
|
14
|
+
:output_tokens,
|
|
15
|
+
:cost,
|
|
16
|
+
:attempt_kind,
|
|
17
|
+
:recorded_at,
|
|
18
|
+
keyword_init: true
|
|
19
|
+
) do
|
|
20
|
+
def self.from_h(hash)
|
|
21
|
+
sym = hash.transform_keys(&:to_sym)
|
|
22
|
+
filtered = sym.slice(*members)
|
|
23
|
+
filtered[:agent_name] = filtered[:agent_name].to_sym if filtered[:agent_name].is_a?(String)
|
|
24
|
+
filtered[:attempt_kind] = filtered[:attempt_kind].to_sym if filtered[:attempt_kind].is_a?(String)
|
|
25
|
+
new(**filtered)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
# rubocop:enable Style/RedundantStructKeywordInit
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
WorkerExecution = Struct.new(:execution_id, :task, :output) do
|
|
8
|
+
def self.run(worker_class, task, schema, budget_runner)
|
|
9
|
+
new(SecureRandom.uuid, task, budget_runner.call(worker_class, task, schema))
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/smith/workflow.rb
CHANGED
|
@@ -6,6 +6,11 @@ require "securerandom"
|
|
|
6
6
|
require "set"
|
|
7
7
|
require "time"
|
|
8
8
|
|
|
9
|
+
require_relative "workflow/agent_result"
|
|
10
|
+
require_relative "workflow/branch_env"
|
|
11
|
+
require_relative "workflow/run_result"
|
|
12
|
+
require_relative "workflow/usage_entry"
|
|
13
|
+
|
|
9
14
|
module Smith
|
|
10
15
|
class Workflow
|
|
11
16
|
include DSL
|
|
@@ -21,113 +26,6 @@ module Smith
|
|
|
21
26
|
|
|
22
27
|
DEFAULT_MAX_TRANSITIONS = 100
|
|
23
28
|
|
|
24
|
-
# `keyword_init: true` is mandatory: `build_run_result` constructs
|
|
25
|
-
# the result via keyword arguments. Plain Ruby Structs treat the
|
|
26
|
-
# kwargs hash as the first positional field, silently leaving the
|
|
27
|
-
# remaining fields nil — verified empirically. The `keyword_init`
|
|
28
|
-
# flag routes kwargs to the right fields. `usage_entries` is the
|
|
29
|
-
# 10th field, added in this slice for hadithi billing.
|
|
30
|
-
RunResult = Struct.new(:state, :output, :steps, :total_cost, :total_tokens, :context, :session_messages,
|
|
31
|
-
:tool_results, :outcome, :usage_entries, keyword_init: true) do
|
|
32
|
-
def done?
|
|
33
|
-
state == :done
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def failed?
|
|
37
|
-
state == :failed
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def terminal_output
|
|
41
|
-
output
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def outcome_kind
|
|
45
|
-
outcome&.dig(:kind)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def outcome_payload
|
|
49
|
-
outcome&.dig(:payload)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def last_error
|
|
53
|
-
steps.reverse.map { |step| step[:error] }.compact.first
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def failed_transition
|
|
57
|
-
failure_detail&.fetch(:transition)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def failure_detail
|
|
61
|
-
failed_step = steps.reverse.find { |step| step[:error] }
|
|
62
|
-
return nil unless failed_step
|
|
63
|
-
|
|
64
|
-
{
|
|
65
|
-
transition: failed_step[:transition],
|
|
66
|
-
from: failed_step[:from],
|
|
67
|
-
to: failed_step[:to],
|
|
68
|
-
error: failed_step[:error]
|
|
69
|
-
}
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# keyword_init: true so adding new fields in future schema versions
|
|
74
|
-
# remains backward-compatible: `from_response` and `from_h` can fill
|
|
75
|
-
# extra fields without breaking existing call sites that pass the
|
|
76
|
-
# historical positional args. Reading old persisted records into a
|
|
77
|
-
# newer Struct shape: from_h slices to current members (unknown keys
|
|
78
|
-
# silently dropped; missing keys default to nil).
|
|
79
|
-
AgentResult = Struct.new(
|
|
80
|
-
:content, :input_tokens, :output_tokens, :cost, :model_used,
|
|
81
|
-
keyword_init: true
|
|
82
|
-
) do
|
|
83
|
-
def self.from_response(response, content, model_used: nil)
|
|
84
|
-
new(
|
|
85
|
-
content: content,
|
|
86
|
-
input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
|
|
87
|
-
output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : nil,
|
|
88
|
-
cost: nil,
|
|
89
|
-
model_used: model_used
|
|
90
|
-
)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def usage_known?
|
|
94
|
-
!input_tokens.nil? && !output_tokens.nil?
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# One row per agent provider call. `usage_id` is a UUID generated
|
|
99
|
-
# at recording time and stable across persist/restore — hadithi
|
|
100
|
-
# uses it as the idempotency anchor on `usage_events.smith_usage_id`.
|
|
101
|
-
# Includes `to_h`/`from_h` for JSON serialization (plain Struct
|
|
102
|
-
# JSON-encodes to `"#<struct ...>"` — useless).
|
|
103
|
-
#
|
|
104
|
-
# keyword_init: true gives forward/backward compatibility:
|
|
105
|
-
# - Adding a new field: old persisted hashes restore cleanly (new
|
|
106
|
-
# field defaults to nil).
|
|
107
|
-
# - Reading new persisted hashes with an older Smith version: from_h
|
|
108
|
-
# slices to known members (unknown keys silently dropped).
|
|
109
|
-
UsageEntry = Struct.new(
|
|
110
|
-
:usage_id,
|
|
111
|
-
:agent_name,
|
|
112
|
-
:model,
|
|
113
|
-
:input_tokens,
|
|
114
|
-
:output_tokens,
|
|
115
|
-
:cost,
|
|
116
|
-
:attempt_kind,
|
|
117
|
-
:recorded_at,
|
|
118
|
-
keyword_init: true
|
|
119
|
-
) do
|
|
120
|
-
def self.from_h(hash)
|
|
121
|
-
sym = hash.transform_keys(&:to_sym)
|
|
122
|
-
filtered = sym.slice(*members)
|
|
123
|
-
# Symbolize :agent_name and :attempt_kind for backward compat
|
|
124
|
-
# with callers that consume the field as Symbol.
|
|
125
|
-
filtered[:agent_name] = filtered[:agent_name].to_sym if filtered[:agent_name].is_a?(String)
|
|
126
|
-
filtered[:attempt_kind] = filtered[:attempt_kind].to_sym if filtered[:attempt_kind].is_a?(String)
|
|
127
|
-
new(**filtered)
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
29
|
# Reconstruct Smith error classes from `@last_failed_step` snapshots.
|
|
132
30
|
# Order matters: more-specific subclasses first, so a real DSF doesn't
|
|
133
31
|
# get caught by the WorkflowError handler. Each lambda preserves the
|
|
@@ -167,24 +65,6 @@ module Smith
|
|
|
167
65
|
RETRYABLE_BEARING_FAMILIES = %w[deterministic_step_failure tool_guardrail_failed].freeze
|
|
168
66
|
private_constant :RETRYABLE_BEARING_FAMILIES
|
|
169
67
|
|
|
170
|
-
# keyword_init: true for forward/backward compat (see UsageEntry).
|
|
171
|
-
BranchEnv = Struct.new(
|
|
172
|
-
:prepared_input, :guardrail_sources, :scoped_store, :branch_estimates, :deadline,
|
|
173
|
-
keyword_init: true
|
|
174
|
-
) do
|
|
175
|
-
def setup_thread
|
|
176
|
-
Smith::Tool.current_guardrails = guardrail_sources
|
|
177
|
-
Smith::Tool.current_deadline = deadline
|
|
178
|
-
Smith.scoped_artifacts = scoped_store
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def teardown_thread
|
|
182
|
-
Smith::Tool.current_guardrails = nil
|
|
183
|
-
Smith::Tool.current_deadline = nil
|
|
184
|
-
Smith.scoped_artifacts = nil
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
|
|
188
68
|
attr_reader :state, :last_prepared_input, :session_messages, :ledger
|
|
189
69
|
|
|
190
70
|
def initialize(context: {}, ledger: nil, created_at: nil)
|
|
@@ -239,12 +119,13 @@ module Smith
|
|
|
239
119
|
end
|
|
240
120
|
|
|
241
121
|
def advance!
|
|
242
|
-
|
|
243
|
-
|
|
122
|
+
ensure_transition_budget!
|
|
123
|
+
@step_work_started = false
|
|
244
124
|
|
|
245
125
|
transition = resolve_transition
|
|
246
126
|
return if transition.nil?
|
|
247
127
|
|
|
128
|
+
@step_work_started = true
|
|
248
129
|
step_result = execute_step(transition)
|
|
249
130
|
@step_count += 1
|
|
250
131
|
@updated_at = Time.now.utc.iso8601
|
|
@@ -274,11 +155,11 @@ module Smith
|
|
|
274
155
|
end
|
|
275
156
|
|
|
276
157
|
def done?
|
|
277
|
-
|
|
158
|
+
state_named?(:done)
|
|
278
159
|
end
|
|
279
160
|
|
|
280
161
|
def failed?
|
|
281
|
-
|
|
162
|
+
state_named?(:failed)
|
|
282
163
|
end
|
|
283
164
|
|
|
284
165
|
def record_persisted_key!(key)
|
|
@@ -360,17 +241,40 @@ module Smith
|
|
|
360
241
|
true
|
|
361
242
|
end
|
|
362
243
|
|
|
244
|
+
def ensure_transition_budget!
|
|
245
|
+
max = self.class.max_transitions || DEFAULT_MAX_TRANSITIONS
|
|
246
|
+
raise MaxTransitionsExceeded if @step_count >= max
|
|
247
|
+
end
|
|
248
|
+
|
|
363
249
|
def resolve_transition
|
|
364
250
|
if @next_transition_name
|
|
365
251
|
name = @next_transition_name
|
|
366
252
|
@next_transition_name = nil
|
|
367
|
-
self.class.find_transition(name) ||
|
|
253
|
+
transition = self.class.find_transition(name) ||
|
|
368
254
|
raise(UnresolvedTransitionError.new(name, self.class, @state))
|
|
255
|
+
validate_transition_origin!(transition)
|
|
256
|
+
transition
|
|
369
257
|
else
|
|
370
258
|
self.class.transitions_from(@state).first
|
|
371
259
|
end
|
|
372
260
|
end
|
|
373
261
|
|
|
262
|
+
def validate_transition_origin!(transition)
|
|
263
|
+
expected = transition.from
|
|
264
|
+
return if expected.nil? || expected == @state
|
|
265
|
+
|
|
266
|
+
raise WorkflowError,
|
|
267
|
+
"transition :#{transition.name} cannot run from state :#{@state}; expected :#{expected}"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def step_work_started?
|
|
271
|
+
@step_work_started == true
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def state_named?(name)
|
|
275
|
+
@state == name || @state.to_s == name.to_s
|
|
276
|
+
end
|
|
277
|
+
|
|
374
278
|
def build_run_result(steps)
|
|
375
279
|
# `output` derivation matches existing semantics on fresh runs
|
|
376
280
|
# (last non-nil step output via `compact.first`). Terminal-restore
|
|
@@ -416,8 +320,8 @@ module Smith
|
|
|
416
320
|
# Skip const_get for retryable-bearing families. An unknown
|
|
417
321
|
# subclass with a message-only constructor would const_get
|
|
418
322
|
# successfully but discard the snapshot's `retryable`/`kind`/
|
|
419
|
-
# `details` (defaults to nil), and
|
|
420
|
-
#
|
|
323
|
+
# `details` (defaults to nil), and host retry classification
|
|
324
|
+
# could misclassify a retryable failure as terminal.
|
|
421
325
|
# Family fallback rebuilds the parent class with kwargs intact.
|
|
422
326
|
family_fallback(snap)
|
|
423
327
|
else
|
|
@@ -436,9 +340,9 @@ module Smith
|
|
|
436
340
|
# symbols; JSON round-trip stringifies them; coerce back to
|
|
437
341
|
# match fresh-run shape exactly.
|
|
438
342
|
{
|
|
439
|
-
transition: snap[:transition]
|
|
440
|
-
from: snap[:from]
|
|
441
|
-
to: snap[:to]
|
|
343
|
+
transition: normalize_transition_name(snap[:transition]),
|
|
344
|
+
from: normalize_state_name(snap[:from]),
|
|
345
|
+
to: normalize_state_name(snap[:to]),
|
|
442
346
|
error: error
|
|
443
347
|
}
|
|
444
348
|
end
|
|
@@ -519,7 +423,7 @@ module Smith
|
|
|
519
423
|
# `Struct#dup` is shallow — it shares mutable string fields between
|
|
520
424
|
# the original and the duplicate. Smith's existing snapshot helpers
|
|
521
425
|
# (`snapshot_context`, etc.) also use this round-trip pattern; the
|
|
522
|
-
#
|
|
426
|
+
# usage-facing RunResult must not alias mutable workflow state.
|
|
523
427
|
# Same rule applies to nested-workflow rollup (see
|
|
524
428
|
# `nested_execution.rb`).
|
|
525
429
|
def snapshot_usage_entries
|
data/lib/smith.rb
CHANGED
|
@@ -224,12 +224,20 @@ require_relative "smith/context/session"
|
|
|
224
224
|
# Agent (depends on RubyLLM::Agent)
|
|
225
225
|
require_relative "smith/agent"
|
|
226
226
|
require_relative "smith/agent/lifecycle"
|
|
227
|
+
require_relative "smith/agent/registry_binding"
|
|
227
228
|
require_relative "smith/agent/registry"
|
|
228
229
|
|
|
229
230
|
# Workflow (Transition, DSL, Persistence, and Execution must load before Workflow)
|
|
230
231
|
require_relative "smith/workflow/transition"
|
|
231
232
|
require_relative "smith/workflow/graph"
|
|
232
233
|
require_relative "smith/workflow/graph_dsl"
|
|
234
|
+
require_relative "smith/workflow/run_result"
|
|
235
|
+
require_relative "smith/workflow/agent_result"
|
|
236
|
+
require_relative "smith/workflow/usage_entry"
|
|
237
|
+
require_relative "smith/workflow/branch_env"
|
|
238
|
+
require_relative "smith/workflow/optimization_state"
|
|
239
|
+
require_relative "smith/workflow/orchestration_state"
|
|
240
|
+
require_relative "smith/workflow/worker_execution"
|
|
233
241
|
require_relative "smith/workflow/dsl"
|
|
234
242
|
require_relative "smith/workflow/persistence"
|
|
235
243
|
require_relative "smith/workflow/durability"
|
|
@@ -243,6 +251,8 @@ require_relative "smith/workflow/nested_execution"
|
|
|
243
251
|
require_relative "smith/workflow/evaluator_optimizer"
|
|
244
252
|
require_relative "smith/workflow/orchestrator_worker"
|
|
245
253
|
require_relative "smith/workflow/parallel_execution"
|
|
254
|
+
require_relative "smith/workflow/fanout_execution"
|
|
255
|
+
require_relative "smith/workflow/retry_execution"
|
|
246
256
|
require_relative "smith/workflow/deterministic_step"
|
|
247
257
|
require_relative "smith/workflow/deterministic_execution"
|
|
248
258
|
require_relative "smith/workflow/execution"
|
|
@@ -252,6 +262,8 @@ require_relative "smith/workflow/execution_frame"
|
|
|
252
262
|
require_relative "smith/workflow/pipeline"
|
|
253
263
|
require_relative "smith/workflow/router"
|
|
254
264
|
require_relative "smith/workflow/parallel"
|
|
265
|
+
require_relative "smith/workflow/parallel/cancellation_signal"
|
|
266
|
+
require_relative "smith/workflow/parallel/cancellation"
|
|
255
267
|
|
|
256
268
|
# Conditional Rails integration
|
|
257
269
|
require_relative "smith/railtie" if defined?(Rails::Railtie)
|