activeagent 1.2.0 → 1.3.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.
@@ -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