actionagent 1.2.2 → 1.5.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/README.md +14 -3
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +69 -43
- data/app/controllers/action_agent/api/agent_runs_controller.rb +28 -8
- data/app/controllers/action_agent/api/agents_controller.rb +191 -56
- data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
- data/app/controllers/action_agent/api/base_controller.rb +16 -0
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +83 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +252 -7
- data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
- data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
- data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
- data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
- data/app/controllers/action_agent/api/templates_controller.rb +25 -21
- data/app/controllers/action_agent/api/traces_controller.rb +25 -5
- data/app/controllers/action_agent/api/usage_controller.rb +20 -0
- data/app/controllers/action_agent/application_controller.rb +25 -2
- data/app/controllers/action_agent/dashboard_controller.rb +3 -1
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
- data/app/jobs/action_agent/agent_execution_job.rb +40 -20
- data/app/jobs/action_agent/application_job.rb +7 -3
- data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
- data/app/models/action_agent/agent.rb +74 -23
- data/app/models/action_agent/agent_run.rb +99 -0
- data/app/models/action_agent/agent_template.rb +22 -7
- data/app/models/action_agent/evaluation.rb +64 -4
- data/app/models/action_agent/evaluation_run.rb +190 -2
- data/app/models/action_agent/evaluation_scenario.rb +59 -0
- data/app/models/action_agent/evaluation_scenario_result.rb +86 -0
- data/app/models/action_agent/recording_action.rb +11 -7
- data/app/models/action_agent/sandbox_session.rb +1 -1
- data/app/models/action_agent/session_recording.rb +31 -8
- data/app/models/action_agent/telemetry_trace.rb +126 -3
- data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
- data/app/models/concerns/action_agent/ownable.rb +15 -2
- data/app/queries/action_agent/metrics_report.rb +498 -0
- data/app/serializers/action_agent/agent_message_serializer.rb +1 -0
- data/app/services/action_agent/agent_execution_service.rb +294 -16
- data/app/services/action_agent/agent_registrar.rb +7 -6
- data/app/services/action_agent/agent_toolbox.rb +49 -7
- data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
- data/app/services/action_agent/evaluation_evidence.rb +234 -0
- data/app/services/action_agent/evaluation_runner_service.rb +13 -3
- data/app/services/action_agent/evaluation_tool_resolver.rb +162 -0
- data/app/services/action_agent/mcp_catalog.rb +46 -8
- data/app/services/action_agent/mcp_client.rb +167 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
- data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
- data/app/services/action_agent/playwright_mcp_client.rb +11 -126
- data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +260 -0
- data/app/services/action_agent/tool_discovery.rb +22 -8
- data/config/routes.rb +36 -3
- data/lib/action_agent/assistant_request_filter.rb +22 -0
- data/lib/action_agent/engine.rb +106 -19
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +104 -6
- data/lib/generators/action_agent/install_generator.rb +20 -7
- data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
- data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
- data/lib/tasks/action_agent.rake +9 -0
- metadata +22 -5
|
@@ -23,6 +23,24 @@ module ActionAgent
|
|
|
23
23
|
|
|
24
24
|
SERVICE_NAME = "activeagents-platform"
|
|
25
25
|
|
|
26
|
+
# Images and PDFs above this size are described rather than sent —
|
|
27
|
+
# a data URI of that size is most of a context window by itself.
|
|
28
|
+
ATTACHMENT_DATA_LIMIT = 8.megabytes
|
|
29
|
+
# Inlined text attachments are cut here: enough for a CSV or a config
|
|
30
|
+
# file, not enough for a log dump to crowd out the conversation.
|
|
31
|
+
ATTACHMENT_TEXT_LIMIT = 20_000
|
|
32
|
+
# ...and only this many bytes are ever read to produce those characters,
|
|
33
|
+
# so a multi-gigabyte log named .csv costs a fixed slice of memory rather
|
|
34
|
+
# than its whole size. Four bytes per character is UTF-8's worst case.
|
|
35
|
+
ATTACHMENT_TEXT_BYTE_LIMIT = ATTACHMENT_TEXT_LIMIT * 4
|
|
36
|
+
# The prompt span records the transcript, not the data URIs; keep the
|
|
37
|
+
# whole serialized list within the same budget as the other attributes.
|
|
38
|
+
PROMPT_SPAN_MESSAGE_LIMIT = 6000
|
|
39
|
+
# Prior turns sent with a pinned conversation: the most recent ones,
|
|
40
|
+
# trimmed oldest-first to a character budget.
|
|
41
|
+
HISTORY_TURN_LIMIT = 40
|
|
42
|
+
HISTORY_CHAR_BUDGET = 60_000
|
|
43
|
+
|
|
26
44
|
def self.call(agent_record, run)
|
|
27
45
|
new(agent_record, run).call
|
|
28
46
|
end
|
|
@@ -59,6 +77,7 @@ module ActionAgent
|
|
|
59
77
|
end
|
|
60
78
|
|
|
61
79
|
def call
|
|
80
|
+
@agent_record.ensure_executable!
|
|
62
81
|
root_span = @root_span = build_root_span
|
|
63
82
|
record_prompt_span(root_span)
|
|
64
83
|
llm_span = root_span.add_span(
|
|
@@ -124,7 +143,9 @@ module ActionAgent
|
|
|
124
143
|
# The outbound prompt as a span, in the SDK's attribute shape — gives the
|
|
125
144
|
# Traces UI its System/User conversation rows and lets the context-pressure
|
|
126
145
|
# meter attribute instructions and tool schemas instead of lumping the
|
|
127
|
-
# whole input into "messages".
|
|
146
|
+
# whole input into "messages". Messages are the text-only transcript:
|
|
147
|
+
# the same list the provider gets, with data URIs replaced by
|
|
148
|
+
# "[image: sales_chart.png]" placeholders.
|
|
128
149
|
def record_prompt_span(root_span)
|
|
129
150
|
span = root_span.add_span("agent.prompt", span_type: :prompt)
|
|
130
151
|
if composed_instructions.present?
|
|
@@ -133,16 +154,69 @@ module ActionAgent
|
|
|
133
154
|
if tool_schemas.present?
|
|
134
155
|
span.set_attribute("prompt.input.tools", tool_schemas.to_json.byteslice(0, 6000).to_s.scrub)
|
|
135
156
|
end
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
157
|
+
transcript = prompt_turn[:transcript].map do |message|
|
|
158
|
+
{ role: message[:role], content: message[:content].to_s.byteslice(0, 4000).to_s.scrub }
|
|
159
|
+
end
|
|
160
|
+
# A replayed conversation is re-sent every turn, so the span keeps the
|
|
161
|
+
# most recent messages that fit rather than the whole history again.
|
|
162
|
+
serialized = transcript.to_json
|
|
163
|
+
while serialized.bytesize > PROMPT_SPAN_MESSAGE_LIMIT && transcript.size > 1
|
|
164
|
+
transcript.shift
|
|
165
|
+
serialized = transcript.to_json
|
|
166
|
+
end
|
|
167
|
+
span.set_attribute("prompt.input.messages", serialized)
|
|
168
|
+
span.set_attribute("messages.count", transcript.size)
|
|
141
169
|
span.finish
|
|
142
170
|
rescue StandardError => e
|
|
143
171
|
Rails.logger.warn("[AgentExecutionService] prompt span failed: #{e.message}")
|
|
144
172
|
end
|
|
145
173
|
|
|
174
|
+
# The list handed to prompt(messages:): the pinned conversation's prior
|
|
175
|
+
# turns, then the new user turn carrying the run's attachments — images
|
|
176
|
+
# and PDFs as data URIs in the provider-neutral {text:, image:} /
|
|
177
|
+
# {document:} shorthand, text files inlined, anything else described.
|
|
178
|
+
# Memoized, so the provider and the prompt span see one list.
|
|
179
|
+
def prompt_messages
|
|
180
|
+
prompt_turn[:messages]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Persists the tool calls this service executed when the provider's
|
|
184
|
+
# response carries no tool-role messages to persist them from (the
|
|
185
|
+
# OpenAI Responses API). With such messages present the usual path
|
|
186
|
+
# — solid_agent's, then #persist_tool_messages — already writes the
|
|
187
|
+
# rows, keyed by tool_call_id, and this is a no-op.
|
|
188
|
+
def persist_tool_invocations(context, response)
|
|
189
|
+
return unless context.respond_to?(:add_tool_message)
|
|
190
|
+
return if @tool_invocations.empty?
|
|
191
|
+
return if Array(response.respond_to?(:messages) ? response.messages : nil).any? do |message|
|
|
192
|
+
message.respond_to?(:role) && message.role.to_s == "tool"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
@tool_invocations.each do |invocation|
|
|
196
|
+
context.add_tool_message(
|
|
197
|
+
tool_call_id: nil,
|
|
198
|
+
tool_name: invocation[:name],
|
|
199
|
+
result: invocation[:result],
|
|
200
|
+
arguments: invocation[:arguments],
|
|
201
|
+
duration_ms: invocation[:duration_ms]
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
Rails.logger.error("[AgentExecutionService] Failed to persist tool invocations: #{e.message}")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The person's own words for this turn — what the persisted user
|
|
209
|
+
# message says, without the file bodies inlined for the model.
|
|
210
|
+
def user_text
|
|
211
|
+
@run.input_prompt.to_s.presence || (attachment_records.any? ? "(see attached files)" : "")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# What was attached, as stored on the persisted user message so the
|
|
215
|
+
# conversation shows the files afterwards.
|
|
216
|
+
def attachment_manifest
|
|
217
|
+
@attachment_manifest ||= @run.attachment_manifest
|
|
218
|
+
end
|
|
219
|
+
|
|
146
220
|
# Per-run provider/model overrides (input_params) let callers replay the
|
|
147
221
|
# same agent under a different model — the basis of evaluation comparison
|
|
148
222
|
# runs. Absent overrides, the agent's own configuration applies.
|
|
@@ -233,7 +307,9 @@ module ActionAgent
|
|
|
233
307
|
when "call_agent"
|
|
234
308
|
call_agent(slug: kwargs[:slug], message: kwargs[:message])
|
|
235
309
|
else
|
|
236
|
-
|
|
310
|
+
# A tool one of the agent's own MCP servers serves is called there;
|
|
311
|
+
# AgentToolbox answers the rest.
|
|
312
|
+
mcp_dispatcher.call(name, kwargs) || AgentToolbox.call(name, **kwargs)
|
|
237
313
|
end
|
|
238
314
|
rescue StandardError => e
|
|
239
315
|
Rails.logger.warn("[AgentExecutionService] Tool #{name} failed: #{e.class} - #{e.message}")
|
|
@@ -262,6 +338,7 @@ module ActionAgent
|
|
|
262
338
|
@tool_invocations << {
|
|
263
339
|
name: name.to_s,
|
|
264
340
|
arguments: kwargs,
|
|
341
|
+
result: result,
|
|
265
342
|
duration_ms: duration_ms,
|
|
266
343
|
error: errored
|
|
267
344
|
}
|
|
@@ -279,6 +356,12 @@ module ActionAgent
|
|
|
279
356
|
# Executes another agent of the same account synchronously and returns
|
|
280
357
|
# its reply, so agents can delegate to each other as a tool call. The
|
|
281
358
|
# sub-run is a real AgentRun with its own trace.
|
|
359
|
+
# One dispatcher per run, so every tool call shares the MCP sessions the
|
|
360
|
+
# first call opens.
|
|
361
|
+
def mcp_dispatcher
|
|
362
|
+
@mcp_dispatcher ||= MCPToolDispatcher.new(@agent_record)
|
|
363
|
+
end
|
|
364
|
+
|
|
282
365
|
def call_agent(slug:, message:)
|
|
283
366
|
depth = Thread.current[:agent_call_depth].to_i
|
|
284
367
|
return { error: "call_agent depth limit (#{MAX_CALL_DEPTH}) reached" } if depth >= MAX_CALL_DEPTH
|
|
@@ -341,7 +424,10 @@ module ActionAgent
|
|
|
341
424
|
model_options.merge!(owner_provider_options(effective_provider))
|
|
342
425
|
klass_name = agent_class_name
|
|
343
426
|
agent_record = @agent_record
|
|
344
|
-
|
|
427
|
+
# Pinned, or the default stream resolved here rather than left to
|
|
428
|
+
# solid_agent's unordered find_or_create_by!, which cannot tell the
|
|
429
|
+
# agent's original conversation from a later one on the same triple.
|
|
430
|
+
pinned = conversation_context
|
|
345
431
|
instructions = composed_instructions
|
|
346
432
|
action = action_name
|
|
347
433
|
run_trace_id = trace_id
|
|
@@ -395,19 +481,52 @@ module ActionAgent
|
|
|
395
481
|
|
|
396
482
|
# One method per invokable action (the default plus each named action
|
|
397
483
|
# prompt) — solid_agent keys the persisted context by action_name, so
|
|
398
|
-
# each action gets its own interaction stream.
|
|
484
|
+
# each action gets its own interaction stream. A run pinned to a
|
|
485
|
+
# conversation continues that context instead.
|
|
399
486
|
define_method action do
|
|
400
487
|
# Thread the run's telemetry trace_id through prompt_options so
|
|
401
488
|
# SolidAgent's provenance (and AgentContext#record_generation_with_
|
|
402
489
|
# provenance!) can correlate the persisted generation with its trace.
|
|
403
490
|
prompt_options[:trace_id] = run_trace_id
|
|
404
|
-
|
|
491
|
+
if pinned
|
|
492
|
+
load_context(context_id: pinned.id)
|
|
493
|
+
else
|
|
494
|
+
load_context(contextable: agent_record)
|
|
495
|
+
end
|
|
405
496
|
|
|
406
|
-
options = {
|
|
497
|
+
options = { messages: service.prompt_messages }
|
|
407
498
|
options[:instructions] = instructions if instructions.present?
|
|
408
499
|
options[:tools] = tool_definitions if tool_definitions.present?
|
|
409
500
|
prompt(**options)
|
|
410
501
|
end
|
|
502
|
+
|
|
503
|
+
# solid_agent's after_prompt callback persists the last prompt
|
|
504
|
+
# message's content: string — so a turn that carries files (a
|
|
505
|
+
# {text:, image:} hash) would never be written, and the history
|
|
506
|
+
# replayed from the pinned context is not this run's to persist.
|
|
507
|
+
# Instead: exactly one user message per run, through the agent-level
|
|
508
|
+
# add_user_message that stamps provenance (its trace_id is how the
|
|
509
|
+
# run detail API finds the run's slice of the conversation), with
|
|
510
|
+
# the attachment manifest alongside.
|
|
511
|
+
define_method(:persist_prompt_to_context) do
|
|
512
|
+
text = service.user_text
|
|
513
|
+
return unless context && text.present?
|
|
514
|
+
|
|
515
|
+
add_user_message(text, attachments: service.attachment_manifest)
|
|
516
|
+
end
|
|
517
|
+
private :persist_prompt_to_context
|
|
518
|
+
|
|
519
|
+
# solid_agent persists the tool exchange from the response's
|
|
520
|
+
# tool-role messages. The Responses API carries function calls as
|
|
521
|
+
# items rather than messages, so that list is empty and the
|
|
522
|
+
# exchange — a render_ui call is the reply — would vanish from the
|
|
523
|
+
# conversation. The service saw every call go by; fall back to its
|
|
524
|
+
# own records, here so the rows land before the assistant turn.
|
|
525
|
+
define_method(:persist_tool_messages_to_context) do
|
|
526
|
+
super()
|
|
527
|
+
service.persist_tool_invocations(context, generation_response)
|
|
528
|
+
end
|
|
529
|
+
private :persist_tool_messages_to_context
|
|
411
530
|
end
|
|
412
531
|
|
|
413
532
|
agent_class.public_send(action).generate_now
|
|
@@ -419,7 +538,11 @@ module ActionAgent
|
|
|
419
538
|
def tool_schemas
|
|
420
539
|
return [] if provider == :mock
|
|
421
540
|
|
|
422
|
-
|
|
541
|
+
# The agent's own MCP servers describe their tools; the toolbox describes
|
|
542
|
+
# the rest. Without the first half a tool the agent declares is never
|
|
543
|
+
# offered to the model, which then answers from memory instead of calling
|
|
544
|
+
# it.
|
|
545
|
+
mcp_dispatcher.tool_definitions + AgentToolbox.definitions_for(@agent_record.tools)
|
|
423
546
|
end
|
|
424
547
|
|
|
425
548
|
# Persists the tool interaction stream to the solid_agent conversation
|
|
@@ -530,10 +653,165 @@ module ActionAgent
|
|
|
530
653
|
@trace_id ||= @run.trace_id.presence || SecureRandom.hex(16)
|
|
531
654
|
end
|
|
532
655
|
|
|
533
|
-
# The solid_agent conversation context this execution persisted into
|
|
534
|
-
#
|
|
656
|
+
# The solid_agent conversation context this execution persisted into:
|
|
657
|
+
# the pinned one when the run continues a conversation, else the
|
|
658
|
+
# agent + action's default stream.
|
|
535
659
|
def conversation_context
|
|
536
|
-
|
|
660
|
+
pinned_context || default_stream_context
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
# The agent + action's default stream: the oldest context on the triple
|
|
664
|
+
# solid_agent keys by, so a second conversation the dashboard started for
|
|
665
|
+
# the same action cannot become the row an unpinned run appends to.
|
|
666
|
+
def default_stream_context
|
|
667
|
+
AgentContext.where(contextable: @agent_record, agent_name: agent_class_name, action_name: action_name)
|
|
668
|
+
.order(:id).first
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# The conversation the run was pinned to (input_params context_id). A
|
|
672
|
+
# context belonging to another agent — or recorded under another action,
|
|
673
|
+
# whose instructions and stream are not this run's — is ignored rather
|
|
674
|
+
# than continued: the run falls back to the default stream as if nothing
|
|
675
|
+
# had been pinned, and reports the conversation it actually wrote to.
|
|
676
|
+
def pinned_context
|
|
677
|
+
return @pinned_context if defined?(@pinned_context)
|
|
678
|
+
|
|
679
|
+
id = run_params[:context_id]
|
|
680
|
+
@pinned_context =
|
|
681
|
+
if id.present?
|
|
682
|
+
# Matched on the action too, not just ownership: a run for another
|
|
683
|
+
# action would append to this conversation and rewrite the recorded
|
|
684
|
+
# instructions with its own. The agent_name is deliberately not part
|
|
685
|
+
# of it — renaming an agent changes that string, and the
|
|
686
|
+
# conversations it already has must stay pinnable.
|
|
687
|
+
AgentContext.find_by(id: id, contextable: @agent_record, action_name: action_name)
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# The pinned conversation's prior turns as plain {role:, content:}
|
|
692
|
+
# messages — the conversation as the person saw it: tool rows and empty
|
|
693
|
+
# assistant rows (a turn that only carried a tool call) are skipped.
|
|
694
|
+
# The most recent turns, dropped oldest-first once the budget is spent.
|
|
695
|
+
def history_messages
|
|
696
|
+
context = pinned_context
|
|
697
|
+
return [] unless context
|
|
698
|
+
|
|
699
|
+
turns = context.messages.chronological
|
|
700
|
+
.where(role: %w[user assistant])
|
|
701
|
+
.where.not(content: [ nil, "" ])
|
|
702
|
+
.last(HISTORY_TURN_LIMIT)
|
|
703
|
+
|
|
704
|
+
budget = HISTORY_CHAR_BUDGET
|
|
705
|
+
kept = turns.reverse_each.with_object([]) do |message, collected|
|
|
706
|
+
content = message.content.to_s
|
|
707
|
+
break collected if content.length > budget
|
|
708
|
+
|
|
709
|
+
budget -= content.length
|
|
710
|
+
collected.unshift(role: message.role, content: content)
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Neither cut lands on a turn boundary, so the oldest survivor can be an
|
|
714
|
+
# assistant reply whose question was dropped. Anthropic rejects a
|
|
715
|
+
# conversation that opens on one, and every provider reads it as an
|
|
716
|
+
# answer to nothing.
|
|
717
|
+
kept.shift while kept.first && kept.first[:role] != "user"
|
|
718
|
+
kept
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Builds the message list and, in the same pass, its text-only
|
|
722
|
+
# transcript for the prompt span (data URIs are too big to trace).
|
|
723
|
+
#
|
|
724
|
+
# The first image or document rides on the user's text as a
|
|
725
|
+
# {text:, image:} / {text:, document:} message; each further one is a
|
|
726
|
+
# message of its own, since the shorthand carries one part per key.
|
|
727
|
+
def prompt_turn
|
|
728
|
+
@prompt_turn ||= begin
|
|
729
|
+
# dup: the inlined file bodies must not land on the run's own
|
|
730
|
+
# input_prompt through in-place mutation.
|
|
731
|
+
text = user_text.dup
|
|
732
|
+
media = []
|
|
733
|
+
|
|
734
|
+
attachment_records.each do |attachment|
|
|
735
|
+
blob = attachment.blob
|
|
736
|
+
filename = blob.filename.to_s
|
|
737
|
+
descriptor = "#{filename} (#{blob.content_type}, #{human_size(blob.byte_size)})"
|
|
738
|
+
|
|
739
|
+
# A file the storage service can no longer produce costs the file,
|
|
740
|
+
# not the run: every branch below degrades to the same descriptor
|
|
741
|
+
# the unsupported kinds get.
|
|
742
|
+
begin
|
|
743
|
+
case AgentRun.attachment_kind(blob.content_type, filename)
|
|
744
|
+
when "text"
|
|
745
|
+
body = text_prefix(blob)
|
|
746
|
+
suffix = body.bytesize < blob.byte_size ? "\n… (truncated)" : ""
|
|
747
|
+
text << "\n\n[Attached file: #{descriptor}]\n```\n#{body}#{suffix}\n```"
|
|
748
|
+
when "image", "document"
|
|
749
|
+
if blob.byte_size > ATTACHMENT_DATA_LIMIT
|
|
750
|
+
text << "\n\n[Attached file: #{descriptor} — not sent to the model]"
|
|
751
|
+
else
|
|
752
|
+
key = blob.content_type.to_s.start_with?("image/") ? :image : :document
|
|
753
|
+
media << { key => data_uri(blob), label: "[#{key}: #{filename}]" }
|
|
754
|
+
end
|
|
755
|
+
else
|
|
756
|
+
text << "\n\n[Attached file: #{descriptor} — not sent to the model]"
|
|
757
|
+
end
|
|
758
|
+
rescue StandardError => e
|
|
759
|
+
Rails.logger.warn("[AgentExecutionService] attachment #{filename} unreadable: #{e.message}")
|
|
760
|
+
text << "\n\n[Attached file: #{descriptor} — not sent to the model]"
|
|
761
|
+
end
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
first, *rest = media
|
|
765
|
+
history = history_messages
|
|
766
|
+
# No prompt and no files sends no turn at all, which leaves the
|
|
767
|
+
# gem's template fallback in charge exactly as before.
|
|
768
|
+
turn =
|
|
769
|
+
if first
|
|
770
|
+
{ role: "user", text: text }.merge(first.except(:label))
|
|
771
|
+
elsif text.present?
|
|
772
|
+
{ role: "user", content: text }
|
|
773
|
+
end
|
|
774
|
+
{
|
|
775
|
+
messages: history + [ turn ].compact + rest.map { |item| { role: "user" }.merge(item.except(:label)) },
|
|
776
|
+
transcript: history +
|
|
777
|
+
[ turn && { role: "user", content: [ text, first&.dig(:label) ].compact.join("\n") } ].compact +
|
|
778
|
+
rest.map { |item| { role: "user", content: item[:label] } }
|
|
779
|
+
}
|
|
780
|
+
end
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def attachment_records
|
|
784
|
+
@attachment_records ||= AgentRun.attachments_available? ? @run.attachments_attachments.includes(:blob).order(:id).to_a : []
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def data_uri(blob)
|
|
788
|
+
"data:#{blob.content_type};base64,#{Base64.strict_encode64(blob.download)}"
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
# The head of a text attachment, reading a bounded number of bytes: only
|
|
792
|
+
# ATTACHMENT_TEXT_LIMIT characters are ever sent, so a huge file must not
|
|
793
|
+
# be materialised whole to produce them. The byte prefix can split a
|
|
794
|
+
# multibyte character, which scrub removes.
|
|
795
|
+
def text_prefix(blob)
|
|
796
|
+
bytes =
|
|
797
|
+
if blob.byte_size <= ATTACHMENT_TEXT_BYTE_LIMIT
|
|
798
|
+
blob.download
|
|
799
|
+
elsif blob.service.respond_to?(:download_chunk)
|
|
800
|
+
blob.service.download_chunk(blob.key, 0...ATTACHMENT_TEXT_BYTE_LIMIT)
|
|
801
|
+
else
|
|
802
|
+
buffer = +""
|
|
803
|
+
blob.download do |chunk|
|
|
804
|
+
buffer << chunk
|
|
805
|
+
break if buffer.bytesize >= ATTACHMENT_TEXT_BYTE_LIMIT
|
|
806
|
+
end
|
|
807
|
+
buffer
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
bytes.to_s.dup.force_encoding(Encoding::UTF_8).scrub[0, ATTACHMENT_TEXT_LIMIT]
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def human_size(bytes)
|
|
814
|
+
ActiveSupport::NumberHelper.number_to_human_size(bytes, precision: 2)
|
|
537
815
|
end
|
|
538
816
|
|
|
539
817
|
# The agent's owner under the configured mode; nil when the install
|
|
@@ -563,7 +841,7 @@ module ActionAgent
|
|
|
563
841
|
tenant = ActionAgent.tenant_for(owner)
|
|
564
842
|
return if trace_model.for_account(tenant).exists?(trace_id: root_span.trace_id)
|
|
565
843
|
|
|
566
|
-
trace_model.create_from_payload(payload, sdk_info, account: tenant)
|
|
844
|
+
trace_model.create_from_payload(payload, sdk_info, account: tenant, agent: @agent_record)
|
|
567
845
|
rescue StandardError => e
|
|
568
846
|
Rails.logger.error("[AgentExecutionService] Failed to record trace #{root_span.trace_id}: #{e.class} - #{e.message}")
|
|
569
847
|
nil
|
|
@@ -10,9 +10,10 @@ module ActionAgent
|
|
|
10
10
|
# metrics can hang off.
|
|
11
11
|
#
|
|
12
12
|
# Identity is (account, service_name, agent_class, agent_action) — one agent
|
|
13
|
-
# per action, not per class.
|
|
14
|
-
# ~$0.03/run) and
|
|
15
|
-
# different agents that share a class name because one app
|
|
13
|
+
# per action, not per class. Assistant.respond (admin assistant, every MCP
|
|
14
|
+
# tool, ~$0.03/run) and Assistant.title (no tools, temperature 0.2,
|
|
15
|
+
# ~$0.0004/run) are different agents that share a class name because one app
|
|
16
|
+
# method spawns both.
|
|
16
17
|
# Collapsing them would blend a $0.03 agent with a $0.0004 one into a single
|
|
17
18
|
# meaningless cost-per-run.
|
|
18
19
|
#
|
|
@@ -60,7 +61,7 @@ module ActionAgent
|
|
|
60
61
|
@trace.agent_action.presence
|
|
61
62
|
end
|
|
62
63
|
|
|
63
|
-
# Name carries the action so the two
|
|
64
|
+
# Name carries the action so the two agents are distinguishable anywhere a
|
|
64
65
|
# bare agent name is shown.
|
|
65
66
|
def display_name
|
|
66
67
|
action_name ? "#{agent_class}.#{action_name}" : agent_class
|
|
@@ -138,8 +139,8 @@ module ActionAgent
|
|
|
138
139
|
|
|
139
140
|
# Config we can only learn by watching: the model actually used, the
|
|
140
141
|
# instructions actually sent (when content capture is on), and the tools
|
|
141
|
-
# actually called — which is how
|
|
142
|
-
#
|
|
142
|
+
# actually called — which is how Assistant.respond acquires a tool list while
|
|
143
|
+
# Assistant.title correctly stays empty.
|
|
143
144
|
def llm_attribute(key)
|
|
144
145
|
spans.filter_map { |span| span.dig("attributes", key).presence }.first
|
|
145
146
|
end
|
|
@@ -46,7 +46,7 @@ module ActionAgent
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
-
# A real browser via a Playwright MCP server (
|
|
49
|
+
# A real browser via a Playwright MCP server (PlaywrightMCPClient).
|
|
50
50
|
# Stateful: navigate changes what snapshot/click see, so these bypass
|
|
51
51
|
# the toolbox result cache.
|
|
52
52
|
"playwright_mcp" => [
|
|
@@ -121,6 +121,33 @@ module ActionAgent
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
],
|
|
124
|
+
# Generative UI. The call is the output: the runner renders the
|
|
125
|
+
# blocks straight from the persisted tool arguments, so the
|
|
126
|
+
# implementation only has to acknowledge them.
|
|
127
|
+
"ui" => [
|
|
128
|
+
{
|
|
129
|
+
name: "render_ui",
|
|
130
|
+
description: "Render interactive UI for the user instead of (or alongside) prose. Pass an array of blocks. Block types and fields: " \
|
|
131
|
+
"card {title, body (markdown), image_url?, footer?}; stat {label, value, delta?, tone? (positive|negative|neutral)}; " \
|
|
132
|
+
"stats {items: [stat...]}; table {columns: [string], rows: [[cell...]]}; " \
|
|
133
|
+
"chart {chart: bar|line|area|pie, title?, x (key), series: [key...], data: [{...}]}; " \
|
|
134
|
+
"list {title?, items: [string], ordered?}; progress {label, value (0-100)}; " \
|
|
135
|
+
"form {title?, submit? (button label), fields: [{name, label, type (text|textarea|number|select|checkbox), options?: [string], placeholder?, required?}]}; " \
|
|
136
|
+
"choices {prompt?, options: [string]}; image {url, alt?, caption?}; callout {tone (info|success|warning|danger), title?, body}; " \
|
|
137
|
+
"code {language?, code}. Blocks render top to bottom.",
|
|
138
|
+
parameters: {
|
|
139
|
+
type: "object",
|
|
140
|
+
properties: {
|
|
141
|
+
blocks: {
|
|
142
|
+
type: "array",
|
|
143
|
+
description: "UI blocks to render, in order",
|
|
144
|
+
items: { type: "object", properties: { type: { type: "string" } }, required: [ "type" ] }
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
required: [ "blocks" ]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
],
|
|
124
151
|
# Memory tools mirror solid_agent's HasMemory contract. They are NOT in
|
|
125
152
|
# FUNCTIONS below — execution is subject-bound, so AgentExecutionService
|
|
126
153
|
# routes them to the run's AgentMemory instead of this module.
|
|
@@ -160,11 +187,14 @@ module ActionAgent
|
|
|
160
187
|
"browse_page" => :browse_page,
|
|
161
188
|
"browser_navigate" => :browser_navigate,
|
|
162
189
|
"browser_snapshot" => :browser_snapshot,
|
|
163
|
-
"browser_click" => :browser_click
|
|
190
|
+
"browser_click" => :browser_click,
|
|
191
|
+
"render_ui" => :render_ui
|
|
164
192
|
}.freeze
|
|
165
193
|
|
|
166
|
-
# Stateful tools whose results must never be replayed from cache
|
|
167
|
-
|
|
194
|
+
# Stateful tools whose results must never be replayed from cache — and
|
|
195
|
+
# render_ui, whose result is the call itself, so there is nothing to
|
|
196
|
+
# replay.
|
|
197
|
+
UNCACHED_FUNCTIONS = %w[browser_navigate browser_snapshot browser_click render_ui].freeze
|
|
168
198
|
|
|
169
199
|
# Hosts browse_page may fetch — the platform's own trusted docs.
|
|
170
200
|
BROWSE_ALLOWED_HOSTS = %w[docs.activeagents.ai].freeze
|
|
@@ -258,6 +288,18 @@ module ActionAgent
|
|
|
258
288
|
{ error: e.message }
|
|
259
289
|
end
|
|
260
290
|
|
|
291
|
+
# No side effects: the blocks are rendered by the runner from the tool
|
|
292
|
+
# call's arguments. Malformed blocks are reported back so the model can
|
|
293
|
+
# fix its call rather than shipping UI the runner would drop.
|
|
294
|
+
def render_ui(blocks:)
|
|
295
|
+
valid = blocks.is_a?(Array) && blocks.all? do |block|
|
|
296
|
+
block.respond_to?(:key?) && (block[:type] || block["type"]).is_a?(String)
|
|
297
|
+
end
|
|
298
|
+
return { error: "blocks must be an array of objects, each with a string type" } unless valid
|
|
299
|
+
|
|
300
|
+
{ rendered: true, blocks: blocks.size }
|
|
301
|
+
end
|
|
302
|
+
|
|
261
303
|
# Trusted-docs browser: fetch_url restricted to BROWSE_ALLOWED_HOSTS,
|
|
262
304
|
# with HTML reduced to readable text so small models aren't drowned in
|
|
263
305
|
# markup. Accepts bare paths ("/docs/agents") against the docs host.
|
|
@@ -288,17 +330,17 @@ module ActionAgent
|
|
|
288
330
|
SNAPSHOT_LINK = /\[Snapshot\]\(([^)]+)\)/
|
|
289
331
|
|
|
290
332
|
def playwright_mcp(tool, arguments, retried: false)
|
|
291
|
-
result =
|
|
333
|
+
result = PlaywrightMCPClient.instance.call_tool(tool, arguments)
|
|
292
334
|
text = inline_snapshot(result[:text].to_s)
|
|
293
335
|
if text.length > PLAYWRIGHT_RESULT_LIMIT
|
|
294
336
|
text = "#{text[0, PLAYWRIGHT_RESULT_LIMIT]}\n…(truncated, #{text.length} chars total)"
|
|
295
337
|
end
|
|
296
338
|
result[:is_error] ? { error: text.presence || "browser tool failed" } : { text: text }
|
|
297
|
-
rescue
|
|
339
|
+
rescue PlaywrightMCPClient::Error => e
|
|
298
340
|
# One fresh-session retry: the first call after a server (re)start can
|
|
299
341
|
# race the browser launch.
|
|
300
342
|
unless retried
|
|
301
|
-
|
|
343
|
+
PlaywrightMCPClient.reset!
|
|
302
344
|
return playwright_mcp(tool, arguments, retried: true)
|
|
303
345
|
end
|
|
304
346
|
{ error: e.message }
|