smith-agents 0.8.0 → 0.10.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +230 -0
- data/docs/CONFIGURATION.md +5 -5
- data/lib/smith/agent/completion_usage_recording.rb +21 -3
- data/lib/smith/agent/invocation_preparation.rb +24 -0
- data/lib/smith/agent/lifecycle.rb +4 -2
- data/lib/smith/agent/provider_attempt.rb +10 -4
- data/lib/smith/agent/provider_call_timing.rb +74 -0
- data/lib/smith/agent/provider_completion.rb +27 -9
- data/lib/smith/agent/provider_failure_handling.rb +2 -2
- data/lib/smith/agent/usage_entry_recording.rb +27 -4
- data/lib/smith/agent/usage_traces.rb +49 -0
- data/lib/smith/agent/usage_tracking.rb +23 -20
- data/lib/smith/attribution.rb +115 -0
- data/lib/smith/event.rb +10 -2
- data/lib/smith/events/bus.rb +63 -5
- data/lib/smith/events/step_completed.rb +3 -0
- data/lib/smith/events/step_failed.rb +25 -0
- data/lib/smith/events/subscription.rb +8 -0
- data/lib/smith/tool/capture.rb +9 -1
- data/lib/smith/tool.rb +10 -1
- data/lib/smith/trace/logger.rb +1 -0
- data/lib/smith/trace/memory.rb +40 -4
- data/lib/smith/trace/open_telemetry.rb +47 -3
- data/lib/smith/trace.rb +24 -5
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/composite/effects.rb +64 -33
- data/lib/smith/workflow/deterministic_execution.rb +2 -1
- data/lib/smith/workflow/deterministic_step.rb +14 -1
- data/lib/smith/workflow/dsl.rb +25 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +3 -1
- data/lib/smith/workflow/event_integration.rb +48 -1
- data/lib/smith/workflow/execution.rb +8 -0
- data/lib/smith/workflow/fanout_execution.rb +1 -1
- data/lib/smith/workflow/guardrail_integration.rb +25 -0
- data/lib/smith/workflow/parallel_execution.rb +6 -0
- data/lib/smith/workflow/persistence.rb +33 -1
- data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +9 -0
- data/lib/smith/workflow/step_context.rb +46 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
- data/lib/smith/workflow/usage_entry.rb +32 -4
- data/lib/smith/workflow.rb +26 -1
- data/lib/smith.rb +7 -2
- metadata +5 -1
|
@@ -2,31 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "completion_usage_recording"
|
|
4
4
|
require_relative "usage_entry_recording"
|
|
5
|
+
require_relative "usage_traces"
|
|
5
6
|
|
|
6
7
|
module Smith
|
|
7
8
|
class Agent
|
|
8
9
|
module UsageTracking
|
|
9
10
|
include CompletionUsageRecording
|
|
10
11
|
include UsageEntryRecording
|
|
12
|
+
include UsageTraces
|
|
11
13
|
|
|
12
14
|
private
|
|
13
15
|
|
|
14
|
-
def account_failed_attempt(error, model_reference, agent_class)
|
|
16
|
+
def account_failed_attempt(error, model_reference, agent_class, attempt_id: nil)
|
|
15
17
|
return unless error.respond_to?(:input_tokens) && error.respond_to?(:output_tokens)
|
|
16
18
|
|
|
17
19
|
input = error.input_tokens
|
|
18
20
|
output = error.output_tokens
|
|
19
21
|
return unless input.is_a?(Integer) && output.is_a?(Integer)
|
|
20
22
|
|
|
21
|
-
record_failed_usage(agent_class, model_reference, input, output, :
|
|
23
|
+
record_failed_usage(agent_class, model_reference, input, output, attempt_id:)
|
|
22
24
|
end
|
|
23
25
|
|
|
24
|
-
def account_completed_prefix(agent_class, model_reference, messages)
|
|
26
|
+
def account_completed_prefix(agent_class, model_reference, messages, attempt_id: nil)
|
|
25
27
|
completion = Completion.from_messages(response: nil, messages: messages)
|
|
26
|
-
record_completion_usage(agent_class, completion, :partial_attempt, model_reference)
|
|
28
|
+
record_completion_usage(agent_class, completion, :partial_attempt, model_reference, attempt_id:)
|
|
27
29
|
end
|
|
28
30
|
|
|
29
|
-
def record_failed_usage(agent_class, model_reference, input_tokens, output_tokens,
|
|
31
|
+
def record_failed_usage(agent_class, model_reference, input_tokens, output_tokens, attempt_id: nil)
|
|
30
32
|
model_reference = coerce_model_reference(model_reference)
|
|
31
33
|
cost = Smith::Pricing.compute_cost(
|
|
32
34
|
model: model_reference.model_id,
|
|
@@ -44,10 +46,10 @@ module Smith
|
|
|
44
46
|
)
|
|
45
47
|
Thread.current[:smith_failed_agent_results] ||= []
|
|
46
48
|
Thread.current[:smith_failed_agent_results] << agent_result
|
|
47
|
-
record_usage(agent_class, agent_result,
|
|
49
|
+
record_usage(agent_class, agent_result, :failed_attempt, model_reference, attempt_id:)
|
|
48
50
|
end
|
|
49
51
|
|
|
50
|
-
def snapshot_and_finalize(agent_class, completion, model_reference)
|
|
52
|
+
def snapshot_and_finalize(agent_class, completion, model_reference, attempt_id: nil)
|
|
51
53
|
model_reference = coerce_model_reference(model_reference)
|
|
52
54
|
agent_result = Workflow::AgentResult.new(
|
|
53
55
|
content: completion.content,
|
|
@@ -59,8 +61,7 @@ module Smith
|
|
|
59
61
|
)
|
|
60
62
|
Thread.current[:smith_last_agent_result] = agent_result
|
|
61
63
|
emit_token_usage(agent_result)
|
|
62
|
-
|
|
63
|
-
record_completion_usage(agent_class, completion, :completed_attempt, model_reference)
|
|
64
|
+
account_completion!(agent_class, completion, model_reference, agent_result, attempt_id)
|
|
64
65
|
|
|
65
66
|
agent_result.content = run_after_completion(agent_class, agent_result.content, @context)
|
|
66
67
|
raise_blank_output!(agent_class, agent_result)
|
|
@@ -83,18 +84,20 @@ module Smith
|
|
|
83
84
|
false
|
|
84
85
|
end
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
87
|
+
# Records one usage entry per provider response and settles the
|
|
88
|
+
# invocation's cost from the recorded per-response sum: that sum is
|
|
89
|
+
# what tiered catalogs bill, so it becomes agent_result.cost (which
|
|
90
|
+
# budget settlement and result surfaces read) and the recorded entries,
|
|
91
|
+
# the budget ledger, and the :cost trace all agree. The trace emits
|
|
92
|
+
# only for fully metered, fully priced invocations, so a partial
|
|
93
|
+
# figure is never presented as the invocation cost; the partial sum
|
|
94
|
+
# still settles the budget because it is what was verifiably billed.
|
|
95
|
+
def account_completion!(agent_class, completion, model_reference, agent_result, attempt_id)
|
|
96
|
+
invocation_cost, fully_priced = record_completion_usage(
|
|
97
|
+
agent_class, completion, :completed_attempt, model_reference, attempt_id:
|
|
97
98
|
)
|
|
99
|
+
agent_result.cost = invocation_cost
|
|
100
|
+
emit_cost_trace(agent_result, invocation_cost) if fully_priced
|
|
98
101
|
end
|
|
99
102
|
|
|
100
103
|
def compute_agent_cost(agent_result)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
# Ambient execution attribution: an immutable, thread-local description of
|
|
5
|
+
# where execution currently is (run identity, transition, fan-out branch,
|
|
6
|
+
# optimizer round). Workflow execution scopes install it; observability
|
|
7
|
+
# consumers (traces, events, usage recording) read it, so emitted facts
|
|
8
|
+
# carry correlation without any consumer knowing about the workflow.
|
|
9
|
+
#
|
|
10
|
+
# Attribution values are opaque identifiers, never content: the trace
|
|
11
|
+
# content policy does not treat them as payload. `execution_key` defaults
|
|
12
|
+
# to the workflow's persistence key during persisted runs; hosts running
|
|
13
|
+
# non-persisted workflows can seed an outer scope explicitly:
|
|
14
|
+
#
|
|
15
|
+
# Smith::Attribution.with(execution_key: "host-run-42") { workflow.run! }
|
|
16
|
+
#
|
|
17
|
+
# Installation inside workflow internals is a plain assignment
|
|
18
|
+
# (Attribution.install) because restoration there is owned by the
|
|
19
|
+
# surrounding ThreadContextSnapshot, which tracks the attribution thread
|
|
20
|
+
# key alongside the other per-step thread state.
|
|
21
|
+
module Attribution
|
|
22
|
+
THREAD_KEY = :smith_attribution
|
|
23
|
+
|
|
24
|
+
Context = Data.define(:execution_key, :transition, :from, :to, :branch_key, :round, :workflow) do
|
|
25
|
+
# Overlay semantics: nil overrides are ignored so an inner scope can
|
|
26
|
+
# only add or replace attribution, never blank an outer value.
|
|
27
|
+
def merge(**overrides)
|
|
28
|
+
filtered = overrides.compact
|
|
29
|
+
return self if filtered.empty?
|
|
30
|
+
|
|
31
|
+
override(**filtered)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Replacement semantics: sets the given fields verbatim, nil included,
|
|
35
|
+
# so a scope that owns a field can reset it (a step whose transition
|
|
36
|
+
# declares no `from` must not inherit an enclosing step's `from`).
|
|
37
|
+
# Built on to_h, not Data#with, which requires Ruby 3.3 while the gem
|
|
38
|
+
# supports 3.2.
|
|
39
|
+
def override(**fields)
|
|
40
|
+
self.class.new(**to_h, **fields)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_fields
|
|
44
|
+
{
|
|
45
|
+
execution_key: execution_key,
|
|
46
|
+
transition: transition,
|
|
47
|
+
from: from,
|
|
48
|
+
to: to,
|
|
49
|
+
branch_key: branch_key,
|
|
50
|
+
round: round,
|
|
51
|
+
workflow: workflow
|
|
52
|
+
}.compact
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
EMPTY = Context.new(
|
|
57
|
+
execution_key: nil, transition: nil, from: nil, to: nil, branch_key: nil, round: nil, workflow: nil
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
def current
|
|
62
|
+
Thread.current[THREAD_KEY]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ambient
|
|
66
|
+
current || EMPTY
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The compacted attribution fields, for merging into emitted payloads.
|
|
70
|
+
def current_fields
|
|
71
|
+
context = current
|
|
72
|
+
context ? context.to_fields : {}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Plain installation with no restoration: callers own restoration,
|
|
76
|
+
# either through ThreadContextSnapshot (workflow internals) or an
|
|
77
|
+
# enclosing #with / #carrying block.
|
|
78
|
+
def install(context)
|
|
79
|
+
Thread.current[THREAD_KEY] = context
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Host-facing scope: overlays the ambient attribution for the block.
|
|
83
|
+
def with(**overrides, &block)
|
|
84
|
+
raise ArgumentError, "block required" unless block
|
|
85
|
+
|
|
86
|
+
swap(ambient.merge(**overrides), &block)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Cross-thread propagation: installs a context captured on another
|
|
90
|
+
# thread (or nil, clearing any stale value on a pooled thread) for the
|
|
91
|
+
# duration of the block.
|
|
92
|
+
def carrying(context, &block)
|
|
93
|
+
raise ArgumentError, "block required" unless block
|
|
94
|
+
|
|
95
|
+
swap(context, &block)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# Matches the gem's scoped thread-state idiom: install and restore run
|
|
101
|
+
# interrupt-deferred, the block itself runs interruptible.
|
|
102
|
+
def swap(context, &block)
|
|
103
|
+
previous = Thread.current[THREAD_KEY]
|
|
104
|
+
Thread.handle_interrupt(Object => :never) do
|
|
105
|
+
Thread.current[THREAD_KEY] = context
|
|
106
|
+
begin
|
|
107
|
+
Thread.handle_interrupt(Object => :immediate, &block)
|
|
108
|
+
ensure
|
|
109
|
+
Thread.current[THREAD_KEY] = previous
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/smith/event.rb
CHANGED
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
require "dry-struct"
|
|
4
4
|
require "securerandom"
|
|
5
5
|
|
|
6
|
+
require_relative "attribution"
|
|
7
|
+
|
|
6
8
|
module Smith
|
|
7
9
|
class Event < Dry::Struct
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
# Both ids default to the ambient Smith::Attribution execution key, so
|
|
11
|
+
# events emitted under one persisted run (or one host-seeded
|
|
12
|
+
# Attribution scope) share one identity a host can group by. Outside
|
|
13
|
+
# any scope, each event falls back to its own random UUID, so
|
|
14
|
+
# non-persisted, unseeded runs have no shared event identity. Callers
|
|
15
|
+
# may always pass explicit values.
|
|
16
|
+
attribute(:execution_id, Types::String.default { Smith::Attribution.ambient.execution_key || SecureRandom.uuid })
|
|
17
|
+
attribute(:trace_id, Types::String.default { Smith::Attribution.ambient.execution_key || SecureRandom.uuid })
|
|
10
18
|
end
|
|
11
19
|
end
|
data/lib/smith/events/bus.rb
CHANGED
|
@@ -18,19 +18,43 @@ module Smith
|
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
REGISTRY_MUTEX = Mutex.new
|
|
22
|
+
private_constant :REGISTRY_MUTEX
|
|
23
|
+
|
|
21
24
|
class << self
|
|
25
|
+
# Registration-ordered snapshot of the live subscriptions. Cancelled
|
|
26
|
+
# subscriptions are detached from the registry, so this reflects only
|
|
27
|
+
# what will actually receive events.
|
|
22
28
|
def subscriptions
|
|
23
|
-
|
|
29
|
+
snapshot = REGISTRY_MUTEX.synchronize { registry.values.flatten }
|
|
30
|
+
snapshot.sort_by!(&:sequence_number)
|
|
24
31
|
end
|
|
25
32
|
|
|
26
33
|
def on(event_class, **opts, &block)
|
|
27
34
|
sub = Subscription.new(event_class, handler: block, predicate: opts[:if])
|
|
28
|
-
|
|
35
|
+
REGISTRY_MUTEX.synchronize do
|
|
36
|
+
@sequence = (@sequence || 0) + 1
|
|
37
|
+
sub.sequence_number = @sequence
|
|
38
|
+
(registry[event_class] ||= []) << sub
|
|
39
|
+
end
|
|
29
40
|
sub
|
|
30
41
|
end
|
|
31
42
|
|
|
43
|
+
# Removes a subscription from the registry. Called by
|
|
44
|
+
# Subscription#cancel; safe to call more than once.
|
|
45
|
+
def detach(subscription)
|
|
46
|
+
REGISTRY_MUTEX.synchronize do
|
|
47
|
+
bucket = registry[subscription.event_class]
|
|
48
|
+
next unless bucket
|
|
49
|
+
|
|
50
|
+
bucket.delete(subscription)
|
|
51
|
+
registry.delete(subscription.event_class) if bucket.empty?
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
32
56
|
def emit(event)
|
|
33
|
-
|
|
57
|
+
matching_subscriptions(event).each { |sub| dispatch_to(sub, event) }
|
|
34
58
|
end
|
|
35
59
|
|
|
36
60
|
def within
|
|
@@ -41,14 +65,48 @@ module Smith
|
|
|
41
65
|
end
|
|
42
66
|
|
|
43
67
|
def reset!
|
|
44
|
-
|
|
68
|
+
REGISTRY_MUTEX.synchronize do
|
|
69
|
+
@registry = {}
|
|
70
|
+
@sequence = 0
|
|
71
|
+
end
|
|
45
72
|
end
|
|
46
73
|
|
|
47
74
|
private
|
|
48
75
|
|
|
76
|
+
def registry
|
|
77
|
+
@registry ||= {}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Subscriptions live in per-class buckets so one emit touches only the
|
|
81
|
+
# buckets for the event's ancestors instead of scanning every
|
|
82
|
+
# subscription; `is_a?` dispatch semantics are preserved exactly
|
|
83
|
+
# because a subscription matches iff its registered class or module is
|
|
84
|
+
# among the event class's ancestors. The merged candidates are ordered
|
|
85
|
+
# by registration sequence, keeping subscription-order dispatch.
|
|
86
|
+
# Handlers run outside the registry lock so a handler may subscribe or
|
|
87
|
+
# cancel without deadlocking.
|
|
88
|
+
def matching_subscriptions(event)
|
|
89
|
+
# singleton_class.ancestors, not class.ancestors: it additionally
|
|
90
|
+
# covers modules mixed into the event instance via extend, which
|
|
91
|
+
# `is_a?` matched before the bucketed registry existed. Immediates
|
|
92
|
+
# (Integer, Symbol, Float) have no singleton class; they also cannot
|
|
93
|
+
# be extended, so their class ancestors are the complete `is_a?` set.
|
|
94
|
+
ancestors = begin
|
|
95
|
+
event.singleton_class.ancestors
|
|
96
|
+
rescue TypeError
|
|
97
|
+
event.class.ancestors
|
|
98
|
+
end
|
|
99
|
+
candidates = REGISTRY_MUTEX.synchronize do
|
|
100
|
+
ancestors.each_with_object([]) do |ancestor, found|
|
|
101
|
+
bucket = registry[ancestor]
|
|
102
|
+
found.concat(bucket) if bucket
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
candidates.sort_by!(&:sequence_number)
|
|
106
|
+
end
|
|
107
|
+
|
|
49
108
|
def dispatch_to(sub, event)
|
|
50
109
|
return if sub.cancelled?
|
|
51
|
-
return unless event.is_a?(sub.event_class)
|
|
52
110
|
return if sub.predicate && !sub.predicate.call(event)
|
|
53
111
|
|
|
54
112
|
sub.handler.call(event)
|
|
@@ -6,6 +6,9 @@ module Smith
|
|
|
6
6
|
attribute :transition, Types::Strict::Symbol
|
|
7
7
|
attribute :from, Types::Strict::Symbol.optional
|
|
8
8
|
attribute :to, Types::Strict::Symbol
|
|
9
|
+
# The emitting workflow's class name; distinguishes nested-child steps
|
|
10
|
+
# from parent steps under the shared root execution identity.
|
|
11
|
+
attribute :workflow, Types::Strict::String.optional.default(nil)
|
|
9
12
|
end
|
|
10
13
|
end
|
|
11
14
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Events
|
|
5
|
+
# Emitted from both step-failure paths (step-body failure and an
|
|
6
|
+
# unresolved transition routed to :fail), closing the success-only
|
|
7
|
+
# observation gap for failures that reach step handling. Terminal errors
|
|
8
|
+
# raised outside it (an unresolved transition with no :fail transition,
|
|
9
|
+
# transition-budget exhaustion, origin validation) still re-raise
|
|
10
|
+
# without a StepFailed. Carries bounded classification only, never raw
|
|
11
|
+
# error messages; `error_family` uses FailureRecord's taxonomy
|
|
12
|
+
# ("agent_error", "deadline_exceeded", ..., "other") and `retryable` is
|
|
13
|
+
# nil when the error does not declare retryability.
|
|
14
|
+
class StepFailed < Smith::Event
|
|
15
|
+
attribute :transition, Types::Strict::Symbol
|
|
16
|
+
attribute :from, Types::Strict::Symbol.optional
|
|
17
|
+
attribute :to, Types::Strict::Symbol.optional
|
|
18
|
+
attribute :error_class, Types::Strict::String
|
|
19
|
+
attribute :error_family, Types::Strict::String
|
|
20
|
+
attribute :retryable, Types::Strict::Bool.optional
|
|
21
|
+
# See StepCompleted#workflow.
|
|
22
|
+
attribute :workflow, Types::Strict::String.optional.default(nil)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -4,6 +4,9 @@ module Smith
|
|
|
4
4
|
module Events
|
|
5
5
|
class Subscription
|
|
6
6
|
attr_reader :event_class, :handler, :predicate
|
|
7
|
+
# Internal registration order, assigned by Events.on; dispatch and the
|
|
8
|
+
# subscriptions snapshot sort by it.
|
|
9
|
+
attr_accessor :sequence_number
|
|
7
10
|
|
|
8
11
|
def initialize(event_class, handler:, predicate: nil)
|
|
9
12
|
@event_class = event_class
|
|
@@ -12,8 +15,13 @@ module Smith
|
|
|
12
15
|
@cancelled = false
|
|
13
16
|
end
|
|
14
17
|
|
|
18
|
+
# Cancelling flags the subscription and detaches it from the registry
|
|
19
|
+
# (so it cannot leak). The flag check against an in-flight emit
|
|
20
|
+
# snapshot is best-effort: a dispatch already past the check may still
|
|
21
|
+
# deliver once after cancel returns.
|
|
15
22
|
def cancel
|
|
16
23
|
@cancelled = true
|
|
24
|
+
Events.detach(self)
|
|
17
25
|
end
|
|
18
26
|
|
|
19
27
|
def cancelled?
|
data/lib/smith/tool/capture.rb
CHANGED
|
@@ -54,8 +54,16 @@ module Smith
|
|
|
54
54
|
capture_failure(e, strict, reason: :capture_block_failed)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# tool_call_id is additive only when present (a provider-batch
|
|
58
|
+
# invocation is ambient), so captures from direct invocations and
|
|
59
|
+
# pre-existing persisted payloads keep their exact two-key shape.
|
|
57
60
|
def append_capture(collector, captured, strict)
|
|
58
|
-
|
|
61
|
+
return unless strict || captured
|
|
62
|
+
|
|
63
|
+
entry = { tool: name.to_s, captured: captured }
|
|
64
|
+
tool_call_id = self.class.current_invocation&.tool_call_id
|
|
65
|
+
entry[:tool_call_id] = tool_call_id if tool_call_id
|
|
66
|
+
collector.call(entry)
|
|
59
67
|
rescue StandardError => e
|
|
60
68
|
capture_failure(e, strict, reason: :collector_failed)
|
|
61
69
|
end
|
data/lib/smith/tool.rb
CHANGED
|
@@ -165,9 +165,18 @@ module Smith
|
|
|
165
165
|
end
|
|
166
166
|
|
|
167
167
|
def emit_tool_trace(kwargs, result, duration)
|
|
168
|
+
# tool_call_id is the provider's correlation id for this invocation
|
|
169
|
+
# (present only when the call came from a provider batch); it lets a
|
|
170
|
+
# host join this trace to its own per-invocation records.
|
|
168
171
|
Smith::Trace.record(
|
|
169
172
|
type: :tool_call,
|
|
170
|
-
data: {
|
|
173
|
+
data: {
|
|
174
|
+
tool: name,
|
|
175
|
+
args: kwargs,
|
|
176
|
+
result: result,
|
|
177
|
+
duration: duration,
|
|
178
|
+
tool_call_id: self.class.current_invocation&.tool_call_id
|
|
179
|
+
}.compact,
|
|
171
180
|
sensitivity: self.class.capabilities&.dig(:sensitivity) || :low
|
|
172
181
|
)
|
|
173
182
|
end
|
data/lib/smith/trace/logger.rb
CHANGED
data/lib/smith/trace/memory.rb
CHANGED
|
@@ -7,26 +7,62 @@ module Smith
|
|
|
7
7
|
transition: :trace_transitions,
|
|
8
8
|
tool_call: :trace_tool_calls,
|
|
9
9
|
token_usage: :trace_token_usage,
|
|
10
|
+
provider_call: :trace_provider_calls,
|
|
10
11
|
cost: :trace_cost,
|
|
11
12
|
normalizer_decision: :trace_normalizer
|
|
12
13
|
}.freeze
|
|
13
14
|
|
|
14
15
|
CONTENT_KEYS = %i[content prompt response args result].freeze
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
# Generous enough that test and development runs never hit it; a bound
|
|
18
|
+
# exists at all so a long-lived process with parallel branches cannot
|
|
19
|
+
# grow this adapter without limit.
|
|
20
|
+
DEFAULT_LIMIT = 10_000
|
|
17
21
|
|
|
18
|
-
|
|
22
|
+
attr_reader :traces, :limit
|
|
23
|
+
|
|
24
|
+
def initialize(limit: DEFAULT_LIMIT)
|
|
25
|
+
unless limit.is_a?(Integer) && limit.positive?
|
|
26
|
+
raise ArgumentError, "Smith::Trace::Memory limit must be a positive integer, got #{limit.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@limit = limit
|
|
19
30
|
@traces = []
|
|
31
|
+
@dropped_count = 0
|
|
32
|
+
@mutex = Mutex.new
|
|
20
33
|
end
|
|
21
34
|
|
|
22
35
|
def record(type:, data:)
|
|
23
36
|
return unless type_enabled?(type)
|
|
24
37
|
|
|
25
|
-
|
|
38
|
+
entry = { type: type, data: filter_content(data) }
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
if @traces.length >= @limit
|
|
41
|
+
@dropped_count += 1
|
|
42
|
+
else
|
|
43
|
+
@traces << entry
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Entries rejected because the adapter was full. Zero in any healthy
|
|
49
|
+
# test run; a growing value means the limit needs raising or the
|
|
50
|
+
# process needs a clear!.
|
|
51
|
+
def dropped_count
|
|
52
|
+
@mutex.synchronize { @dropped_count }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A consistent copy for readers that may race concurrent recording;
|
|
56
|
+
# #traces stays the live array for compatibility.
|
|
57
|
+
def snapshot
|
|
58
|
+
@mutex.synchronize { @traces.dup }
|
|
26
59
|
end
|
|
27
60
|
|
|
28
61
|
def clear!
|
|
29
|
-
@
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@traces = []
|
|
64
|
+
@dropped_count = 0
|
|
65
|
+
end
|
|
30
66
|
end
|
|
31
67
|
|
|
32
68
|
private
|
|
@@ -7,7 +7,9 @@ module Smith
|
|
|
7
7
|
transition: :trace_transitions,
|
|
8
8
|
tool_call: :trace_tool_calls,
|
|
9
9
|
token_usage: :trace_token_usage,
|
|
10
|
-
|
|
10
|
+
provider_call: :trace_provider_calls,
|
|
11
|
+
cost: :trace_cost,
|
|
12
|
+
normalizer_decision: :trace_normalizer
|
|
11
13
|
}.freeze
|
|
12
14
|
|
|
13
15
|
CONTENT_KEYS = %i[content prompt response args result].freeze
|
|
@@ -23,18 +25,60 @@ module Smith
|
|
|
23
25
|
)
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
# Smith trace events describe operations that already finished, so the
|
|
29
|
+
# span is created retroactively: when the event carries a duration
|
|
30
|
+
# (:tool_call seconds, :provider_call milliseconds) the span's start is
|
|
31
|
+
# backdated by it and the span duration is real; otherwise the span is
|
|
32
|
+
# an instant. Uses only the documented opentelemetry-api surface
|
|
33
|
+
# (Tracer#start_span with start_timestamp, Span#finish with
|
|
34
|
+
# end_timestamp) so any SDK the host installs applies.
|
|
26
35
|
def record(type:, data:)
|
|
27
36
|
return unless @tracer
|
|
28
37
|
return unless type_enabled?(type)
|
|
29
38
|
|
|
30
39
|
filtered = filter_content(data)
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
finished_at = Time.now
|
|
41
|
+
span = @tracer.start_span("smith.#{type}", start_timestamp: span_start(filtered, finished_at))
|
|
42
|
+
begin
|
|
43
|
+
apply_attributes(span, filtered)
|
|
44
|
+
ensure
|
|
45
|
+
span.finish(end_timestamp: finished_at)
|
|
33
46
|
end
|
|
34
47
|
end
|
|
35
48
|
|
|
36
49
|
private
|
|
37
50
|
|
|
51
|
+
def span_start(data, finished_at)
|
|
52
|
+
seconds = duration_seconds(data)
|
|
53
|
+
seconds ? finished_at - seconds : finished_at
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def duration_seconds(data)
|
|
57
|
+
return data[:duration].to_f if data[:duration].is_a?(Numeric)
|
|
58
|
+
return data[:duration_ms] / 1000.0 if data[:duration_ms].is_a?(Numeric)
|
|
59
|
+
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def apply_attributes(span, data)
|
|
64
|
+
data.each do |key, value|
|
|
65
|
+
coerced = attribute_value(value)
|
|
66
|
+
span.set_attribute("smith.#{key}", coerced) unless coerced.nil?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# OpenTelemetry attributes accept strings, integers, floats, and
|
|
71
|
+
# booleans; numeric values keep their type instead of arriving as
|
|
72
|
+
# strings, everything else (symbols included) becomes a string, nil
|
|
73
|
+
# drops.
|
|
74
|
+
def attribute_value(value)
|
|
75
|
+
case value
|
|
76
|
+
when String, Integer, Float, true, false then value
|
|
77
|
+
when nil then nil
|
|
78
|
+
else value.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
38
82
|
def type_enabled?(type)
|
|
39
83
|
config_key = CONFIG_MAP[type]
|
|
40
84
|
return true unless config_key
|
data/lib/smith/trace.rb
CHANGED
|
@@ -1,34 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "attribution"
|
|
4
|
+
|
|
3
5
|
module Smith
|
|
4
6
|
module Trace
|
|
5
7
|
SENSITIVITY_CONTENT_KEYS = %i[args result].freeze
|
|
8
|
+
ADAPTER_MUTEX = Mutex.new
|
|
9
|
+
private_constant :ADAPTER_MUTEX
|
|
6
10
|
|
|
7
11
|
def self.record(type:, data:, sensitivity: :low)
|
|
8
12
|
adapter = resolve_adapter
|
|
9
13
|
return unless adapter
|
|
10
14
|
|
|
11
|
-
filtered = apply_content_policy(data, sensitivity)
|
|
15
|
+
filtered = apply_content_policy(attributed(data), sensitivity)
|
|
12
16
|
filtered = filter_fields(type, filtered)
|
|
13
17
|
adapter.record(type: type, data: filtered)
|
|
14
18
|
rescue StandardError => e
|
|
15
19
|
Smith.config.logger&.error("Smith::Trace adapter error: #{e.message}")
|
|
16
20
|
end
|
|
17
21
|
|
|
22
|
+
# Ambient attribution keys are identifiers, not content: they merge in
|
|
23
|
+
# under the caller's own keys (the caller wins on conflict) and then pass
|
|
24
|
+
# through the same content policy and field allowlist as everything else,
|
|
25
|
+
# so a host's configured trace_fields contract keeps holding.
|
|
26
|
+
def self.attributed(data)
|
|
27
|
+
return data unless Smith.config.trace_attribution
|
|
28
|
+
|
|
29
|
+
fields = Attribution.current_fields
|
|
30
|
+
return data if fields.empty?
|
|
31
|
+
|
|
32
|
+
fields.merge(data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Class-configured adapters memoize one instance under a mutex so
|
|
36
|
+
# concurrent first records (every fan-out branch emits) share a single
|
|
37
|
+
# adapter instead of racing separate instances and losing entries.
|
|
18
38
|
def self.resolve_adapter
|
|
19
39
|
configured = Smith.config.trace_adapter
|
|
20
40
|
return nil unless configured
|
|
41
|
+
return configured unless configured.is_a?(Class)
|
|
21
42
|
|
|
22
|
-
|
|
43
|
+
ADAPTER_MUTEX.synchronize do
|
|
23
44
|
@adapter_instances ||= {}
|
|
24
45
|
@adapter_instances[configured] ||= configured.new
|
|
25
|
-
else
|
|
26
|
-
configured
|
|
27
46
|
end
|
|
28
47
|
end
|
|
29
48
|
|
|
30
49
|
def self.reset!
|
|
31
|
-
@adapter_instances = nil
|
|
50
|
+
ADAPTER_MUTEX.synchronize { @adapter_instances = nil }
|
|
32
51
|
end
|
|
33
52
|
|
|
34
53
|
def self.apply_content_policy(data, sensitivity)
|