durable_agent 0.1.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/DESIGN.md +614 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +23 -0
  6. data/Rakefile +46 -0
  7. data/app/controllers/durable_agent/application_controller.rb +15 -0
  8. data/app/controllers/durable_agent/events_controller.rb +28 -0
  9. data/app/controllers/durable_agent/tools_controller.rb +33 -0
  10. data/app/models/concerns/durable_agent/harness_record.rb +11 -0
  11. data/app/models/durable_agent/application_record.rb +5 -0
  12. data/app/models/durable_agent/connection.rb +66 -0
  13. data/app/models/durable_agent/conversation.rb +107 -0
  14. data/app/models/durable_agent/document/revision.rb +16 -0
  15. data/app/models/durable_agent/document.rb +27 -0
  16. data/app/models/durable_agent/entry.rb +56 -0
  17. data/app/models/durable_agent/report.rb +14 -0
  18. data/app/models/durable_agent/submission.rb +57 -0
  19. data/app/models/durable_agent/task.rb +17 -0
  20. data/config/routes.rb +5 -0
  21. data/docs/guide.md +421 -0
  22. data/lib/durable_agent/account.rb +29 -0
  23. data/lib/durable_agent/client.rb +121 -0
  24. data/lib/durable_agent/configuration.rb +81 -0
  25. data/lib/durable_agent/current.rb +6 -0
  26. data/lib/durable_agent/engine.rb +7 -0
  27. data/lib/durable_agent/indexed_string.rb +14 -0
  28. data/lib/durable_agent/message_text.rb +23 -0
  29. data/lib/durable_agent/sidecar.rb +97 -0
  30. data/lib/durable_agent/test_helper.rb +110 -0
  31. data/lib/durable_agent/tool.rb +91 -0
  32. data/lib/durable_agent/version.rb +3 -0
  33. data/lib/durable_agent.rb +67 -0
  34. data/lib/generators/durable_agent/install/install_generator.rb +75 -0
  35. data/lib/generators/durable_agent/install/templates/create_durable_agent_tables.rb.tt +120 -0
  36. data/lib/generators/durable_agent/install/templates/durable_agent.rb.tt +36 -0
  37. data/lib/tasks/durable_agent_tasks.rake +6 -0
  38. data/sidecar/dist/sidecar.js +76417 -0
  39. data/sidecar/package.json +32 -0
  40. data/sidecar/sql/schema.postgres.sql +120 -0
  41. data/sidecar/sql/schema.sqlite.sql +120 -0
  42. metadata +115 -0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Mikkel Malmberg
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # durable_agent
2
+
3
+ Crash-safe, resumable AI agent conversations for Rails. The gem ships [pi-durable](https://github.com/earendil-works/pi-durable)
4
+ as a bundled sidecar process that keeps its state in your app's own database, Postgres or SQLite, which Rails reads
5
+ with Active Record. Tools are Ruby classes. Pages update over Turbo.
6
+
7
+ ```sh
8
+ bundle add durable_agent
9
+ bin/rails g durable_agent:install # migration, initializer, mount, Procfile.dev line
10
+ bin/rails db:migrate
11
+ bin/dev # web, sidecar
12
+ ```
13
+
14
+ ```ruby
15
+ conversation = DurableAgent.start
16
+ conversation.say("Summarize the invoice", request_id: message.id)
17
+ conversation.transcript
18
+ ```
19
+
20
+ Early. The contract is in [DESIGN.md](DESIGN.md), the guide in [docs/guide.md](docs/guide.md), the example app in
21
+ [test/dummy](test/dummy). Needs Ruby 3.3+, Rails 8+, Node 22.19+.
22
+
23
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,46 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
7
+
8
+ # The gem ships the bundled sidecar (sidecar/dist/sidecar.js), which is not checked in. `rake build` (and
9
+ # `rake release`, which runs it) bundles it first so it is never missing or stale; the gemspec refuses a bare
10
+ # `gem build` without it.
11
+ namespace :sidecar do
12
+ desc "Bundle the sidecar into sidecar/dist/sidecar.js"
13
+ task :build do
14
+ sh "npm ci --prefix sidecar"
15
+ sh "npm run build --prefix sidecar"
16
+ abort "sidecar/dist/sidecar.js was not built" unless File.exist?(File.expand_path("sidecar/dist/sidecar.js", __dir__))
17
+ end
18
+ end
19
+ task build: "sidecar:build"
20
+
21
+ # The dummy app's pages need its Tailwind build (gitignored), so the test tasks build it first.
22
+ namespace :dummy do
23
+ desc "Build test/dummy's CSS"
24
+ task :css do
25
+ sh "cd test/dummy && bin/rails tailwindcss:build"
26
+ end
27
+ end
28
+
29
+ # The suite runs on either database; database.yml picks the adapter from DURABLE_AGENT_DB.
30
+ desc "Run the tests on SQLite"
31
+ task test: "dummy:css" do
32
+ sh({ "DURABLE_AGENT_DB" => "sqlite" }, "bin/rails test")
33
+ end
34
+
35
+ namespace :test do
36
+ desc "Run the tests on Postgres (durable_agent_test)"
37
+ task postgres: "dummy:css" do
38
+ sh({ "DURABLE_AGENT_DB" => "postgres", "RAILS_ENV" => "test" }, "bin/rails db:create")
39
+ sh({ "DURABLE_AGENT_DB" => "postgres" }, "bin/rails test")
40
+ end
41
+
42
+ desc "Run the tests on SQLite, then on Postgres"
43
+ task all: [ :test, :postgres ]
44
+ end
45
+
46
+ task default: :test
@@ -0,0 +1,15 @@
1
+ module DurableAgent
2
+ # The engine renders no views: its only endpoints are the ones the sidecar calls.
3
+ class ApplicationController < ActionController::API
4
+ before_action :authenticate_sidecar
5
+
6
+ private
7
+ # `Authorization: Bearer <token>`, exactly: a bare token, another scheme or extra whitespace is refused.
8
+ def authenticate_sidecar
9
+ scheme, given = request.authorization.to_s.split(/ /, 2)
10
+ unless scheme == "Bearer" && ActiveSupport::SecurityUtils.secure_compare(given.to_s, DurableAgent.config.token)
11
+ render json: { error: "unauthorized" }, status: :unauthorized
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,28 @@
1
+ module DurableAgent
2
+ # The sidecar posts the conversations and reports each commit touched, and the providers whose sign-in changed.
3
+ # Events are a hint: the state is already in the database, so ids that do not exist are skipped and a failing
4
+ # callback never fails the request.
5
+ class EventsController < ApplicationController
6
+ def create
7
+ Conversation.where(id: ids("conversations")).each { |conversation| notify(:on_change, conversation, id: conversation.id) }
8
+ Report.where(id: ids("reports")).each { |report| notify(:on_report, report, id: report.id) }
9
+ notify(:on_auth, providers, providers: providers) if providers.any?
10
+ head :no_content
11
+ end
12
+
13
+ private
14
+ def ids(key)
15
+ Array(params[key]).map(&:to_i)
16
+ end
17
+
18
+ def providers
19
+ @providers ||= Array(params[:auth]).map(&:to_s).compact_blank.uniq
20
+ end
21
+
22
+ def notify(callback, argument, **context)
23
+ DurableAgent.config.public_send(callback).call(argument)
24
+ rescue => error
25
+ Rails.error.report(error, context: { durable_agent: callback, **context })
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ module DurableAgent
2
+ # The model sees a tool's error message, so only errors meant for it carry their message: bad arguments, a
3
+ # missing record, and DurableAgent::Tool::Error. Anything else is reported to Rails.error and reaches the model as
4
+ # its class name only, so a message holding SQL, paths or secrets stays in the app.
5
+ class ToolsController < ApplicationController
6
+ MODEL_FACING = [ ArgumentError, ActiveRecord::RecordNotFound, DurableAgent::Tool::Error ]
7
+
8
+ def index
9
+ render json: DurableAgent.config.tools.map(&:to_definition)
10
+ end
11
+
12
+ def create
13
+ if tool = DurableAgent.config.tool(params[:name])
14
+ Current.set(conversation_id: body["conversation_id"], call_id: body["call_id"]) do
15
+ render json: { result: tool.run(body["arguments"]) }
16
+ end
17
+ else
18
+ render json: { error: "unknown tool #{params[:name]}" }, status: :not_found
19
+ end
20
+ rescue *MODEL_FACING => error
21
+ render json: { error: error.message }, status: :unprocessable_content
22
+ rescue StandardError => error
23
+ Rails.error.report(error, context: { durable_agent: :tool, tool: params[:name], conversation_id: @body.to_h["conversation_id"] })
24
+ render json: { error: "#{error.class} while running #{params[:name]}" }, status: :unprocessable_content
25
+ end
26
+
27
+ private
28
+ # The raw JSON, not params: arguments are the model's object exactly as it produced it.
29
+ def body
30
+ @body ||= JSON.parse(request.raw_post.presence || "{}")
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,11 @@
1
+ # Rows of pi-durable's own tables, which the sidecar's harness writes. Rails never changes them: a write here
2
+ # would bypass the harness and corrupt its state.
3
+ module DurableAgent::HarnessRecord
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ self.table_name = "durable_agent_#{model_name.element.pluralize}"
8
+ end
9
+
10
+ def readonly? = true
11
+ end
@@ -0,0 +1,5 @@
1
+ module DurableAgent
2
+ class ApplicationRecord < DurableAgent.config.parent_record_class.to_s.constantize
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,66 @@
1
+ # Provider sign-in as Rails sees it. Pure sidecar calls: the sidecar stores the tokens and keys in
2
+ # durable_agent_credentials, encrypted with a key derived from DURABLE_AGENT_TOKEN, and Rails never reads or
3
+ # decrypts them.
4
+ #
5
+ # connection = DurableAgent::Connection.new("openai")
6
+ # login = connection.start_login # => {"id" => ..., "status" => "awaiting_browser", "url" => "https://..."}
7
+ # connection.complete_login(login["id"], pasted_url)
8
+ #
9
+ # With an account the connection is that account's own credential, which the conversations started with
10
+ # `DurableAgent.start(account:)` run on:
11
+ #
12
+ # DurableAgent::Connection.new("openai", account: user.id).set_api_key(params[:key])
13
+ class DurableAgent::Connection
14
+ PROVIDERS = { "openai" => "ChatGPT", "anthropic" => "Claude" }.freeze
15
+
16
+ attr_reader :provider, :account, :error
17
+
18
+ def self.all(account: nil)
19
+ statuses = DurableAgent.auth(account: account)
20
+ PROVIDERS.keys.map { |provider| new(provider, account: account, status: statuses[provider]) }
21
+ rescue DurableAgent::Error => error
22
+ PROVIDERS.keys.map { |provider| new(provider, account: account, status: nil, error: error.message) }
23
+ end
24
+
25
+ def initialize(provider, account: nil, status: :unknown, error: nil)
26
+ @provider = provider.to_s
27
+ raise ArgumentError, "unknown provider #{provider}" unless PROVIDERS.key?(@provider)
28
+ @account = DurableAgent::Account.normalize(account)
29
+ @status, @error = status, error
30
+ end
31
+
32
+ def status
33
+ @status = DurableAgent.auth(account: account)[provider] if @status == :unknown
34
+ @status
35
+ rescue DurableAgent::Error => error
36
+ @error = error.message
37
+ @status = nil
38
+ end
39
+
40
+ def name = PROVIDERS.fetch(provider)
41
+ def scoped_provider = DurableAgent::Account.scoped(provider, account)
42
+ def stream_name = DurableAgent.auth_stream(account)
43
+
44
+ def connected? = status.present?
45
+ def subscription? = status&.dig("type") == "oauth"
46
+ def api_key? = status&.dig("type") == "api_key"
47
+ def source = status&.dig("source")
48
+ def unreachable? = status.nil? && error.present?
49
+
50
+ # Stores the key on the sidecar, encrypted, and returns the new status.
51
+ def set_api_key(key)
52
+ raise ArgumentError, "API key is blank" if key.blank?
53
+ @error = nil
54
+ @status = client.set_api_key(provider, key, **scope)
55
+ end
56
+
57
+ def start_login = client.start_login(provider, **scope)
58
+ def login(id) = client.login(provider, id)
59
+ def complete_login(id, url) = client.complete_login(provider, id, url)
60
+ def cancel_login(id) = client.cancel_login(provider, id)
61
+ def disconnect = client.logout(provider, **scope)
62
+
63
+ private
64
+ def client = DurableAgent.client
65
+ def scope = DurableAgent.account_scope(account)
66
+ end
@@ -0,0 +1,107 @@
1
+ # A pi-durable conversation, read straight from the harness tables. The sidecar writes the rows; Rails reads them
2
+ # and sends commands through DurableAgent.client. Your own record (a Chat, a Ticket) holds the conversation id.
3
+ class DurableAgent::Conversation < DurableAgent::ApplicationRecord
4
+ include DurableAgent::HarnessRecord
5
+
6
+ attribute :record, :json
7
+
8
+ # Own rows only: a forked conversation's inherited entries are not followed.
9
+ has_many :entries, dependent: nil
10
+ has_many :submissions, dependent: nil
11
+ has_many :tasks, dependent: nil
12
+ has_many :reports, dependent: nil
13
+ has_many :documents, -> { current.where(scope_kind: "conversation") }, foreign_key: :owner_id, dependent: nil
14
+ belongs_to :owner, class_name: "DurableAgent::Conversation", foreign_key: :owner_conversation_id, optional: true
15
+
16
+ # The sidecar creates the root conversation (id 1) at boot, before any app asks for one.
17
+ ROOT_ID = 1
18
+
19
+ scope :top_level, -> { where(owner_conversation_id: nil).where.not(id: ROOT_ID) }
20
+
21
+ def self.root = find_by(id: ROOT_ID)
22
+
23
+ def root? = id == ROOT_ID
24
+
25
+ # The sidecar fills created_at from the column default when it inserts the row.
26
+ def last_activity_at
27
+ entries.maximum(:created_at) || created_at
28
+ end
29
+
30
+ def parent
31
+ DurableAgent::Conversation.find_by(id: record.dig("parent", "conversationId")) if record["parent"]
32
+ end
33
+
34
+ # The entry of the parent conversation through which this one inherits history.
35
+ def parent_at
36
+ DurableAgent::Entry.find_by(id: record.dig("parent", "at")) if record["parent"]
37
+ end
38
+
39
+ def document(kind)
40
+ documents.of_kind(kind).first&.value || {}
41
+ end
42
+
43
+ def live = document("pi.live")
44
+ def inbox = document("pi.inbox").fetch("items", [])
45
+ def agent = document("pi.agent")
46
+
47
+ # The model this conversation runs on, "provider/modelId", pinned when it was started. The sidecar's default
48
+ # model only applies to conversations started without one.
49
+ def model
50
+ ref = agent["model"]
51
+ "#{ref["provider"]}/#{ref["modelId"]}" if ref
52
+ end
53
+
54
+ # "openai" and "u42" from the model "openai@u42/gpt-6.1-sol"; account is nil for a conversation on the app's
55
+ # own credentials.
56
+ def provider = scoped_provider.first
57
+ def account = scoped_provider.second
58
+
59
+ def busy?
60
+ live["run"].present?
61
+ end
62
+
63
+ def streaming_message
64
+ live.dig("generation", "message")
65
+ end
66
+
67
+ def running_tools
68
+ live.fetch("tools", [])
69
+ end
70
+
71
+ def transcript
72
+ entries.chronologically.visible
73
+ end
74
+
75
+ # The newest input that settled without an answer, if it is more recent than the last answer. An assistant
76
+ # entry that ended in an error or an abort is not an answer.
77
+ def last_failure
78
+ failure = submissions.unanswered.reverse_chronologically.first
79
+ return unless failure
80
+
81
+ last_answer = entries.reverse_chronologically.of_kind("pi.assistant").find(&:answer?)
82
+ failure if last_answer.nil? || failure.entry_id.to_i > last_answer.id
83
+ end
84
+
85
+ # pi.usage: tokens and cost per model over the whole conversation, and per tool.
86
+ def usage = document("pi.usage")
87
+ def usage_by_model = usage.fetch("models", {})
88
+ def total_tokens = usage_by_model.values.sum { |model| model["totalTokens"].to_i }
89
+ def total_cost = usage_by_model.values.sum { |model| model.dig("cost", "total").to_f }
90
+
91
+ # The sidecar commits the submission before it answers, so the row is there to find.
92
+ # Both clear the query cache, so busy?, live and transcript read what the sidecar committed.
93
+ def say(content, request_id:, when_busy: nil)
94
+ response = DurableAgent.client.submit(id, content, request_id: request_id, when_busy: when_busy)
95
+ DurableAgent.changed_by_sidecar
96
+ submissions.find(response.fetch("id"))
97
+ end
98
+
99
+ def abort
100
+ DurableAgent.client.abort(id).tap { DurableAgent.changed_by_sidecar }
101
+ end
102
+
103
+ private
104
+ def scoped_provider
105
+ model ? DurableAgent::Account.parse(model.split("/", 2).first) : []
106
+ end
107
+ end
@@ -0,0 +1,16 @@
1
+ # A base (the whole value) or a delta (chord ops) of a document, in commit order.
2
+ class DurableAgent::Document::Revision < DurableAgent::ApplicationRecord
3
+ include DurableAgent::HarnessRecord
4
+
5
+ self.table_name = "durable_agent_document_revisions"
6
+ self.primary_key = [ :document_id, :seq ]
7
+
8
+ belongs_to :document
9
+
10
+ def base? = kind == "base"
11
+ def delta? = kind == "delta"
12
+
13
+ def value
14
+ JSON.parse(content) if base?
15
+ end
16
+ end
@@ -0,0 +1,27 @@
1
+ # One incarnation of a pi-durable document: pi.agent, pi.live, pi.inbox, pi.usage, pi.provider, or an
2
+ # application's. The sidecar stores every document Rails reads as a single base revision (it folds each delta
3
+ # into a new base before committing), so the current value is the content of the newest revision.
4
+ class DurableAgent::Document < DurableAgent::ApplicationRecord
5
+ include DurableAgent::HarnessRecord
6
+
7
+ # The newest revision is a delta: reading it would need a port of chord's delta replay.
8
+ class HistoryNotSupported < StandardError; end
9
+
10
+ attribute :record, :json
11
+
12
+ has_many :revisions, -> { order(:seq) }, dependent: nil
13
+
14
+ scope :current, -> { where(retired_at: nil) }
15
+ scope :of_kind, ->(kind) { where(kind: DurableAgent::IndexedString.encode(kind)) }
16
+
17
+ def kind = DurableAgent::IndexedString.decode(super)
18
+ def key = (DurableAgent::IndexedString.decode(key_value) if keyed?)
19
+ def keyed? = family == 1
20
+ def retired? = retired_at.present?
21
+
22
+ def value
23
+ newest = revisions.reorder(seq: :desc).first
24
+ raise HistoryNotSupported, "document #{id} (#{kind}) ends in a delta revision" if newest&.delta?
25
+ newest&.value
26
+ end
27
+ end
@@ -0,0 +1,56 @@
1
+ # One immutable transcript record. `kind` is pi's (`pi.user`, `pi.assistant`, `pi.tool-result`, `pi.system`,
2
+ # `pi.reset`, `pi.compaction`) or an application kind. `model` holds the messages the model sees; `data` is the
3
+ # application payload. All of it lives in the `record` JSON, so kind filtering happens in Ruby.
4
+ class DurableAgent::Entry < DurableAgent::ApplicationRecord
5
+ include DurableAgent::HarnessRecord
6
+
7
+ HIDDEN_KINDS = %w[ pi.system pi.reset ].freeze
8
+
9
+ attribute :record, :json
10
+
11
+ belongs_to :conversation
12
+
13
+ scope :chronologically, -> { order(:id) }
14
+ scope :reverse_chronologically, -> { order(id: :desc) }
15
+
16
+ def self.visible = all.select(&:visible?)
17
+ def self.of_kind(*kinds) = all.select { |entry| entry.kind.in?(kinds.flatten) }
18
+
19
+ def kind = record["kind"]
20
+ def data = record["data"]
21
+ def model = record["model"]
22
+ def head = record["head"]
23
+ def by_task_id = record["byTaskId"]
24
+
25
+ def visible? = !kind.in?(HIDDEN_KINDS)
26
+ def user? = kind == "pi.user"
27
+ def assistant? = kind == "pi.assistant"
28
+ def tool_result? = kind == "pi.tool-result"
29
+ def compaction? = kind == "pi.compaction"
30
+
31
+ def messages = Array.wrap(model)
32
+ def text = DurableAgent::MessageText.text(messages)
33
+ def tool_calls = DurableAgent::MessageText.tool_calls(messages)
34
+ def thinking? = DurableAgent::MessageText.thinking?(messages)
35
+
36
+ def tool_name = messages.first&.dig("toolName")
37
+ def error? = messages.any? { |message| message["isError"] }
38
+
39
+ # What pi-ai records on each message: the model that answered, how it stopped, tokens, timing.
40
+ def message = messages.first || {}
41
+ def provider = message["provider"]
42
+ def model_id = message["model"] # not model_name, which is Active Model's
43
+ def model_ref = ("#{provider}/#{model_id}" if provider && model_id)
44
+ def stop_reason = message["stopReason"] # "stop", "length", "toolUse", "aborted", "error"
45
+ def error_message = message["errorMessage"]
46
+ def usage = message["usage"] || {}
47
+ def total_tokens = usage["totalTokens"]
48
+ def cost = usage.dig("cost", "total")
49
+ def duration_ms = message["durationMs"]
50
+ def timestamp = (Time.zone.at(message["timestamp"] / 1000.0) if message["timestamp"])
51
+
52
+ # An assistant entry the model request ended in an error or an abort: it carries no answer, and the input it was
53
+ # for settles unanswered.
54
+ def failed? = assistant? && stop_reason.in?(%w[ error aborted ])
55
+ def answer? = assistant? && !failed?
56
+ end
@@ -0,0 +1,14 @@
1
+ # A failure the harness reported without failing the operation: a faulted or parked task, a hook error, a
2
+ # failed session. Show these to operators.
3
+ class DurableAgent::Report < DurableAgent::ApplicationRecord
4
+ include DurableAgent::HarnessRecord
5
+
6
+ self.inheritance_column = nil
7
+
8
+ attribute :info, :json
9
+
10
+ belongs_to :conversation, optional: true
11
+
12
+ scope :reverse_chronologically, -> { order(id: :desc) }
13
+ scope :latest, -> { reverse_chronologically.limit(20) }
14
+ end
@@ -0,0 +1,57 @@
1
+ # Something Rails handed to a conversation: queued, placed as an entry, then done (answered) or unanswered.
2
+ # `request_id` is the id Rails chose, so a retried request finds the same submission. pi-durable stores it
3
+ # JSON-encoded ("\"42\""), as JSON.stringify writes it, so lookups encode it the same way.
4
+ class DurableAgent::Submission < DurableAgent::ApplicationRecord
5
+ include DurableAgent::HarnessRecord
6
+
7
+ attribute :record, :json
8
+
9
+ belongs_to :conversation
10
+
11
+ scope :chronologically, -> { order(:id) }
12
+ scope :reverse_chronologically, -> { order(id: :desc) }
13
+ scope :pending, -> { where(status: %w[ queued placed ]) }
14
+ scope :settled, -> { where(status: %w[ done unanswered ]) }
15
+ scope :unanswered, -> { where(status: "unanswered") }
16
+ scope :for_request, ->(request_id) { where(request_id: DurableAgent::IndexedString.encode(request_id)) }
17
+
18
+ # Why an input went unanswered, for people. pi-durable reasons: no_model, run_failed, aborted, withdrawn...
19
+ REASONS = {
20
+ "no_model" => "No model could run: the conversation has none, or the sidecar does not know its model id.",
21
+ "model_error" => "The model returned an error.",
22
+ "run_failed" => "The run failed.",
23
+ "aborted" => "The run was stopped."
24
+ }.freeze
25
+
26
+ def request_id
27
+ DurableAgent::IndexedString.decode(super) if super
28
+ end
29
+
30
+ def type = record["type"] # "input" or "write"
31
+ def entry_id = record["entry"]
32
+ def answer_entry_id = record["answer"]
33
+ def reason = record["reason"]
34
+ def detail = record["detail"]
35
+
36
+ def entry = DurableAgent::Entry.find_by(id: entry_id)
37
+ def answer = DurableAgent::Entry.find_by(id: answer_entry_id)
38
+
39
+ # The detail pi-durable recorded: a string for model_error (the provider's message), a hash for run_failed.
40
+ def detail_text
41
+ detail.is_a?(Hash) ? detail["message"] : detail.presence
42
+ end
43
+
44
+ # no_model names the conversation's pinned model when it has one: the sidecar does not register that provider, or
45
+ # the id is not in its catalog. pi-durable records no detail for it. model_error and run_failed carry theirs.
46
+ def explanation
47
+ if reason == "no_model" && (model = conversation&.model)
48
+ "Model #{model} is not available on the sidecar. Its provider is not registered, or the model id is unknown."
49
+ else
50
+ [ REASONS.fetch(reason.to_s) { "The agent did not answer (#{reason})." }, detail_text ].compact.join(" ")
51
+ end
52
+ end
53
+
54
+ def pending? = status.in?(%w[ queued placed ])
55
+ def done? = status == "done"
56
+ def unanswered? = status == "unanswered"
57
+ end
@@ -0,0 +1,17 @@
1
+ # Durable work inside a conversation: a run, a tool call, a compaction. `kind` is the task definition's name.
2
+ class DurableAgent::Task < DurableAgent::ApplicationRecord
3
+ include DurableAgent::HarnessRecord
4
+
5
+ attribute :record, :json
6
+
7
+ belongs_to :conversation
8
+
9
+ scope :of_kind, ->(kind) { where(kind: DurableAgent::IndexedString.encode(kind)) }
10
+ scope :unfinished, -> { where.not(status: "terminal") }
11
+
12
+ def kind = DurableAgent::IndexedString.decode(super)
13
+
14
+ def terminal? = status == "terminal"
15
+ def abort_requested? = abort_requested == 1
16
+ def background? = background == 1
17
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ DurableAgent::Engine.routes.draw do
2
+ get "tools", to: "tools#index"
3
+ post "tools/:name", to: "tools#create", as: :tool
4
+ post "events", to: "events#create"
5
+ end