actionagent 1.6.2 → 1.6.4

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.
@@ -11,6 +11,14 @@ module ActionAgent
11
11
  has_many :agent_runs, dependent: :destroy
12
12
  has_many :evaluations, dependent: :destroy
13
13
  has_many :agent_memories, as: :memorable, dependent: :destroy
14
+ # Generations hang off AgentContext polymorphically, which is an
15
+ # implementation detail of how contexts are modelled — so without these a
16
+ # host that wants an agent's recorded history writes that join itself and is
17
+ # coupled to the shape. Deliberately no `dependent:` on the contexts: the
18
+ # association is added to read them, and destroying an agent has never taken
19
+ # its conversations with it. Making it do so is a separate call.
20
+ has_many :agent_contexts, as: :contextable
21
+ has_many :generations, through: :agent_contexts
14
22
 
15
23
  # Polymorphic rows (agent_memories, agent_contexts) store this string.
16
24
  # A host app that grew these tables under its own Agent constant keeps
@@ -100,10 +100,16 @@ module ActionAgent
100
100
  # ActiveAgent::Evals::ScenarioParser output). Keys already in the suite keep their records, so
101
101
  # earlier runs' results still resolve to their scenario, and keep their
102
102
  # enabled flag unless the attributes set it (a paste cannot).
103
- def replace_scenarios!(attributes)
103
+ # `on_removed:` decides what happens to a scenario the new attributes no
104
+ # longer name: `:destroy` drops it, `:disable` keeps the row with
105
+ # `enabled: false` so runs that scored it still resolve their results.
106
+ def replace_scenarios!(attributes, on_removed: :destroy)
107
+ raise ArgumentError, "on_removed must be :destroy or :disable" unless %i[destroy disable].include?(on_removed)
108
+
104
109
  transaction do
105
110
  keep = attributes.map { |attrs| attrs["key"] }
106
- scenarios.where.not(key: keep).destroy_all
111
+ removed = scenarios.where.not(key: keep)
112
+ on_removed == :disable ? removed.update_all(enabled: false) : removed.destroy_all
107
113
 
108
114
  attributes.each_with_index do |attrs, index|
109
115
  scenario = scenarios.find_or_initialize_by(key: attrs["key"])
@@ -33,9 +33,13 @@ module ActionAgent
33
33
  # so a multi-gigabyte log named .csv costs a fixed slice of memory rather
34
34
  # than its whole size. Four bytes per character is UTF-8's worst case.
35
35
  ATTACHMENT_TEXT_BYTE_LIMIT = ATTACHMENT_TEXT_LIMIT * 4
36
+ # What one prompt-span attribute stores. The value is a preview for reading,
37
+ # so it is clipped; a size that has to stay exact travels as its own
38
+ # `*.tokens` attribute instead.
39
+ PROMPT_SPAN_ATTRIBUTE_LIMIT = 6000
36
40
  # The prompt span records the transcript, not the data URIs; keep the
37
41
  # whole serialized list within the same budget as the other attributes.
38
- PROMPT_SPAN_MESSAGE_LIMIT = 6000
42
+ PROMPT_SPAN_MESSAGE_LIMIT = PROMPT_SPAN_ATTRIBUTE_LIMIT
39
43
  # Prior turns sent with a pinned conversation: the most recent ones,
40
44
  # trimmed oldest-first to a character budget.
41
45
  HISTORY_TURN_LIMIT = 40
@@ -164,11 +168,19 @@ module ActionAgent
164
168
  def record_prompt_span(root_span)
165
169
  span = root_span.add_span("agent.prompt", span_type: :prompt)
166
170
  if composed_instructions.present?
