actionagent 1.6.2 → 1.6.3
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/app/assets/builds/action_agent.js +45 -45
- data/app/models/action_agent/agent.rb +8 -0
- data/app/models/action_agent/evaluation.rb +8 -2
- data/app/services/action_agent/agent_execution_service.rb +63 -12
- data/app/services/action_agent/evaluation_runner_service.rb +1 -3
- data/app/services/action_agent/scenario_evaluation_runner.rb +16 -1
- data/lib/action_agent/version.rb +1 -1
- metadata +2 -2
|
@@ -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
|
-
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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 =
|
|
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
|
-
|
|
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) ]
|
data/lib/action_agent/version.rb
CHANGED
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.
|
|
4
|
+
version: 1.6.3
|
|
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-
|
|
11
|
+
date: 2026-09-18 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activeagent
|