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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE +21 -0
  4. data/README.md +209 -18
  5. data/Rakefile +22 -2
  6. data/docs/agent-md-spec.md +803 -0
  7. data/docs/parser-design.md +1369 -0
  8. data/docs/registry-api.md +882 -0
  9. data/examples/README.md +60 -0
  10. data/examples/manifests/changelog_writer.agent.md +81 -0
  11. data/examples/manifests/usage.rb +96 -0
  12. data/examples/memory_handoff/app/agents/researcher_agent.rb +36 -0
  13. data/examples/memory_handoff/app/agents/writer_agent.rb +41 -0
  14. data/examples/memory_handoff/usage.rb +45 -0
  15. data/examples/persistent_conversation/app/agents/support_agent.rb +59 -0
  16. data/examples/persistent_conversation/app/controllers/support_conversations_controller.rb +24 -0
  17. data/examples/persistent_conversation/app/views/agents/support/instructions.md.erb +8 -0
  18. data/examples/persistent_conversation/usage.rb +51 -0
  19. data/examples/reasoning/app/agents/analysis_agent.rb +52 -0
  20. data/examples/reasoning/usage.rb +52 -0
  21. data/examples/run_tracking/app/agents/report_agent.rb +30 -0
  22. data/examples/run_tracking/app/controllers/agent_runs_controller.rb +43 -0
  23. data/examples/run_tracking/app/jobs/document_analysis_job.rb +17 -0
  24. data/examples/run_tracking/app/services/document_analysis_run.rb +68 -0
  25. data/examples/run_tracking/usage.rb +85 -0
  26. data/examples/tool_streaming/app/agents/browser_agent.rb +65 -0
  27. data/examples/tool_streaming/app/channels/tool_status_channel.rb +24 -0
  28. data/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb +15 -0
  29. data/examples/tool_streaming/usage.rb +47 -0
  30. data/lib/generators/solid_agent/agent/agent_generator.rb +2 -2
  31. data/lib/generators/solid_agent/agent/templates/agent.rb.erb +3 -3
  32. data/lib/generators/solid_agent/context/templates/context_model.rb.erb +50 -16
  33. data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +8 -0
  34. data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +4 -0
  35. data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +11 -0
  36. data/lib/generators/solid_agent/install/install_generator.rb +9 -0
  37. data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +60 -17
  38. data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +23 -6
  39. data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
  40. data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
  41. data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
  42. data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +13 -0
  43. data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
  44. data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +5 -0
  45. data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
  46. data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
  47. data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
  48. data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
  49. data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
  50. data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
  51. data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
  52. data/lib/solid_agent/agent_manifest/errors.rb +26 -0
  53. data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
  54. data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
  55. data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
  56. data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
  57. data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
  58. data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
  59. data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
  60. data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
  61. data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
  62. data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
  63. data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
  64. data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
  65. data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
  66. data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
  67. data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
  68. data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
  69. data/lib/solid_agent/agent_manifest/resource.rb +103 -0
  70. data/lib/solid_agent/agent_manifest/tool.rb +160 -0
  71. data/lib/solid_agent/agent_manifest/validator.rb +368 -0
  72. data/lib/solid_agent/agent_manifest.rb +381 -0
  73. data/lib/solid_agent/has_context.rb +251 -30
  74. data/lib/solid_agent/has_memory.rb +136 -0
  75. data/lib/solid_agent/has_reasons.rb +230 -0
  76. data/lib/solid_agent/model_naming.rb +42 -0
  77. data/lib/solid_agent/model_pricing.rb +93 -0
  78. data/lib/solid_agent/reasonable/reason.rb +205 -0
  79. data/lib/solid_agent/reasonable.rb +181 -0
  80. data/lib/solid_agent/records/agent.rb +520 -0
  81. data/lib/solid_agent/records/agent_run.rb +520 -0
  82. data/lib/solid_agent/records/agent_template.rb +142 -0
  83. data/lib/solid_agent/records/agent_version.rb +141 -0
  84. data/lib/solid_agent/records/ownable.rb +130 -0
  85. data/lib/solid_agent/records.rb +152 -0
  86. data/lib/solid_agent/run_fingerprint.rb +51 -0
  87. data/lib/solid_agent/tool_cache.rb +91 -0
  88. data/lib/solid_agent/version.rb +1 -1
  89. data/lib/solid_agent.rb +70 -3
  90. 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 :contextable, type: :string, default: nil,
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
- @contextable = options[:contextable]
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[:<%= @contextable || 'contextable' %>]
22
+ # Context is auto-created from params[:<%= @contextual || 'contextual' %>]
23
23
  <%- if @context_name -%>
24
- has_context :<%= @context_name %>, contextable: :<%= @contextable || 'contextable' %>
24
+ has_context :<%= @context_name %>, contextual: :<%= @contextual || 'contextual' %>
25
25
  <%- else -%>
26
- has_context contextable: :<%= @contextable || 'contextable' %>
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
- def record_generation!(response)
45
- generation = generations.create!(
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.model,
48
- provider: response.provider,
49
- finish_reason: response.finish_reason,
50
- input_tokens: response.usage&.input_tokens || 0,
51
- output_tokens: response.usage&.output_tokens || 0,
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.raw_response,
54
- duration_seconds: response.duration
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, tool_calls: generation.tool_calls)
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 extract_tool_calls(response)
94
- return [] unless response.message&.tool_calls.present?
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
- response.message.tool_calls.map do |tc|
97
- { id: tc.id, name: tc.name, arguments: tc.arguments }
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