167
- span.set_attribute("prompt.input.instructions", composed_instructions.to_s.byteslice(0, 6000).to_s.scrub)
168
- end
169
- if tool_schemas.present?
170
- span.set_attribute("prompt.input.tools", tool_schemas.to_json.byteslice(0, 6000).to_s.scrub)
171
+ instructions = composed_instructions.to_s
172
+ span.set_attribute("prompt.input.instructions", instructions.byteslice(0, PROMPT_SPAN_ATTRIBUTE_LIMIT).to_s.scrub)
173
+ span.set_attribute("prompt.input.instructions.tokens", estimated_tokens(instructions))
171
174
  end
175
+ # MCP and toolbox schemas are attributed separately so the meter can name
176
+ # which half fills the window, and each carries its size. The content
177
+ # attributes are truncated previews for reading: sizing the context from
178
+ # one understates it by whatever the clip dropped, which for a twelve-tool
179
+ # agent is most of the schema.
180
+ record_tool_schema_attributes(span)
181
+ full_transcript_json = prompt_turn[:transcript].map do |message|
182
+ { role: message[:role], content: message[:content].to_s }
183
+ end.to_json
172
184
  transcript = prompt_turn[:transcript].map do |message|
173
185
  { role: message[:role], content: message[:content].to_s.byteslice(0, 4000).to_s.scrub }
174
186
  end
@@ -181,6 +193,13 @@ module ActionAgent
181
193
  end
182
194
  span.set_attribute("prompt.input.messages", serialized)
183
195
  span.set_attribute("messages.count", transcript.size)
196
+ # The stored attribute is the tail of the history that fit, so its size is
197
+ # not the transcript's. The meter apportions the provider's prompt_tokens
198
+ # across the segments it can size, and a transcript missing from that set
199
+ # is not merely imprecise: the segments that remain are scaled up to cover
200
+ # it, so a long conversation reads as an enormous system prompt. Measured
201
+ # over the full turn, before either the per-message clip or the trim.
202
+ span.set_attribute("prompt.input.messages.tokens", estimated_tokens(full_transcript_json))
184
203
  span.finish
185
204
  rescue StandardError => e
186
205
  Rails.logger.warn("[AgentExecutionService] prompt span failed: #{e.message}")
@@ -385,6 +404,30 @@ module ActionAgent
385
404
  @mcp_dispatcher ||= MCPToolDispatcher.new(@agent_record)
386
405
  end
387
406
 
407
+ # Splits the offered schemas the way `tool_schemas` assembles them, so the
408
+ # span reports what the model was actually sent: nothing for a mock run, and
409
+ # one MCP round trip rather than a second one for telemetry.
410
+ def record_tool_schema_attributes(span)
411
+ mcp_definitions, toolbox_definitions = tool_schema_halves
412
+ {
413
+ "prompt.input.tools" => toolbox_definitions,
414
+ "prompt.input.mcp_tools" => mcp_definitions
415
+ }.each do |key, definitions|
416
+ next if definitions.blank?
417
+
418
+ json = definitions.to_json
419
+ span.set_attribute(key, json.byteslice(0, PROMPT_SPAN_ATTRIBUTE_LIMIT).to_s.scrub)
420
+ span.set_attribute("#{key}.tokens", estimated_tokens(json))
421
+ end
422
+ end
423
+
424
+ # ~4 chars/token, the same approximation the context meter applies to content
425
+ # it sizes itself. Taken before truncation, so the meter reads the whole
426
+ # schema rather than the preview the attribute stores.
427
+ def estimated_tokens(text)
428
+ (text.length / 4.0).round
429
+ end
430
+
388
431
  def call_agent(slug:, message:)
389
432
  depth = Thread.current[:agent_call_depth].to_i
390
433
  return { error: "call_agent depth limit (#{MAX_CALL_DEPTH}) reached" } if depth >= MAX_CALL_DEPTH
@@ -562,13 +605,21 @@ module ActionAgent
562
605
  # server-side implementations (none for mock runs — the mock provider
563
606
  # doesn't do tool calling).
564
607
  def tool_schemas
