solid_agent 0.0.0 → 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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE +21 -0
  4. data/README.md +321 -0
  5. data/Rakefile +32 -0
  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 +95 -0
  31. data/lib/generators/solid_agent/agent/templates/action.text.erb +10 -0
  32. data/lib/generators/solid_agent/agent/templates/agent.rb.erb +93 -0
  33. data/lib/generators/solid_agent/context/context_generator.rb +124 -0
  34. data/lib/generators/solid_agent/context/templates/context_model.rb.erb +134 -0
  35. data/lib/generators/solid_agent/context/templates/create_context.rb.erb +32 -0
  36. data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +46 -0
  37. data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +37 -0
  38. data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +51 -0
  39. data/lib/generators/solid_agent/context/templates/message_model.rb.erb +47 -0
  40. data/lib/generators/solid_agent/install/install_generator.rb +92 -0
  41. data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +171 -0
  42. data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +76 -0
  43. data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
  44. data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
  45. data/lib/generators/solid_agent/install/templates/agent_message.rb.erb +76 -0
  46. data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
  47. data/lib/generators/solid_agent/install/templates/create_agent_contexts.rb.erb +32 -0
  48. data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +51 -0
  49. data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
  50. data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +38 -0
  51. data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
  52. data/lib/generators/solid_agent/install/templates/initializer.rb.erb +51 -0
  53. data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
  54. data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
  55. data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
  56. data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
  57. data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
  58. data/lib/generators/solid_agent/tool/templates/tool.json.erb +19 -0
  59. data/lib/generators/solid_agent/tool/tool_generator.rb +117 -0
  60. data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
  61. data/lib/solid_agent/agent_manifest/errors.rb +26 -0
  62. data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
  63. data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
  64. data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
  65. data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
  66. data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
  67. data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
  68. data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
  69. data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
  70. data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
  71. data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
  72. data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
  73. data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
  74. data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
  75. data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
  76. data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
  77. data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
  78. data/lib/solid_agent/agent_manifest/resource.rb +103 -0
  79. data/lib/solid_agent/agent_manifest/tool.rb +160 -0
  80. data/lib/solid_agent/agent_manifest/validator.rb +368 -0
  81. data/lib/solid_agent/agent_manifest.rb +381 -0
  82. data/lib/solid_agent/engine.rb +16 -0
  83. data/lib/solid_agent/has_context.rb +670 -0
  84. data/lib/solid_agent/has_memory.rb +136 -0
  85. data/lib/solid_agent/has_reasons.rb +230 -0
  86. data/lib/solid_agent/has_tools.rb +257 -0
  87. data/lib/solid_agent/model_naming.rb +42 -0
  88. data/lib/solid_agent/model_pricing.rb +93 -0
  89. data/lib/solid_agent/reasonable/reason.rb +205 -0
  90. data/lib/solid_agent/reasonable.rb +181 -0
  91. data/lib/solid_agent/records/agent.rb +520 -0
  92. data/lib/solid_agent/records/agent_run.rb +520 -0
  93. data/lib/solid_agent/records/agent_template.rb +142 -0
  94. data/lib/solid_agent/records/agent_version.rb +141 -0
  95. data/lib/solid_agent/records/ownable.rb +130 -0
  96. data/lib/solid_agent/records.rb +152 -0
  97. data/lib/solid_agent/run_fingerprint.rb +51 -0
  98. data/lib/solid_agent/streams_tool_updates.rb +178 -0
  99. data/lib/solid_agent/tool_cache.rb +91 -0
  100. data/lib/solid_agent/version.rb +5 -0
  101. data/lib/solid_agent.rb +95 -0
  102. data/sig/solid_agent.rbs +4 -0
  103. metadata +174 -14
