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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -3
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +69 -43
  5. data/app/controllers/action_agent/api/agent_runs_controller.rb +28 -8
  6. data/app/controllers/action_agent/api/agents_controller.rb +191 -56
  7. data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
  8. data/app/controllers/action_agent/api/base_controller.rb +16 -0
  9. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +83 -0
  10. data/app/controllers/action_agent/api/evaluations_controller.rb +252 -7
  11. data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
  12. data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
  13. data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
  14. data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
  15. data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
  16. data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
  17. data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
  18. data/app/controllers/action_agent/api/templates_controller.rb +25 -21
  19. data/app/controllers/action_agent/api/traces_controller.rb +25 -5
  20. data/app/controllers/action_agent/api/usage_controller.rb +20 -0
  21. data/app/controllers/action_agent/application_controller.rb +25 -2
  22. data/app/controllers/action_agent/dashboard_controller.rb +3 -1
  23. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
  24. data/app/jobs/action_agent/agent_execution_job.rb +40 -20
  25. data/app/jobs/action_agent/application_job.rb +7 -3
  26. data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
  27. data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
  28. data/app/models/action_agent/agent.rb +74 -23
  29. data/app/models/action_agent/agent_run.rb +99 -0
  30. data/app/models/action_agent/agent_template.rb +22 -7
  31. data/app/models/action_agent/evaluation.rb +64 -4
  32. data/app/models/action_agent/evaluation_run.rb +190 -2
  33. data/app/models/action_agent/evaluation_scenario.rb +59 -0
  34. data/app/models/action_agent/evaluation_scenario_result.rb +86 -0
  35. data/app/models/action_agent/recording_action.rb +11 -7
  36. data/app/models/action_agent/sandbox_session.rb +1 -1
  37. data/app/models/action_agent/session_recording.rb +31 -8
  38. data/app/models/action_agent/telemetry_trace.rb +126 -3
  39. data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
  40. data/app/models/concerns/action_agent/ownable.rb +15 -2
  41. data/app/queries/action_agent/metrics_report.rb +498 -0
  42. data/app/serializers/action_agent/agent_message_serializer.rb +1 -0
  43. data/app/services/action_agent/agent_execution_service.rb +294 -16
  44. data/app/services/action_agent/agent_registrar.rb +7 -6
  45. data/app/services/action_agent/agent_toolbox.rb +49 -7
  46. data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
  47. data/app/services/action_agent/evaluation_evidence.rb +234 -0
  48. data/app/services/action_agent/evaluation_runner_service.rb +13 -3
  49. data/app/services/action_agent/evaluation_tool_resolver.rb +162 -0
  50. data/app/services/action_agent/mcp_catalog.rb +46 -8
  51. data/app/services/action_agent/mcp_client.rb +167 -0
  52. data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
  53. data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
  54. data/app/services/action_agent/playwright_mcp_client.rb +11 -126
  55. data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
  56. data/app/services/action_agent/scenario_evaluation_runner.rb +260 -0
  57. data/app/services/action_agent/tool_discovery.rb +22 -8
  58. data/config/routes.rb +36 -3
  59. data/lib/action_agent/assistant_request_filter.rb +22 -0
  60. data/lib/action_agent/engine.rb +106 -19
  61. data/lib/action_agent/version.rb +1 -1
  62. data/lib/action_agent.rb +104 -6
  63. data/lib/generators/action_agent/install_generator.rb +20 -7
  64. data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
  65. data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
  66. data/lib/tasks/action_agent.rake +9 -0
  67. 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
- span.set_attribute(
137
- "prompt.input.messages",
138
- [ { role: "user", content: @run.input_prompt.to_s.byteslice(0, 4000).to_s.scrub } ].to_json
139
- )
140
- span.set_attribute("messages.count", 1)
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
- AgentToolbox.call(name, **kwargs)
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
- input = @run.input_prompt
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
- load_context(contextable: agent_record)
491
+ if pinned
492
+ load_context(context_id: pinned.id)
493
+ else
494
+ load_context(contextable: agent_record)
495
+ end
405
496
 
406
- options = { message: input }
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
- AgentToolbox.definitions_for(@agent_record.tools)
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
- # (one per agent + action on this platform).
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
- AgentContext.find_by(contextable: @agent_record, agent_name: agent_class_name, action_name: action_name)
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. Clara.respond (admin assistant, every MCP tool,
14
- # ~$0.03/run) and Clara.title (no tools, temperature 0.2, ~$0.0004/run) are
15
- # different agents that share a class name because one app method spawns both.
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 Claras are distinguishable anywhere a
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 Clara.respond acquires a tool list while
142
- # Clara.title correctly stays empty.
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 (PlaywrightMcpClient).
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
- UNCACHED_FUNCTIONS = %w[browser_navigate browser_snapshot browser_click].freeze
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 = PlaywrightMcpClient.instance.call_tool(tool, arguments)
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 PlaywrightMcpClient::Error => e
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
- PlaywrightMcpClient.reset!
343
+ PlaywrightMCPClient.reset!
302
344
  return playwright_mcp(tool, arguments, retried: true)
303
345
  end
304
346
  { error: e.message }