565
- return [] if provider == :mock
566
-
567
- # The agent's own MCP servers describe their tools; the toolbox describes
568
- # the rest. Without the first half a tool the agent declares is never
569
- # offered to the model, which then answers from memory instead of calling
570
- # it.
571
- mcp_dispatcher.tool_definitions + AgentToolbox.definitions_for(@agent_record.tools)
608
+ mcp_definitions, toolbox_definitions = tool_schema_halves
609
+ mcp_definitions + toolbox_definitions
610
+ end
611
+
612
+ # The agent's own MCP servers describe their tools; the toolbox describes the
613
+ # rest. Without the first half a tool the agent declares is never offered to
614
+ # the model, which then answers from memory instead of calling it. Memoized
615
+ # because listing a server's tools is a request to that server.
616
+ def tool_schema_halves
617
+ @tool_schema_halves ||=
618
+ if provider == :mock
619
+ [ [], [] ]
620
+ else
621
+ [ mcp_dispatcher.tool_definitions, AgentToolbox.definitions_for(@agent_record.tools) ]
622
+ end
572
623
  end
573
624
 
574
625
  # Persists the tool interaction stream to the solid_agent conversation
@@ -149,9 +149,7 @@ module ActionAgent
149
149
  end
150
150
 
151
151
  def sample_generations(model: nil)
152
- scope = AgentGeneration
153
- .joins(:agent_context)
154
- .where(AgentContext.table_name => { contextable: @evaluation.agent })
152
+ scope = @evaluation.agent.generations
155
153
  scope = scope.where(model: model) if model
156
154
  scope.order(created_at: :desc).limit(@evaluation.sample_size).to_a
157
155
  end
@@ -64,7 +64,16 @@ module ActionAgent
64
64
  report = if adapter
65
65
  raise ArgumentError, "scenario evaluation adapter must be callable" unless adapter.respond_to?(:call)
66
66
 
67
- adapter.call(evaluation: @evaluation, owner: owner, scenarios: tasks, models: specs, on_result: on_result)
67
+ # The default path meters each replay itself (see #replay). An adapter
68
+ # runs the agent in the host's own runtime, out of reach of that call,
69
+ # so the execution is counted here as each result lands — one per
70
+ # scenario x model, the same unit. Otherwise a host that adapts the
71
+ # replay is silently unmetered unless it remembers to meter itself.
72
+ metered = lambda do |result|
73
+ ActionAgent.record_usage(owner, :execution)
74
+ on_result.call(result)
75
+ end
76
+ adapter.call(evaluation: @evaluation, owner: owner, scenarios: tasks, models: specs, on_result: metered)
68
77
  else
69
78
  ensure_judge_defined_kpis! if @evaluation.judge_defined?
70
79
  default_report(tasks, specs, on_result)
@@ -122,6 +131,12 @@ module ActionAgent
122
131
  def model_specs
123
132
  names = Array(@selection[:models]).presence || @evaluation.compare_models
124
133
  specs = Evals::ModelSpec.parse_all(names, default_provider: @evaluation.agent.provider, providers: Agent::PROVIDERS + %w[mock])
134
+ # parse_all resolves a bare name against `providers:` but passes through a
135
+ # `provider/model` whose provider is not in that list, so the run would
136
+ # otherwise reach the replay with a provider nothing can serve.
137
+ unsupported = specs.map(&:provider).uniq - (Agent::PROVIDERS + %w[mock])
138
+ raise ArgumentError, "unsupported model provider: #{unsupported.to_sentence}" if unsupported.any?
139
+
125
140
  return specs if specs.any?
126
141
 
127
142
  [ Evals::ModelSpec.new(label: @evaluation.agent.model, provider: @evaluation.agent.provider, model: @evaluation.agent.model) ]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionAgent
4
- VERSION = "1.6.2"
4
+ VERSION = "1.6.4"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionagent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.2
4
+ version: 1.6.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-17 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activeagent