@@ -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
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module SolidAgent
6
+ module Generators
7
+ class AgentGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Generates a new ActiveAgent agent with SolidAgent concerns"
11
+
12
+ class_option :context, type: :boolean, default: true,
13
+ desc: "Include HasContext concern"
14
+
15
+ class_option :context_name, type: :string, default: nil,
16
+ desc: "Custom context name (e.g., 'conversation', 'research_session')"
17
+
18
+ class_option :contextual, type: :string, default: nil,
19
+ desc: "Param key for auto-context (e.g., 'user', 'document')"
20
+
21
+ class_option :tools, type: :boolean, default: false,
22
+ desc: "Include HasTools concern"
23
+
24
+ class_option :streaming, type: :boolean, default: false,
25
+ desc: "Include StreamsToolUpdates concern"
26
+
27
+ class_option :actions, type: :array, default: ["perform"],
28
+ desc: "Agent actions to generate"
29
+
30
+ class_option :parent, type: :string, default: "ApplicationAgent",
31
+ desc: "Parent class for the agent"
32
+
33
+ def create_agent_file
34
+ @parent_class = options[:parent]
35
+ @include_context = options[:context]
36
+ @context_name = options[:context_name]
37
+ @contextual = options[:contextual]
38
+ @include_tools = options[:tools]
39
+ @include_streaming = options[:streaming]
40
+ @actions = options[:actions]
41
+
42
+ template "agent.rb.erb", "app/agents/#{file_name}_agent.rb"
43
+ end
44
+
45
+ def create_view_directory
46
+ empty_directory "app/views/#{file_name}_agent"
47
+
48
+ @actions.each do |action|
49
+ template "action.text.erb", "app/views/#{file_name}_agent/#{action}.text.erb",
50
+ action_name: action
51
+ end
52
+ end
53
+
54
+ def create_tools_directory
55
+ return unless @include_tools
56
+
57
+ empty_directory "app/views/#{file_name}_agent/tools"
58
+ end
59
+
60
+ def show_next_steps
61
+ say ""
62
+ say "Agent created successfully!", :green
63
+ say ""
64
+ say "Files generated:"
65
+ say " app/agents/#{file_name}_agent.rb"
66
+ say " app/views/#{file_name}_agent/"
67
+ @actions.each do |action|
68
+ say " app/views/#{file_name}_agent/#{action}.text.erb"
69
+ end
70
+ say " app/views/#{file_name}_agent/tools/" if @include_tools
71
+ say ""
72
+
73
+ if @include_tools
74
+ say "To add tools, run:", :yellow
75
+ say " rails g solid_agent:tool search #{class_name}Agent --parameters query:string:required --description \"Search for content\""
76
+ say ""
77
+ end
78
+
79
+ say "Example usage:", :yellow
80
+ say " #{class_name}Agent.with(content: \"Hello\").#{@actions.first}.generate_now"
81
+ say ""
82
+ end
83
+
84
+ private
85
+
86
+ def file_name
87
+ name.underscore
88
+ end
89
+
90
+ def class_name
91
+ name.camelize
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,10 @@
1
+ <%% # Action: <%= config[:action_name] %> %>
2
+ <%% # This template is rendered as the user message for the prompt %>
3
+
4
+ <%% if @content.present? %>
5
+ Please process the following content:
6
+
7
+ <%%= @content %>
8
+ <%% else %>
9
+ Please help me with my request.
10
+ <%% end %>
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %>Agent < <%= @parent_class %>
4
+ <%- if @include_context || @include_tools || @include_streaming -%>
5
+ # SolidAgent concerns
6
+ <%- end -%>
7
+ <%- if @include_context -%>
8
+ include SolidAgent::HasContext
9
+ <%- end -%>
10
+ <%- if @include_tools -%>
11
+ include SolidAgent::HasTools
12
+ <%- end -%>
13
+ <%- if @include_streaming -%>
14
+ include SolidAgent::StreamsToolUpdates
15
+ <%- end -%>
16
+
17
+ # Configure the generation provider and model
18
+ generate_with :openai, model: "gpt-4o"
19
+
20
+ <%- if @include_context -%>
21
+ # Enable database-backed context persistence
22
+ # Context is auto-created from params[:<%= @contextual || 'contextual' %>]
23
+ <%- if @context_name -%>
24
+ has_context :<%= @context_name %>, contextual: :<%= @contextual || 'contextual' %>
25
+ <%- else -%>
26
+ has_context contextual: :<%= @contextual || 'contextual' %>
27
+ <%- end -%>
28
+ <%- end -%>
29
+
30
+ <%- if @include_tools -%>
31
+ # Declare tools (auto-discover from app/views/<%= file_name %>_agent/tools/*.json.erb)
32
+ # has_tools
33
+ #
34
+ # Or declare specific tools:
35
+ # has_tools :search, :analyze
36
+ #
37
+ # Or define inline:
38
+ # tool :example do
39
+ # description "An example tool"
40
+ # parameter :input, type: :string, required: true
41
+ # end
42
+ <%- end -%>
43
+
44
+ <%- if @include_streaming -%>
45
+ # Streaming callbacks for real-time updates
46
+ on_stream :broadcast_chunk
47
+ on_stream_close :broadcast_complete
48
+
49
+ # Tool descriptions for UI feedback
50
+ # tool_description :search, ->(args) { "Searching for '#{args[:query]}'..." }
51
+ <%- end -%>
52
+
53
+ <%- @actions.each do |action| -%>
54
+ # Action: <%= action %>
55
+ def <%= action %>
56
+ <%- if @include_tools -%>
57
+ # Include tools in the prompt (context auto-created)
58
+ prompt(tools: tools, tool_choice: "auto")
59
+ <%- else -%>
60
+ # Render the action template (context auto-created)
61
+ prompt
62
+ <%- end -%>
63
+ end
64
+
65
+ <%- end -%>
66
+ <%- if @include_streaming -%>
67
+ private
68
+
69
+ def broadcast_chunk(chunk)
70
+ return unless chunk.delta
71
+ return unless params[:stream_id]
72
+
73
+ ActionCable.server.broadcast(params[:stream_id], { content: chunk.delta })
74
+ end
75
+
76
+ def broadcast_complete(chunk)
77
+ return unless params[:stream_id]
78
+
79
+ ActionCable.server.broadcast(params[:stream_id], { done: true })
80
+ end
81
+ <%- end -%>
82
+ <%- if @include_tools && !@include_streaming -%>
83
+ private
84
+
85
+ # Tool implementations go here
86
+ # def search(query:)
87
+ # Rails.logger.info "[<%= class_name %>Agent] Tool called: search(#{query})"
88
+ # { success: true, results: [] }
89
+ # rescue => e
90
+ # { success: false, error: e.message }
91
+ # end
92
+ <%- end -%>
93
+ end