actionagent 0.0.0 → 1.2.1
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/LICENSE +21 -0
- data/README.md +103 -0
- data/app/assets/builds/action_agent.css +2 -0
- data/app/assets/builds/action_agent.js +163 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
- data/app/controllers/action_agent/api/agents_controller.rb +411 -0
- data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
- data/app/controllers/action_agent/api/base_controller.rb +112 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
- data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
- data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
- data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
- data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
- data/app/controllers/action_agent/api/templates_controller.rb +94 -0
- data/app/controllers/action_agent/api/tools_controller.rb +58 -0
- data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
- data/app/controllers/action_agent/api/traces_controller.rb +136 -0
- data/app/controllers/action_agent/application_controller.rb +105 -0
- data/app/controllers/action_agent/dashboard_controller.rb +104 -0
- data/app/controllers/action_agent/traces_controller.rb +121 -0
- data/app/jobs/action_agent/agent_execution_job.rb +52 -0
- data/app/jobs/action_agent/application_job.rb +12 -0
- data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
- data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
- data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
- data/app/jobs/action_agent/trace_retention_job.rb +57 -0
- data/app/models/action_agent/agent.rb +343 -0
- data/app/models/action_agent/agent_context.rb +129 -0
- data/app/models/action_agent/agent_generation.rb +48 -0
- data/app/models/action_agent/agent_memory.rb +50 -0
- data/app/models/action_agent/agent_memory_entry.rb +14 -0
- data/app/models/action_agent/agent_message.rb +43 -0
- data/app/models/action_agent/agent_run.rb +151 -0
- data/app/models/action_agent/agent_template.rb +182 -0
- data/app/models/action_agent/agent_version.rb +48 -0
- data/app/models/action_agent/api_key.rb +53 -0
- data/app/models/action_agent/application_record.rb +27 -0
- data/app/models/action_agent/evaluation.rb +80 -0
- data/app/models/action_agent/evaluation_run.rb +20 -0
- data/app/models/action_agent/model_pricing.rb +80 -0
- data/app/models/action_agent/provider_key.rb +60 -0
- data/app/models/action_agent/recording_action.rb +119 -0
- data/app/models/action_agent/recording_snapshot.rb +88 -0
- data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
- data/app/models/action_agent/sandbox_run.rb +45 -0
- data/app/models/action_agent/sandbox_session.rb +160 -0
- data/app/models/action_agent/session_recording.rb +178 -0
- data/app/models/action_agent/telemetry_trace.rb +357 -0
- data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
- data/app/models/concerns/action_agent/ownable.rb +86 -0
- data/app/models/concerns/action_agent/session_recordable.rb +91 -0
- data/app/queries/action_agent/agent_executions.rb +201 -0
- data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
- data/app/serializers/action_agent/interaction_preview.rb +22 -0
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
- data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
- data/app/services/action_agent/agent_execution_service.rb +572 -0
- data/app/services/action_agent/agent_registrar.rb +197 -0
- data/app/services/action_agent/agent_scorecard.rb +191 -0
- data/app/services/action_agent/agent_toolbox.rb +504 -0
- data/app/services/action_agent/evaluation_runner_service.rb +481 -0
- data/app/services/action_agent/mcp_catalog.rb +247 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
- data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
- data/app/services/action_agent/playwright_mcp_client.rb +148 -0
- data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
- data/app/services/action_agent/session_recording_service.rb +228 -0
- data/app/services/action_agent/tool_discovery.rb +617 -0
- data/app/views/action_agent/dashboard/index.html.erb +5 -0
- data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
- data/app/views/action_agent/traces/index.html.erb +135 -0
- data/app/views/action_agent/traces/metrics.html.erb +145 -0
- data/app/views/action_agent/traces/show.html.erb +36 -0
- data/app/views/layouts/action_agent/application.html.erb +94 -0
- data/app/views/layouts/action_agent/react.html.erb +19 -0
- data/config/routes.rb +144 -0
- data/lib/action_agent/compatibility.rb +49 -0
- data/lib/action_agent/engine.rb +92 -0
- data/lib/action_agent/version.rb +5 -0
- data/lib/action_agent.rb +388 -0
- data/lib/actionagent.rb +6 -0
- data/lib/generators/action_agent/install_generator.rb +137 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
- data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +334 -0
- data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +71 -0
- metadata +209 -12
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Executes a dashboard-configured Agent through the activeagent gem and
|
|
5
|
+
# records a telemetry trace for the run.
|
|
6
|
+
#
|
|
7
|
+
# The requested provider is used when credentials are available — the
|
|
8
|
+
# account's own provider key (Settings -> Provider API Keys) when configured,
|
|
9
|
+
# else the platform keys in config/active_agent.yml. Without credentials the
|
|
10
|
+
# run fails with an actionable error: execution never falls back to mock
|
|
11
|
+
# output, so every stored run, trace and generation reflects a real provider
|
|
12
|
+
# response. (The gem's mock provider is a test double, accepted only in the
|
|
13
|
+
# test environment.)
|
|
14
|
+
#
|
|
15
|
+
# Traces are built with the gem's ActiveAgent::Telemetry::Span and persisted
|
|
16
|
+
# through TelemetryTrace.create_from_payload — the same normalizer used by
|
|
17
|
+
# the telemetry ingest endpoint — so platform-executed runs and SDK-reported
|
|
18
|
+
# runs share one pipeline.
|
|
19
|
+
class AgentExecutionService
|
|
20
|
+
# Raised when the requested provider has no usable credentials. The run is
|
|
21
|
+
# marked failed with this message — never silently degraded to mock output.
|
|
22
|
+
class ProviderNotConfiguredError < StandardError; end
|
|
23
|
+
|
|
24
|
+
SERVICE_NAME = "activeagents-platform"
|
|
25
|
+
|
|
26
|
+
def self.call(agent_record, run)
|
|
27
|
+
new(agent_record, run).call
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(agent_record, run)
|
|
31
|
+
@agent_record = agent_record
|
|
32
|
+
@run = run
|
|
33
|
+
@tool_invocations = []
|
|
34
|
+
@event_sequence = 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Emits a progress event on the run (streamed to the UI by pollers).
|
|
38
|
+
# Never lets telemetry break execution.
|
|
39
|
+
def emit_event(**kwargs)
|
|
40
|
+
@run.append_event(**kwargs)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
Rails.logger.warn("[AgentExecutionService] event emit failed: #{e.message}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def next_event_id
|
|
46
|
+
@event_sequence += 1
|
|
47
|
+
"#{@run.id}-#{@event_sequence}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Compact human preview of a tool result for the live activity feed:
|
|
51
|
+
# prefer the long readable field (page text, sub-agent output) over JSON.
|
|
52
|
+
def event_result_preview(result)
|
|
53
|
+
return nil unless result.respond_to?(:[])
|
|
54
|
+
|
|
55
|
+
readable = %i[text output content body].filter_map { |field| result[field] || result[field.to_s] }
|
|
56
|
+
.find { |value| value.is_a?(String) && value.strip.present? }
|
|
57
|
+
preview = readable ? readable.gsub(/\s+/, " ").strip : result.to_json
|
|
58
|
+
preview.byteslice(0, 1000).to_s.scrub
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def call
|
|
62
|
+
root_span = @root_span = build_root_span
|
|
63
|
+
record_prompt_span(root_span)
|
|
64
|
+
llm_span = root_span.add_span(
|
|
65
|
+
"llm.generate",
|
|
66
|
+
span_type: :llm,
|
|
67
|
+
"llm.provider" => provider.to_s,
|
|
68
|
+
"llm.model" => model
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
llm_eid = next_event_id
|
|
72
|
+
llm_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
73
|
+
emit_event(eid: llm_eid, kind: "llm", label: "#{provider}/#{model} generating", status: "started")
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
response = generate!
|
|
77
|
+
usage = response.usage
|
|
78
|
+
input = usage&.input_tokens.to_i
|
|
79
|
+
output = usage&.output_tokens.to_i
|
|
80
|
+
thinking = usage&.reasoning_tokens.to_i
|
|
81
|
+
|
|
82
|
+
llm_span.set_tokens(input: input, output: output, thinking: thinking)
|
|
83
|
+
llm_span.finish
|
|
84
|
+
emit_event(
|
|
85
|
+
eid: llm_eid, kind: "llm", label: "#{provider}/#{model} generating", status: "done",
|
|
86
|
+
duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - llm_started) * 1000).round,
|
|
87
|
+
detail: "#{input} in / #{output} out tokens#{thinking.positive? ? " / #{thinking} thinking" : ""}"
|
|
88
|
+
)
|
|
89
|
+
tool_calls = record_tool_spans(root_span, response)
|
|
90
|
+
persist_tool_messages(response)
|
|
91
|
+
sync_context_instructions
|
|
92
|
+
root_span.finish
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
output: response.message&.content,
|
|
96
|
+
metadata: {
|
|
97
|
+
provider: provider.to_s,
|
|
98
|
+
model: model,
|
|
99
|
+
action: action_name,
|
|
100
|
+
instructions: composed_instructions,
|
|
101
|
+
requested_provider: @agent_record.provider,
|
|
102
|
+
trace_id: root_span.trace_id,
|
|
103
|
+
context_id: conversation_context&.id,
|
|
104
|
+
tool_calls: tool_calls
|
|
105
|
+
},
|
|
106
|
+
usage: {
|
|
107
|
+
input_tokens: input,
|
|
108
|
+
output_tokens: output,
|
|
109
|
+
total_tokens: usage&.total_tokens || input + output + thinking
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
llm_span.record_error(e)
|
|
114
|
+
llm_span.finish
|
|
115
|
+
root_span.record_error(e)
|
|
116
|
+
root_span.finish
|
|
117
|
+
emit_event(eid: llm_eid, kind: "llm", label: "#{provider}/#{model} generating", status: "error", detail: e.message)
|
|
118
|
+
raise
|
|
119
|
+
ensure
|
|
120
|
+
record_trace(root_span)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The outbound prompt as a span, in the SDK's attribute shape — gives the
|
|
125
|
+
# Traces UI its System/User conversation rows and lets the context-pressure
|
|
126
|
+
# meter attribute instructions and tool schemas instead of lumping the
|
|
127
|
+
# whole input into "messages".
|
|
128
|
+
def record_prompt_span(root_span)
|
|
129
|
+
span = root_span.add_span("agent.prompt", span_type: :prompt)
|
|
130
|
+
if composed_instructions.present?
|
|
131
|
+
span.set_attribute("prompt.input.instructions", composed_instructions.to_s.byteslice(0, 6000).to_s.scrub)
|
|
132
|
+
end
|
|
133
|
+
if tool_schemas.present?
|
|
134
|
+
span.set_attribute("prompt.input.tools", tool_schemas.to_json.byteslice(0, 6000).to_s.scrub)
|
|
135
|
+
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)
|
|
141
|
+
span.finish
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
Rails.logger.warn("[AgentExecutionService] prompt span failed: #{e.message}")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Per-run provider/model overrides (input_params) let callers replay the
|
|
147
|
+
# same agent under a different model — the basis of evaluation comparison
|
|
148
|
+
# runs. Absent overrides, the agent's own configuration applies.
|
|
149
|
+
def run_params
|
|
150
|
+
@run_params ||= (@run.input_params || {}).with_indifferent_access
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def requested_provider
|
|
154
|
+
@requested_provider ||= (run_params[:provider_override].presence || @agent_record.provider).to_s
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def requested_model
|
|
158
|
+
@requested_model ||= run_params[:model_override].presence || @agent_record.model
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Returns the provider used for this execution, or raises when its
|
|
162
|
+
# credentials are missing.
|
|
163
|
+
def provider
|
|
164
|
+
@provider ||= begin
|
|
165
|
+
unless provider_available?(requested_provider)
|
|
166
|
+
raise ProviderNotConfiguredError,
|
|
167
|
+
"No credentials configured for provider '#{requested_provider}' — " \
|
|
168
|
+
"add an API key in Settings -> Provider API Keys, or configure platform credentials"
|
|
169
|
+
end
|
|
170
|
+
requested_provider.to_sym
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The named action this run invokes (falls back to the default). Named
|
|
175
|
+
# actions execute under composed instructions: base + the action's prompt.
|
|
176
|
+
def action_name
|
|
177
|
+
@action_name ||= begin
|
|
178
|
+
requested = @run.action_name.presence || Agent::DEFAULT_ACTION
|
|
179
|
+
@agent_record.available_actions.include?(requested) ? requested : Agent::DEFAULT_ACTION
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def composed_instructions
|
|
184
|
+
@composed_instructions ||= @agent_record.composed_instructions_for(action_name)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Routes a provider tool call to its implementation: memory tools bind to
|
|
188
|
+
# the agent record's AgentMemory (the solid_agent HasMemory contract);
|
|
189
|
+
# everything else is stateless and lives in AgentToolbox.
|
|
190
|
+
#
|
|
191
|
+
# Each call is wrapped in a live :tool span (real start/end around the
|
|
192
|
+
# execution) and recorded in @tool_invocations so tool names, arguments
|
|
193
|
+
# and durations reach Traces and the persisted conversation.
|
|
194
|
+
def execute_tool(name, **kwargs)
|
|
195
|
+
# Record the absolute URL browse_page will actually fetch, not the bare
|
|
196
|
+
# path the model passed — spans/events/persisted args stay unambiguous.
|
|
197
|
+
kwargs[:url] = AgentToolbox.resolve_browse_url(kwargs[:url]) if name.to_s == "browse_page" && kwargs[:url]
|
|
198
|
+
|
|
199
|
+
span = @root_span&.add_span("tool.#{name}", span_type: :tool)
|
|
200
|
+
span&.set_attribute("tool.name", name.to_s)
|
|
201
|
+
# tool.input.args is the key the Traces UI and TraceInteractionSerializer
|
|
202
|
+
# read — the call's in: side.
|
|
203
|
+
span&.set_attribute("tool.input.args", kwargs.to_json.byteslice(0, 500).to_s.scrub) if kwargs.present?
|
|
204
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
205
|
+
|
|
206
|
+
event_kind = name.to_s == "call_agent" ? "agent" : "tool"
|
|
207
|
+
event_label = name.to_s == "call_agent" ? "call_agent → #{kwargs[:slug]}" : name.to_s
|
|
208
|
+
event_id = next_event_id
|
|
209
|
+
emit_event(eid: event_id, kind: event_kind, label: event_label, status: "started", detail: kwargs.to_json)
|
|
210
|
+
|
|
211
|
+
result = begin
|
|
212
|
+
case name.to_s
|
|
213
|
+
when "save_memory"
|
|
214
|
+
entry = agent_memory.remember(
|
|
215
|
+
kwargs[:content].to_s,
|
|
216
|
+
source_agent: agent_class_name,
|
|
217
|
+
category: kwargs[:category]
|
|
218
|
+
)
|
|
219
|
+
{ saved: true, id: entry.id, content: entry.content }
|
|
220
|
+
when "recall_memory"
|
|
221
|
+
entries = agent_memory.recall(limit: kwargs[:limit], category: kwargs[:category])
|
|
222
|
+
{
|
|
223
|
+
count: entries.size,
|
|
224
|
+
entries: entries.map do |entry|
|
|
225
|
+
{
|
|
226
|
+
content: entry.content,
|
|
227
|
+
category: entry.category,
|
|
228
|
+
source_agent: entry.source_agent,
|
|
229
|
+
created_at: entry.created_at&.iso8601
|
|
230
|
+
}.compact
|
|
231
|
+
end
|
|
232
|
+
}
|
|
233
|
+
when "call_agent"
|
|
234
|
+
call_agent(slug: kwargs[:slug], message: kwargs[:message])
|
|
235
|
+
else
|
|
236
|
+
AgentToolbox.call(name, **kwargs)
|
|
237
|
+
end
|
|
238
|
+
rescue StandardError => e
|
|
239
|
+
Rails.logger.warn("[AgentExecutionService] Tool #{name} failed: #{e.class} - #{e.message}")
|
|
240
|
+
{ error: "#{name} failed: #{e.message}" }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
|
|
244
|
+
errored = result.respond_to?(:key?) && (result.key?(:error) || result.key?("error"))
|
|
245
|
+
span&.set_attribute("tool.error", true) if errored
|
|
246
|
+
# Record the readable side of the result (most tools wrap one long text
|
|
247
|
+
# field); byteslicing whole-JSON breaks it mid-string and the UI can't
|
|
248
|
+
# parse the remainder.
|
|
249
|
+
result_text =
|
|
250
|
+
if result.respond_to?(:key?) && (result[:text] || result["text"]).is_a?(String)
|
|
251
|
+
result[:text] || result["text"]
|
|
252
|
+
else
|
|
253
|
+
result.to_json
|
|
254
|
+
end
|
|
255
|
+
span&.set_attribute("tool.output.result", result_text.byteslice(0, 4000).to_s.scrub)
|
|
256
|
+
span&.finish
|
|
257
|
+
emit_event(
|
|
258
|
+
eid: event_id, kind: event_kind, label: event_label,
|
|
259
|
+
status: errored ? "error" : "done", duration_ms: duration_ms,
|
|
260
|
+
detail: errored ? (result[:error] || result["error"]).to_s : event_result_preview(result)
|
|
261
|
+
)
|
|
262
|
+
@tool_invocations << {
|
|
263
|
+
name: name.to_s,
|
|
264
|
+
arguments: kwargs,
|
|
265
|
+
duration_ms: duration_ms,
|
|
266
|
+
error: errored
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
result
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private
|
|
273
|
+
|
|
274
|
+
# Maximum agent-to-agent delegation depth for the call_agent tool. A
|
|
275
|
+
# thread-local counter guards it because the sub-agent runs synchronously
|
|
276
|
+
# on the same thread via Agent#test_execute.
|
|
277
|
+
MAX_CALL_DEPTH = 2
|
|
278
|
+
|
|
279
|
+
# Executes another agent of the same account synchronously and returns
|
|
280
|
+
# its reply, so agents can delegate to each other as a tool call. The
|
|
281
|
+
# sub-run is a real AgentRun with its own trace.
|
|
282
|
+
def call_agent(slug:, message:)
|
|
283
|
+
depth = Thread.current[:agent_call_depth].to_i
|
|
284
|
+
return { error: "call_agent depth limit (#{MAX_CALL_DEPTH}) reached" } if depth >= MAX_CALL_DEPTH
|
|
285
|
+
|
|
286
|
+
target = workspace_agents.where.not(id: @agent_record.id).find_by(slug: slug.to_s)
|
|
287
|
+
return { error: "No agent with slug '#{slug}' in this workspace" } unless target
|
|
288
|
+
|
|
289
|
+
Thread.current[:agent_call_depth] = depth + 1
|
|
290
|
+
begin
|
|
291
|
+
sub_run = target.test_execute(message.to_s)
|
|
292
|
+
{
|
|
293
|
+
agent: target.slug,
|
|
294
|
+
run_id: sub_run.id,
|
|
295
|
+
status: sub_run.status,
|
|
296
|
+
output: sub_run.output.presence || sub_run.error_message
|
|
297
|
+
}
|
|
298
|
+
ensure
|
|
299
|
+
Thread.current[:agent_call_depth] = depth
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Keeps the persisted context's instructions current so the Interactions
|
|
304
|
+
# view can render the conversation's system message.
|
|
305
|
+
def sync_context_instructions
|
|
306
|
+
context = conversation_context
|
|
307
|
+
return unless context
|
|
308
|
+
return if context.instructions == composed_instructions
|
|
309
|
+
|
|
310
|
+
context.update_column(:instructions, composed_instructions)
|
|
311
|
+
rescue StandardError => e
|
|
312
|
+
Rails.logger.warn("[AgentExecutionService] Failed to sync context instructions: #{e.message}")
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Agents callable via call_agent: everything the calling agent's owner
|
|
316
|
+
# owns. A single-user install has no owner, so every agent is in scope.
|
|
317
|
+
def workspace_agents
|
|
318
|
+
ActionAgent.agents_for(owner).where.not(status: :archived)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def agent_memory
|
|
322
|
+
@agent_memory ||= AgentMemory.for(@agent_record)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def model
|
|
326
|
+
requested_model
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Newer Anthropic models (Opus 4.7+, Sonnet 5, Fable 5/Mythos 5) reject
|
|
330
|
+
# sampling parameters with a 400 — they are thinking-first models steered
|
|
331
|
+
# by prompting/effort instead.
|
|
332
|
+
SAMPLING_UNSUPPORTED_MODELS = /\Aclaude-(opus-5|opus-4-[78]|sonnet-5|fable-5|mythos-5)/
|
|
333
|
+
|
|
334
|
+
def generate!
|
|
335
|
+
effective_provider = provider
|
|
336
|
+
provider_model = requested_model
|
|
337
|
+
model_options = @agent_record.model_config.to_h.symbolize_keys.slice(:temperature, :max_tokens, :top_p)
|
|
338
|
+
model_options.except!(:temperature, :top_p) if provider_model.to_s.match?(SAMPLING_UNSUPPORTED_MODELS)
|
|
339
|
+
# The owner's own credential (API key, or host URL for ollama)
|
|
340
|
+
# overrides the host app's config/active_agent.yml settings.
|
|
341
|
+
model_options.merge!(owner_provider_options(effective_provider))
|
|
342
|
+
klass_name = agent_class_name
|
|
343
|
+
agent_record = @agent_record
|
|
344
|
+
input = @run.input_prompt
|
|
345
|
+
instructions = composed_instructions
|
|
346
|
+
action = action_name
|
|
347
|
+
run_trace_id = trace_id
|
|
348
|
+
tool_definitions = tool_schemas
|
|
349
|
+
service = self
|
|
350
|
+
|
|
351
|
+
agent_class = Class.new(ActiveAgent::Base) do
|
|
352
|
+
# SolidAgent persists contexts under self.class.name; anonymous
|
|
353
|
+
# classes would fail its agent_name presence validation.
|
|
354
|
+
define_singleton_method(:name) { klass_name }
|
|
355
|
+
|
|
356
|
+
# Persist the conversation (agent_contexts / agent_messages /
|
|
357
|
+
# agent_generations) via solid_agent. contextable: false — the context
|
|
358
|
+
# is loaded explicitly in the action below.
|
|
359
|
+
#
|
|
360
|
+
# The model classes are named explicitly because solid_agent infers
|
|
361
|
+
# bare "AgentContext"/"AgentMessage"/"AgentGeneration" and resolves
|
|
362
|
+
# them against Object. The engine's models are namespaced, so the
|
|
363
|
+
# inferred names only resolve in a host app that happens to have
|
|
364
|
+
# top-level models of its own.
|
|
365
|
+
include SolidAgent::HasContext
|
|
366
|
+
has_context contextable: false,
|
|
367
|
+
class_name: "ActionAgent::AgentContext",
|
|
368
|
+
message_class: "ActionAgent::AgentMessage",
|
|
369
|
+
generation_class: "ActionAgent::AgentGeneration"
|
|
370
|
+
|
|
371
|
+
if effective_provider == :mock
|
|
372
|
+
# Test environment only (see #provider_available?).
|
|
373
|
+
generate_with :mock
|
|
374
|
+
else
|
|
375
|
+
generate_with effective_provider, model: provider_model, **model_options
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Expose the agent's server-executable tools as public methods so the
|
|
379
|
+
# gem's tools_function can route provider tool calls to them. The
|
|
380
|
+
# service routes each call to AgentToolbox or, for memory tools, to
|
|
381
|
+
# the run's AgentMemory.
|
|
382
|
+
tool_definitions.each do |definition|
|
|
383
|
+
define_method(definition[:name]) do |**kwargs|
|
|
384
|
+
service.execute_tool(definition[:name], **kwargs)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# One method per invokable action (the default plus each named action
|
|
389
|
+
# prompt) — solid_agent keys the persisted context by action_name, so
|
|
390
|
+
# each action gets its own interaction stream.
|
|
391
|
+
define_method action do
|
|
392
|
+
# Thread the run's telemetry trace_id through prompt_options so
|
|
393
|
+
# SolidAgent's provenance (and AgentContext#record_generation_with_
|
|
394
|
+
# provenance!) can correlate the persisted generation with its trace.
|
|
395
|
+
prompt_options[:trace_id] = run_trace_id
|
|
396
|
+
load_context(contextable: agent_record)
|
|
397
|
+
|
|
398
|
+
options = { message: input }
|
|
399
|
+
options[:instructions] = instructions if instructions.present?
|
|
400
|
+
options[:tools] = tool_definitions if tool_definitions.present?
|
|
401
|
+
prompt(**options)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
agent_class.public_send(action).generate_now
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Function-calling schemas for the agent's enabled tools that have
|
|
409
|
+
# server-side implementations (none for mock runs — the mock provider
|
|
410
|
+
# doesn't do tool calling).
|
|
411
|
+
def tool_schemas
|
|
412
|
+
return [] if provider == :mock
|
|
413
|
+
|
|
414
|
+
AgentToolbox.definitions_for(@agent_record.tools)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Persists the tool interaction stream to the solid_agent conversation
|
|
418
|
+
# context so the Interactions view shows the full agent <-> tool
|
|
419
|
+
# exchange. Deduped by tool_call_id — newer solid_agent versions persist
|
|
420
|
+
# these from HasContext already, in which case this is a no-op.
|
|
421
|
+
def persist_tool_messages(response)
|
|
422
|
+
context = conversation_context
|
|
423
|
+
return unless context
|
|
424
|
+
return unless response.respond_to?(:messages)
|
|
425
|
+
|
|
426
|
+
tool_messages = Array(response.messages).select do |message|
|
|
427
|
+
message.respond_to?(:role) && message.role.to_s == "tool"
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
tool_messages.each_with_index do |message, index|
|
|
431
|
+
tool_call_id = message.respond_to?(:tool_call_id) ? message.tool_call_id : nil
|
|
432
|
+
next if tool_call_id.present? && context.messages.exists?(role: "tool", tool_call_id: tool_call_id)
|
|
433
|
+
|
|
434
|
+
# Provider tool messages often carry no name (Ollama's don't); fall
|
|
435
|
+
# back to the service's own invocation record, matched by order.
|
|
436
|
+
invocation = @tool_invocations[index]
|
|
437
|
+
name = (message.name if message.respond_to?(:name)).presence || invocation&.dig(:name)
|
|
438
|
+
|
|
439
|
+
context.add_tool_message(
|
|
440
|
+
tool_call_id: tool_call_id,
|
|
441
|
+
tool_name: name,
|
|
442
|
+
result: (message.content if message.respond_to?(:content)),
|
|
443
|
+
arguments: invocation&.dig(:arguments),
|
|
444
|
+
duration_ms: invocation&.dig(:duration_ms)
|
|
445
|
+
)
|
|
446
|
+
end
|
|
447
|
+
rescue StandardError => e
|
|
448
|
+
Rails.logger.error("[AgentExecutionService] Failed to persist tool messages: #{e.message}")
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Tool names for run metadata. Spans are recorded live in execute_tool;
|
|
452
|
+
# the response-message scan only covers calls the provider executed
|
|
453
|
+
# without routing through the service (none today, but cheap insurance).
|
|
454
|
+
def record_tool_spans(root_span, response)
|
|
455
|
+
return @tool_invocations.map { |invocation| invocation[:name] } if @tool_invocations.any?
|
|
456
|
+
|
|
457
|
+
messages = response.respond_to?(:messages) ? Array(response.messages) : []
|
|
458
|
+
tool_messages = messages.select { |message| message.respond_to?(:role) && message.role.to_s == "tool" }
|
|
459
|
+
|
|
460
|
+
tool_messages.map do |message|
|
|
461
|
+
name = message.respond_to?(:name) && message.name.presence || "unknown"
|
|
462
|
+
tool_span = root_span.add_span("tool.#{name}", span_type: :tool)
|
|
463
|
+
tool_span.set_attribute("tool.name", name)
|
|
464
|
+
if message.respond_to?(:tool_call_id) && message.tool_call_id.present?
|
|
465
|
+
tool_span.set_attribute("tool.id", message.tool_call_id)
|
|
466
|
+
end
|
|
467
|
+
tool_span.finish
|
|
468
|
+
name
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def provider_available?(name)
|
|
473
|
+
# The gem's mock provider is a test double: accepted only in the test
|
|
474
|
+
# environment so app runs can never store fabricated output.
|
|
475
|
+
return Rails.env.test? if name.to_s == "mock"
|
|
476
|
+
return true if owner_provider_options(name).any?
|
|
477
|
+
|
|
478
|
+
config = ActiveAgent.configuration[name.to_sym]
|
|
479
|
+
return false unless config.respond_to?(:[])
|
|
480
|
+
|
|
481
|
+
if name.to_s == "ollama"
|
|
482
|
+
config[:host].present?
|
|
483
|
+
else
|
|
484
|
+
config[:access_token].present?
|
|
485
|
+
end
|
|
486
|
+
rescue StandardError
|
|
487
|
+
false
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Credential overrides for +name+: whatever the host app resolves for
|
|
491
|
+
# this owner first, then the dashboard's own stored ProviderKey.
|
|
492
|
+
def owner_provider_options(name)
|
|
493
|
+
@owner_provider_options ||= {}
|
|
494
|
+
@owner_provider_options[name.to_s] ||= begin
|
|
495
|
+
from_host = ActionAgent.provider_credentials(owner, name.to_s)
|
|
496
|
+
from_host.presence || ProviderKey.for_owner(owner).find_by(provider: name.to_s)&.generation_options || {}
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def build_root_span
|
|
501
|
+
ActiveAgent::Telemetry::Span.new(
|
|
502
|
+
"#{agent_class_name}.prompt",
|
|
503
|
+
trace_id: trace_id,
|
|
504
|
+
span_type: :root,
|
|
505
|
+
"agent.class" => agent_class_name,
|
|
506
|
+
"agent.action" => action_name,
|
|
507
|
+
"agent.provider" => provider.to_s,
|
|
508
|
+
"agent.model" => model,
|
|
509
|
+
"service.name" => SERVICE_NAME,
|
|
510
|
+
"service.environment" => Rails.env,
|
|
511
|
+
"telemetry.sdk.name" => "activeagent",
|
|
512
|
+
"telemetry.sdk.version" => ActiveAgent::VERSION
|
|
513
|
+
)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def agent_class_name
|
|
517
|
+
@agent_record.telemetry_agent_class
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Reuse the run's trace_id so AgentRun and TelemetryTrace correlate.
|
|
521
|
+
def trace_id
|
|
522
|
+
@trace_id ||= @run.trace_id.presence || SecureRandom.hex(16)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# The solid_agent conversation context this execution persisted into
|
|
526
|
+
# (one per agent + action on this platform).
|
|
527
|
+
def conversation_context
|
|
528
|
+
AgentContext.find_by(contextable: @agent_record, agent_name: agent_class_name, action_name: action_name)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# The agent's owner under the configured mode; nil when the install
|
|
532
|
+
# has no owner model at all.
|
|
533
|
+
def owner
|
|
534
|
+
@owner ||= @agent_record.owner
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def record_trace(root_span)
|
|
538
|
+
payload = {
|
|
539
|
+
trace_id: root_span.trace_id,
|
|
540
|
+
service_name: SERVICE_NAME,
|
|
541
|
+
environment: Rails.env,
|
|
542
|
+
timestamp: Time.current.iso8601(6),
|
|
543
|
+
resource_attributes: { "platform.agent_id" => @agent_record.id, "platform.run_id" => @run.id },
|
|
544
|
+
spans: flatten_spans(root_span)
|
|
545
|
+
}.as_json
|
|
546
|
+
|
|
547
|
+
sdk_info = {
|
|
548
|
+
name: "activeagent",
|
|
549
|
+
version: ActiveAgent::VERSION,
|
|
550
|
+
language: "ruby",
|
|
551
|
+
runtime_version: RUBY_VERSION
|
|
552
|
+
}.as_json
|
|
553
|
+
|
|
554
|
+
trace_model = ActionAgent.trace_model
|
|
555
|
+
tenant = ActionAgent.tenant_for(owner)
|
|
556
|
+
return if trace_model.for_account(tenant).exists?(trace_id: root_span.trace_id)
|
|
557
|
+
|
|
558
|
+
trace_model.create_from_payload(payload, sdk_info, account: tenant)
|
|
559
|
+
rescue StandardError => e
|
|
560
|
+
Rails.logger.error("[AgentExecutionService] Failed to record trace #{root_span.trace_id}: #{e.class} - #{e.message}")
|
|
561
|
+
nil
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# Flattens the span hierarchy the same way the gem's Tracer does before
|
|
565
|
+
# reporting (children stripped, parent_span_id links preserved).
|
|
566
|
+
def flatten_spans(span)
|
|
567
|
+
result = [ span.to_h.except(:children) ]
|
|
568
|
+
span.children.each { |child| result.concat(flatten_spans(child)) }
|
|
569
|
+
result
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
end
|