solid_agent 0.1.1 → 0.2.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/CHANGELOG.md +68 -0
- data/LICENSE +21 -0
- data/README.md +209 -18
- data/Rakefile +22 -2
- data/docs/agent-md-spec.md +803 -0
- data/docs/parser-design.md +1369 -0
- data/docs/registry-api.md +882 -0
- data/examples/README.md +60 -0
- data/examples/manifests/changelog_writer.agent.md +81 -0
- data/examples/manifests/usage.rb +96 -0
- data/examples/memory_handoff/app/agents/researcher_agent.rb +36 -0
- data/examples/memory_handoff/app/agents/writer_agent.rb +41 -0
- data/examples/memory_handoff/usage.rb +45 -0
- data/examples/persistent_conversation/app/agents/support_agent.rb +59 -0
- data/examples/persistent_conversation/app/controllers/support_conversations_controller.rb +24 -0
- data/examples/persistent_conversation/app/views/agents/support/instructions.md.erb +8 -0
- data/examples/persistent_conversation/usage.rb +51 -0
- data/examples/reasoning/app/agents/analysis_agent.rb +52 -0
- data/examples/reasoning/usage.rb +52 -0
- data/examples/run_tracking/app/agents/report_agent.rb +30 -0
- data/examples/run_tracking/app/controllers/agent_runs_controller.rb +43 -0
- data/examples/run_tracking/app/jobs/document_analysis_job.rb +17 -0
- data/examples/run_tracking/app/services/document_analysis_run.rb +68 -0
- data/examples/run_tracking/usage.rb +85 -0
- data/examples/tool_streaming/app/agents/browser_agent.rb +65 -0
- data/examples/tool_streaming/app/channels/tool_status_channel.rb +24 -0
- data/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb +15 -0
- data/examples/tool_streaming/usage.rb +47 -0
- data/lib/generators/solid_agent/agent/agent_generator.rb +2 -2
- data/lib/generators/solid_agent/agent/templates/agent.rb.erb +3 -3
- data/lib/generators/solid_agent/context/templates/context_model.rb.erb +50 -16
- data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +8 -0
- data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +4 -0
- data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +11 -0
- data/lib/generators/solid_agent/install/install_generator.rb +9 -0
- data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +60 -17
- data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +23 -6
- data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
- data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
- data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
- data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +13 -0
- data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
- data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +5 -0
- data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
- data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
- data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
- data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
- data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
- data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
- data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
- data/lib/solid_agent/agent_manifest/errors.rb +26 -0
- data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
- data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
- data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
- data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
- data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
- data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
- data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
- data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
- data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
- data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
- data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
- data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
- data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
- data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
- data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
- data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
- data/lib/solid_agent/agent_manifest/resource.rb +103 -0
- data/lib/solid_agent/agent_manifest/tool.rb +160 -0
- data/lib/solid_agent/agent_manifest/validator.rb +368 -0
- data/lib/solid_agent/agent_manifest.rb +381 -0
- data/lib/solid_agent/has_context.rb +251 -30
- data/lib/solid_agent/has_memory.rb +136 -0
- data/lib/solid_agent/has_reasons.rb +230 -0
- data/lib/solid_agent/model_naming.rb +42 -0
- data/lib/solid_agent/model_pricing.rb +93 -0
- data/lib/solid_agent/reasonable/reason.rb +205 -0
- data/lib/solid_agent/reasonable.rb +181 -0
- data/lib/solid_agent/records/agent.rb +520 -0
- data/lib/solid_agent/records/agent_run.rb +520 -0
- data/lib/solid_agent/records/agent_template.rb +142 -0
- data/lib/solid_agent/records/agent_version.rb +141 -0
- data/lib/solid_agent/records/ownable.rb +130 -0
- data/lib/solid_agent/records.rb +152 -0
- data/lib/solid_agent/run_fingerprint.rb +51 -0
- data/lib/solid_agent/tool_cache.rb +91 -0
- data/lib/solid_agent/version.rb +1 -1
- data/lib/solid_agent.rb +70 -3
- metadata +87 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The polling endpoint the progress stream exists for. Events are appended
|
|
4
|
+
# with update_column from the run's own thread, so a request mid-run reads
|
|
5
|
+
# whatever has landed so far.
|
|
6
|
+
class AgentRunsController < ApplicationController
|
|
7
|
+
def create
|
|
8
|
+
document = Document.find(params[:document_id])
|
|
9
|
+
|
|
10
|
+
run = AgentRun.create!(
|
|
11
|
+
runnable: document,
|
|
12
|
+
agent_name: "ReportAgent",
|
|
13
|
+
action_name: "analyze",
|
|
14
|
+
input_prompt: params[:question]
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
DocumentAnalysisJob.perform_later(run.id)
|
|
18
|
+
|
|
19
|
+
render json: { id: run.id, status: run.status }, status: :accepted
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def show
|
|
23
|
+
run = AgentRun.find(params[:id])
|
|
24
|
+
|
|
25
|
+
render json: {
|
|
26
|
+
status: run.status,
|
|
27
|
+
in_progress: run.in_progress?,
|
|
28
|
+
events: run.events,
|
|
29
|
+
output: run.output,
|
|
30
|
+
error: run.error_message,
|
|
31
|
+
tokens: run.total_tokens,
|
|
32
|
+
duration_ms: run.calculated_duration_ms(fallback_end: Time.current)
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def destroy
|
|
37
|
+
run = AgentRun.find(params[:id])
|
|
38
|
+
|
|
39
|
+
# Returns false when the run already finished — cancellation is a
|
|
40
|
+
# request, not a guarantee.
|
|
41
|
+
render json: { cancelled: run.cancel! }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Runs exist because the work happens somewhere the request can't watch.
|
|
4
|
+
class DocumentAnalysisJob < ApplicationJob
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
def perform(run_id)
|
|
8
|
+
run = AgentRun.find(run_id)
|
|
9
|
+
return if run.finished? # cancelled before a worker picked it up
|
|
10
|
+
|
|
11
|
+
DocumentAnalysisRun.new(run).call
|
|
12
|
+
rescue StandardError
|
|
13
|
+
# The service already recorded the failure on the run; re-raising lets
|
|
14
|
+
# Active Job apply its own retry policy.
|
|
15
|
+
raise
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A durable record of one agent execution.
|
|
4
|
+
#
|
|
5
|
+
# AgentRun is the row a background job writes so a UI has something to
|
|
6
|
+
# poll: lifecycle status, the input, the output, token and duration
|
|
7
|
+
# accounting, an append-only progress stream, and an instructions
|
|
8
|
+
# fingerprint that groups runs into cohorts when you change the prompt.
|
|
9
|
+
#
|
|
10
|
+
# Nothing in SolidAgent creates these for you — the executor does, which is
|
|
11
|
+
# what this service is. It takes a run that already exists (the controller
|
|
12
|
+
# creates it so the client has an id to poll immediately) and drives it
|
|
13
|
+
# through its lifecycle.
|
|
14
|
+
#
|
|
15
|
+
# Docs: https://docs.activeagents.ai/solid_agent/runs
|
|
16
|
+
class DocumentAnalysisRun
|
|
17
|
+
def initialize(run)
|
|
18
|
+
@run = run
|
|
19
|
+
@document = run.runnable
|
|
20
|
+
@question = run.input_prompt
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
# Fingerprint the instructions this run executed under. Runs sharing a
|
|
25
|
+
# digest are one cohort — that is how "did the new prompt help?"
|
|
26
|
+
# becomes a comparable question.
|
|
27
|
+
@run.record_instructions(ReportAgent::INSTRUCTIONS)
|
|
28
|
+
@run.trace_id ||= SecureRandom.uuid
|
|
29
|
+
@run.save!
|
|
30
|
+
@run.start!
|
|
31
|
+
|
|
32
|
+
# Progress events pair up by eid: "started" stays pending in the UI
|
|
33
|
+
# until a "done" or "error" with the same eid lands.
|
|
34
|
+
@run.append_event(kind: "llm", label: "analyze", eid: "gen-1", status: "started")
|
|
35
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
36
|
+
|
|
37
|
+
response = ReportAgent.with(
|
|
38
|
+
document: @document,
|
|
39
|
+
question: @question,
|
|
40
|
+
trace_id: @run.trace_id
|
|
41
|
+
).analyze.generate_now
|
|
42
|
+
|
|
43
|
+
@run.append_event(
|
|
44
|
+
kind: "llm", label: "analyze", eid: "gen-1", status: "done",
|
|
45
|
+
duration_ms: elapsed_ms(started)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@run.complete!(
|
|
49
|
+
output: response.message.content,
|
|
50
|
+
input_tokens: response.usage&.input_tokens,
|
|
51
|
+
output_tokens: response.usage&.output_tokens,
|
|
52
|
+
metadata: { model: "gpt-4o-mini" }
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@run
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
# fail! records the message, stamps completed_at, computes duration.
|
|
58
|
+
@run.append_event(kind: "llm", label: "analyze", eid: "gen-1", status: "error", detail: e.message)
|
|
59
|
+
@run.fail!(e)
|
|
60
|
+
raise
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def elapsed_ms(started)
|
|
66
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Runs, cohorts and cost — rails console walkthrough.
|
|
4
|
+
#
|
|
5
|
+
# Docs: https://docs.activeagents.ai/solid_agent/runs
|
|
6
|
+
|
|
7
|
+
document = Document.find(1)
|
|
8
|
+
|
|
9
|
+
# The controller creates the row so the client has an id to poll; here we
|
|
10
|
+
# do both halves in one go.
|
|
11
|
+
run = AgentRun.create!(
|
|
12
|
+
runnable: document,
|
|
13
|
+
agent_name: "ReportAgent",
|
|
14
|
+
action_name: "analyze",
|
|
15
|
+
input_prompt: "Who owns the IP?"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
DocumentAnalysisRun.new(run).call
|
|
19
|
+
|
|
20
|
+
run.status # => "complete"
|
|
21
|
+
run.finished? # => true
|
|
22
|
+
run.total_tokens
|
|
23
|
+
run.duration_ms
|
|
24
|
+
run.output
|
|
25
|
+
|
|
26
|
+
# The progress stream, oldest first. Each event is
|
|
27
|
+
# { at, eid, kind, label, status, detail, duration_ms }.
|
|
28
|
+
run.events
|
|
29
|
+
# => [{"at" => "2026-08-14T12:00:00.123Z", "eid" => "gen-1", "kind" => "llm",
|
|
30
|
+
# "label" => "analyze", "status" => "started"},
|
|
31
|
+
# {"at" => "...", "eid" => "gen-1", "kind" => "llm", "label" => "analyze",
|
|
32
|
+
# "status" => "done", "duration_ms" => 1840}]
|
|
33
|
+
|
|
34
|
+
# --- Cohorts ------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
# Runs are grouped by the instructions they executed under. The digest is
|
|
37
|
+
# stable; the codename is the readable form of the same value.
|
|
38
|
+
run.instructions_digest # => "a1b2c3d4"
|
|
39
|
+
run.instructions_codename # => "calm-heron"
|
|
40
|
+
|
|
41
|
+
AgentRun.where(instructions_digest: run.instructions_digest).count
|
|
42
|
+
|
|
43
|
+
# "Did the new instructions help?" — one row per cohort.
|
|
44
|
+
AgentRun.for_agent("ReportAgent").where(status: "complete")
|
|
45
|
+
.group(:instructions_digest)
|
|
46
|
+
.average(:duration_ms)
|
|
47
|
+
.transform_keys { |digest| SolidAgent::RunFingerprint.codename(digest) }
|
|
48
|
+
# => { "calm-heron" => 2400.0, "misty-atoll" => 1810.0 }
|
|
49
|
+
|
|
50
|
+
# Fingerprinting without a run record:
|
|
51
|
+
SolidAgent::RunFingerprint.digest(ReportAgent::INSTRUCTIONS)
|
|
52
|
+
SolidAgent::RunFingerprint.codename("a1b2c3d4")
|
|
53
|
+
|
|
54
|
+
# --- Scopes and correlation --------------------------------------------
|
|
55
|
+
|
|
56
|
+
AgentRun.recent.limit(20)
|
|
57
|
+
AgentRun.for_agent("ReportAgent").for_status("failed")
|
|
58
|
+
AgentRun.with_trace(run.trace_id)
|
|
59
|
+
AgentContext.with_trace(run.trace_id)
|
|
60
|
+
AgentGeneration.with_trace(run.trace_id)
|
|
61
|
+
|
|
62
|
+
# --- Cost ---------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
# Token counts are recorded; pricing is layered on top, so every figure is
|
|
65
|
+
# an estimate. Rates come from RubyLLM's registry when that gem is loaded
|
|
66
|
+
# and knows the model, and from a static pattern table otherwise.
|
|
67
|
+
SolidAgent::ModelPricing.estimate(
|
|
68
|
+
model: "claude-sonnet-5", input_tokens: 12_000, output_tokens: 800
|
|
69
|
+
)
|
|
70
|
+
# => 0.048
|
|
71
|
+
|
|
72
|
+
SolidAgent::ModelPricing.rate_for("gpt-4o-mini") # => [0.15, 0.6] per 1M tokens
|
|
73
|
+
|
|
74
|
+
# The generated AgentGeneration#estimated_cost uses it automatically, and
|
|
75
|
+
# takes explicit rates when you have negotiated your own.
|
|
76
|
+
AgentGeneration.recent.first.estimated_cost
|
|
77
|
+
AgentGeneration.recent.first.estimated_cost(
|
|
78
|
+
input_price_per_million: 0.10, output_price_per_million: 0.40
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Spend for a day, by model. Pricing is per-model, so total it in Ruby
|
|
82
|
+
# rather than in SQL:
|
|
83
|
+
AgentGeneration.where(created_at: 1.day.ago..)
|
|
84
|
+
.group_by(&:model)
|
|
85
|
+
.transform_values { |generations| generations.sum { |g| g.estimated_cost.to_f }.round(4) }
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rails does not guarantee net/http is loaded, and fetch_url below needs it.
|
|
4
|
+
require "net/http"
|
|
5
|
+
|
|
6
|
+
# Tools three ways, plus live progress and result caching.
|
|
7
|
+
#
|
|
8
|
+
# - `has_tools :fetch_url` loads a schema from a JSON view template, so the
|
|
9
|
+
# schema lives next to the rest of the agent's views and can use ERB.
|
|
10
|
+
# - `tool :summarize_page do ... end` defines a schema inline.
|
|
11
|
+
# - `tool_description` wraps the tool method so each call broadcasts a
|
|
12
|
+
# human-readable status over ActionCable before it runs.
|
|
13
|
+
# - `SolidAgent::ToolCache.fetch` replays an identical call instead of
|
|
14
|
+
# paying for the side effect twice.
|
|
15
|
+
#
|
|
16
|
+
# Docs: https://docs.activeagents.ai/solid_agent/tools
|
|
17
|
+
class BrowserAgent < ApplicationAgent
|
|
18
|
+
include SolidAgent::HasTools
|
|
19
|
+
include SolidAgent::StreamsToolUpdates
|
|
20
|
+
|
|
21
|
+
generate_with :openai, model: "gpt-4o-mini"
|
|
22
|
+
|
|
23
|
+
# Loaded from app/views/browser_agent/tools/fetch_url.json.erb.
|
|
24
|
+
# `has_tools` with no arguments discovers every template in that
|
|
25
|
+
# directory instead.
|
|
26
|
+
has_tools :fetch_url
|
|
27
|
+
|
|
28
|
+
tool :summarize_page do
|
|
29
|
+
description "Summarize text that was already fetched"
|
|
30
|
+
parameter :text, type: :string, required: true, description: "Page text to summarize"
|
|
31
|
+
parameter :sentences, type: :integer, default: 3
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Static or dynamic — a proc receives the tool's arguments. Declaring a
|
|
35
|
+
# description is what wraps the method for broadcasting; tools without
|
|
36
|
+
# one still run, they just stay quiet.
|
|
37
|
+
tool_description :fetch_url, ->(args) { "Fetching #{args[:url]}..." }
|
|
38
|
+
tool_description :summarize_page, "Summarizing the page..."
|
|
39
|
+
|
|
40
|
+
def browse
|
|
41
|
+
# `tools` is every schema this agent declares: templates first, then
|
|
42
|
+
# inline definitions.
|
|
43
|
+
prompt tools: tools
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Tool methods take keyword arguments and are named after the schema.
|
|
47
|
+
def fetch_url(url:)
|
|
48
|
+
# Identical (tool, args) pairs inside the TTL replay the stored result
|
|
49
|
+
# and come back tagged cached: true. Error-shaped results are never
|
|
50
|
+
# cached, so a transient failure doesn't stick for five minutes.
|
|
51
|
+
SolidAgent::ToolCache.fetch(tool: "fetch_url", args: { url: url }, ttl: 5.minutes) do
|
|
52
|
+
response = Net::HTTP.get_response(URI(url))
|
|
53
|
+
|
|
54
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
55
|
+
{ url: url, body: response.body.first(10_000) }
|
|
56
|
+
else
|
|
57
|
+
{ error: "HTTP #{response.code}" }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def summarize_page(text:, sentences: 3)
|
|
63
|
+
{ summary: text.split(/(?<=\.)\s+/).first(sentences).join(" ") }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The client half of StreamsToolUpdates. The agent broadcasts to whatever
|
|
4
|
+
# string it was handed as params[:stream_id], so the channel just streams
|
|
5
|
+
# from that name.
|
|
6
|
+
#
|
|
7
|
+
# // app/javascript/channels/tool_status_channel.js
|
|
8
|
+
# consumer.subscriptions.create(
|
|
9
|
+
# { channel: "ToolStatusChannel", stream_id: streamId },
|
|
10
|
+
# { received({ tool_status }) {
|
|
11
|
+
# document.getElementById("status").textContent = tool_status.description
|
|
12
|
+
# } }
|
|
13
|
+
# )
|
|
14
|
+
class ToolStatusChannel < ApplicationCable::Channel
|
|
15
|
+
def subscribed
|
|
16
|
+
stream_id = params[:stream_id].to_s
|
|
17
|
+
|
|
18
|
+
# Scope the id to the current user so one subscriber can't listen in on
|
|
19
|
+
# another's run.
|
|
20
|
+
reject unless stream_id.start_with?("tool_status:#{current_user.id}:")
|
|
21
|
+
|
|
22
|
+
stream_from stream_id
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "function",
|
|
3
|
+
"name": "fetch_url",
|
|
4
|
+
"description": "Fetch a web page and return its text",
|
|
5
|
+
"parameters": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"url": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "Absolute http(s) URL to fetch"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"required": ["url"]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tools, live status and caching — rails console walkthrough.
|
|
4
|
+
#
|
|
5
|
+
# Docs: https://docs.activeagents.ai/solid_agent/tools
|
|
6
|
+
|
|
7
|
+
# What the agent will send to the provider: the template-loaded schema
|
|
8
|
+
# first, then the inline one.
|
|
9
|
+
BrowserAgent.new.tools.map { |t| t[:name] }
|
|
10
|
+
# => ["fetch_url", "summarize_page"]
|
|
11
|
+
|
|
12
|
+
# Editing a JSON template while the server is running? Drop the cache:
|
|
13
|
+
agent = BrowserAgent.new
|
|
14
|
+
agent.reload_tools!
|
|
15
|
+
|
|
16
|
+
# Without a stream_id nothing is broadcast — the same agent runs silently
|
|
17
|
+
# from a job or a console.
|
|
18
|
+
BrowserAgent.with(message: "Summarize https://rubyonrails.org").browse.generate_now
|
|
19
|
+
|
|
20
|
+
# With one, every described tool announces itself before it runs:
|
|
21
|
+
# { tool_status: { name: "fetch_url",
|
|
22
|
+
# description: "Fetching https://rubyonrails.org...",
|
|
23
|
+
# timestamp: "2026-08-14T12:00:00Z" } }
|
|
24
|
+
stream_id = "tool_status:#{current_user.id}:#{SecureRandom.uuid}"
|
|
25
|
+
|
|
26
|
+
BrowserAgent.with(
|
|
27
|
+
stream_id: stream_id,
|
|
28
|
+
message: "Summarize https://rubyonrails.org"
|
|
29
|
+
).browse.generate_now
|
|
30
|
+
|
|
31
|
+
# --- Tool cache ---------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
# The cache is keyed by (tool, normalized args) — argument order and
|
|
34
|
+
# symbol/string keys don't change the key.
|
|
35
|
+
SolidAgent::ToolCache.cache_key("fetch_url", { url: "https://example.com" })
|
|
36
|
+
# => "solid_agent:tool_cache:fetch_url:9f2c..."
|
|
37
|
+
|
|
38
|
+
result = SolidAgent::ToolCache.fetch(tool: "fetch_url", args: { url: "https://example.com" }) do
|
|
39
|
+
{ body: "expensive" }
|
|
40
|
+
end
|
|
41
|
+
result[:cached] # => nil on the first call, true on a replay
|
|
42
|
+
|
|
43
|
+
# Backed by Rails.cache by default; swap the store (or switch it off) in
|
|
44
|
+
# tests and non-Rails runtimes.
|
|
45
|
+
SolidAgent::ToolCache.store = ActiveSupport::Cache::MemoryStore.new
|
|
46
|
+
SolidAgent::ToolCache.default_ttl = 60
|
|
47
|
+
SolidAgent::ToolCache.enabled = false
|
|
@@ -15,7 +15,7 @@ module SolidAgent
|
|
|
15
15
|
class_option :context_name, type: :string, default: nil,
|
|
16
16
|
desc: "Custom context name (e.g., 'conversation', 'research_session')"
|
|
17
17
|
|
|
18
|
-
class_option :
|
|
18
|
+
class_option :contextual, type: :string, default: nil,
|
|
19
19
|
desc: "Param key for auto-context (e.g., 'user', 'document')"
|
|
20
20
|
|
|
21
21
|
class_option :tools, type: :boolean, default: false,
|
|
@@ -34,7 +34,7 @@ module SolidAgent
|
|
|
34
34
|
@parent_class = options[:parent]
|
|
35
35
|
@include_context = options[:context]
|
|
36
36
|
@context_name = options[:context_name]
|
|
37
|
-
@
|
|
37
|
+
@contextual = options[:contextual]
|
|
38
38
|
@include_tools = options[:tools]
|
|
39
39
|
@include_streaming = options[:streaming]
|
|
40
40
|
@actions = options[:actions]
|
|
@@ -19,11 +19,11 @@ class <%= class_name %>Agent < <%= @parent_class %>
|
|
|
19
19
|
|
|
20
20
|
<%- if @include_context -%>
|
|
21
21
|
# Enable database-backed context persistence
|
|
22
|
-
# Context is auto-created from params[:<%= @
|
|
22
|
+
# Context is auto-created from params[:<%= @contextual || 'contextual' %>]
|
|
23
23
|
<%- if @context_name -%>
|
|
24
|
-
has_context :<%= @context_name %>,
|
|
24
|
+
has_context :<%= @context_name %>, contextual: :<%= @contextual || 'contextual' %>
|
|
25
25
|
<%- else -%>
|
|
26
|
-
has_context
|
|
26
|
+
has_context contextual: :<%= @contextual || 'contextual' %>
|
|
27
27
|
<%- end -%>
|
|
28
28
|
<%- end -%>
|
|
29
29
|
|
|
@@ -40,28 +40,46 @@ class <%= class_name %> < ApplicationRecord
|
|
|
40
40
|
options&.dig("input_params") || options&.dig(:input_params) || {}
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
# Records a generation response and updates token counts
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
# Records a generation response and updates token counts.
|
|
44
|
+
# Response attributes are read defensively — see the install generator's
|
|
45
|
+
# AgentContext template for details.
|
|
46
|
+
def record_generation!(response, extra_attributes = {})
|
|
47
|
+
usage = response.respond_to?(:usage) ? response.usage : nil
|
|
48
|
+
|
|
49
|
+
generation = generations.create!({
|
|
46
50
|
content: response.message&.content,
|
|
47
|
-
model: response
|
|
48
|
-
provider: response
|
|
49
|
-
finish_reason: response
|
|
50
|
-
input_tokens:
|
|
51
|
-
output_tokens:
|
|
51
|
+
model: response_value(response, :model),
|
|
52
|
+
provider: response_value(response, :provider),
|
|
53
|
+
finish_reason: response_value(response, :finish_reason),
|
|
54
|
+
input_tokens: usage&.input_tokens || 0,
|
|
55
|
+
output_tokens: usage&.output_tokens || 0,
|
|
56
|
+
cached_tokens: response_value(usage, :cached_tokens) || 0,
|
|
57
|
+
reasoning_tokens: response_value(usage, :reasoning_tokens) || 0,
|
|
52
58
|
tool_calls: extract_tool_calls(response),
|
|
53
|
-
raw_response: response
|
|
54
|
-
duration_seconds: response
|
|
55
|
-
)
|
|
59
|
+
raw_response: response_value(response, :raw_response),
|
|
60
|
+
duration_seconds: extract_duration_seconds(response, usage)
|
|
61
|
+
}.merge(extra_attributes))
|
|
56
62
|
|
|
57
63
|
increment!(:total_input_tokens, generation.input_tokens)
|
|
58
64
|
increment!(:total_output_tokens, generation.output_tokens)
|
|
59
65
|
|
|
60
|
-
add_assistant_message(response.message&.content,
|
|
66
|
+
add_assistant_message(response.message&.content, metadata: { "tool_calls" => generation.tool_calls })
|
|
61
67
|
|
|
62
68
|
generation
|
|
63
69
|
end
|
|
64
70
|
|
|
71
|
+
# Records a generation together with its provenance snapshot (called
|
|
72
|
+
# automatically by SolidAgent::HasContext when this method exists).
|
|
73
|
+
def record_generation_with_provenance!(response, provenance)
|
|
74
|
+
provenance = (provenance || {}).deep_stringify_keys
|
|
75
|
+
|
|
76
|
+
record_generation!(
|
|
77
|
+
response,
|
|
78
|
+
trace_id: provenance["trace_id"],
|
|
79
|
+
provenance: provenance
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
65
83
|
def add_user_message(content, **attributes)
|
|
66
84
|
messages.create!(role: "user", content: content, **attributes)
|
|
67
85
|
end
|
|
@@ -90,11 +108,27 @@ class <%= class_name %> < ApplicationRecord
|
|
|
90
108
|
|
|
91
109
|
private
|
|
92
110
|
|
|
93
|
-
def
|
|
94
|
-
|
|
111
|
+
def response_value(response, method)
|
|
112
|
+
response.respond_to?(method) ? response.public_send(method) : nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def extract_duration_seconds(response, usage)
|
|
116
|
+
return response.duration if response.respond_to?(:duration) && response.duration
|
|
95
117
|
|
|
96
|
-
|
|
97
|
-
|
|
118
|
+
duration_ms = usage.respond_to?(:duration_ms) ? usage.duration_ms : nil
|
|
119
|
+
duration_ms ? duration_ms / 1000.0 : nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def extract_tool_calls(response)
|
|
123
|
+
message = response.message
|
|
124
|
+
return [] unless message.respond_to?(:tool_calls) && message.tool_calls.present?
|
|
125
|
+
|
|
126
|
+
message.tool_calls.map do |tc|
|
|
127
|
+
{
|
|
128
|
+
id: tc.respond_to?(:id) ? tc.id : nil,
|
|
129
|
+
name: tc.respond_to?(:name) ? tc.name : nil,
|
|
130
|
+
arguments: tc.respond_to?(:arguments) ? tc.arguments : nil
|
|
131
|
+
}
|
|
98
132
|
end
|
|
99
133
|
end
|
|
100
134
|
end
|
|
@@ -18,6 +18,9 @@ class Create<%= generation_class_name.pluralize %> < ActiveRecord::Migration<%=
|
|
|
18
18
|
# Token usage for this generation
|
|
19
19
|
t.integer :input_tokens, default: 0
|
|
20
20
|
t.integer :output_tokens, default: 0
|
|
21
|
+
# Provider prompt-cache hits and extended-thinking usage
|
|
22
|
+
t.integer :cached_tokens, default: 0
|
|
23
|
+
t.integer :reasoning_tokens, default: 0
|
|
21
24
|
|
|
22
25
|
# Tool calls made in this generation
|
|
23
26
|
t.jsonb :tool_calls, default: []
|
|
@@ -28,11 +31,16 @@ class Create<%= generation_class_name.pluralize %> < ActiveRecord::Migration<%=
|
|
|
28
31
|
# Timing
|
|
29
32
|
t.float :duration_seconds
|
|
30
33
|
|
|
34
|
+
# Telemetry correlation + provenance snapshot
|
|
35
|
+
t.string :trace_id
|
|
36
|
+
t.jsonb :provenance, default: {}
|
|
37
|
+
|
|
31
38
|
t.timestamps
|
|
32
39
|
end
|
|
33
40
|
|
|
34
41
|
add_index :<%= generation_table_name %>, :model
|
|
35
42
|
add_index :<%= generation_table_name %>, :finish_reason
|
|
43
|
+
add_index :<%= generation_table_name %>, :trace_id
|
|
36
44
|
add_index :<%= generation_table_name %>, [:<%= file_name %>_id, :created_at]
|
|
37
45
|
end
|
|
38
46
|
end
|
|
@@ -23,6 +23,10 @@ class Create<%= message_class_name.pluralize %> < ActiveRecord::Migration<%= mig
|
|
|
23
23
|
# Metadata
|
|
24
24
|
t.jsonb :metadata, default: {}
|
|
25
25
|
|
|
26
|
+
# Provenance snapshot + content checksum (populated by HasContext)
|
|
27
|
+
t.jsonb :provenance, default: {}
|
|
28
|
+
t.string :content_checksum
|
|
29
|
+
|
|
26
30
|
t.timestamps
|
|
27
31
|
end
|
|
28
32
|
|
|
@@ -10,12 +10,23 @@ class <%= generation_class_name %> < ApplicationRecord
|
|
|
10
10
|
scope :recent, -> { order(created_at: :desc) }
|
|
11
11
|
scope :by_model, ->(model) { where(model: model) }
|
|
12
12
|
scope :with_tool_calls, -> { where.not(tool_calls: []) }
|
|
13
|
+
scope :with_trace, ->(trace_id) { where(trace_id: trace_id) }
|
|
13
14
|
scope :completed, -> { where(finish_reason: "stop") }
|
|
14
15
|
|
|
15
16
|
def total_tokens
|
|
16
17
|
input_tokens + output_tokens
|
|
17
18
|
end
|
|
18
19
|
|
|
20
|
+
# Provider prompt-cache hit on this generation?
|
|
21
|
+
def cache_hit?
|
|
22
|
+
cached_tokens.to_i.positive?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Extended thinking captured?
|
|
26
|
+
def thinking?
|
|
27
|
+
reasoning_tokens.to_i.positive?
|
|
28
|
+
end
|
|
29
|
+
|
|
19
30
|
def has_tool_calls?
|
|
20
31
|
tool_calls.present? && tool_calls.any?
|
|
21
32
|
end
|
|
@@ -29,6 +29,12 @@ module SolidAgent
|
|
|
29
29
|
|
|
30
30
|
migration_template "create_agent_generations.rb.erb",
|
|
31
31
|
"db/migrate/create_agent_generations.rb"
|
|
32
|
+
|
|
33
|
+
migration_template "create_agent_memories.rb.erb",
|
|
34
|
+
"db/migrate/create_agent_memories.rb"
|
|
35
|
+
|
|
36
|
+
migration_template "create_agent_runs.rb.erb",
|
|
37
|
+
"db/migrate/create_agent_runs.rb"
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
def create_models
|
|
@@ -37,6 +43,9 @@ module SolidAgent
|
|
|
37
43
|
template "agent_context.rb.erb", "app/models/agent_context.rb"
|
|
38
44
|
template "agent_message.rb.erb", "app/models/agent_message.rb"
|
|
39
45
|
template "agent_generation.rb.erb", "app/models/agent_generation.rb"
|
|
46
|
+
template "agent_memory.rb.erb", "app/models/agent_memory.rb"
|
|
47
|
+
template "agent_memory_entry.rb.erb", "app/models/agent_memory_entry.rb"
|
|
48
|
+
template "agent_run.rb.erb", "app/models/agent_run.rb"
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
def create_initializer
|