smith-agents 0.12.6 → 0.13.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -0
  3. data/README.md +11 -4
  4. data/docs/AGENTS.md +9 -5
  5. data/docs/ERRORS_AND_RETRIES.md +20 -4
  6. data/docs/PATTERNS.md +5 -3
  7. data/docs/PERSISTENCE.md +41 -9
  8. data/docs/TOOLS_AND_GUARDRAILS.md +86 -6
  9. data/lib/smith/agent/attempt_captures.rb +62 -0
  10. data/lib/smith/agent/lifecycle.rb +7 -3
  11. data/lib/smith/agent/output_integrity.rb +7 -6
  12. data/lib/smith/agent/output_recovery.rb +6 -4
  13. data/lib/smith/agent/provider_attempt.rb +3 -0
  14. data/lib/smith/agent/provider_completion.rb +18 -16
  15. data/lib/smith/agent/provider_failure_handling.rb +5 -3
  16. data/lib/smith/agent/provider_streaming.rb +3 -1
  17. data/lib/smith/errors.rb +6 -3
  18. data/lib/smith/response_memo.rb +2 -1
  19. data/lib/smith/tool/budget_enforcement.rb +1 -1
  20. data/lib/smith/tool/capability_builder.rb +14 -1
  21. data/lib/smith/tool/capability_declaration.rb +45 -0
  22. data/lib/smith/tool/capture_configuration.rb +5 -2
  23. data/lib/smith/tool/declarations.rb +73 -0
  24. data/lib/smith/tool/execution_tracker.rb +13 -2
  25. data/lib/smith/tool.rb +8 -38
  26. data/lib/smith/version.rb +1 -1
  27. data/lib/smith/workflow/budget_pause.rb +8 -1
  28. data/lib/smith/workflow/child_run_execution.rb +2 -1
  29. data/lib/smith/workflow/dsl.rb +123 -0
  30. data/lib/smith/workflow/durability.rb +4 -3
  31. data/lib/smith/workflow/graph/rename_diagnostics.rb +69 -0
  32. data/lib/smith/workflow/graph/validator.rb +1 -0
  33. data/lib/smith/workflow/graph.rb +1 -0
  34. data/lib/smith/workflow/guarded_step_execution.rb +8 -8
  35. data/lib/smith/workflow/nested_execution.rb +3 -3
  36. data/lib/smith/workflow/persistence.rb +69 -1
  37. data/lib/smith/workflow/remembered_calls.rb +14 -2
  38. data/lib/smith/workflow/retry_execution.rb +24 -3
  39. data/lib/smith/workflow/retry_wait.rb +2 -0
  40. data/lib/smith/workflow/step_context.rb +2 -1
  41. data/lib/smith/workflow/wait_execution.rb +2 -1
  42. data/lib/smith/workflow.rb +11 -0
  43. metadata +5 -1
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ # Renames (renamed_state, renamed_transition) found wanting before a
7
+ # run restores through one: a rename leading to no name the workflow
8
+ # declares is an error, as a run persisted under its old name cannot
9
+ # restore; one whose old name the workflow still declares is a
10
+ # warning, as a run persisted under that name stays there.
11
+ # `O(R·(S + T))` for `R` renames.
12
+ class RenameDiagnostics
13
+ attr_reader :graph
14
+
15
+ def initialize(graph)
16
+ @graph = graph
17
+ end
18
+
19
+ def to_a
20
+ workflow = graph.workflow_class
21
+
22
+ [
23
+ *diagnostics_for(:state, workflow.renamed_states) do |name|
24
+ [workflow.resolve_state_name(name), workflow.current_state_name(name)]
25
+ end,
26
+ *diagnostics_for(:transition, workflow.renamed_transitions) do |name|
27
+ [workflow.transition_named(name), workflow.current_transition_name(name)]
28
+ end
29
+ ]
30
+ end
31
+
32
+ private
33
+
34
+ def diagnostics_for(kind, renames)
35
+ renames.filter_map do |old, new|
36
+ declared, current = yield(old)
37
+ if declared
38
+ shadowed(kind, old, new)
39
+ elsif current.nil?
40
+ leads_nowhere(kind, old, new)
41
+ end
42
+ end
43
+ end
44
+
45
+ def shadowed(kind, old, new)
46
+ Diagnostic.new(
47
+ severity: :warning,
48
+ code: :rename_shadowed,
49
+ kind => old,
50
+ message: "Renamed #{kind} #{old.inspect} is still declared, so a run persisted in it stays there " \
51
+ "rather than moving to #{new.inspect}.",
52
+ suggestion: "Drop the rename, or move such runs with persistence_schema_version and migrate_from."
53
+ )
54
+ end
55
+
56
+ def leads_nowhere(kind, old, new)
57
+ Diagnostic.new(
58
+ severity: :error,
59
+ code: :"renamed_to_unknown_#{kind}",
60
+ kind => old,
61
+ message: "Renamed #{kind} #{old.inspect} leads through #{new.inspect} to no #{kind} this workflow " \
62
+ "declares, so a run persisted in it cannot restore.",
63
+ suggestion: "Declare #{kind} #{new.inspect} or rename #{old.inspect} to a declared #{kind}."
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -29,6 +29,7 @@ module Smith
29
29
  def all_diagnostics
30
30
  [
31
31
  *StateDiagnostics.new(graph).to_a,
32
+ *RenameDiagnostics.new(graph).to_a,
32
33
  *TransitionDiagnostics.new(graph).to_a,
33
34
  *ReachabilityDiagnostics.new(graph, reachable_transition_names).to_a
34
35
  ]
@@ -88,6 +88,7 @@ require_relative "graph/identifier_projection"
88
88
  require_relative "graph/transition_contract"
89
89
  require_relative "graph/diagnostic"
90
90
  require_relative "graph/state_diagnostics"
91
+ require_relative "graph/rename_diagnostics"
91
92
  require_relative "graph/targets"
92
93
  require_relative "graph/execution_successors"
93
94
  require_relative "graph/reachability"
@@ -22,20 +22,20 @@ module Smith
22
22
  ensure
23
23
  # Tool evidence travels up: a step running inside another step
24
24
  # (a nested workflow's step) marks the enclosing step too, so the
25
- # enclosing step is never retried over tools that already ran.
26
- previous_tracker&.mark_started! if tracker.started?
25
+ # enclosing step is never retried over tools that already ran,
26
+ # unless they were all replay-safe.
27
+ previous_tracker&.mark_started!(replay_safe: !tracker.uncertain?) if tracker.started?
27
28
  Tool.current_tool_execution_tracker = previous_tracker
28
29
  end
29
30
  end
30
31
  end
31
32
 
32
- # A failure after a tool ran leaves its outcome uncertain, unless its
33
- # retry could not run the tool again: a terminal failure, or a tool
34
- # loop paused for budget, whose retry continues from its tools'
35
- # results.
33
+ # A failure after a tool that is not replay-safe ran leaves its outcome
34
+ # uncertain, unless its retry could not run the tool again: a terminal
35
+ # failure, or a tool loop this run's step paused for budget, whose
36
+ # retry continues from its tools' results (BudgetPause#own_pause?).
36
37
  def uncertain_after_tools?(tracker, error)
37
- tracker.started? && !terminal_retry_error?(error) &&
38
- !(error.is_a?(BudgetUnavailable) && error.continuation)
38
+ tracker.uncertain? && !terminal_retry_error?(error) && !own_pause?(error)
39
39
  end
40
40
 
41
41
  def run_tracked_guarded_step(transition)
@@ -16,9 +16,9 @@ module Smith
16
16
  end
17
17
 
18
18
  # A child that raises passes its error to the parent unchanged, after
19
- # its billable usage is rolled up. If a tool ran in the child, the
20
- # parent's step already carries that evidence and turns the failure
21
- # into ToolOutcomeUncertain (GuardedStepExecution).
19
+ # its billable usage is rolled up. If a tool that is not replay-safe
20
+ # ran in the child, the parent's step already carries that evidence
21
+ # and turns the failure into ToolOutcomeUncertain (GuardedStepExecution).
22
22
  def run_child_workflow(child)
23
23
  child.run!
24
24
  rescue StandardError
@@ -87,7 +87,7 @@ module Smith
87
87
  def restore_state(hash, allow_step_in_progress: false)
88
88
  validate_raw_step_in_progress!(hash) if
89
89
  self.class.idempotency_mode == :strict && !allow_step_in_progress
90
- migrated = migrate_if_needed(hash)
90
+ migrated = apply_renames(migrate_if_needed(hash))
91
91
  normalized = normalize_persisted_state(migrated)
92
92
  persistence_version = validated_persistence_version(normalized)
93
93
  restore_persisted_keys(normalized)
@@ -447,6 +447,74 @@ module Smith
447
447
  payload
448
448
  end
449
449
 
450
+ # A run persisted under names a later version renamed
451
+ # (renamed_state, renamed_transition) carries on under the new
452
+ # ones: its state, the transition it takes next, the transition its
453
+ # wait, pending retry and pending batch are in, and its last failed
454
+ # step's names, each kept the type it was persisted as. A name the
455
+ # class still declares stays as it is.
456
+ RENAMED_RECORDS = {
457
+ awaiting: { transition: %w[transition] },
458
+ pending_retry: { transition: %w[transition] },
459
+ pending_batch: { transition: %w[transition] },
460
+ last_failed_step: { transition: %w[transition], state: %w[from to] }
461
+ }.freeze
462
+ private_constant :RENAMED_RECORDS
463
+
464
+ def apply_renames(payload)
465
+ return payload unless renames?
466
+
467
+ renamed = payload.dup
468
+ renamed[:state] = renamed_state_name(payload[:state])
469
+ renamed[:next_transition_name] = renamed_transition_name(payload[:next_transition_name]) if
470
+ payload.key?(:next_transition_name)
471
+ RENAMED_RECORDS.each_key { |key| renamed[key] = renamed_record(payload[key], RENAMED_RECORDS[key]) }
472
+ renamed
473
+ end
474
+
475
+ def renames? = !(self.class.renamed_states.empty? && self.class.renamed_transitions.empty?)
476
+
477
+ def renamed_state_name(name)
478
+ renamed_value(name) do |text|
479
+ self.class.send(:follow_renames, text, self.class.renamed_states) do |candidate|
480
+ self.class.resolve_state_name(candidate)
481
+ end
482
+ end
483
+ end
484
+
485
+ def renamed_transition_name(name)
486
+ renamed_value(name) do |text|
487
+ self.class.send(:follow_renames, text, self.class.renamed_transitions) do |candidate|
488
+ self.class.transition_named(candidate)
489
+ end
490
+ end
491
+ end
492
+
493
+ # A persisted name renamed, kept the type it was persisted as.
494
+ def renamed_value(name)
495
+ return name unless name.is_a?(String) || name.is_a?(Symbol)
496
+
497
+ renamed = yield(name.to_s)
498
+ name.is_a?(Symbol) ? renamed.to_sym : renamed
499
+ end
500
+
501
+ # A persisted record's name fields renamed, each by its kind
502
+ # (:state or :transition), under String or Symbol keys alike.
503
+ def renamed_record(record, fields_by_kind)
504
+ return record unless record.is_a?(Hash)
505
+
506
+ renamed = record.dup
507
+ fields_by_kind.each do |kind, fields|
508
+ fields.each do |field|
509
+ key = [field, field.to_sym].find { |candidate| renamed.key?(candidate) }
510
+ next if key.nil?
511
+
512
+ renamed[key] = kind == :state ? renamed_state_name(renamed[key]) : renamed_transition_name(renamed[key])
513
+ end
514
+ end
515
+ renamed
516
+ end
517
+
450
518
  def normalize_persisted_state(hash)
451
519
  normalized = snapshot_value(hash).transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
452
520
  normalized[:outcome] = symbolize_value(normalized[:outcome]) if normalized[:outcome].is_a?(Hash)
@@ -53,12 +53,24 @@ module Smith
53
53
  messages = kept.fetch(:messages)
54
54
  messages.each { chat.add_message(_1) }
55
55
  ran = messages.filter_map { _1.tool_call_id if _1.role == :tool }
56
- Tool.current_tool_execution_tracker&.mark_started! if ran.any?
57
- charge_kept_calls(ran_calls(messages, ran))
56
+ calls = ran_calls(messages, ran)
57
+ mark_kept_tools(chat, ran, calls) if ran.any?
58
+ charge_kept_calls(calls)
58
59
  end
59
60
 
60
61
  def ran_calls(messages, ran) = messages.flat_map { _1.tool_calls&.values.to_a }.select { ran.include?(_1.id) }
61
62
 
63
+ # As the tools themselves would mark the step: replay-safe only when
64
+ # each kept result answers a call to a tool of the chat's that
65
+ # declares it.
66
+ def mark_kept_tools(chat, ran, calls)
67
+ tools = calls.to_h { [_1.id, chat.tools[_1.name.to_sym]] }
68
+ safe = ran.all? { replay_safe_tool?(tools[_1]) }
69
+ Tool.current_tool_execution_tracker&.mark_started!(replay_safe: safe)
70
+ end
71
+
72
+ def replay_safe_tool?(tool) = tool.is_a?(Tool) && tool.class.replay_safe?
73
+
62
74
  # Each kept call against the agent's tool-call allowance and the
63
75
  # workflow's `tool_calls` budget, as a call that runs is, so a replay
64
76
  # runs no more tools than the step may. A call past them (a budget
@@ -34,9 +34,7 @@ module Smith
34
34
  schedule = retry_schedule(config)
35
35
  begin
36
36
  attempt += 1
37
- @step_attempt = attempt
38
- @step_key_generation = key_generation
39
- attempt_scope = attempt_idempotency_scope(key_generation)
37
+ attempt_scope = enter_attempt(attempt, key_generation)
40
38
  run_attempt_under_keys(transition, attempt_scope)
41
39
  rescue StandardError => e
42
40
  Interruptions.pass!(e)
@@ -48,10 +46,33 @@ module Smith
48
46
  return wait_to_retry(transition, delay, attempt + 1, key_generation, e) if config[:durable]
49
47
 
50
48
  sleep(delay) if delay.positive?
49
+ drop_attempt_tool_results(e)
51
50
  retry
52
51
  end
53
52
  end
54
53
 
54
+ # The attempt under way, under `key_generation`; returns its
55
+ # idempotency scope. `@attempt_tool_results_from` (transient, like
56
+ # `@step_attempt`) is where its tool results start.
57
+ def enter_attempt(attempt, key_generation)
58
+ @step_attempt = attempt
59
+ @step_key_generation = key_generation
60
+ @attempt_tool_results_from = @tool_results_mutex.synchronize { @tool_results.length }
61
+ attempt_idempotency_scope(key_generation)
62
+ end
63
+
64
+ # Once its retry is certain (in process, or waiting), a failed attempt's
65
+ # tool results go: its tools were replay-safe, or it would not be
66
+ # retried, and the next attempt runs them again. A tool loop this
67
+ # run's step paused for budget keeps them, as its retry goes on from
68
+ # them.
69
+ def drop_attempt_tool_results(error)
70
+ from = @attempt_tool_results_from
71
+ return if from.nil? || own_pause?(error)
72
+
73
+ @tool_results_mutex.synchronize { @tool_results.slice!(from..) }
74
+ end
75
+
55
76
  def attempt_idempotency_scope(key_generation)
56
77
  step_scope = IdempotencyKey.__send__(:current_scope)
57
78
  return step_scope if step_scope.nil? || key_generation.zero?
@@ -24,6 +24,7 @@ module Smith
24
24
  # it waits to make. Like any wait it is not a step. The pending retry
25
25
  # is recorded before the wait, so an interrupt between the two leaves
26
26
  # a step that ended without a wait, whose pending retry is cleared.
27
+ # The run waits without the failed attempt's tool captures.
27
28
  def wait_to_retry(transition, seconds, attempt, key_generation, error)
28
29
  if @split_step_phase == :executing
29
30
  raise WorkflowError, "a durable retry cannot wait inside split-step execution"
@@ -38,6 +39,7 @@ module Smith
38
39
  @awaiting = WaitRecord.build_retry(transition, wait_token_for(transition, ["retry", attempt]), seconds,
39
40
  Time.now.utc)
40
41
  record_retry_trace(transition, :retrying, attempt:, error: error_name, deadline_at: @awaiting["deadline_at"])
42
+ drop_attempt_tool_results(error)
41
43
  WAIT_PENDING
42
44
  end
43
45
 
@@ -99,7 +99,8 @@ module Smith
99
99
  namespace = ArtifactIntegration.instance_method(:execution_namespace).bind_call(self)
100
100
  outer = IdempotencyKey.__send__(:current_scope)
101
101
  base = outer && outer.run == namespace ? outer : IdempotencyKey.__send__(:root_scope, namespace)
102
- IdempotencyKey.__send__(:install, base.within(:step, @step_count + 1, transition.name.to_s))
102
+ position = base.within(:step, @step_count + 1, self.class.transition_key_name(transition.name))
103
+ IdempotencyKey.__send__(:install, position)
103
104
  end
104
105
 
105
106
  def teardown_step_context
@@ -140,7 +140,8 @@ module Smith
140
140
 
141
141
  def wait_token_for(transition, scope)
142
142
  IdempotencyKey.__send__(:root_scope, ArtifactIntegration.instance_method(:execution_namespace).bind_call(self))
143
- .within(:step, @step_count + 1, transition.name.to_s).within(:wait).key(scope)
143
+ .within(:step, @step_count + 1, self.class.transition_key_name(transition.name))
144
+ .within(:wait).key(scope)
144
145
  end
145
146
 
146
147
  def check_wait_admissible!(transition, seconds)
@@ -465,6 +465,17 @@ module Smith
465
465
  ->(entry) { @tool_results_mutex.synchronize { @tool_results << entry } }
466
466
  end
467
467
 
468
+ # Takes out what an attempt the step set aside had captured
469
+ # (Agent::AttemptCaptures), by identity, so an equal entry of another
470
+ # attempt or branch stays. `O(N + K)`.
471
+ def drop_tool_results(entries)
472
+ return if entries.empty?
473
+
474
+ dropped = {}.compare_by_identity
475
+ entries.each { dropped[_1] = true }
476
+ @tool_results_mutex.synchronize { @tool_results.reject! { dropped.key?(_1) } }
477
+ end
478
+
468
479
  def initialize_tool_result_state
469
480
  @tool_results = []
470
481
  @tool_results_mutex = Mutex.new
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smith-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.6
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Ralak
@@ -161,6 +161,7 @@ files:
161
161
  - lib/generators/smith/install/templates/workflow_state.rb.tt
162
162
  - lib/smith.rb
163
163
  - lib/smith/agent.rb
164
+ - lib/smith/agent/attempt_captures.rb
164
165
  - lib/smith/agent/attempt_chat.rb
165
166
  - lib/smith/agent/chat_construction.rb
166
167
  - lib/smith/agent/completion.rb
@@ -348,11 +349,13 @@ files:
348
349
  - lib/smith/tool/call_budget.rb
349
350
  - lib/smith/tool/call_reservation.rb
350
351
  - lib/smith/tool/capability_builder.rb
352
+ - lib/smith/tool/capability_declaration.rb
351
353
  - lib/smith/tool/capture.rb
352
354
  - lib/smith/tool/capture_configuration.rb
353
355
  - lib/smith/tool/chat_execution_callbacks.rb
354
356
  - lib/smith/tool/chat_execution_context.rb
355
357
  - lib/smith/tool/compatibility.rb
358
+ - lib/smith/tool/declarations.rb
356
359
  - lib/smith/tool/execution_authority.rb
357
360
  - lib/smith/tool/execution_authorization.rb
358
361
  - lib/smith/tool/execution_batch.rb
@@ -485,6 +488,7 @@ files:
485
488
  - lib/smith/workflow/graph/reachability.rb
486
489
  - lib/smith/workflow/graph/reachability_diagnostics.rb
487
490
  - lib/smith/workflow/graph/reference.rb
491
+ - lib/smith/workflow/graph/rename_diagnostics.rb
488
492
  - lib/smith/workflow/graph/report.rb
489
493
  - lib/smith/workflow/graph/retry_policy_diagnostic.rb
490
494
  - lib/smith/workflow/graph/run_launcher_diagnostic.rb