activeagent 1.2.0 → 1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +147 -1
- data/README.md +34 -0
- data/lib/active_agent/base.rb +11 -2
- data/lib/active_agent/concerns/delegation.rb +385 -0
- data/lib/active_agent/delegation/backend.rb +109 -0
- data/lib/active_agent/delegation/budget.rb +138 -0
- data/lib/active_agent/delegation/contract.rb +117 -0
- data/lib/active_agent/delegation/definition.rb +95 -0
- data/lib/active_agent/delegation/ledger.rb +56 -0
- data/lib/active_agent/delegation/pricing.rb +106 -0
- data/lib/active_agent/delegation/runner.rb +283 -0
- data/lib/active_agent/delegation/schema.rb +220 -0
- data/lib/active_agent/providers/_base_provider.rb +97 -1
- data/lib/active_agent/providers/open_ai/chat_provider.rb +21 -2
- data/lib/active_agent/telemetry/instrumentation.rb +34 -1
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +1 -0
- metadata +10 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Delegation
|
|
5
|
+
# The provider and options a delegated call runs against.
|
|
6
|
+
#
|
|
7
|
+
# A sub-agent's contract — what it accepts, what it returns — is separate
|
|
8
|
+
# from what actually serves it. Backend is that seam: the same
|
|
9
|
+
# +SummarizerAgent+ can run on a cheap local model inside one parent and on
|
|
10
|
+
# a frontier model inside another, and neither parent's code changes when
|
|
11
|
+
# you move it.
|
|
12
|
+
#
|
|
13
|
+
# @example Swap the model, keep the provider
|
|
14
|
+
# delegate_to SummarizerAgent, backend: { model: "gpt-4o-mini", temperature: 0 }
|
|
15
|
+
#
|
|
16
|
+
# @example Swap the provider entirely
|
|
17
|
+
# delegate_to SummarizerAgent, backend: :ollama
|
|
18
|
+
#
|
|
19
|
+
# @example Swap both
|
|
20
|
+
# delegate_to SummarizerAgent, backend: { provider: :anthropic, model: "claude-haiku-4-5" }
|
|
21
|
+
class Backend
|
|
22
|
+
# @return [Symbol, nil] provider reference (+:openai+, +:anthropic+, ...)
|
|
23
|
+
attr_reader :provider
|
|
24
|
+
# @return [Hash] prompt options applied at the call site
|
|
25
|
+
attr_reader :options
|
|
26
|
+
|
|
27
|
+
# @param spec [Backend, Symbol, String, Hash, nil]
|
|
28
|
+
# @return [Backend]
|
|
29
|
+
def self.build(spec = nil)
|
|
30
|
+
case spec
|
|
31
|
+
when Backend then spec
|
|
32
|
+
when nil then new
|
|
33
|
+
when Symbol, String then new(provider: spec)
|
|
34
|
+
when Hash then new(**spec.symbolize_keys)
|
|
35
|
+
else
|
|
36
|
+
raise ArgumentError, "Delegation backend must be a Symbol, Hash or #{name}, got #{spec.inspect}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param provider [Symbol, String, nil]
|
|
41
|
+
# @param options [Hash] prompt options (model, temperature, ...)
|
|
42
|
+
def initialize(provider: nil, **options)
|
|
43
|
+
@provider = provider&.to_sym
|
|
44
|
+
@options = options
|
|
45
|
+
@mutex = Mutex.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Boolean] whether this backend changes anything
|
|
49
|
+
def overrides?
|
|
50
|
+
provider.present? || options.any?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Resolves the class a delegated call should instantiate.
|
|
54
|
+
#
|
|
55
|
+
# Swapping providers means rebuilding provider configuration (host, keys,
|
|
56
|
+
# service), not just merging a hash — so the swap goes through a cached
|
|
57
|
+
# subclass configured by +generate_with+, the same code path a
|
|
58
|
+
# hand-written agent takes. The subclass reports its parent's name so
|
|
59
|
+
# template lookup keeps resolving to the original agent's views.
|
|
60
|
+
#
|
|
61
|
+
# @param agent_class [Class] the declared sub-agent class
|
|
62
|
+
# @return [Class]
|
|
63
|
+
def agent_class_for(agent_class)
|
|
64
|
+
return agent_class if provider.blank?
|
|
65
|
+
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
@agent_classes ||= {}
|
|
68
|
+
@agent_classes[agent_class] ||= build_agent_class(agent_class)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Applies call-site options to a prepared agent instance.
|
|
73
|
+
#
|
|
74
|
+
# Applied after the action has run so the delegation site wins over the
|
|
75
|
+
# sub-agent's own +prompt+ options — a call-site override is runtime
|
|
76
|
+
# configuration, which outranks class configuration everywhere else in
|
|
77
|
+
# ActiveAgent.
|
|
78
|
+
#
|
|
79
|
+
# @param agent [ActiveAgent::Base]
|
|
80
|
+
# @return [ActiveAgent::Base]
|
|
81
|
+
def apply(agent)
|
|
82
|
+
agent.prompt_options.merge!(options) if options.any?
|
|
83
|
+
agent
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Hash]
|
|
87
|
+
def to_h
|
|
88
|
+
{ provider: provider }.compact.merge(options)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# @param agent_class [Class]
|
|
94
|
+
# @return [Class]
|
|
95
|
+
def build_agent_class(agent_class)
|
|
96
|
+
backend_provider = provider
|
|
97
|
+
inherited_name = agent_class.name
|
|
98
|
+
|
|
99
|
+
Class.new(agent_class) do
|
|
100
|
+
# Keep the parent's identity so `agent_name`, and therefore view
|
|
101
|
+
# lookup, resolves to the original agent's templates.
|
|
102
|
+
define_singleton_method(:name) { inherited_name }
|
|
103
|
+
|
|
104
|
+
generate_with backend_provider
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Delegation
|
|
5
|
+
# Cost and latency limits for delegated work.
|
|
6
|
+
#
|
|
7
|
+
# A sub-agent is a loop inside a loop: the parent model decides how often
|
|
8
|
+
# to call it, and each call spends tokens and wall-clock time nobody
|
|
9
|
+
# explicitly authorized. A budget puts a ceiling on that — per delegation
|
|
10
|
+
# and across the agent as a whole — so a runaway hand-off degrades into a
|
|
11
|
+
# bounded answer instead of an unbounded bill.
|
|
12
|
+
#
|
|
13
|
+
# Every limit is optional; an empty budget imposes nothing.
|
|
14
|
+
#
|
|
15
|
+
# @example Per delegation
|
|
16
|
+
# delegate_to SummarizerAgent, budget: { max_calls: 3, max_tokens: 8_000, timeout: 20 }
|
|
17
|
+
#
|
|
18
|
+
# @example Across every delegation this agent makes
|
|
19
|
+
# delegation_budget max_calls: 10, max_duration: 60, max_cost: 0.25,
|
|
20
|
+
# rates: { input: 0.15, output: 0.60 }
|
|
21
|
+
class Budget
|
|
22
|
+
# What to do when a limit is reached.
|
|
23
|
+
#
|
|
24
|
+
# +:stop+ — return a structured "budget exhausted" result to the calling
|
|
25
|
+
# model so it can finish with what it already has (default).
|
|
26
|
+
# +:raise+ — raise {BudgetExceededError} and abort the generation.
|
|
27
|
+
POLICIES = %i[stop raise].freeze
|
|
28
|
+
|
|
29
|
+
LIMITS = %i[max_calls max_tokens max_cost max_duration].freeze
|
|
30
|
+
KEYS = (LIMITS + %i[timeout rates on_exceeded]).freeze
|
|
31
|
+
|
|
32
|
+
# A limit that has been reached.
|
|
33
|
+
Violation = Struct.new(:limit, :allowed, :used, keyword_init: true) do
|
|
34
|
+
# @return [String] wording aimed at the calling model, not at a developer
|
|
35
|
+
def message
|
|
36
|
+
"Delegation budget exhausted (#{limit}: #{format_number(used)} of #{format_number(allowed)} used). " \
|
|
37
|
+
"Do not retry this tool; answer with the information you already have."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def format_number(value)
|
|
43
|
+
value.is_a?(Float) ? value.round(6) : value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Integer, nil] maximum number of delegated calls
|
|
48
|
+
attr_reader :max_calls
|
|
49
|
+
# @return [Integer, nil] maximum cumulative tokens across delegated calls
|
|
50
|
+
attr_reader :max_tokens
|
|
51
|
+
# @return [Float, nil] maximum cumulative spend in USD
|
|
52
|
+
attr_reader :max_cost
|
|
53
|
+
# @return [Float, nil] maximum cumulative wall-clock seconds
|
|
54
|
+
attr_reader :max_duration
|
|
55
|
+
# @return [Float, nil] per-call wall-clock timeout in seconds
|
|
56
|
+
attr_reader :timeout
|
|
57
|
+
# @return [Hash, nil] inline token rates in USD per 1M tokens
|
|
58
|
+
attr_reader :rates
|
|
59
|
+
# @return [Symbol, nil] :stop or :raise
|
|
60
|
+
attr_reader :on_exceeded
|
|
61
|
+
|
|
62
|
+
# Coerces a budget spec into a Budget.
|
|
63
|
+
#
|
|
64
|
+
# @param spec [Budget, Hash, nil]
|
|
65
|
+
# @return [Budget]
|
|
66
|
+
# @raise [ArgumentError] on unknown keys or an invalid policy
|
|
67
|
+
def self.build(spec = nil)
|
|
68
|
+
case spec
|
|
69
|
+
when Budget then spec
|
|
70
|
+
when nil then new
|
|
71
|
+
when Hash then new(**spec.symbolize_keys)
|
|
72
|
+
else
|
|
73
|
+
raise ArgumentError, "Delegation budget must be a Hash or #{name}, got #{spec.inspect}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def initialize(**options)
|
|
78
|
+
unknown = options.keys - KEYS
|
|
79
|
+
raise ArgumentError, "Unknown delegation budget keys: #{unknown.join(", ")}. Valid keys: #{KEYS.join(", ")}" if unknown.any?
|
|
80
|
+
|
|
81
|
+
@max_calls = options[:max_calls]
|
|
82
|
+
@max_tokens = options[:max_tokens]
|
|
83
|
+
@max_cost = options[:max_cost]
|
|
84
|
+
@max_duration = options[:max_duration]
|
|
85
|
+
@timeout = options[:timeout]
|
|
86
|
+
@rates = options[:rates]
|
|
87
|
+
@on_exceeded = options[:on_exceeded]&.to_sym
|
|
88
|
+
|
|
89
|
+
if @on_exceeded && !POLICIES.include?(@on_exceeded)
|
|
90
|
+
raise ArgumentError, "Unknown delegation budget policy #{@on_exceeded.inspect}. Valid policies: #{POLICIES.join(", ")}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns a budget where +other+'s settings win over this one's.
|
|
95
|
+
#
|
|
96
|
+
# @param other [Budget, nil]
|
|
97
|
+
# @return [Budget]
|
|
98
|
+
def merge(other)
|
|
99
|
+
return self if other.nil?
|
|
100
|
+
|
|
101
|
+
self.class.new(**to_h.merge(other.to_h))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Boolean] whether any limit is set
|
|
105
|
+
def limited?
|
|
106
|
+
LIMITS.any? { |limit| public_send(limit) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [Symbol] the effective policy
|
|
110
|
+
def policy
|
|
111
|
+
on_exceeded || :stop
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Finds the first limit the ledger has already reached.
|
|
115
|
+
#
|
|
116
|
+
# Limits are checked *before* a call runs, because token spend can only
|
|
117
|
+
# be measured after the fact. A budget of +max_tokens: 8_000+ therefore
|
|
118
|
+
# means "stop delegating once 8,000 tokens have been spent", not "never
|
|
119
|
+
# exceed 8,000 tokens".
|
|
120
|
+
#
|
|
121
|
+
# @param ledger [Ledger]
|
|
122
|
+
# @return [Violation, nil]
|
|
123
|
+
def violation_for(ledger)
|
|
124
|
+
return Violation.new(limit: :max_calls, allowed: max_calls, used: ledger.calls) if max_calls && ledger.calls >= max_calls
|
|
125
|
+
return Violation.new(limit: :max_tokens, allowed: max_tokens, used: ledger.tokens) if max_tokens && ledger.tokens >= max_tokens
|
|
126
|
+
return Violation.new(limit: :max_cost, allowed: max_cost, used: ledger.cost) if max_cost && ledger.cost >= max_cost
|
|
127
|
+
return Violation.new(limit: :max_duration, allowed: max_duration, used: ledger.duration) if max_duration && ledger.duration >= max_duration
|
|
128
|
+
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Hash] only the settings that were actually provided
|
|
133
|
+
def to_h
|
|
134
|
+
KEYS.index_with { |key| public_send(key) }.compact
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module ActiveAgent
|
|
6
|
+
module Delegation
|
|
7
|
+
# What a sub-agent promises: one action, its inputs, and optionally the
|
|
8
|
+
# shape of what it returns.
|
|
9
|
+
#
|
|
10
|
+
# The contract is declared on the sub-agent itself, next to the action it
|
|
11
|
+
# describes, so a delegation stays correct when the action changes. Callers
|
|
12
|
+
# then say only which agent they want — they never restate its parameters.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# class SummarizerAgent < ApplicationAgent
|
|
16
|
+
# delegation :summarize, description: "Condense a document into key points" do
|
|
17
|
+
# string :text, required: true, description: "Full document text"
|
|
18
|
+
# integer :limit, description: "Maximum number of key points"
|
|
19
|
+
#
|
|
20
|
+
# returns do
|
|
21
|
+
# string :summary, required: true, description: "One-paragraph summary"
|
|
22
|
+
# array :points, of: :string, required: true, description: "Key points"
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# def summarize(text:, limit: 5)
|
|
27
|
+
# prompt(message: text, limit: limit)
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
class Contract
|
|
31
|
+
# What to do when a sub-agent's output does not satisfy its +returns+ schema.
|
|
32
|
+
#
|
|
33
|
+
# +:error+ — hand the calling model a structured error so it can retry or
|
|
34
|
+
# route around the failure (default).
|
|
35
|
+
# +:raise+ — raise {InvalidResultError} and abort the generation.
|
|
36
|
+
INVALID_POLICIES = %i[error raise].freeze
|
|
37
|
+
|
|
38
|
+
# @return [Symbol] the sub-agent action this contract describes
|
|
39
|
+
attr_reader :action
|
|
40
|
+
# @return [String] what the action does, written for the calling model
|
|
41
|
+
attr_reader :description
|
|
42
|
+
# @return [Schema] declared inputs
|
|
43
|
+
attr_reader :schema
|
|
44
|
+
# @return [Schema, nil] declared output shape, when the action returns structured data
|
|
45
|
+
attr_reader :returns
|
|
46
|
+
# @return [Budget] default budget suggested by the sub-agent
|
|
47
|
+
attr_reader :budget
|
|
48
|
+
# @return [Symbol] :error or :raise
|
|
49
|
+
attr_reader :on_invalid
|
|
50
|
+
|
|
51
|
+
# @param action [Symbol, String]
|
|
52
|
+
# @param description [String]
|
|
53
|
+
# @param schema [Schema, Hash, Class, nil] inputs, or nil to use the block DSL
|
|
54
|
+
# @param returns [Schema, Hash, Class, nil] declared output shape
|
|
55
|
+
# @param budget [Budget, Hash, nil] default budget for callers
|
|
56
|
+
# @param on_invalid [Symbol] :error or :raise
|
|
57
|
+
# @yield input DSL; may also call +returns+ to declare the output shape
|
|
58
|
+
def initialize(action:, description:, schema: nil, returns: nil, budget: nil, on_invalid: :error, &block)
|
|
59
|
+
@action = action.to_sym
|
|
60
|
+
@description = description
|
|
61
|
+
@returns = returns && Schema.build(returns)
|
|
62
|
+
@budget = Budget.build(budget)
|
|
63
|
+
@on_invalid = on_invalid.to_sym
|
|
64
|
+
|
|
65
|
+
unless INVALID_POLICIES.include?(@on_invalid)
|
|
66
|
+
raise ArgumentError, "Unknown delegation on_invalid policy #{@on_invalid.inspect}. " \
|
|
67
|
+
"Valid policies: #{INVALID_POLICIES.join(", ")}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, "A delegation needs a description — it is the only thing the calling model reads" if @description.blank?
|
|
71
|
+
|
|
72
|
+
@schema = Schema.build(schema)
|
|
73
|
+
|
|
74
|
+
if block
|
|
75
|
+
dsl = DSL.new(self, @schema)
|
|
76
|
+
block.arity == 1 ? block.call(dsl) : dsl.instance_eval(&block)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Boolean]
|
|
81
|
+
def structured?
|
|
82
|
+
returns.present?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Sets the declared output shape. Used by the DSL's +returns+ helper.
|
|
86
|
+
#
|
|
87
|
+
# @param schema [Schema]
|
|
88
|
+
# @return [Schema]
|
|
89
|
+
# @api private
|
|
90
|
+
def returns=(schema)
|
|
91
|
+
@returns = schema
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Wraps the input schema DSL so that +returns+ inside a delegation block
|
|
95
|
+
# declares the *output* shape rather than an input property.
|
|
96
|
+
#
|
|
97
|
+
# @api private
|
|
98
|
+
class DSL < SimpleDelegator
|
|
99
|
+
# @param contract [Contract]
|
|
100
|
+
# @param schema [Schema]
|
|
101
|
+
def initialize(contract, schema)
|
|
102
|
+
@contract = contract
|
|
103
|
+
super(schema)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Declares the shape the action returns.
|
|
107
|
+
#
|
|
108
|
+
# @param source [Schema, Hash, Class, nil]
|
|
109
|
+
# @yield output DSL
|
|
110
|
+
# @return [Schema]
|
|
111
|
+
def returns(source = nil, &block)
|
|
112
|
+
@contract.returns = Schema.build(source, &block)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Delegation
|
|
5
|
+
# A sub-agent bound into a calling agent as a tool.
|
|
6
|
+
#
|
|
7
|
+
# Pairs the sub-agent's {Contract} — what it accepts and returns — with the
|
|
8
|
+
# decisions that belong to the caller: what to call it, what it may spend,
|
|
9
|
+
# and which backend serves it.
|
|
10
|
+
class Definition
|
|
11
|
+
# @return [Class] the sub-agent class
|
|
12
|
+
attr_reader :agent_class
|
|
13
|
+
# @return [Contract]
|
|
14
|
+
attr_reader :contract
|
|
15
|
+
# @return [Symbol] the tool name exposed to the calling model
|
|
16
|
+
attr_reader :tool_name
|
|
17
|
+
# @return [String]
|
|
18
|
+
attr_reader :description
|
|
19
|
+
# @return [Backend]
|
|
20
|
+
attr_reader :backend
|
|
21
|
+
# @return [Budget]
|
|
22
|
+
attr_reader :budget
|
|
23
|
+
# @return [Hash, Symbol, Proc, nil] params forwarded to the sub-agent
|
|
24
|
+
attr_reader :params
|
|
25
|
+
|
|
26
|
+
# @param agent_class [Class]
|
|
27
|
+
# @param contract [Contract]
|
|
28
|
+
# @param tool_name [Symbol, String, nil] defaults to the contract's action
|
|
29
|
+
# @param description [String, nil] overrides the contract's description
|
|
30
|
+
# @param backend [Backend, Symbol, Hash, nil]
|
|
31
|
+
# @param budget [Budget, Hash, nil] merged over the contract's default budget
|
|
32
|
+
# @param params [Hash, Symbol, Proc, nil]
|
|
33
|
+
def initialize(agent_class:, contract:, tool_name: nil, description: nil, backend: nil, budget: nil, params: nil)
|
|
34
|
+
@agent_class = agent_class
|
|
35
|
+
@contract = contract
|
|
36
|
+
@tool_name = (tool_name || contract.action).to_sym
|
|
37
|
+
@description = description.presence || contract.description
|
|
38
|
+
@backend = Backend.build(backend)
|
|
39
|
+
@budget = contract.budget.merge(Budget.build(budget))
|
|
40
|
+
@params = params
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Symbol] the sub-agent action invoked
|
|
44
|
+
def action = contract.action
|
|
45
|
+
|
|
46
|
+
# @return [Schema] declared inputs
|
|
47
|
+
def schema = contract.schema
|
|
48
|
+
|
|
49
|
+
# @return [Schema, nil] declared outputs
|
|
50
|
+
def returns = contract.returns
|
|
51
|
+
|
|
52
|
+
# @return [Boolean]
|
|
53
|
+
def structured? = contract.structured?
|
|
54
|
+
|
|
55
|
+
# @return [Symbol]
|
|
56
|
+
def on_invalid = contract.on_invalid
|
|
57
|
+
|
|
58
|
+
# The class a call actually instantiates, after any backend swap.
|
|
59
|
+
#
|
|
60
|
+
# @return [Class]
|
|
61
|
+
def resolved_agent_class
|
|
62
|
+
backend.agent_class_for(agent_class)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Tool definition in ActiveAgent's common tools format.
|
|
66
|
+
#
|
|
67
|
+
# This is what the calling model sees — the whole point of declaring a
|
|
68
|
+
# schema rather than describing the sub-agent in prose.
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash]
|
|
71
|
+
def to_tool
|
|
72
|
+
{
|
|
73
|
+
name: tool_name.to_s,
|
|
74
|
+
description: description,
|
|
75
|
+
parameters: schema.to_json_schema
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns a copy with call-site overrides applied.
|
|
80
|
+
#
|
|
81
|
+
# @return [Definition]
|
|
82
|
+
def with(tool_name: nil, description: nil, backend: nil, budget: nil, params: nil)
|
|
83
|
+
self.class.new(
|
|
84
|
+
agent_class: agent_class,
|
|
85
|
+
contract: contract,
|
|
86
|
+
tool_name: tool_name || self.tool_name,
|
|
87
|
+
description: description || self.description,
|
|
88
|
+
backend: backend || self.backend,
|
|
89
|
+
budget: budget ? self.budget.merge(Budget.build(budget)) : self.budget,
|
|
90
|
+
params: params || self.params
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Delegation
|
|
5
|
+
# Running tally of what delegated work has consumed.
|
|
6
|
+
#
|
|
7
|
+
# One ledger is kept per delegation plus one for the agent as a whole, and
|
|
8
|
+
# both live on the agent instance — which is created fresh for every
|
|
9
|
+
# generation. A budget is therefore scoped to a single generation and its
|
|
10
|
+
# entire tool loop, with no cross-request bleed and nothing to reset.
|
|
11
|
+
#
|
|
12
|
+
# @example Inspecting spend after a generation
|
|
13
|
+
# agent = ResearchAgent.new
|
|
14
|
+
# agent.process(:research, topic: "hydrogen storage")
|
|
15
|
+
# agent.process_prompt
|
|
16
|
+
# agent.delegation_ledger.to_h
|
|
17
|
+
# #=> { calls: 2, tokens: 1_840, cost: 0.0004, duration: 3.1 }
|
|
18
|
+
class Ledger
|
|
19
|
+
# @return [Integer] delegated calls completed
|
|
20
|
+
attr_reader :calls
|
|
21
|
+
# @return [Integer] cumulative total tokens
|
|
22
|
+
attr_reader :tokens
|
|
23
|
+
# @return [Float] cumulative USD spend (0.0 when no rates are known)
|
|
24
|
+
attr_reader :cost
|
|
25
|
+
# @return [Float] cumulative wall-clock seconds
|
|
26
|
+
attr_reader :duration
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@calls = 0
|
|
30
|
+
@tokens = 0
|
|
31
|
+
@cost = 0.0
|
|
32
|
+
@duration = 0.0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Records one delegated call.
|
|
36
|
+
#
|
|
37
|
+
# @param tokens [Integer, nil]
|
|
38
|
+
# @param cost [Float, nil] nil when the model's rates are unknown
|
|
39
|
+
# @param duration [Float] seconds
|
|
40
|
+
# @return [self]
|
|
41
|
+
def record(tokens: 0, cost: nil, duration: 0.0)
|
|
42
|
+
@calls += 1
|
|
43
|
+
@tokens += tokens.to_i
|
|
44
|
+
@cost += cost.to_f
|
|
45
|
+
@duration += duration.to_f
|
|
46
|
+
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Hash]
|
|
51
|
+
def to_h
|
|
52
|
+
{ calls: calls, tokens: tokens, cost: cost, duration: duration }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Delegation
|
|
5
|
+
# Token price registry used to turn usage into dollars for cost budgets.
|
|
6
|
+
#
|
|
7
|
+
# ActiveAgent deliberately ships no built-in price list: vendor pricing
|
|
8
|
+
# changes far faster than a gem release, and a stale table silently
|
|
9
|
+
# under-reports spend. Register the rates your app actually pays — once,
|
|
10
|
+
# in an initializer — and every cost budget in the app uses them.
|
|
11
|
+
#
|
|
12
|
+
# Rates are expressed in **USD per one million tokens**, matching how
|
|
13
|
+
# every major provider publishes them.
|
|
14
|
+
#
|
|
15
|
+
# @example Register rates for the models you use
|
|
16
|
+
# # config/initializers/active_agent.rb
|
|
17
|
+
# ActiveAgent::Delegation::Pricing.register("gpt-4o-mini", input: 0.15, output: 0.60)
|
|
18
|
+
# ActiveAgent::Delegation::Pricing.register(/\Aclaude-haiku/, input: 1.00, output: 5.00)
|
|
19
|
+
#
|
|
20
|
+
# @example Or state rates inline on a single budget
|
|
21
|
+
# delegate_to SummarizerAgent, budget: { max_cost: 0.05, rates: { input: 0.15, output: 0.60 } }
|
|
22
|
+
module Pricing
|
|
23
|
+
# A registered rate card.
|
|
24
|
+
Rate = Struct.new(:pattern, :input, :output, keyword_init: true) do
|
|
25
|
+
# @param model [String]
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def matches?(model)
|
|
28
|
+
case pattern
|
|
29
|
+
when Regexp then pattern.match?(model)
|
|
30
|
+
else model.to_s.start_with?(pattern.to_s)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# @return [Array<Rate>] registered rates, most recently registered first
|
|
37
|
+
def rates
|
|
38
|
+
@rates ||= []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Registers a rate card.
|
|
42
|
+
#
|
|
43
|
+
# String patterns match by prefix (so +"gpt-4o-mini"+ covers
|
|
44
|
+
# +"gpt-4o-mini-2024-07-18"+); Regexp patterns match as written. Later
|
|
45
|
+
# registrations win over earlier ones.
|
|
46
|
+
#
|
|
47
|
+
# @param pattern [String, Regexp] matched against the model name
|
|
48
|
+
# @param input [Float] USD per 1M input tokens
|
|
49
|
+
# @param output [Float] USD per 1M output tokens
|
|
50
|
+
# @return [Rate]
|
|
51
|
+
def register(pattern, input:, output:)
|
|
52
|
+
Rate.new(pattern: pattern, input: input.to_f, output: output.to_f).tap do |rate|
|
|
53
|
+
rates.unshift(rate)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Clears the registry. Mostly useful in tests.
|
|
58
|
+
#
|
|
59
|
+
# @return [void]
|
|
60
|
+
def reset!
|
|
61
|
+
@rates = []
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param model [String, nil]
|
|
65
|
+
# @return [Hash, nil] +{ input:, output: }+ in USD per 1M tokens
|
|
66
|
+
def rates_for(model)
|
|
67
|
+
return nil if model.blank?
|
|
68
|
+
|
|
69
|
+
rate = rates.find { |candidate| candidate.matches?(model) }
|
|
70
|
+
{ input: rate.input, output: rate.output } if rate
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Computes the dollar cost of a single generation.
|
|
74
|
+
#
|
|
75
|
+
# @param usage [ActiveAgent::Providers::Common::Usage, nil]
|
|
76
|
+
# @param model [String, nil] used to look up registered rates
|
|
77
|
+
# @param rates [Hash, nil] inline +{ input:, output: }+ overriding the registry
|
|
78
|
+
# @return [Float, nil] nil when no rates are known for the model
|
|
79
|
+
def cost_for(usage:, model: nil, rates: nil)
|
|
80
|
+
return nil if usage.nil?
|
|
81
|
+
|
|
82
|
+
resolved = normalize(rates) || rates_for(model)
|
|
83
|
+
return nil if resolved.nil?
|
|
84
|
+
|
|
85
|
+
input = (usage.input_tokens || 0) * resolved[:input]
|
|
86
|
+
output = (usage.output_tokens || 0) * resolved[:output]
|
|
87
|
+
|
|
88
|
+
(input + output) / 1_000_000.0
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# @param rates [Hash, nil]
|
|
94
|
+
# @return [Hash, nil]
|
|
95
|
+
def normalize(rates)
|
|
96
|
+
return nil if rates.blank?
|
|
97
|
+
|
|
98
|
+
rates = rates.symbolize_keys
|
|
99
|
+
return nil unless rates[:input] || rates[:output]
|
|
100
|
+
|
|
101
|
+
{ input: rates[:input].to_f, output: rates[:output].to_f }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|