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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/DESIGN.md +614 -0
- data/MIT-LICENSE +20 -0
- data/README.md +23 -0
- data/Rakefile +46 -0
- data/app/controllers/durable_agent/application_controller.rb +15 -0
- data/app/controllers/durable_agent/events_controller.rb +28 -0
- data/app/controllers/durable_agent/tools_controller.rb +33 -0
- data/app/models/concerns/durable_agent/harness_record.rb +11 -0
- data/app/models/durable_agent/application_record.rb +5 -0
- data/app/models/durable_agent/connection.rb +66 -0
- data/app/models/durable_agent/conversation.rb +107 -0
- data/app/models/durable_agent/document/revision.rb +16 -0
- data/app/models/durable_agent/document.rb +27 -0
- data/app/models/durable_agent/entry.rb +56 -0
- data/app/models/durable_agent/report.rb +14 -0
- data/app/models/durable_agent/submission.rb +57 -0
- data/app/models/durable_agent/task.rb +17 -0
- data/config/routes.rb +5 -0
- data/docs/guide.md +421 -0
- data/lib/durable_agent/account.rb +29 -0
- data/lib/durable_agent/client.rb +121 -0
- data/lib/durable_agent/configuration.rb +81 -0
- data/lib/durable_agent/current.rb +6 -0
- data/lib/durable_agent/engine.rb +7 -0
- data/lib/durable_agent/indexed_string.rb +14 -0
- data/lib/durable_agent/message_text.rb +23 -0
- data/lib/durable_agent/sidecar.rb +97 -0
- data/lib/durable_agent/test_helper.rb +110 -0
- data/lib/durable_agent/tool.rb +91 -0
- data/lib/durable_agent/version.rb +3 -0
- data/lib/durable_agent.rb +67 -0
- data/lib/generators/durable_agent/install/install_generator.rb +75 -0
- data/lib/generators/durable_agent/install/templates/create_durable_agent_tables.rb.tt +120 -0
- data/lib/generators/durable_agent/install/templates/durable_agent.rb.tt +36 -0
- data/lib/tasks/durable_agent_tasks.rake +6 -0
- data/sidecar/dist/sidecar.js +76417 -0
- data/sidecar/package.json +32 -0
- data/sidecar/sql/schema.postgres.sql +120 -0
- data/sidecar/sql/schema.sqlite.sql +120 -0
- 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,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
|