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
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module DurableAgent
|
|
4
|
+
# pi-durable indexes some strings (task and document kinds, document keys, request ids) by their JSON encoding,
|
|
5
|
+
# written in the sidecar by JavaScript's JSON.stringify. A lookup must produce the same bytes. Rails' to_json
|
|
6
|
+
# escapes &, < and > (and U+2028/U+2029) as \uXXXX, which JSON.stringify never does; JSON.generate escapes only
|
|
7
|
+
# what JSON requires, as JSON.stringify does.
|
|
8
|
+
module IndexedString
|
|
9
|
+
extend self
|
|
10
|
+
|
|
11
|
+
def encode(value) = JSON.generate(value.to_s)
|
|
12
|
+
def decode(text) = JSON.parse(text)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module DurableAgent
|
|
2
|
+
# Turns pi messages (an entry's `model` column, or the streaming partial in pi.live) into plain text.
|
|
3
|
+
# Content is either a string or an array of blocks: text, thinking, toolCall, image.
|
|
4
|
+
module MessageText
|
|
5
|
+
extend self
|
|
6
|
+
|
|
7
|
+
def text(messages)
|
|
8
|
+
blocks(messages).filter_map { |block| block.is_a?(String) ? block : (block["text"] if block["type"] == "text") }.join
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def tool_calls(messages)
|
|
12
|
+
blocks(messages).select { |block| block.is_a?(Hash) && block["type"] == "toolCall" }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def thinking?(messages)
|
|
16
|
+
blocks(messages).any? { |block| block.is_a?(Hash) && block["type"] == "thinking" }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def blocks(messages)
|
|
20
|
+
Array.wrap(messages).flat_map { |message| Array.wrap(message["content"]) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
require "erb"
|
|
2
|
+
|
|
3
|
+
module DurableAgent
|
|
4
|
+
# Starts the bundled sidecar with every setting derived from the Rails app, so the two halves agree.
|
|
5
|
+
module Sidecar
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
PASS_THROUGH = %w[ OPENAI_API_KEY ANTHROPIC_API_KEY ].freeze
|
|
9
|
+
|
|
10
|
+
def env
|
|
11
|
+
config = DurableAgent.config
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
"DATABASE_URL" => database_url,
|
|
15
|
+
"DURABLE_AGENT_TOKEN" => config.token,
|
|
16
|
+
"DURABLE_AGENT_HOST" => config.host.to_s,
|
|
17
|
+
"DURABLE_AGENT_PORT" => config.port.to_s,
|
|
18
|
+
"DURABLE_AGENT_RAILS_URL" => config.rails_url.to_s,
|
|
19
|
+
"DURABLE_AGENT_MODEL" => config.model.to_s,
|
|
20
|
+
"DURABLE_AGENT_WORKSPACE_ROOT" => config.workspace_root.to_s,
|
|
21
|
+
"DURABLE_AGENT_NAME" => config.agent_name.to_s
|
|
22
|
+
}.merge(ENV.slice(*PASS_THROUGH))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The one place to swap in a compiled binary later. Falls back to the TypeScript source when no bundle was
|
|
26
|
+
# built, which Node runs directly with type stripping.
|
|
27
|
+
def command
|
|
28
|
+
bundle = root.join("dist/sidecar.js")
|
|
29
|
+
[ "node", (bundle.exist? ? bundle : root.join("src/main.ts")).to_s ]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def exec
|
|
33
|
+
FileUtils.mkdir_p(DurableAgent.config.workspace_root)
|
|
34
|
+
Kernel.exec(env, *command)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def root
|
|
38
|
+
DurableAgent::Engine.root.join("sidecar")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The app's primary database, which the sidecar opens too: pi-durable's tables live there.
|
|
42
|
+
def database_url(db_config = ActiveRecord::Base.connection_db_config)
|
|
43
|
+
return node_sslmode_in(db_config.url) if db_config.respond_to?(:url) && db_config.url.present?
|
|
44
|
+
|
|
45
|
+
case db_config.adapter
|
|
46
|
+
when "sqlite3" then sqlite_url(db_config.configuration_hash)
|
|
47
|
+
when "postgresql" then postgres_url(db_config.configuration_hash)
|
|
48
|
+
else raise ArgumentError, "durable_agent needs a postgresql or sqlite3 database, not #{db_config.adapter}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# libpq's sslmode=require encrypts without verifying the server's certificate; node-postgres reads `require`
|
|
53
|
+
# as verify-full and would refuse the self-signed certificates many hosted databases use. `no-verify` is
|
|
54
|
+
# node-postgres' name for libpq's `require`. verify-ca and verify-full pass through and verify.
|
|
55
|
+
NODE_SSLMODES = { "require" => "no-verify" }.freeze
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
def sqlite_url(config)
|
|
59
|
+
"sqlite://#{File.expand_path(config[:database].to_s, Rails.root)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def postgres_url(config)
|
|
63
|
+
userinfo = [ config[:username], config[:password] ].compact.map { |part| ERB::Util.url_encode(part.to_s) }.join(":")
|
|
64
|
+
host = config[:host].presence || "localhost"
|
|
65
|
+
query = {}
|
|
66
|
+
|
|
67
|
+
# A socket directory cannot sit in the authority part of the URL; libpq and node-postgres take it as ?host=.
|
|
68
|
+
if host.start_with?("/")
|
|
69
|
+
query[:host] = host
|
|
70
|
+
host = ""
|
|
71
|
+
end
|
|
72
|
+
query[:sslmode] = NODE_SSLMODES.fetch(config[:sslmode].to_s, config[:sslmode].to_s) if config[:sslmode].present?
|
|
73
|
+
%i[ sslrootcert sslcert sslkey ].each do |key|
|
|
74
|
+
query[key] = File.expand_path(config[key].to_s, Rails.root) if config[key].present?
|
|
75
|
+
end
|
|
76
|
+
query[:options] = "-c search_path=#{search_path(config[:schema_search_path])}" if config[:schema_search_path].present?
|
|
77
|
+
|
|
78
|
+
authority = [ userinfo.presence, host ].compact.join("@")
|
|
79
|
+
authority += ":#{config[:port]}" if config[:port].present?
|
|
80
|
+
url = "postgres://#{authority}/#{ERB::Util.url_encode(config[:database].to_s)}"
|
|
81
|
+
query.any? ? "#{url}?#{query.map { |key, value| "#{key}=#{ERB::Util.url_encode(value)}" }.join("&")}" : url
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A startup option is split on spaces, so "public, shared" must lose its spaces (and any left be escaped).
|
|
85
|
+
def search_path(value)
|
|
86
|
+
value.to_s.split(",").map(&:strip).join(",").gsub(" ", "\\ ")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def node_sslmode_in(url)
|
|
90
|
+
if url.start_with?("postgres:", "postgresql:")
|
|
91
|
+
url.sub(/([?&]sslmode=)([^&#]*)/) { "#{$1}#{NODE_SSLMODES.fetch($2, $2)}" }
|
|
92
|
+
else
|
|
93
|
+
url
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module DurableAgent
|
|
2
|
+
# Test support for apps built on durable_agent. In test/test_helper.rb:
|
|
3
|
+
#
|
|
4
|
+
# require "durable_agent/test_helper"
|
|
5
|
+
#
|
|
6
|
+
# class ActiveSupport::TestCase
|
|
7
|
+
# include DurableAgent::TestHelper
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# `stub_sidecar` replaces DurableAgent.client calls with canned responses or callables and records each call.
|
|
11
|
+
# It also seals the client: every other call raises DurableAgent::Unavailable, so a test never reaches a sidecar
|
|
12
|
+
# that happens to be running. `fake_sidecar` goes further for the three commands that write: like the real sidecar,
|
|
13
|
+
# it commits the conversation or submission row before it answers, so `DurableAgent.start` and `conversation.say`
|
|
14
|
+
# find them.
|
|
15
|
+
#
|
|
16
|
+
# fake_sidecar
|
|
17
|
+
# chat = Chat.create! # calls DurableAgent.start
|
|
18
|
+
# chat.conversation.say("Hi", request_id: 1)
|
|
19
|
+
# assert_equal 1, sidecar_calls(:submit).size
|
|
20
|
+
module TestHelper
|
|
21
|
+
DISCONNECTED = { "openai" => nil, "anthropic" => nil }.freeze
|
|
22
|
+
CONNECTED = { "openai" => { "type" => "oauth", "source" => "OAuth" }, "anthropic" => nil }.freeze
|
|
23
|
+
|
|
24
|
+
# Ids the fake hands out: above the ids pi-durable starts from, and above the gem's own test fixtures.
|
|
25
|
+
FAKE_IDS_FROM = 950_000
|
|
26
|
+
|
|
27
|
+
# The client's calls to the sidecar; `url` and `token` are configuration.
|
|
28
|
+
SIDECAR_CALLS = DurableAgent::Client.public_instance_methods(false) - %i[ url token ]
|
|
29
|
+
|
|
30
|
+
# stub_sidecar(auth: CONNECTED, submit: ->(id, content, **) { { "id" => 1 } }, abort: DurableAgent::Unavailable)
|
|
31
|
+
def stub_sidecar(**responses)
|
|
32
|
+
client = DurableAgent.client
|
|
33
|
+
responses.each_key { |name| raise ArgumentError, "DurableAgent::Client has no method #{name}" unless client.respond_to?(name) }
|
|
34
|
+
|
|
35
|
+
@sidecar_stubbed ||= []
|
|
36
|
+
sealed = (SIDECAR_CALLS - @sidecar_stubbed - responses.keys).index_with do |name|
|
|
37
|
+
->(*, **) { raise DurableAgent::Unavailable.new(nil, nil, "DurableAgent.client.#{name} is not stubbed; stub_sidecar(#{name}: ...)") }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
calls = sidecar_calls
|
|
41
|
+
responses.merge(sealed).each do |name, response|
|
|
42
|
+
@sidecar_stubbed << name
|
|
43
|
+
client.define_singleton_method(name) do |*args, **kwargs|
|
|
44
|
+
calls << [ name, args, kwargs ]
|
|
45
|
+
if response.is_a?(Exception) || (response.is_a?(Class) && response < Exception)
|
|
46
|
+
raise response
|
|
47
|
+
else
|
|
48
|
+
response.respond_to?(:call) ? response.call(*args, **kwargs) : response
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Every stubbed call as [name, args, kwargs], or only those of one name.
|
|
55
|
+
def sidecar_calls(name = nil)
|
|
56
|
+
calls = (@sidecar_calls ||= [])
|
|
57
|
+
name ? calls.select { |call| call.first == name } : calls
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# create_conversation, submit and abort answered as the sidecar answers them, after committing the rows it
|
|
61
|
+
# commits. Submissions are idempotent on request_id, and an unknown conversation is a 404, as on the sidecar.
|
|
62
|
+
# Further responses stub other calls: fake_sidecar(auth: CONNECTED).
|
|
63
|
+
def fake_sidecar(**responses)
|
|
64
|
+
stub_sidecar(create_conversation: method(:fake_create_conversation), submit: method(:fake_submit),
|
|
65
|
+
abort: method(:fake_abort), **responses)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def teardown
|
|
69
|
+
super
|
|
70
|
+
Array(@sidecar_stubbed).uniq.each { |name| DurableAgent.client.singleton_class.send(:remove_method, name) }
|
|
71
|
+
@sidecar_stubbed = @sidecar_calls = @fake_sidecar_id = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
def fake_create_conversation(model: nil, instructions: nil, account: nil)
|
|
76
|
+
id = next_fake_sidecar_id
|
|
77
|
+
DurableAgent::Conversation.insert({ id: id, record: { id: id } })
|
|
78
|
+
{ "id" => id }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def fake_submit(conversation_id, content, request_id:, when_busy: nil)
|
|
82
|
+
fake_conversation!(conversation_id)
|
|
83
|
+
|
|
84
|
+
submission = DurableAgent::Submission.where(conversation_id: conversation_id).for_request(request_id).first
|
|
85
|
+
submission ||= begin
|
|
86
|
+
id = next_fake_sidecar_id
|
|
87
|
+
DurableAgent::Submission.insert({ id: id, conversation_id: conversation_id, request_id: DurableAgent::IndexedString.encode(request_id),
|
|
88
|
+
status: "queued", record: { id: id, conversationId: conversation_id, requestId: request_id.to_s, type: "input", status: "queued" } })
|
|
89
|
+
DurableAgent::Submission.find(id)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
{ "id" => submission.id, "status" => submission.status }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def fake_abort(conversation_id)
|
|
96
|
+
fake_conversation!(conversation_id)
|
|
97
|
+
{ "ok" => true }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def fake_conversation!(id)
|
|
101
|
+
unless DurableAgent::Conversation.exists?(id)
|
|
102
|
+
raise DurableAgent::Error.new(404, { "error" => "conversation #{id} not found" })
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def next_fake_sidecar_id
|
|
107
|
+
@fake_sidecar_id = (@fake_sidecar_id || FAKE_IDS_FROM) + 1
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module DurableAgent
|
|
2
|
+
# A tool the agent can call, written in Ruby. The sidecar fetches every tool's definition from
|
|
3
|
+
# GET /durable_agent/tools and runs one with POST /durable_agent/tools/:name.
|
|
4
|
+
#
|
|
5
|
+
# class LookupInvoice < DurableAgent::Tool
|
|
6
|
+
# description "Fetch one invoice by number"
|
|
7
|
+
# param :number, :string, "Invoice number like INV-1042"
|
|
8
|
+
# param :include_lines, :boolean, "Include line items", required: false
|
|
9
|
+
# replay :safe
|
|
10
|
+
#
|
|
11
|
+
# def call(number:, include_lines: false)
|
|
12
|
+
# Invoice.find_by!(number:).as_json
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
class Tool
|
|
16
|
+
# Raise this with a message for the model: "No invoice INV-9; numbers look like INV-1042". Other errors reach
|
|
17
|
+
# the model as their class name only.
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
|
|
20
|
+
TYPES = %i[ string integer number boolean array object ].freeze
|
|
21
|
+
REPLAY_MODES = %i[ safe unsafe ].freeze
|
|
22
|
+
|
|
23
|
+
class_attribute :params, instance_accessor: false, default: {}
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def description(text = nil)
|
|
27
|
+
text ? @description = text : @description.to_s
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Extra JSON Schema keywords (enum:, items:, properties:) pass straight through.
|
|
31
|
+
def param(name, type, description = nil, required: true, **schema)
|
|
32
|
+
raise ArgumentError, "unknown param type #{type.inspect}" unless type.in?(TYPES)
|
|
33
|
+
self.params = params.merge(name.to_sym => { type: type, description: description, required: required, schema: schema })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether pi may run the tool again after a crash mid-call. Unsafe tools are reported instead of repeated.
|
|
37
|
+
def replay(mode = nil)
|
|
38
|
+
if mode
|
|
39
|
+
raise ArgumentError, "replay is :safe or :unsafe" unless mode.in?(REPLAY_MODES)
|
|
40
|
+
@replay = mode
|
|
41
|
+
else
|
|
42
|
+
@replay || (superclass.respond_to?(:replay) ? superclass.replay : :unsafe)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def tool_name(name = nil)
|
|
47
|
+
name ? @tool_name = name.to_s : @tool_name || self.name.demodulize.underscore
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_definition
|
|
51
|
+
{
|
|
52
|
+
name: tool_name,
|
|
53
|
+
description: description,
|
|
54
|
+
parameters: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: params.transform_values { |param| { type: param[:type].to_s, description: param[:description] }.compact.merge(param[:schema]) },
|
|
57
|
+
required: params.select { |_, param| param[:required] }.keys.map(&:to_s),
|
|
58
|
+
additionalProperties: false
|
|
59
|
+
},
|
|
60
|
+
replay: replay.to_s
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Runs the tool with the arguments object the model produced (JSON, string keys).
|
|
65
|
+
def run(arguments)
|
|
66
|
+
new.call(**coerce(arguments))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def coerce(arguments)
|
|
70
|
+
arguments ||= {}
|
|
71
|
+
raise ArgumentError, "arguments must be an object" unless arguments.is_a?(Hash)
|
|
72
|
+
arguments = arguments.transform_keys(&:to_s)
|
|
73
|
+
|
|
74
|
+
unknown = arguments.keys - params.keys.map(&:to_s)
|
|
75
|
+
raise ArgumentError, "unknown #{"argument".pluralize(unknown.size)}: #{unknown.join(", ")}" if unknown.any?
|
|
76
|
+
|
|
77
|
+
# Models send null for optional arguments they skip; let the method's default apply instead.
|
|
78
|
+
given = arguments.compact.transform_keys(&:to_sym)
|
|
79
|
+
|
|
80
|
+
missing = params.select { |name, param| param[:required] && !given.key?(name) }.keys
|
|
81
|
+
raise ArgumentError, "missing required #{"argument".pluralize(missing.size)}: #{missing.join(", ")}" if missing.any?
|
|
82
|
+
|
|
83
|
+
given
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def call(**)
|
|
88
|
+
raise NotImplementedError, "#{self.class.name} must implement #call"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require "durable_agent/version"
|
|
2
|
+
require "durable_agent/engine"
|
|
3
|
+
require "durable_agent/configuration"
|
|
4
|
+
require "durable_agent/account"
|
|
5
|
+
require "durable_agent/client"
|
|
6
|
+
require "durable_agent/current"
|
|
7
|
+
require "durable_agent/message_text"
|
|
8
|
+
require "durable_agent/indexed_string"
|
|
9
|
+
require "durable_agent/tool"
|
|
10
|
+
require "durable_agent/sidecar"
|
|
11
|
+
|
|
12
|
+
module DurableAgent
|
|
13
|
+
class << self
|
|
14
|
+
def configure
|
|
15
|
+
yield config
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def config
|
|
19
|
+
@config ||= Configuration.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def client
|
|
23
|
+
@client ||= Client.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Creates a conversation on the sidecar and returns its row, which the sidecar commits before it answers.
|
|
27
|
+
# Without a model the sidecar uses its default, which `durable_agent:sidecar` sets from config.model.
|
|
28
|
+
#
|
|
29
|
+
# With an account the conversation runs on that account's provider credentials (see Connection), without one
|
|
30
|
+
# on the app's. The sidecar commits in its own transaction, so this cannot join the caller's: when the app's own
|
|
31
|
+
# record fails to save afterwards, the conversation stays behind, unreferenced.
|
|
32
|
+
def start(model: nil, instructions: nil, account: nil)
|
|
33
|
+
response = client.create_conversation(model: model, instructions: instructions, **account_scope(account))
|
|
34
|
+
changed_by_sidecar
|
|
35
|
+
conversation(response.fetch("id"))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def conversation(id)
|
|
39
|
+
Conversation.find(id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Every provider the sidecar knows, signed in or not, with how: {"openai" => {"type" => "oauth", "source" =>
|
|
43
|
+
# "OAuth"}, "anthropic" => {"type" => "api_key", "source" => "ANTHROPIC_API_KEY"}, "google" => nil, ...}.
|
|
44
|
+
# Includes "faux" when the sidecar's model is faux/faux-1. With an account, that account's providers, under
|
|
45
|
+
# their base names.
|
|
46
|
+
def auth(account: nil)
|
|
47
|
+
client.auth(**account_scope(account))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The stream config.on_auth refreshes when the app's sign-ins change, or the account's:
|
|
51
|
+
# `turbo_stream_from DurableAgent.auth_stream(account)`.
|
|
52
|
+
def auth_stream(account = nil)
|
|
53
|
+
[ "durable_agent:auth", Account.normalize(account) ].compact.join(":")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The sidecar just committed rows this thread may already have read: drop the query cache so the next read in
|
|
57
|
+
# the same request sees them.
|
|
58
|
+
def changed_by_sidecar
|
|
59
|
+
DurableAgent::ApplicationRecord.connection_pool.clear_query_cache
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The keyword arguments that scope a client call to an account: none for the app's own account.
|
|
63
|
+
def account_scope(account)
|
|
64
|
+
{ account: Account.normalize(account) }.compact
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module DurableAgent
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
include Rails::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
SIDECAR = "sidecar: bin/rails durable_agent:sidecar"
|
|
12
|
+
# foreman gives the first process PORT=5000; the sidecar reaches Rails at rails_url, port 3000 by default.
|
|
13
|
+
WEB = "web: bin/rails server -p 3000"
|
|
14
|
+
|
|
15
|
+
def self.next_migration_number(dirname)
|
|
16
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def copy_migration
|
|
20
|
+
migration_template "create_durable_agent_tables.rb.tt", "db/migrate/create_durable_agent_tables.rb"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_initializer
|
|
24
|
+
template "durable_agent.rb.tt", "config/initializers/durable_agent.rb"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def mount_engine
|
|
28
|
+
routes = File.join(destination_root, "config/routes.rb")
|
|
29
|
+
route %(mount DurableAgent::Engine => "/durable_agent") unless File.read(routes).include?("DurableAgent::Engine")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add_sidecar_process
|
|
33
|
+
procfile = File.join(destination_root, "Procfile.dev")
|
|
34
|
+
|
|
35
|
+
if !File.exist?(procfile)
|
|
36
|
+
create_file "Procfile.dev", "#{WEB}\n#{SIDECAR}\n"
|
|
37
|
+
else
|
|
38
|
+
append_to_file "Procfile.dev", "#{SIDECAR}\n", force: true unless File.read(procfile).include?(SIDECAR)
|
|
39
|
+
warn_about_web_port File.read(procfile)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def show_next_steps
|
|
44
|
+
say <<~TEXT
|
|
45
|
+
|
|
46
|
+
durable_agent is installed. Next:
|
|
47
|
+
|
|
48
|
+
bin/rails db:migrate
|
|
49
|
+
bin/dev # runs web and sidecar from Procfile.dev (needs foreman or overmind)
|
|
50
|
+
|
|
51
|
+
Development and test derive the sidecar token from secret_key_base. In production set one:
|
|
52
|
+
|
|
53
|
+
bin/rails credentials:edit # durable_agent:
|
|
54
|
+
# token: #{SecureRandom.hex(32)}
|
|
55
|
+
|
|
56
|
+
or export DURABLE_AGENT_TOKEN for both the web and the sidecar processes. If production uses
|
|
57
|
+
force_ssl or config.hosts, see the note at the end of config/initializers/durable_agent.rb.
|
|
58
|
+
TEXT
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
def warn_about_web_port(procfile)
|
|
63
|
+
web = procfile[/^web:.*$/]
|
|
64
|
+
if web && web !~ /\s(-p|--port)[\s=]?\d+/
|
|
65
|
+
say <<~TEXT, :yellow
|
|
66
|
+
|
|
67
|
+
Procfile.dev's web process sets no port: foreman runs it on PORT=5000, but the sidecar calls this app at
|
|
68
|
+
config.rails_url, http://127.0.0.1:3000 by default. Change the line to `#{WEB}`,
|
|
69
|
+
or set DURABLE_AGENT_RAILS_URL=http://127.0.0.1:5000.
|
|
70
|
+
TEXT
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# The first nine tables are pi-durable's own storage schema (version 1) under the durable_agent_ prefix: the
|
|
2
|
+
# sidecar runs pi-durable's storage core over them, so names, columns and indexes must match it. Rails owns the
|
|
3
|
+
# DDL and the sidecar never runs any; the sidecar is the only writer, Rails reads. Ids and singletons are pi's: the
|
|
4
|
+
# sidecar supplies every one, so the default primary keys are only a type (an INTEGER PRIMARY KEY rowid alias on
|
|
5
|
+
# SQLite, as in pi-durable's own DDL; bigserial on Postgres, its sequence unused). created_at is extra: pi-durable's
|
|
6
|
+
# inserts name their columns, so the database's default fills it. JSON lives in text columns, never jsonb, which
|
|
7
|
+
# rejects \u0000 that a model may produce. No foreign keys: pi-durable writes the rows of one commit in its own
|
|
8
|
+
# order. No data: the sidecar seeds the singletons at boot.
|
|
9
|
+
class CreateDurableAgentTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
10
|
+
def change
|
|
11
|
+
create_table :durable_agent_durable_schema, primary_key: :singleton do |t| # one row, singleton = 1
|
|
12
|
+
t.integer :version, null: false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
create_table :durable_agent_durable_metadata, primary_key: :singleton do |t|
|
|
16
|
+
t.text :next_id, null: false # pi stores it as text: ids can exceed 2^53 in principle
|
|
17
|
+
t.bigint :next_seq, null: false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
create_table :durable_agent_record_ids do |t|
|
|
21
|
+
t.text :record_type, null: false # conversation | entry | task | submission | document
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
create_table :durable_agent_conversations do |t|
|
|
25
|
+
t.bigint :owner_conversation_id
|
|
26
|
+
t.bigint :owner_task_id
|
|
27
|
+
t.text :record, null: false # ConversationRecord JSON: {id, parent?: {conversationId, at}, owner?, ...}
|
|
28
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
29
|
+
t.index [ :owner_conversation_id, :id ], name: "durable_agent_conversations_by_owner_conversation"
|
|
30
|
+
t.index [ :owner_task_id, :id ], name: "durable_agent_conversations_by_owner_task"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
create_table :durable_agent_entries do |t|
|
|
34
|
+
t.bigint :conversation_id, null: false
|
|
35
|
+
t.bigint :head
|
|
36
|
+
t.bigint :commit_seq, null: false
|
|
37
|
+
t.text :record, null: false # EntryRecord JSON: {id, conversationId, kind, data?, model?, head?, byTaskId?}
|
|
38
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
39
|
+
t.index [ :conversation_id, :id ], order: { id: :desc }, name: "durable_agent_entries_by_conversation"
|
|
40
|
+
t.index [ :conversation_id, :id ], order: { id: :desc }, name: "durable_agent_entry_heads_by_conversation", where: "head IS NOT NULL"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
create_table :durable_agent_tasks do |t|
|
|
44
|
+
t.bigint :conversation_id, null: false
|
|
45
|
+
t.text :kind, null: false # JSON-encoded string, e.g. "\"pi.run\""
|
|
46
|
+
t.text :status, null: false # pending | running | waiting | completing | terminal
|
|
47
|
+
t.integer :abort_requested, null: false # 0 | 1
|
|
48
|
+
t.integer :background, null: false # 0 | 1
|
|
49
|
+
t.text :record, null: false
|
|
50
|
+
t.index [ :status, :id ], name: "durable_agent_tasks_by_status"
|
|
51
|
+
t.index [ :conversation_id, :id ], name: "durable_agent_tasks_by_conversation"
|
|
52
|
+
t.index [ :kind, :id ], name: "durable_agent_tasks_by_kind"
|
|
53
|
+
t.index [ :abort_requested, :id ], name: "durable_agent_tasks_by_abort_requested"
|
|
54
|
+
t.index [ :background, :id ], name: "durable_agent_tasks_by_background"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
create_table :durable_agent_submissions do |t|
|
|
58
|
+
t.bigint :conversation_id, null: false
|
|
59
|
+
t.text :request_id # JSON-encoded string, e.g. "\"42\""; NULL when the harness made it
|
|
60
|
+
t.text :status, null: false # queued | placed | done | unanswered
|
|
61
|
+
t.text :record, null: false # SubmissionRecord JSON: {id, conversationId, requestId?, type, status, entry?, answer?, reason?, detail?}
|
|
62
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
63
|
+
t.index [ :conversation_id, :request_id ], name: "durable_agent_submissions_by_request"
|
|
64
|
+
t.index [ :conversation_id, :id ], name: "durable_agent_submissions_by_conversation"
|
|
65
|
+
t.index [ :status, :id ], name: "durable_agent_submissions_by_status"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
create_table :durable_agent_documents do |t|
|
|
69
|
+
t.text :kind, null: false # JSON-encoded string, e.g. "\"pi.live\""
|
|
70
|
+
t.integer :family, null: false # 0 = unkeyed, 1 = keyed
|
|
71
|
+
t.text :key_value, null: false # JSON-encoded key, "\"\"" when unkeyed
|
|
72
|
+
t.text :scope_kind, null: false # session | conversation | task
|
|
73
|
+
t.bigint :owner_id, null: false # 0, the conversation id, or the task id
|
|
74
|
+
t.bigint :created_at, null: false # commit seq, not a time
|
|
75
|
+
t.bigint :retired_at # commit seq, not a time
|
|
76
|
+
t.text :record, null: false # DocumentRecord JSON: {id, kind, scope: {kind, conversationId?|taskId?}, key?, history, fork?, createdAt, retiredAt?}
|
|
77
|
+
t.index [ :kind, :scope_kind, :owner_id, :family, :key_value, :created_at, :retired_at ], order: { created_at: :desc },
|
|
78
|
+
name: "durable_agent_documents_by_address"
|
|
79
|
+
t.index [ :scope_kind, :owner_id, :id ], name: "durable_agent_documents_by_scope"
|
|
80
|
+
t.index [ :scope_kind, :owner_id, :kind, :id ], name: "durable_agent_documents_by_scope_kind"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
create_table :durable_agent_document_revisions, primary_key: [ :document_id, :seq ] do |t|
|
|
84
|
+
t.bigint :document_id, null: false
|
|
85
|
+
t.bigint :seq, null: false
|
|
86
|
+
t.text :kind, null: false # base | delta
|
|
87
|
+
t.bigint :version, null: false
|
|
88
|
+
t.text :content, null: false # base: the value as JSON; delta: chord ops as JSON
|
|
89
|
+
t.index [ :document_id, :kind, :seq ], order: { seq: :desc }, name: "durable_agent_document_revisions_by_kind"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Written by the sidecar through its database facade.
|
|
93
|
+
create_table :durable_agent_reports do |t|
|
|
94
|
+
t.text :type, null: false
|
|
95
|
+
t.bigint :conversation_id
|
|
96
|
+
t.bigint :task_id
|
|
97
|
+
t.text :task_kind
|
|
98
|
+
t.text :extension
|
|
99
|
+
t.text :message, null: false
|
|
100
|
+
t.text :info, null: false # JSON text
|
|
101
|
+
t.datetime :created_at, null: false
|
|
102
|
+
t.index [ :conversation_id, :id ]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Provider sign-ins, encrypted by the sidecar with a key derived from DURABLE_AGENT_TOKEN. Rails has no model
|
|
106
|
+
# for this table and never decrypts it; connection status comes from the sidecar's GET /auth.
|
|
107
|
+
create_table :durable_agent_credentials, id: false do |t|
|
|
108
|
+
t.text :provider_id, primary_key: true
|
|
109
|
+
t.text :credential, null: false # the encrypted envelope as JSON text
|
|
110
|
+
t.datetime :updated_at, null: false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# device_id and schema_version, written by the sidecar.
|
|
114
|
+
create_table :durable_agent_settings, id: false do |t|
|
|
115
|
+
t.text :key, primary_key: true
|
|
116
|
+
t.text :value, null: false
|
|
117
|
+
t.datetime :updated_at, null: false
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
DurableAgent.configure do |config|
|
|
2
|
+
# The default model, "provider/modelId". Unset, it is ENV["DURABLE_AGENT_MODEL"], then "openai/gpt-6.1-sol", so
|
|
3
|
+
# `DURABLE_AGENT_MODEL=faux/faux-1 bin/dev` runs the scripted offline model. Setting it here wins over the variable.
|
|
4
|
+
# config.model = "openai/gpt-6.1-sol"
|
|
5
|
+
|
|
6
|
+
# Tools are DurableAgent::Tool subclasses. Give their names as strings so code reloading keeps working.
|
|
7
|
+
# config.tools = %w[ LookupInvoice ]
|
|
8
|
+
|
|
9
|
+
# Where the sidecar listens, and where it reaches this app for tools and change events.
|
|
10
|
+
# config.host = "127.0.0.1"
|
|
11
|
+
# config.port = 4010
|
|
12
|
+
# config.rails_url = "http://127.0.0.1:3000"
|
|
13
|
+
|
|
14
|
+
# The shared secret. Defaults to credentials.durable_agent.token, then ENV["DURABLE_AGENT_TOKEN"], and in
|
|
15
|
+
# development and test to a value derived from secret_key_base.
|
|
16
|
+
# config.token = "..."
|
|
17
|
+
|
|
18
|
+
# The models' base class. If ApplicationRecord uses connects_to (roles, shards), inherit from it.
|
|
19
|
+
# config.parent_record_class = "ApplicationRecord"
|
|
20
|
+
|
|
21
|
+
# Called in this app's web process when the sidecar reports a change. The defaults refresh the pages subscribed
|
|
22
|
+
# with `turbo_stream_from conversation`; reports without a conversation refresh `turbo_stream_from
|
|
23
|
+
# "durable_agent:reports"`; sign-ins and sign-outs refresh `turbo_stream_from "durable_agent:auth"`.
|
|
24
|
+
# config.on_change = ->(conversation) { Turbo::StreamsChannel.broadcast_refresh_to(conversation) }
|
|
25
|
+
# config.on_report = ->(report) { Rails.logger.warn(report.message) }
|
|
26
|
+
# on_auth gets provider ids, scoped for accounts ("openai", "openai@u42"); its default also refreshes
|
|
27
|
+
# `turbo_stream_from DurableAgent.auth_stream(account)` for each account named.
|
|
28
|
+
# config.on_auth = ->(providers) { Turbo::StreamsChannel.broadcast_refresh_to("durable_agent:auth") }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# In production the sidecar calls this app over plain HTTP at rails_url (loopback by default). With
|
|
32
|
+
# config.force_ssl, keep /durable_agent out of the HTTPS redirect; with config.hosts set, allow the sidecar's
|
|
33
|
+
# host. In config/environments/production.rb:
|
|
34
|
+
#
|
|
35
|
+
# config.ssl_options = { redirect: { exclude: ->(request) { request.path.start_with?("/durable_agent") } } }
|
|
36
|
+
# config.hosts << "127.0.0.1"
|