yrby-agent 0.1.0.alpha1
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-agent.md +37 -0
- data/LICENSE +21 -0
- data/lib/y/action_cable/client/outbox.rb +42 -0
- data/lib/y/action_cable/client/session.rb +185 -0
- data/lib/y/action_cable/client.rb +356 -0
- data/lib/y/agent/tools.rb +86 -0
- data/lib/y/agent.rb +85 -0
- data/lib/y/collaborator/lock.rb +71 -0
- data/lib/y/collaborator.rb +228 -0
- data/lib/yrby/agent/version.rb +7 -0
- data/lib/yrby-agent.rb +4 -0
- metadata +142 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bc39c696ec3eda40ebaa1aca8cbca2193d47f45fa255d03684276b6b3d1f0830
|
|
4
|
+
data.tar.gz: eff30a5794a471040bcc6bae86a359c1b89d34c7727e9b69ef3aeb848d56b8c2
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3c76f601752b0fd45c385897eaec1e8dfdd6119703a4eb7c193a25bfd514a383cbcec851bbcd89e0e1bcad07c4efda2958303d3aeebe692fd51a8553f0a382e2
|
|
7
|
+
data.tar.gz: 71dadfd11c7920843dc982d6659d644f022af574306dcdc19bc99c84d748234e737643daec03f40e8cd35d9e37e001af60048517688c957a5c3c3dd53183cdf0
|
data/CHANGELOG-agent.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog: yrby-agent
|
|
2
|
+
|
|
3
|
+
All notable changes to the `yrby-agent` gem are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
|
|
5
|
+
project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
The first release. Everything here is experimental and may change in any
|
|
10
|
+
release. See [docs/experimental.md](docs/experimental.md).
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `Y::ActionCable::Client` joins a document over the cable's websocket with a
|
|
15
|
+
grant, as a browser does. `edit` sends a change, every update is resent
|
|
16
|
+
until acked, and a dropped socket reconnects. `presence=` shows the client
|
|
17
|
+
to others and renews itself. It sends the URL's origin, and `headers:`
|
|
18
|
+
add anything else the connection checks.
|
|
19
|
+
- `Y::Collaborator` takes part in a document the way a person does, from a
|
|
20
|
+
job or anywhere else. A subclass defines `joined` and `changed(blocks)`,
|
|
21
|
+
where an AI agent calls its model, and sets `self.presence =`.
|
|
22
|
+
`Reviewer.run(post.collaborative_document(:body), url:, headers:)` joins
|
|
23
|
+
with a grant, calls `changed` with live handles to the blocks people
|
|
24
|
+
changed once their typing settles, and returns when nobody else has been
|
|
25
|
+
present for `idle` seconds, after `max_stay`, or on `leave`. One
|
|
26
|
+
collaborator works on a document at a time, through a lock in
|
|
27
|
+
`Rails.cache`, or one per person with `as: person`. An error in `joined`
|
|
28
|
+
or `changed` ends the collaborator and raises out of `run`.
|
|
29
|
+
`require "y/collaborator"` to use it.
|
|
30
|
+
- `Y::Agent`, a `Y::Collaborator` with a model. A subclass sets `model` and
|
|
31
|
+
`instructions`, which are RubyLLM::Agent's, and runs from a job. When
|
|
32
|
+
people's typing settles, `changed` asks the model about the blocks they
|
|
33
|
+
changed, in a fresh chat with tools that read the document
|
|
34
|
+
(`read_document`) and edit its blocks (`rewrite_block`, `insert_paragraph`,
|
|
35
|
+
`delete_block`). The tools name blocks by short ids that stay on their
|
|
36
|
+
blocks, and write through `edit`. `ask` asks anything else, and
|
|
37
|
+
`document_tools` picks which tools the model gets.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
4
|
+
class Client
|
|
5
|
+
# The local updates the server has not acknowledged yet, in the order
|
|
6
|
+
# they were made. Each carries the id the server acks, and an ack
|
|
7
|
+
# confirms only its own update: the server may record the updates of one
|
|
8
|
+
# connection out of order, so an ack for a later update says nothing about
|
|
9
|
+
# an earlier one. Nothing here touches the socket: the session sends what
|
|
10
|
+
# `pending` holds and this drops what `ack` confirms. A retransmit sends
|
|
11
|
+
# the pending updates one by one, in order.
|
|
12
|
+
class Outbox
|
|
13
|
+
Pending = Data.define(:id, :update)
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@pending = []
|
|
17
|
+
@next_id = 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pending = @pending.dup
|
|
21
|
+
|
|
22
|
+
def pending? = !@pending.empty?
|
|
23
|
+
|
|
24
|
+
# Queue a local update. Returns it with the id it will be acked by.
|
|
25
|
+
def push(update)
|
|
26
|
+
entry = Pending.new(id: @next_id, update: update)
|
|
27
|
+
@next_id += 1
|
|
28
|
+
@pending << entry
|
|
29
|
+
entry
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Confirm delivery of the update sent as `id`. The value comes off the
|
|
33
|
+
# wire: one that is not an integer, or names an id not pending, drops
|
|
34
|
+
# nothing. Returns true when something was confirmed.
|
|
35
|
+
def ack(id) # rubocop:disable Naming/PredicateMethod -- an action that reports whether it did anything
|
|
36
|
+
return false unless id.is_a?(Integer)
|
|
37
|
+
|
|
38
|
+
!@pending.reject! { |entry| entry.id == id }.nil?
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "y/action_cable/sync"
|
|
6
|
+
require "y/action_cable/client/outbox"
|
|
7
|
+
|
|
8
|
+
module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
9
|
+
class Client
|
|
10
|
+
# The client side of the protocol with no socket: cable messages in,
|
|
11
|
+
# cable messages out through the block, and the document kept current.
|
|
12
|
+
# It is what the JS provider's session is, in Ruby, so it can be driven
|
|
13
|
+
# in a test with nothing listening. The client feeds it every message
|
|
14
|
+
# the socket delivers and every command the caller makes, all on one
|
|
15
|
+
# reactor, so its own state needs no lock.
|
|
16
|
+
#
|
|
17
|
+
# Messages are the JSON hashes the cable carries. A command goes out as
|
|
18
|
+
# { command:, identifier:, data: } and a channel message comes in as
|
|
19
|
+
# { identifier:, message: }, the message being Sync's envelope.
|
|
20
|
+
class Session
|
|
21
|
+
# The second byte of a sync frame is y-protocols' sync type; 1 is
|
|
22
|
+
# SyncStep2, the server's answer to our SyncStep1.
|
|
23
|
+
SYNC_STEP2 = 1
|
|
24
|
+
|
|
25
|
+
# `root:` names the root XmlText whose top-level blocks on_update
|
|
26
|
+
# reports as changed; nil turns block tracking off.
|
|
27
|
+
def initialize(doc, identifier, root: "root", &transmit)
|
|
28
|
+
@doc = doc
|
|
29
|
+
@identifier = identifier
|
|
30
|
+
@root = root
|
|
31
|
+
@transmit = transmit
|
|
32
|
+
@outbox = Outbox.new
|
|
33
|
+
@subscribed = false
|
|
34
|
+
@synced = false
|
|
35
|
+
@loaded = false
|
|
36
|
+
@presence = nil
|
|
37
|
+
@asked = nil
|
|
38
|
+
@on_update = nil
|
|
39
|
+
@on_awareness = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def on_update(&block) = @on_update = block
|
|
43
|
+
def on_awareness(&block) = @on_awareness = block
|
|
44
|
+
def synced? = @synced
|
|
45
|
+
def pending? = @outbox.pending?
|
|
46
|
+
|
|
47
|
+
# One message from the server. Returns what it was: :welcome, :ping,
|
|
48
|
+
# :confirmed, :rejected, :disconnect, :synced for the message that
|
|
49
|
+
# completed a handshake, :ack, :update, or :awareness for a message of
|
|
50
|
+
# this subscription, :message for one it ignored, and nil for anything
|
|
51
|
+
# else. The connection acts on :synced, :rejected, and :disconnect.
|
|
52
|
+
def receive(message)
|
|
53
|
+
case message["type"]
|
|
54
|
+
when "welcome" then welcome
|
|
55
|
+
when "ping" then :ping
|
|
56
|
+
when "confirm_subscription" then confirmed if mine?(message)
|
|
57
|
+
when "reject_subscription" then :rejected if mine?(message)
|
|
58
|
+
when "disconnect" then :disconnect
|
|
59
|
+
else
|
|
60
|
+
payload = message["message"]
|
|
61
|
+
receive_payload(payload) || :message if mine?(message) && payload.is_a?(Hash)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The socket dropped. Pending updates stay for the next confirmation.
|
|
66
|
+
def dropped
|
|
67
|
+
@subscribed = false
|
|
68
|
+
@synced = false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A local update: kept until the server acks it, sent now when
|
|
72
|
+
# subscribed and otherwise when the subscription is next confirmed.
|
|
73
|
+
def send_update(update)
|
|
74
|
+
entry = @outbox.push(update)
|
|
75
|
+
send_entry(entry) if @subscribed
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Presence goes out as is. The last frame is said again on a reconnect,
|
|
79
|
+
# so peers see this client back without waiting for its next refresh.
|
|
80
|
+
def send_awareness(frame)
|
|
81
|
+
@presence = frame
|
|
82
|
+
send_frame(frame) if @subscribed
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Send everything unacked, in order. The retransmit timer calls this.
|
|
86
|
+
def resend
|
|
87
|
+
return unless @subscribed
|
|
88
|
+
|
|
89
|
+
@outbox.pending.each { |entry| send_entry(entry) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def unsubscribe
|
|
93
|
+
command("unsubscribe") if @subscribed
|
|
94
|
+
@subscribed = false
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# The server says welcome once the socket is up, and takes commands
|
|
100
|
+
# from then on.
|
|
101
|
+
def welcome
|
|
102
|
+
command("subscribe")
|
|
103
|
+
:welcome
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The subscription is confirmed: answer the server's opening SyncStep1
|
|
107
|
+
# if it came first, send ours, say who we are, and replay what is
|
|
108
|
+
# still unacked. The server's SyncStep2 completes the handshake.
|
|
109
|
+
def confirmed
|
|
110
|
+
@subscribed = true
|
|
111
|
+
send_frame(@doc.handle_sync_message(@asked)[2]) if @asked
|
|
112
|
+
@asked = nil
|
|
113
|
+
send_frame(@doc.sync_step1)
|
|
114
|
+
send_frame(@presence) if @presence
|
|
115
|
+
resend
|
|
116
|
+
:confirmed
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def receive_payload(payload)
|
|
120
|
+
return @outbox.ack(payload["ack"]) && :ack if payload.key?("ack")
|
|
121
|
+
|
|
122
|
+
# AnyCable whispers presence under its own key; the server relays
|
|
123
|
+
# document frames under "update".
|
|
124
|
+
encoded = payload["awareness"] || payload["update"]
|
|
125
|
+
frame = decode(encoded) if encoded.is_a?(String)
|
|
126
|
+
receive_frame(frame) if frame
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def decode(encoded)
|
|
130
|
+
Base64.strict_decode64(encoded)
|
|
131
|
+
rescue ArgumentError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A malformed frame is dropped by Y.message_kind. The server's SyncStep1
|
|
136
|
+
# is answered with our full state, the way a browser answers it; one
|
|
137
|
+
# that arrives before the subscription is confirmed (Action Cable sends
|
|
138
|
+
# it from `subscribed`) waits for the confirmation.
|
|
139
|
+
def receive_frame(frame)
|
|
140
|
+
case Y.message_kind(frame)
|
|
141
|
+
when Sync::MSG_KIND_SYNC_STEP1
|
|
142
|
+
@subscribed ? send_frame(@doc.handle_sync_message(frame)[2]) : @asked = frame
|
|
143
|
+
nil
|
|
144
|
+
when Sync::MSG_KIND_UPDATE then receive_update(frame)
|
|
145
|
+
when Sync::MSG_KIND_AWARENESS
|
|
146
|
+
@on_awareness&.call(frame)
|
|
147
|
+
:awareness
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Apply what the doc does not already hold (its own updates come back
|
|
152
|
+
# from the server too) and report it. The first SyncStep2 is the load,
|
|
153
|
+
# not a change, so it does not fire on_update; a later one, after a
|
|
154
|
+
# reconnect, carries what was missed and does.
|
|
155
|
+
def receive_update(frame)
|
|
156
|
+
update = Y.update_from_message(frame)
|
|
157
|
+
apply(update) if update && @doc.update_advances?(update)
|
|
158
|
+
return :update unless frame.getbyte(1) == SYNC_STEP2 && !@synced
|
|
159
|
+
|
|
160
|
+
@synced = true
|
|
161
|
+
@loaded = true
|
|
162
|
+
:synced
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def apply(update)
|
|
166
|
+
changed = @root ? @doc.apply_update_changes(update, @root) : (@doc.apply_update(update) && nil)
|
|
167
|
+
@on_update&.call(update, @doc, changed) if @loaded
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def send_entry(entry)
|
|
171
|
+
message("update" => Base64.strict_encode64(Y.wrap_update(entry.update)), "id" => entry.id)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def send_frame(bytes) = message("update" => Base64.strict_encode64(bytes))
|
|
175
|
+
|
|
176
|
+
def message(data)
|
|
177
|
+
@transmit.call("command" => "message", "identifier" => @identifier, "data" => JSON.generate(data))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def command(name) = @transmit.call("command" => name, "identifier" => @identifier)
|
|
181
|
+
|
|
182
|
+
def mine?(message) = message["identifier"] == @identifier
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "async"
|
|
5
|
+
require "async/http/endpoint"
|
|
6
|
+
require "async/websocket/client"
|
|
7
|
+
rescue LoadError => e
|
|
8
|
+
raise LoadError, "Y::ActionCable::Client needs the async-websocket gem, which yrby-agent depends on. (#{e.message})"
|
|
9
|
+
end
|
|
10
|
+
require "json"
|
|
11
|
+
require "logger"
|
|
12
|
+
require "y/action_cable/client/session"
|
|
13
|
+
|
|
14
|
+
# y/collaborator and y/agent load this file too, so one warning covers all three.
|
|
15
|
+
warn "yrby-agent: Y::ActionCable::Client, Y::Collaborator, and Y::Agent are experimental " \
|
|
16
|
+
"and may change in any release. Warning[:experimental] = false turns this off.", category: :experimental
|
|
17
|
+
|
|
18
|
+
module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
19
|
+
# Joins a document the way a browser does: over the
|
|
20
|
+
# cable's websocket, as a client of the document channel. It holds a
|
|
21
|
+
# Y::Doc, follows the document, and sends its own edits and presence
|
|
22
|
+
# through the channel, so the server records, acks, and distributes them
|
|
23
|
+
# like anyone else's. The process touches neither the pubsub nor the
|
|
24
|
+
# store. It needs the cable URL and the same way in a browser has: the
|
|
25
|
+
# channel's name and params, such as a grant the app minted with
|
|
26
|
+
# record.collaborative_document(name).grant.
|
|
27
|
+
#
|
|
28
|
+
# client = Y::ActionCable::Client.new("wss://example.com/cable",
|
|
29
|
+
# params: { grant: post.collaborative_document(:body).grant, name: "body" })
|
|
30
|
+
# client.on_update { |update, doc, changed| ... } # changed: block ordinals touched
|
|
31
|
+
# client.subscribe # connects and returns once the document has arrived
|
|
32
|
+
# client.presence = { "user" => { "name" => "Reviewer", "color" => "#7c3aed" } }
|
|
33
|
+
# client.edit { |doc| Y::Lexxy.append_paragraph(doc, "hello") }
|
|
34
|
+
# client.unsubscribe # waits for acks, clears presence, and closes
|
|
35
|
+
#
|
|
36
|
+
# The client sends the URL's own origin as its Origin, which Action Cable
|
|
37
|
+
# accepts. `headers:` go on the websocket request for anything more: an
|
|
38
|
+
# Origin when the cable is reached at another address than the site's, or
|
|
39
|
+
# a token for an ApplicationCable::Connection that requires a login, since
|
|
40
|
+
# the process has no cookie.
|
|
41
|
+
#
|
|
42
|
+
# The client runs as a task of the reactor `subscribe` is called from
|
|
43
|
+
# when there is one (Falcon, or an Async block), and in a thread of its
|
|
44
|
+
# own otherwise (Puma, a script). Callbacks run on that reactor; keep
|
|
45
|
+
# them short. edit and presence= may be called from any thread or fiber.
|
|
46
|
+
#
|
|
47
|
+
# Delivery is at least once, as in the JS provider: every update carries
|
|
48
|
+
# an id and is kept until the server acks it, resent on a timer and after
|
|
49
|
+
# a reconnect. A dropped socket reconnects with backoff. A rejected
|
|
50
|
+
# subscription, or a server asking not to reconnect, ends the client.
|
|
51
|
+
class Client
|
|
52
|
+
PROTOCOL = "actioncable-v1-json"
|
|
53
|
+
RESEND = 1 # seconds between retransmits of unacked updates
|
|
54
|
+
STALE = 15 # seconds without a message before the socket is given up; the server pings every 3
|
|
55
|
+
RENEW = 15 # seconds between presence renewals; peers drop a client they haven't heard from in 30
|
|
56
|
+
LEAVE_GRACE = 0.25 # seconds between clearing presence and unsubscribing
|
|
57
|
+
BACKOFF = [1, 2, 4, 8].freeze # seconds before each reconnect, then the last for good
|
|
58
|
+
UNACKED = "unsubscribed before the server acked every update"
|
|
59
|
+
|
|
60
|
+
# `channel:` and `params:` make the subscription identifier, the way
|
|
61
|
+
# yrby-client's provider does. `root:` is the root XmlText on_update
|
|
62
|
+
# reports changed blocks for; nil turns that off.
|
|
63
|
+
def initialize(url, params:, channel: "Y::DocumentChannel", headers: {}, doc: Y::Doc.new, root: "root", logger: nil) # rubocop:disable Metrics/ParameterLists -- the subscription's parts and the doc's
|
|
64
|
+
@endpoint = Async::HTTP::Endpoint.parse(url)
|
|
65
|
+
subscription = { "channel" => channel }.merge(params.transform_keys(&:to_s))
|
|
66
|
+
@identifier = JSON.generate(subscription)
|
|
67
|
+
# What logs and errors name the subscription by. The grant opens the
|
|
68
|
+
# document, so it stays out of them.
|
|
69
|
+
@label = JSON.generate(subscription.except("grant"))
|
|
70
|
+
@headers = headers.map { |name, value| [name.to_s.downcase, value.to_s] }
|
|
71
|
+
@doc = doc
|
|
72
|
+
@awareness = Y::Awareness.new(doc.client_id)
|
|
73
|
+
@presence = nil
|
|
74
|
+
@session = Session.new(doc, @identifier, root: root) do |message|
|
|
75
|
+
@outgoing << JSON.generate(message)
|
|
76
|
+
end
|
|
77
|
+
@logger = logger || Logger.new($stderr)
|
|
78
|
+
@commands = Thread::Queue.new # from any thread to the reactor
|
|
79
|
+
@outgoing = Thread::Queue.new # from the reactor to the socket
|
|
80
|
+
@events = Thread::Queue.new # from the reactor to whoever waits in subscribe
|
|
81
|
+
@queued = 0 # updates handed over but not yet taken up by the reactor
|
|
82
|
+
@lock = Mutex.new
|
|
83
|
+
@closed = false
|
|
84
|
+
@turned_away = false # the server closed the client, not the caller
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def on_update(&block)
|
|
88
|
+
@session.on_update { |*args| callback(block, args) }
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def on_awareness(&block)
|
|
93
|
+
@session.on_awareness { |*args| callback(block, args) }
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def synced? = @session.synced?
|
|
98
|
+
|
|
99
|
+
# True while updates are in flight: handed over, sent, but not yet acked.
|
|
100
|
+
def pending? = @lock.synchronize { @queued.positive? } || @session.pending?
|
|
101
|
+
|
|
102
|
+
# True once the client has left, or the server ended its subscription
|
|
103
|
+
# (rejected it on a reconnect, or asked it not to reconnect).
|
|
104
|
+
def closed? = @closed
|
|
105
|
+
|
|
106
|
+
# Connect, subscribe, and wait up to `timeout` seconds for the document
|
|
107
|
+
# to arrive. Raises Y::Error when the subscription is rejected or the
|
|
108
|
+
# server does not answer in time.
|
|
109
|
+
def subscribe(timeout: 10)
|
|
110
|
+
raise Y::Error, "the client was closed" if @closed
|
|
111
|
+
|
|
112
|
+
@runner = Async::Task.current? ? Async { run } : Thread.new { Sync { run } }
|
|
113
|
+
event = @events.pop(timeout: timeout)
|
|
114
|
+
case event
|
|
115
|
+
when :synced then self
|
|
116
|
+
when :rejected then close_and_raise("subscription rejected for #{@label}")
|
|
117
|
+
when String then close_and_raise("the server closed the connection: #{event}")
|
|
118
|
+
else close_and_raise("no sync within #{timeout}s for #{@label}")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Leave: wait up to `timeout` seconds for acks of what is still in
|
|
123
|
+
# flight, tell the channel, and close the socket. Nothing is delivered
|
|
124
|
+
# after this. Raises Y::Error when an update was still unacked, since
|
|
125
|
+
# it may never have reached the server.
|
|
126
|
+
def unsubscribe(timeout: 5)
|
|
127
|
+
# The server may have closed the client already, turning it away on a
|
|
128
|
+
# reconnect. What it never acked is lost all the same, and the first
|
|
129
|
+
# unsubscribe says so.
|
|
130
|
+
if @closed
|
|
131
|
+
lost = @turned_away && pending?
|
|
132
|
+
@turned_away = false
|
|
133
|
+
raise Y::Error, UNACKED if lost
|
|
134
|
+
|
|
135
|
+
return self
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
@closed = true
|
|
139
|
+
# From inside a callback the runner is the caller, so it can't be
|
|
140
|
+
# waited on. It finishes leaving once the callback returns, and logs
|
|
141
|
+
# an update still unacked instead.
|
|
142
|
+
inside = Thread.current[:yrby_client_callback].equal?(self)
|
|
143
|
+
@commands << [:close, [timeout, inside]]
|
|
144
|
+
return self if inside
|
|
145
|
+
|
|
146
|
+
@runner.is_a?(Thread) ? @runner.join(timeout + 5) : @runner&.wait
|
|
147
|
+
raise Y::Error, UNACKED if pending?
|
|
148
|
+
|
|
149
|
+
self
|
|
150
|
+
rescue Async::Stop
|
|
151
|
+
self
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Edit the document and send what the block changed. Returns the update,
|
|
155
|
+
# or nil when there's nothing to send. A remote update applied while the
|
|
156
|
+
# block runs stays out of it (see Y::Doc#edit). Raises Y::Error once the
|
|
157
|
+
# client is closed, since nothing it edits then would reach anyone.
|
|
158
|
+
def edit
|
|
159
|
+
raise Y::Error, "the client was closed" if @closed
|
|
160
|
+
|
|
161
|
+
# Only the block can be interrupted, so an update is never taken from
|
|
162
|
+
# the document and then dropped before it's handed over.
|
|
163
|
+
Thread.handle_interrupt(Object => :never) do
|
|
164
|
+
update = @doc.edit { Thread.handle_interrupt(Object => :immediate) { yield @doc } }
|
|
165
|
+
send_update(update)
|
|
166
|
+
update
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Show this client to the document's other participants, the way a
|
|
171
|
+
# browser's presence does. `state` is the hash editors render, such as
|
|
172
|
+
# { "user" => { "name" => "Reviewer", "color" => "#7c3aed" } }, plus a
|
|
173
|
+
# caret if you have one. The client renews it every RENEW seconds and
|
|
174
|
+
# clears it when it leaves.
|
|
175
|
+
def presence=(state)
|
|
176
|
+
@presence = JSON.generate(state)
|
|
177
|
+
send_awareness(@awareness.set_local_state(@presence))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
attr_reader :doc
|
|
183
|
+
|
|
184
|
+
# Hand a document update to the reactor, which keeps it until the server
|
|
185
|
+
# acks it.
|
|
186
|
+
def send_update(update)
|
|
187
|
+
return unless update
|
|
188
|
+
raise Y::Error, "the client closed before this update was sent" if @closed
|
|
189
|
+
|
|
190
|
+
@lock.synchronize { @queued += 1 }
|
|
191
|
+
@commands << [:update, update]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def send_awareness(frame)
|
|
195
|
+
@commands << [:awareness, frame] if frame && !@closed
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Runs a caller's callback, marked so `unsubscribe` knows it is inside one.
|
|
199
|
+
def callback(block, args)
|
|
200
|
+
Thread.current[:yrby_client_callback] = self
|
|
201
|
+
block.call(*args)
|
|
202
|
+
ensure
|
|
203
|
+
Thread.current[:yrby_client_callback] = nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def close_and_raise(reason)
|
|
207
|
+
@closed = true
|
|
208
|
+
@commands << [:close, [0, false]]
|
|
209
|
+
raise Y::Error, reason
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The whole life of the client, on its reactor: commands from callers
|
|
213
|
+
# are applied to the session as they arrive, and the socket is kept
|
|
214
|
+
# connected until the client is closed.
|
|
215
|
+
def run
|
|
216
|
+
@task = Async::Task.current
|
|
217
|
+
pump = @task.async { each_command }
|
|
218
|
+
renewal = @task.async { renew_presence }
|
|
219
|
+
attempts = 0
|
|
220
|
+
until @closed
|
|
221
|
+
if connection == :stop
|
|
222
|
+
@closed = @turned_away = true
|
|
223
|
+
break
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
attempts += 1
|
|
227
|
+
sleep BACKOFF[[attempts, BACKOFF.size].min - 1]
|
|
228
|
+
end
|
|
229
|
+
ensure
|
|
230
|
+
pump&.stop
|
|
231
|
+
renewal&.stop
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# One socket: reader here, a writer task for the outgoing queue, and a
|
|
235
|
+
# timer that resends unacked updates. Returns :stop when the client
|
|
236
|
+
# should not reconnect, :dropped otherwise.
|
|
237
|
+
def connection
|
|
238
|
+
Async::WebSocket::Client.connect(@endpoint, headers: request_headers, protocols: [PROTOCOL]) do |socket|
|
|
239
|
+
writer = @task.async { each_outgoing(socket) }
|
|
240
|
+
timer = @task.async { retransmit }
|
|
241
|
+
read_loop(socket)
|
|
242
|
+
ensure
|
|
243
|
+
writer&.stop
|
|
244
|
+
timer&.stop
|
|
245
|
+
@session.dropped
|
|
246
|
+
@outgoing.clear
|
|
247
|
+
end
|
|
248
|
+
rescue Async::Stop
|
|
249
|
+
raise
|
|
250
|
+
rescue StandardError => e
|
|
251
|
+
@logger.warn("Y::ActionCable::Client #{@label}: #{e.class}: #{e.message}")
|
|
252
|
+
:dropped
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def read_loop(socket)
|
|
256
|
+
loop do
|
|
257
|
+
message = @task.with_timeout(STALE) { socket.read } or return :dropped
|
|
258
|
+
data = JSON.parse(message.to_str)
|
|
259
|
+
case @session.receive(data)
|
|
260
|
+
when :synced then @events << :synced
|
|
261
|
+
when :rejected
|
|
262
|
+
@events << :rejected
|
|
263
|
+
return :stop
|
|
264
|
+
when :disconnect
|
|
265
|
+
return :dropped unless data["reconnect"] == false
|
|
266
|
+
|
|
267
|
+
# Such as "unauthorized" from a connection that failed to connect.
|
|
268
|
+
@events << data["reason"].to_s
|
|
269
|
+
return :stop
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
rescue Async::TimeoutError, JSON::ParserError => e
|
|
273
|
+
@logger.warn("Y::ActionCable::Client #{@label}: #{e.class}: #{e.message}")
|
|
274
|
+
:dropped
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# y-protocols peers drop a client whose presence goes quiet for 30
|
|
278
|
+
# seconds, so a client with presence says it again on a timer.
|
|
279
|
+
def renew_presence
|
|
280
|
+
loop do
|
|
281
|
+
sleep RENEW
|
|
282
|
+
@session.send_awareness(@awareness.set_local_state(@presence)) if @presence
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def retransmit
|
|
287
|
+
loop do
|
|
288
|
+
sleep RESEND
|
|
289
|
+
@session.resend
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def each_outgoing(socket)
|
|
294
|
+
while (text = @outgoing.pop)
|
|
295
|
+
socket.write(text)
|
|
296
|
+
socket.flush
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def each_command
|
|
301
|
+
while (command, argument = @commands.pop)
|
|
302
|
+
case command
|
|
303
|
+
when :update
|
|
304
|
+
@session.send_update(argument)
|
|
305
|
+
@lock.synchronize { @queued -= 1 }
|
|
306
|
+
when :awareness then @session.send_awareness(argument)
|
|
307
|
+
when :close then return leave(*argument)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Give in-flight updates a moment to be acked, say goodbye, let the
|
|
313
|
+
# goodbye reach the socket, and stop the reactor task.
|
|
314
|
+
#
|
|
315
|
+
# Action Cable runs one connection's messages on a worker pool, so an
|
|
316
|
+
# unsubscribe sent right behind the presence removal can be handled first,
|
|
317
|
+
# and the server then drops the removal. The client lets the removal go
|
|
318
|
+
# out and waits LEAVE_GRACE before unsubscribing. If it is lost anyway,
|
|
319
|
+
# peers drop the presence after 30 seconds.
|
|
320
|
+
def leave(timeout, inside_callback)
|
|
321
|
+
deadline = now + timeout
|
|
322
|
+
sleep 0.05 while @session.pending? && now < deadline
|
|
323
|
+
@logger.error(UNACKED) if inside_callback && @session.pending?
|
|
324
|
+
if @presence
|
|
325
|
+
@session.send_awareness(@awareness.clear_local_state)
|
|
326
|
+
flush
|
|
327
|
+
sleep LEAVE_GRACE
|
|
328
|
+
end
|
|
329
|
+
@session.unsubscribe
|
|
330
|
+
flush
|
|
331
|
+
@task.stop
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def flush
|
|
335
|
+
deadline = now + 1
|
|
336
|
+
sleep 0.05 until @outgoing.empty? || now > deadline
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
340
|
+
|
|
341
|
+
# The caller's headers, plus an Origin unless they set one. It's a new
|
|
342
|
+
# array each time, because async-websocket adds the handshake's key to
|
|
343
|
+
# the one it's given, and a reconnect has to send only its own key.
|
|
344
|
+
def request_headers
|
|
345
|
+
headers = @headers.dup
|
|
346
|
+
headers.unshift(["origin", origin]) unless headers.any? { |name, _| name == "origin" }
|
|
347
|
+
headers
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Action Cable checks the origin against the host unless forgery
|
|
351
|
+
# protection is off; the socket's own host is always allowed.
|
|
352
|
+
def origin
|
|
353
|
+
"#{@endpoint.secure? ? "https" : "http"}://#{@endpoint.authority}"
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
class Agent < Collaborator
|
|
5
|
+
# The RubyLLM tools an agent gives its model. The model names blocks by
|
|
6
|
+
# the ids read_document lists, and every write goes through the agent's
|
|
7
|
+
# `edit`, so it reaches people as the model makes it.
|
|
8
|
+
module Tools
|
|
9
|
+
# `blocks` as the model reads them: one line per block, "b3 (paragraph): text".
|
|
10
|
+
def self.listing(agent, blocks)
|
|
11
|
+
blocks.map do |block|
|
|
12
|
+
kind = block.attributes.values_at("__type", "__tag").compact.join(" ")
|
|
13
|
+
"#{agent.block_id(block)} (#{kind}): #{block.text}"
|
|
14
|
+
end.join("\n")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Tool < RubyLLM::Tool
|
|
18
|
+
def self.tool_name = name.split("::").last.gsub(/(?<=[a-z])(?=[A-Z])/, "_").downcase
|
|
19
|
+
|
|
20
|
+
def initialize(agent)
|
|
21
|
+
super()
|
|
22
|
+
@agent = agent
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def missing(id) = { error: "There's no block #{id}. Call read_document for the blocks there are now." }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class ReadDocument < Tool
|
|
31
|
+
description "Lists the document's blocks in order, one per line, as `id (type): text`. " \
|
|
32
|
+
"Name a block by its id in the other tools. Ids stay on their blocks while people edit."
|
|
33
|
+
|
|
34
|
+
def execute
|
|
35
|
+
blocks = @agent.document_blocks
|
|
36
|
+
blocks.empty? ? "The document is empty." : Tools.listing(@agent, blocks)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class RewriteBlock < Tool
|
|
41
|
+
description "Replaces the text of one block with `text`, as plain text. " \
|
|
42
|
+
"The block's formatting, such as bold or links, is lost."
|
|
43
|
+
parameter :id, description: "The block's id, such as b3"
|
|
44
|
+
parameter :text, description: "The block's new text"
|
|
45
|
+
|
|
46
|
+
def execute(id:, text:)
|
|
47
|
+
block = @agent.block(id) or return missing(id)
|
|
48
|
+
@agent.edit { Lexical.replace_runs(block, text) }
|
|
49
|
+
"Rewrote #{id}."
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class InsertParagraph < Tool
|
|
54
|
+
description "Adds a paragraph holding `text`, after the block `after`, or at the end of the document."
|
|
55
|
+
parameter :text, description: "The paragraph's text"
|
|
56
|
+
parameter :after, description: "The id of the block it goes after. Leave it out to add it at the end.",
|
|
57
|
+
required: false
|
|
58
|
+
|
|
59
|
+
def execute(text:, after: nil)
|
|
60
|
+
block = nil
|
|
61
|
+
@agent.edit do |doc|
|
|
62
|
+
at = after ? @agent.ordinal(after)&.succ : -1
|
|
63
|
+
block = Lexical.insert_paragraph(doc, at, text) if at
|
|
64
|
+
end
|
|
65
|
+
block ? "Added #{@agent.block_id(block)}." : missing(after)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class DeleteBlock < Tool
|
|
70
|
+
description "Deletes one block."
|
|
71
|
+
parameter :id, description: "The block's id, such as b3"
|
|
72
|
+
|
|
73
|
+
def execute(id:)
|
|
74
|
+
deleted = false
|
|
75
|
+
@agent.edit do |doc|
|
|
76
|
+
ordinal = @agent.ordinal(id)
|
|
77
|
+
deleted = Lexical.delete_block(doc, ordinal) if ordinal
|
|
78
|
+
end
|
|
79
|
+
deleted ? "Deleted #{id}." : missing(id)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
ALL = [ReadDocument, RewriteBlock, InsertParagraph, DeleteBlock].freeze
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/y/agent.rb
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require "y/collaborator"
|
|
5
|
+
require "yrby/agent/version"
|
|
6
|
+
require "y/agent/tools"
|
|
7
|
+
|
|
8
|
+
module Y
|
|
9
|
+
# A collaborator with a model. It joins a document the way Y::Collaborator
|
|
10
|
+
# does, and when people's typing settles it hands the blocks they changed to
|
|
11
|
+
# a model through RubyLLM, with tools that read and edit the document:
|
|
12
|
+
#
|
|
13
|
+
# class Proofreader < Y::Agent
|
|
14
|
+
# model "gpt-5.6-luna"
|
|
15
|
+
# instructions "Fix typos in the blocks people change. Change nothing else."
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# class ProofreadJob < ApplicationJob
|
|
19
|
+
# def perform(post) = Proofreader.run(post.collaborative_document(:body), url: "wss://example.com/cable")
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# `model`, `instructions`, `tools`, `thinking`, and `temperature` are
|
|
23
|
+
# RubyLLM::Agent's, and `llm` is the RubyLLM::Agent itself, for any other
|
|
24
|
+
# setting. Each ask is a fresh chat: the tools read the document as it is
|
|
25
|
+
# now, so the model needs no history, and a stay of hours never grows one.
|
|
26
|
+
class Agent < Collaborator
|
|
27
|
+
class << self
|
|
28
|
+
# The RubyLLM::Agent this agent's chats come from. A subclass starts
|
|
29
|
+
# with a copy of its parent's.
|
|
30
|
+
def llm
|
|
31
|
+
@llm ||= Class.new(superclass.respond_to?(:llm) ? superclass.llm : RubyLLM::Agent)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
%i[model instructions tools thinking temperature].each do |setting|
|
|
35
|
+
define_method(setting) { |*args, **options, &block| llm.public_send(setting, *args, **options, &block) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(document, **)
|
|
40
|
+
super
|
|
41
|
+
@anchors = {} # block id => anchor
|
|
42
|
+
@ids = {} # anchor => block id
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Asks the model `message` in a fresh chat with the document tools, and
|
|
46
|
+
# returns the reply, a RubyLLM::Message. A block gets each chunk as it
|
|
47
|
+
# streams in. The model's edits go through `edit`, so they reach people
|
|
48
|
+
# as it makes them.
|
|
49
|
+
def ask(message, &) = chat.ask(message, &)
|
|
50
|
+
|
|
51
|
+
# Asks the model about the blocks people changed. Override it to ask
|
|
52
|
+
# something else, or to call `ask` only sometimes.
|
|
53
|
+
def changed(blocks)
|
|
54
|
+
ask("People changed these blocks:\n\n#{Tools.listing(self, blocks)}") if blocks.any?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The tools the model gets with each ask: Tools::ALL, which read the
|
|
58
|
+
# document and edit its blocks. Override it to give the model fewer,
|
|
59
|
+
# such as [Tools::ReadDocument.new(self)] for an agent that only answers.
|
|
60
|
+
def document_tools = Tools::ALL.map { |tool| tool.new(self) }
|
|
61
|
+
|
|
62
|
+
# Live handles to the document's top-level blocks, in order.
|
|
63
|
+
def document_blocks
|
|
64
|
+
root = doc.get_xml_text(ROOT)
|
|
65
|
+
(0...root.size).map { |ordinal| root.xml_text(ordinal) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A short id for `block`, such as "b3", that the model can name it by.
|
|
69
|
+
# The id stays on its block for the whole stay, wherever people move it.
|
|
70
|
+
def block_id(block)
|
|
71
|
+
anchor = block.anchor
|
|
72
|
+
@ids[anchor] ||= "b#{@ids.size + 1}".tap { |id| @anchors[id] = anchor }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The live handle to the block `id` names, or nil once it's deleted.
|
|
76
|
+
def block(id) = (anchor = @anchors[id]) && doc.find(anchor)
|
|
77
|
+
|
|
78
|
+
# Where the block `id` names is now, or nil once it's deleted.
|
|
79
|
+
def ordinal(id) = (anchor = @anchors[id]) && doc.block_at(anchor)
|
|
80
|
+
|
|
81
|
+
# A fresh RubyLLM chat with the model, the instructions, and the
|
|
82
|
+
# document tools. `ask` asks one; take one to set more on it first.
|
|
83
|
+
def chat = self.class.llm.chat.with_tools(*document_tools)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Y
|
|
7
|
+
class Collaborator
|
|
8
|
+
# The collaborator lock: a key in an ActiveSupport::Cache store holding
|
|
9
|
+
# this collaborator's token. It is written only if absent, and a task on
|
|
10
|
+
# the collaborator's reactor renews it every LOCK_TTL / 3 seconds while
|
|
11
|
+
# the collaborator stays, so a handler waiting on a model call doesn't
|
|
12
|
+
# let it lapse. It lapses LOCK_TTL seconds after the last renewal, so a
|
|
13
|
+
# collaborator whose process died doesn't hold the document for good.
|
|
14
|
+
#
|
|
15
|
+
# Cache stores have no atomic compare-and-set, so a renewal and a release
|
|
16
|
+
# read the key and then write or delete it. That leaves one gap: a process
|
|
17
|
+
# that stalls longer than LOCK_TTL loses the lock, and while it comes
|
|
18
|
+
# back, it can briefly overlap the collaborator that took over. Its next
|
|
19
|
+
# renewal sees the other token and it stops.
|
|
20
|
+
class Lock
|
|
21
|
+
# One key per document, or per document and `as` when it's given. A
|
|
22
|
+
# record is named by its GlobalID, so a Person and a Team with the same
|
|
23
|
+
# id don't share a key.
|
|
24
|
+
def self.key(document, as)
|
|
25
|
+
return "yrby:collaborator:#{document.key}" if as.nil?
|
|
26
|
+
|
|
27
|
+
"yrby:collaborator:#{document.key}:#{as.respond_to?(:to_global_id) ? as.to_global_id : as}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(store, key)
|
|
31
|
+
@store = store
|
|
32
|
+
@key = key
|
|
33
|
+
@token = SecureRandom.uuid
|
|
34
|
+
@held = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Takes the lock if nobody holds it, and starts renewing it in a task
|
|
38
|
+
# of the current reactor. `release` stops the task.
|
|
39
|
+
def take
|
|
40
|
+
@held = @store.write(@key, @token, unless_exist: true, expires_in: LOCK_TTL) ? true : false
|
|
41
|
+
@renewer = Async::Task.current.async { renew_until_lost } if @held
|
|
42
|
+
@held
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# False once another collaborator holds the lock.
|
|
46
|
+
def held? = @held
|
|
47
|
+
|
|
48
|
+
def release
|
|
49
|
+
@renewer&.stop
|
|
50
|
+
@store.delete(@key) if @held && @store.read(@key) == @token
|
|
51
|
+
@held = false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def renew_until_lost
|
|
57
|
+
while @held
|
|
58
|
+
sleep LOCK_TTL / 3.0
|
|
59
|
+
renew
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def renew
|
|
64
|
+
holder = @store.read(@key)
|
|
65
|
+
return @held = false if holder && holder != @token
|
|
66
|
+
|
|
67
|
+
@store.write(@key, @token, expires_in: LOCK_TTL)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/duration"
|
|
4
|
+
require "logger"
|
|
5
|
+
require "y/action_cable/client"
|
|
6
|
+
require "y/collaborator/lock"
|
|
7
|
+
|
|
8
|
+
module Y
|
|
9
|
+
# Takes part in a document the way a person does. It joins over the cable
|
|
10
|
+
# with a grant, shows up among the people editing, hears what they write
|
|
11
|
+
# once their typing settles, and leaves when they do. It has no model of its
|
|
12
|
+
# own: an AI agent is a collaborator whose `changed` calls one. Subclass it,
|
|
13
|
+
# and run it from a job:
|
|
14
|
+
#
|
|
15
|
+
# class Reviewer < Y::Collaborator
|
|
16
|
+
# def joined
|
|
17
|
+
# self.presence = { "user" => { "name" => "Reviewer", "color" => "#7c3aed" } }
|
|
18
|
+
# edit { |doc| Y::Lexxy.append_paragraph(doc, "Reviewing.") }
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# def changed(blocks) # live handles to the top-level blocks people changed
|
|
22
|
+
# edit { |doc| Y::Lexxy.append_paragraph(doc, "Saw #{blocks.map(&:text).join(" / ")}.") }
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# class ReviewJob < ApplicationJob
|
|
27
|
+
# def perform(post) = Reviewer.run(post.collaborative_document(:body), url: "wss://example.com/cable")
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# `run` follows the document until nobody else has been present for `idle`
|
|
31
|
+
# seconds, the collaborator has stayed `max_stay` seconds, or `leave` is
|
|
32
|
+
# called. It always clears its presence and unsubscribes on the way out. An
|
|
33
|
+
# error in `joined` or `changed` ends the collaborator and raises out of
|
|
34
|
+
# `run`; rescue it in the method to carry on.
|
|
35
|
+
#
|
|
36
|
+
# Only one collaborator works on a document at a time. Pass `as:` to allow
|
|
37
|
+
# one per person instead, such as `as: person` for the collaborator a person
|
|
38
|
+
# brought. The lock lives in `lock:`, Rails.cache by default, so it holds
|
|
39
|
+
# across processes when the cache is shared. `run` returns :busy without
|
|
40
|
+
# joining when another collaborator holds it.
|
|
41
|
+
class Collaborator
|
|
42
|
+
SETTLE = 1.5 # seconds of quiet before changed sees a burst of edits
|
|
43
|
+
SETTLE_AT_MOST = 5 # seconds of steady typing before changed sees it anyway
|
|
44
|
+
IDLE = 120 # seconds with nobody else present before it leaves
|
|
45
|
+
MAX_STAY = 2 * 60 * 60
|
|
46
|
+
GONE = 30 # a person whose presence hasn't renewed in this long has left
|
|
47
|
+
LOCK_TTL = 60 # the lock lapses this long after its last renewal
|
|
48
|
+
ROOT = "root" # the root XmlText Lexical and Lexxy edit, whose blocks changed reports
|
|
49
|
+
|
|
50
|
+
# Runs a collaborator until it leaves. Returns :left, or :busy when another
|
|
51
|
+
# collaborator holds the document.
|
|
52
|
+
def self.run(document, **) = new(document, **).run
|
|
53
|
+
|
|
54
|
+
attr_reader :document
|
|
55
|
+
|
|
56
|
+
# `document` is a record's collaborative document, such as
|
|
57
|
+
# post.collaborative_document(:body), which names its own grant and
|
|
58
|
+
# channel.
|
|
59
|
+
def initialize(document, url:, headers: {}, idle: IDLE, max_stay: MAX_STAY, as: nil, # rubocop:disable Metrics/ParameterLists -- the document, the connection, and how long to stay
|
|
60
|
+
lock: Collaborator.default_lock)
|
|
61
|
+
@document = document
|
|
62
|
+
@url = url
|
|
63
|
+
@headers = headers
|
|
64
|
+
@idle = idle
|
|
65
|
+
@max_stay = max_stay
|
|
66
|
+
@lock = lock && Lock.new(lock, Lock.key(document, as))
|
|
67
|
+
@changes = Thread::Queue.new
|
|
68
|
+
@people = Y::Awareness.new
|
|
69
|
+
@renewals = {} # client id => [clock, when it last moved]
|
|
70
|
+
@people_lock = Mutex.new
|
|
71
|
+
@leaving = false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Runs as a task of the caller's reactor when there is one. Otherwise it
|
|
75
|
+
# starts one on the calling thread, such as a job's, and holds that
|
|
76
|
+
# thread until the collaborator leaves.
|
|
77
|
+
def run
|
|
78
|
+
Sync do
|
|
79
|
+
next :busy if @lock && !@lock.take
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
join
|
|
83
|
+
joined
|
|
84
|
+
follow
|
|
85
|
+
@client.unsubscribe # raises when an update was never acked
|
|
86
|
+
:left
|
|
87
|
+
ensure
|
|
88
|
+
leave_after_error
|
|
89
|
+
@lock&.release
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# How the collaborator appears to the people in the document: the hash
|
|
95
|
+
# editors render, such as { "user" => { "name" => "Reviewer" } }. nil, the
|
|
96
|
+
# default, keeps it out of their roster. Set it in `joined`, or in
|
|
97
|
+
# `initialize` to show up as the collaborator joins, and again to change
|
|
98
|
+
# it.
|
|
99
|
+
attr_reader :presence
|
|
100
|
+
|
|
101
|
+
def presence=(state)
|
|
102
|
+
@presence = state
|
|
103
|
+
@client&.presence = state
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Called once the collaborator has joined and the document has loaded.
|
|
107
|
+
def joined; end
|
|
108
|
+
|
|
109
|
+
# Called with live handles (Y::XmlText) to the top-level blocks people
|
|
110
|
+
# changed, in document order, once their typing has paused for SETTLE
|
|
111
|
+
# seconds, or after SETTLE_AT_MOST seconds of steady typing. A handle
|
|
112
|
+
# stays on its block while people edit around it, and writing to one
|
|
113
|
+
# whose block was deleted raises Y::Error. A change outside the root,
|
|
114
|
+
# such as to a map, comes right away with an empty list: it's a click,
|
|
115
|
+
# not typing. The collaborator's own edits never show up here.
|
|
116
|
+
def changed(blocks); end
|
|
117
|
+
|
|
118
|
+
# Edit the document. Returns the update, or nil when nothing changed.
|
|
119
|
+
# Raises Y::Error once another collaborator holds the document, so a
|
|
120
|
+
# collaborator that outlived the lock stops writing.
|
|
121
|
+
def edit(&)
|
|
122
|
+
raise Y::Error, "another collaborator holds #{@document.key}" unless holding?
|
|
123
|
+
|
|
124
|
+
@client.edit(&)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Everyone else in the document, as { client_id => their presence state }.
|
|
128
|
+
def people
|
|
129
|
+
own = doc.client_id
|
|
130
|
+
@people_lock.synchronize do
|
|
131
|
+
@people.states.select do |id, state|
|
|
132
|
+
id != own && state && (renewed = @renewals[id]) && now - renewed.last < GONE
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Leave once `joined` or `changed` returns.
|
|
138
|
+
def leave = @leaving = true
|
|
139
|
+
|
|
140
|
+
def self.default_lock = (Rails.cache if defined?(Rails.cache))
|
|
141
|
+
def self.default_logger = (defined?(Rails.logger) && Rails.logger) || Logger.new($stderr)
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# The collaborator's copy of the document, for its hooks to read.
|
|
146
|
+
attr_reader :doc
|
|
147
|
+
|
|
148
|
+
def join
|
|
149
|
+
grant = @document.grant(expires_in: ActiveSupport::Duration.build(@max_stay + LOCK_TTL))
|
|
150
|
+
params = { grant:, name: @document.name }
|
|
151
|
+
@doc = Y::Doc.new
|
|
152
|
+
@client = Y::ActionCable::Client.new(@url, params:, headers: @headers, channel: @document.channel, doc: @doc,
|
|
153
|
+
root: ROOT, logger: Collaborator.default_logger)
|
|
154
|
+
@client.on_update { |_update, doc, touched| @changes << anchors(doc, touched || []) }
|
|
155
|
+
@client.on_awareness { |frame| see(frame) }
|
|
156
|
+
@client.subscribe
|
|
157
|
+
@client.presence = @presence if @presence
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def follow
|
|
161
|
+
started = now
|
|
162
|
+
alone_since = nil
|
|
163
|
+
until @leaving || @client.closed? || now - started > @max_stay || !holding?
|
|
164
|
+
alone_since = alone?(started) ? alone_since || now : nil
|
|
165
|
+
break if alone_since && now - alone_since > @idle
|
|
166
|
+
|
|
167
|
+
touched = @changes.pop(timeout: 1)
|
|
168
|
+
next unless touched
|
|
169
|
+
|
|
170
|
+
touched = settle(touched) unless touched.empty?
|
|
171
|
+
changed(blocks(touched)) if holding?
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Unsubscribe on the way out of an error, which says more than an unacked
|
|
176
|
+
# update would. After a normal leave this does nothing.
|
|
177
|
+
def leave_after_error
|
|
178
|
+
@client&.unsubscribe
|
|
179
|
+
rescue Y::Error => e
|
|
180
|
+
Collaborator.default_logger.error(e.message)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def holding? = @lock.nil? || @lock.held?
|
|
184
|
+
|
|
185
|
+
# People already in the document announce themselves only when they next
|
|
186
|
+
# renew their presence, so the collaborator can't know it is alone until one
|
|
187
|
+
# renewal window has passed since it joined.
|
|
188
|
+
def alone?(started) = now - started > Y::ActionCable::Client::RENEW && people.empty?
|
|
189
|
+
|
|
190
|
+
# Anchors to the blocks an update touched, taken as it arrives: a later
|
|
191
|
+
# update that adds or removes a block moves an ordinal, not an anchor.
|
|
192
|
+
def anchors(doc, ordinals)
|
|
193
|
+
root = doc.get_xml_text(ROOT) unless ordinals.empty?
|
|
194
|
+
ordinals.filter_map { |ordinal| root.xml_text(ordinal)&.anchor }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Waits for a burst of edits to pause, or for SETTLE_AT_MOST seconds of
|
|
198
|
+
# steady typing, and returns the anchors of every block it touched.
|
|
199
|
+
def settle(anchors)
|
|
200
|
+
deadline = now + SETTLE_AT_MOST
|
|
201
|
+
while (wait = [SETTLE, deadline - now].min).positive? && (more = @changes.pop(timeout: wait))
|
|
202
|
+
anchors |= more
|
|
203
|
+
end
|
|
204
|
+
anchors
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Live handles to the blocks, in document order, leaving out any that
|
|
208
|
+
# were deleted while the edits settled.
|
|
209
|
+
def blocks(anchors)
|
|
210
|
+
anchors.filter_map { |anchor| (ordinal = doc.block_at(anchor)) && [ordinal, doc.find(anchor)] }
|
|
211
|
+
.sort_by(&:first).map(&:last)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Presence frames from everyone, this collaborator's own included. A
|
|
215
|
+
# person's editor renews their presence every 15 seconds, so a clock that
|
|
216
|
+
# stops moving means they left without saying so.
|
|
217
|
+
def see(frame)
|
|
218
|
+
@people_lock.synchronize do
|
|
219
|
+
@people.apply_update(frame)
|
|
220
|
+
@people.clocks.each do |id, clock|
|
|
221
|
+
@renewals[id] = [clock, now] unless @renewals[id]&.first == clock
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
227
|
+
end
|
|
228
|
+
end
|
data/lib/yrby-agent.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: yrby-agent
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0.alpha1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- JP Camara
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: async-websocket
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.30'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.30'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: yrby
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.9.1
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.9.1
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: yrby-rails
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.8'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.8'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: ruby_llm
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '2.1'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '2.1'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: minitest
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '5.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '5.0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: rake
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '13.0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '13.0'
|
|
96
|
+
description: Y::ActionCable::Client joins a yrby document over Action Cable the way
|
|
97
|
+
a browser does. Y::Collaborator takes part in a document the way a person does.
|
|
98
|
+
Y::Agent is a collaborator that hands what people changed to a model through RubyLLM,
|
|
99
|
+
with tools that read the document and edit its blocks live.
|
|
100
|
+
email:
|
|
101
|
+
- johnpcamara@gmail.com
|
|
102
|
+
executables: []
|
|
103
|
+
extensions: []
|
|
104
|
+
extra_rdoc_files: []
|
|
105
|
+
files:
|
|
106
|
+
- CHANGELOG-agent.md
|
|
107
|
+
- LICENSE
|
|
108
|
+
- lib/y/action_cable/client.rb
|
|
109
|
+
- lib/y/action_cable/client/outbox.rb
|
|
110
|
+
- lib/y/action_cable/client/session.rb
|
|
111
|
+
- lib/y/agent.rb
|
|
112
|
+
- lib/y/agent/tools.rb
|
|
113
|
+
- lib/y/collaborator.rb
|
|
114
|
+
- lib/y/collaborator/lock.rb
|
|
115
|
+
- lib/yrby-agent.rb
|
|
116
|
+
- lib/yrby/agent/version.rb
|
|
117
|
+
homepage: https://github.com/jpcamara/yrby
|
|
118
|
+
licenses:
|
|
119
|
+
- MIT
|
|
120
|
+
metadata:
|
|
121
|
+
source_code_uri: https://github.com/jpcamara/yrby
|
|
122
|
+
changelog_uri: https://github.com/jpcamara/yrby/blob/main/CHANGELOG-agent.md
|
|
123
|
+
bug_tracker_uri: https://github.com/jpcamara/yrby/issues
|
|
124
|
+
rubygems_mfa_required: 'true'
|
|
125
|
+
rdoc_options: []
|
|
126
|
+
require_paths:
|
|
127
|
+
- lib
|
|
128
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
129
|
+
requirements:
|
|
130
|
+
- - ">="
|
|
131
|
+
- !ruby/object:Gem::Version
|
|
132
|
+
version: 3.4.0
|
|
133
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '0'
|
|
138
|
+
requirements: []
|
|
139
|
+
rubygems_version: 3.6.9
|
|
140
|
+
specification_version: 4
|
|
141
|
+
summary: Ruby processes and AI agents that edit yrby documents alongside people
|
|
142
|
+
test_files: []
|