pinecall 0.11.15 → 0.11.16
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 +4 -4
- data/CHANGELOG.md +52 -0
- data/lib/pinecall/agent/author.rb +11 -4
- data/lib/pinecall/agent/state.rb +1 -1
- data/lib/pinecall/agent/tools.rb +6 -5
- data/lib/pinecall/bridge.rb +82 -27
- data/lib/pinecall/call_world.rb +15 -9
- data/lib/pinecall/client/agent.rb +13 -5
- data/lib/pinecall/client/call.rb +12 -3
- data/lib/pinecall/client/connection.rb +6 -2
- data/lib/pinecall/client.rb +18 -1
- data/lib/pinecall/serve/held.rb +5 -2
- data/lib/pinecall/serve/masking.rb +109 -0
- data/lib/pinecall/serve.rb +11 -8
- data/lib/pinecall/testing.rb +5 -4
- data/lib/pinecall/version.rb +1 -1
- data/lib/pinecall/wire/codec.rb +15 -3
- data/lib/pinecall/wire/enums.rb +4 -1
- data/lib/pinecall/wire/registry.rb +3 -1
- data/lib/pinecall/wire/shapes.rb +1 -1
- data/lib/pinecall/wire/shapes_call_events.rb +2 -1
- data/lib/pinecall/wire/shapes_commands.rb +5 -2
- data/lib/pinecall/wire/shapes_parts.rb +4 -0
- data/sig/pinecall.rbs +25 -7
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ccadca23b8a8165ca26d6378d9309a21fe194aa7f36768186feec8ded71113a9
|
|
4
|
+
data.tar.gz: db44663b616cdaaed4b616cddc7ef84105a2eb59c50228b359c975a4c433888a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b81c136cc6986a767110f1dc169367c00799a45e42897150b506cfec1949d4f3470bddfe40dd864ae510240eb8fa93979a043e62de5d0a9c30b0fec71ba9024
|
|
7
|
+
data.tar.gz: 9b6afb0e418e3820b427e82bdbf868aa912f68459ce7e9f4388ea6c7e605be4fc3aaab6505e5e8e2b4d2b23edfbe86327a1fc35542d1253f0870788b93e538ea
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,58 @@ Every change a person using this gem would notice. The format is
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version numbers and tags are the
|
|
5
5
|
maintainer's call.
|
|
6
6
|
|
|
7
|
+
## [0.11.16] — no object prints the API key, a failing view or hook fails its call, values reach the wire as fields (2026-10-23)
|
|
8
|
+
|
|
9
|
+
- **SECURITY: no object of the gem prints the API key any more.** `Client` and its connection held the key
|
|
10
|
+
in an instance variable and Ruby's default `inspect` printed it (and the websocket driver's
|
|
11
|
+
`Authorization` header), as did every object holding a client (`Serve::Held`, `Client::Agent`, a `p`, an
|
|
12
|
+
exception that interpolated one, a log line); both now say their door and their agents, never the key.
|
|
13
|
+
(found by the 2026-10-23 eval, J F8 side note)
|
|
14
|
+
- **The serve entry no longer prints the values an agent declared private.** `--events` printed every entry
|
|
15
|
+
as the gateway wrote it, so a tool's `pii` arguments, the state fields declared `pii` and a private value
|
|
16
|
+
echoed in a tool's answer or a logged line reached the terminal; they are masked as the call's log masks
|
|
17
|
+
them (by name, and by value as a whole word once a tool call carried it, never a value shorter than three
|
|
18
|
+
characters). (found by the 2026-10-23 eval, A F3, B D7, L D7)
|
|
19
|
+
- **A `Data` or a `Struct` reaches the wire as its fields, wherever a Ruby value becomes JSON.** A tool's
|
|
20
|
+
return, a state field, a logged line or a payload holding one was sent as its `inspect` string
|
|
21
|
+
(`knowledge.search(…)` handed the model `"#<data Pinecall::CallWorld::Found …>"`); every command's data
|
|
22
|
+
now passes through one `Wire::Codec.plain` before it is validated, and `call.log` of a `Data` or a
|
|
23
|
+
`Struct` is its fields. (found by the 2026-10-23 eval, D D-2)
|
|
24
|
+
- **A view that raises fails its own call and nothing else.** The call's log gets a `render.failed` entry
|
|
25
|
+
with the exception's class and message, the model keeps the view it had and the process goes on; before,
|
|
26
|
+
the first render's failure left the call without a prompt and without a trace in its log. (found by the
|
|
27
|
+
2026-10-23 eval, B B8)
|
|
28
|
+
- **A hook that raises fails its own call and nothing else.** `on_call`, `on_event`, `on_memory` and `on_end`
|
|
29
|
+
raised through the call's job thread, which only printed a line: the call was left without a prompt, an
|
|
30
|
+
`on_event`'s cause stayed on the next write and an `on_end` never said `call.done`. The call's log now
|
|
31
|
+
gets a `hook.failed` entry naming the hook (and the event) and the error, and the call goes on with what
|
|
32
|
+
the hook wrote. (found by the 2026-10-23 eval, B B8)
|
|
33
|
+
- **Every state write names its author on the wire.** Each `state.set` carries `cause`: the tool and the
|
|
34
|
+
model's id for its call, the outside fact (name and the entry's seq), or the hook (`on_call`,
|
|
35
|
+
`on_memory`); only the first write of a tool had one, from the runtime, and a hook's none. The
|
|
36
|
+
`state.cause` log line is gone, `on_event`'s `meta[:seq]` is the entry's seq, and the wire knows
|
|
37
|
+
`StateCauseHook`, `StateSet.cause` and `CallStarted.metadata`. A client tool's `run` takes the model's
|
|
38
|
+
call id as a third argument. (found by the 2026-10-23 eval, B B4)
|
|
39
|
+
- **A line logged in `on_call` lands in the call's log.** The log was forwarded only after the hook had run.
|
|
40
|
+
(found by the 2026-10-23 eval, B B3)
|
|
41
|
+
- **The gem sends `call.done` after the end hook, and registers `says_done`**, so the gateway's seal waits
|
|
42
|
+
for what the hook logs before it seals the log. (found by the 2026-10-23 eval, S2)
|
|
43
|
+
- **`call.to`, `call.direction`, `call.caller` and `call.metadata`** are on the call a tool or a hook reads,
|
|
44
|
+
as in the TypeScript package; the testing gateway's `call_started` takes `direction:` and `metadata:`.
|
|
45
|
+
(found by the 2026-10-23 eval, K F6)
|
|
46
|
+
- **A stop from the org leaves no `IOError: closed stream` behind.** The driver's answer to the gateway's
|
|
47
|
+
close frame was written to a socket this side had already closed and reported as an error; a write after
|
|
48
|
+
this side closed on purpose is not one. (found by the 2026-10-23 eval, J F2)
|
|
49
|
+
- **`Pinecall.serve`'s `held.stop` says where the calls went**, one line on stderr as the serve entry does
|
|
50
|
+
(`draining · no live calls`); `Serve::Held.new` takes `err:`. (found by the 2026-10-23 eval, J F8)
|
|
51
|
+
- **The serve entry says when an admin kills one of its agents**: one line on stderr, `<slug> ✗ <slug> was
|
|
52
|
+
killed by <name>`, and `Client#on_killed` hands over the slug and the sentence. (found by the 2026-10-23
|
|
53
|
+
eval, J F3)
|
|
54
|
+
- **The app no longer answers `end_call`.** The platform writes that call and its result itself; an error
|
|
55
|
+
result from the app was a second one. (found by the 2026-10-23 eval, R2)
|
|
56
|
+
- **The testing page shows what `call.tool` returns when the tool succeeds**: `{ call_id:, name:, output:,
|
|
57
|
+
duration_s: }`, the tool's value under `[:output]`. (found by the 2026-10-23 eval, D R0-1)
|
|
58
|
+
|
|
7
59
|
## [0.11.15] — the serve entry registers as its host (2026-10-23)
|
|
8
60
|
|
|
9
61
|
- **The serve entry registers as `PINECALL_HOST` when it is set**, else as this machine's
|
|
@@ -8,6 +8,7 @@ module Pinecall
|
|
|
8
8
|
# `Fiber[]` is inherited by child fibers and separate per thread.
|
|
9
9
|
module Author
|
|
10
10
|
KEY = :pinecall_author
|
|
11
|
+
CALL = :pinecall_author_call
|
|
11
12
|
|
|
12
13
|
module_function
|
|
13
14
|
|
|
@@ -16,14 +17,20 @@ module Pinecall
|
|
|
16
17
|
Fiber[KEY]
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
#
|
|
20
|
-
def
|
|
21
|
-
|
|
20
|
+
# The model's id for the tool call the current author is running, or nil.
|
|
21
|
+
def call_id
|
|
22
|
+
Fiber[CALL]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Run the block with state writes attributed to `name`; a tool also says the model's `call_id`.
|
|
26
|
+
def with(name, call_id = nil)
|
|
27
|
+
held = [Fiber[KEY], Fiber[CALL]]
|
|
22
28
|
Fiber[KEY] = name
|
|
29
|
+
Fiber[CALL] = call_id
|
|
23
30
|
begin
|
|
24
31
|
yield
|
|
25
32
|
ensure
|
|
26
|
-
Fiber[KEY] = held
|
|
33
|
+
Fiber[KEY], Fiber[CALL] = held
|
|
27
34
|
end
|
|
28
35
|
end
|
|
29
36
|
end
|
data/lib/pinecall/agent/state.rb
CHANGED
|
@@ -98,7 +98,7 @@ module Pinecall
|
|
|
98
98
|
# back as JSON is a string, and is the declared stage it names.
|
|
99
99
|
def restore(state)
|
|
100
100
|
state = state.transform_keys(&:to_sym)
|
|
101
|
-
Author.with(Author.current || "restore") do
|
|
101
|
+
Author.with(Author.current || "restore", Author.call_id) do
|
|
102
102
|
self.class.declared_state.each_key do |name|
|
|
103
103
|
value = state.key?(name) ? state[name] : nil
|
|
104
104
|
write_state(name, name == :stage ? declared_stage(value) : value)
|
data/lib/pinecall/agent/tools.rb
CHANGED
|
@@ -70,12 +70,13 @@ module Pinecall
|
|
|
70
70
|
self.class.declared_tools.values.select { |declared| Tools.shows?(declared, state) }
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
# Run a tool as the bridge does, with its writes attributed to it
|
|
74
|
-
|
|
73
|
+
# Run a tool as the bridge does, with its writes attributed to it; `call_id` is the model's id
|
|
74
|
+
# for the call, when there is one.
|
|
75
|
+
def run_tool(name, arguments = {}, call_id = nil)
|
|
75
76
|
declared = self.class.tool_named(name)
|
|
76
77
|
raise ToolFailed, "#{name}: this agent declares no such tool" if declared.nil?
|
|
77
78
|
|
|
78
|
-
Tools.run(self, declared, arguments)
|
|
79
|
+
Tools.run(self, declared, arguments, call_id)
|
|
79
80
|
end
|
|
80
81
|
|
|
81
82
|
class << self
|
|
@@ -93,10 +94,10 @@ module Pinecall
|
|
|
93
94
|
end
|
|
94
95
|
|
|
95
96
|
# Check arguments, run the method with its writes attributed, and trim to `preview`.
|
|
96
|
-
def run(agent, declared, arguments)
|
|
97
|
+
def run(agent, declared, arguments, call_id = nil)
|
|
97
98
|
named = arguments.to_h { |key, value| [key.to_sym, value] }
|
|
98
99
|
refuse_arguments_the_tool_never_asked_for(declared, named)
|
|
99
|
-
result = Author.with(declared.name.to_s) { agent.public_send(declared.name, **named) }
|
|
100
|
+
result = Author.with(declared.name.to_s, call_id) { agent.public_send(declared.name, **named) }
|
|
100
101
|
preview(result, declared.options[:preview])
|
|
101
102
|
end
|
|
102
103
|
|
data/lib/pinecall/bridge.rb
CHANGED
|
@@ -70,7 +70,7 @@ module Pinecall
|
|
|
70
70
|
live = {}
|
|
71
71
|
# Throwaway instance used only to read the class's declarations.
|
|
72
72
|
probe = klass.new
|
|
73
|
-
options = klass.wire_config(tools: tools_for(probe, live)).merge(takes_unclaimed:)
|
|
73
|
+
options = klass.wire_config(tools: tools_for(probe, live)).merge(takes_unclaimed:, says_done: true)
|
|
74
74
|
agent = client.agent(name, **options)
|
|
75
75
|
agent.on("call.started") { |started, call| start(klass, live, call, client, last, started[:state]) }
|
|
76
76
|
agent.on("call.attached") { |_data, call| attached(klass, live, call, client, last) }
|
|
@@ -81,11 +81,11 @@ module Pinecall
|
|
|
81
81
|
# Tool specs with a `run` routed to the instance serving the calling call.
|
|
82
82
|
def tools_for(probe, live)
|
|
83
83
|
probe.tools.map do |spec|
|
|
84
|
-
spec.merge(run: lambda do |arguments, call|
|
|
84
|
+
spec.merge(run: lambda do |arguments, call, call_id|
|
|
85
85
|
serving = live[call.id]
|
|
86
86
|
raise ToolFailed, "#{spec[:name]}: this call is no longer being served" if serving.nil?
|
|
87
87
|
|
|
88
|
-
serving.agent.run_tool(spec[:name], arguments)
|
|
88
|
+
serving.agent.run_tool(spec[:name], arguments, call_id)
|
|
89
89
|
end)
|
|
90
90
|
end
|
|
91
91
|
end
|
|
@@ -103,6 +103,7 @@ module Pinecall
|
|
|
103
103
|
instance.serving(serving.world)
|
|
104
104
|
live[call.id] = serving
|
|
105
105
|
serving.later do
|
|
106
|
+
forward_log(serving, call)
|
|
106
107
|
instance.restore(call.state)
|
|
107
108
|
sync(serving, call)
|
|
108
109
|
listen(serving, call)
|
|
@@ -117,7 +118,8 @@ module Pinecall
|
|
|
117
118
|
|
|
118
119
|
def world_for(call, client)
|
|
119
120
|
searching = ->(query, k) { client.search(call.id, query, k:) }
|
|
120
|
-
CallWorld.new(id: call.id, contact: call.contact&.dig(:id) || call.from, from: call.from,
|
|
121
|
+
CallWorld.new(id: call.id, contact: call.contact&.dig(:id) || call.from, from: call.from, to: call.to,
|
|
122
|
+
channel: call.channel, direction: call.direction, caller: call.contact, metadata: call.metadata,
|
|
121
123
|
medium: call.medium, today: call.today, claimed: call.claimed, searching:) do |type, data|
|
|
122
124
|
call.command(type, data)
|
|
123
125
|
end
|
|
@@ -125,7 +127,8 @@ module Pinecall
|
|
|
125
127
|
|
|
126
128
|
# Build the instance and run `on_call` before listening, so the first prompt is sent once
|
|
127
129
|
# rather than once per field. The state the opener asked for (`call.started.state`) is applied
|
|
128
|
-
# after the hook, which would overwrite it, and before the first render.
|
|
130
|
+
# after the hook, which would overwrite it, and before the first render. The log is open from the
|
|
131
|
+
# hook on: a line `on_call` writes is the call's.
|
|
129
132
|
def start(klass, live, call, client, last, opened_in)
|
|
130
133
|
instance = klass.new.seal
|
|
131
134
|
instance.reads_last_from(last) unless last.nil?
|
|
@@ -134,32 +137,56 @@ module Pinecall
|
|
|
134
137
|
serving = Live.new(instance, world)
|
|
135
138
|
live[call.id] = serving
|
|
136
139
|
serving.later do
|
|
137
|
-
|
|
140
|
+
forward_log(serving, call)
|
|
141
|
+
hooked(serving, call, :on_call, world)
|
|
138
142
|
instance.start_in(opened_in) unless opened_in.nil?
|
|
139
|
-
call.set_state(instance.snapshot)
|
|
143
|
+
call.set_state(instance.snapshot, *opening(instance))
|
|
140
144
|
sync(serving, call)
|
|
141
145
|
listen(serving, call)
|
|
142
146
|
end
|
|
143
147
|
end
|
|
144
148
|
|
|
145
|
-
#
|
|
149
|
+
# What the opening `state.set` says: when `on_call` wrote, the fields written and the hook as the
|
|
150
|
+
# cause. Nothing written, nothing said: the whole state is the news.
|
|
151
|
+
def opening(instance)
|
|
152
|
+
return [] unless instance.changes.any? { |change| change.author == "hook:on_call" }
|
|
153
|
+
|
|
154
|
+
[instance.changes.map(&:field).uniq, { kind: "hook", hook: "on_call" }]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Re-sync the prompt on every state change, each one sent with who wrote it.
|
|
146
158
|
def listen(serving, call)
|
|
147
159
|
instance = serving.agent
|
|
148
160
|
serving.stops[:state] = instance.on_change do |change|
|
|
149
|
-
call.set_state(instance.snapshot, [change.field])
|
|
150
|
-
# `state.set` has no cause field on the wire, so log it separately.
|
|
151
|
-
cause = serving.world.cause
|
|
152
|
-
call.log("state.cause", { field: change.field.to_s, kind: "event" }.merge(cause)) unless cause.nil?
|
|
161
|
+
call.set_state(instance.snapshot, [change.field], cause_of(change, serving))
|
|
153
162
|
sync(serving, call)
|
|
154
163
|
end
|
|
155
|
-
serving.stops[:log] = instance.on_log { |entry| call.log(entry.name, as_object(entry.data)) }
|
|
156
164
|
serving.stops[:entries] = call.on_any do |event|
|
|
157
165
|
serving.world.take(event.type, event.data, Time.now.to_f)
|
|
158
|
-
received(serving, event
|
|
166
|
+
received(serving, call, event) if event.type == "event.received"
|
|
159
167
|
rerendered(serving, call, event)
|
|
160
168
|
end
|
|
161
169
|
end
|
|
162
170
|
|
|
171
|
+
# Who made a write, as the wire's `StateCause` says it: the lifecycle hook, the outside fact the hook is
|
|
172
|
+
# handling, or the tool and the model's id for the call that ran it. A write the wire has no word for
|
|
173
|
+
# (a restore) says nothing.
|
|
174
|
+
def cause_of(change, serving)
|
|
175
|
+
author = change.author.to_s
|
|
176
|
+
case author
|
|
177
|
+
when "hook:on_call", "hook:on_memory" then { kind: "hook", hook: author.delete_prefix("hook:") }
|
|
178
|
+
when "hook:on_event" then (fact = serving.world.cause) && { kind: "event", **fact }
|
|
179
|
+
else
|
|
180
|
+
call_id = Agent::Author.call_id
|
|
181
|
+
{ kind: "tool", tool: author, call_id: } if call_id && serving.agent.class.tool_named(author)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# What the class logs reaches the call's log until `on_end` has run.
|
|
186
|
+
def forward_log(serving, call)
|
|
187
|
+
serving.stops[:log] = serving.agent.on_log { |entry| call.log(entry.name, as_object(entry.data)) }
|
|
188
|
+
end
|
|
189
|
+
|
|
163
190
|
# The view answers the turn being taken, not the one before it; a claim lets the caller see the
|
|
164
191
|
# page; recall is not state, so nothing else would render what it brought. What memory did then
|
|
165
192
|
# reaches `on_memory`, after the view already says it.
|
|
@@ -169,7 +196,7 @@ module Pinecall
|
|
|
169
196
|
when "memory.ops"
|
|
170
197
|
serving.remembered = words_recalled(event.data)
|
|
171
198
|
sync(serving, call)
|
|
172
|
-
serving.later { serving
|
|
199
|
+
serving.later { hooked(serving, call, :on_memory, event.data[:ops] || [], serving.world) }
|
|
173
200
|
end
|
|
174
201
|
end
|
|
175
202
|
|
|
@@ -182,19 +209,23 @@ module Pinecall
|
|
|
182
209
|
|
|
183
210
|
# Drop events whose (name, source) the class did not declare: an `:app` event arriving from
|
|
184
211
|
# a browser could be spoofed.
|
|
185
|
-
def received(serving,
|
|
212
|
+
def received(serving, call, event)
|
|
213
|
+
fact = event.data
|
|
186
214
|
name = fact[:name].to_s
|
|
187
215
|
allowed = agent_class(serving).declared_events[name]
|
|
188
216
|
unless allowed&.include?(fact[:source].to_s)
|
|
189
217
|
return warn("pinecall: #{name} from #{fact[:source]} is not declared by this agent")
|
|
190
218
|
end
|
|
191
219
|
|
|
192
|
-
meta = { source: fact[:source], identity: fact[:identity], seq:
|
|
220
|
+
meta = { source: fact[:source], identity: fact[:identity], seq: event.seq }.compact
|
|
193
221
|
serving.later do
|
|
194
|
-
serving.world.cause = {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
222
|
+
serving.world.cause = { name:, seq: event.seq }
|
|
223
|
+
begin
|
|
224
|
+
hooked(serving, call, :on_event, name, fact[:data] || {}, meta, event: name)
|
|
225
|
+
serving.agent.notify_heard(name, fact[:data] || {}, meta)
|
|
226
|
+
ensure
|
|
227
|
+
serving.world.cause = nil
|
|
228
|
+
end
|
|
198
229
|
end
|
|
199
230
|
end
|
|
200
231
|
|
|
@@ -205,8 +236,11 @@ module Pinecall
|
|
|
205
236
|
|
|
206
237
|
serving.stops.values_at(:state, :entries).compact.each(&:call)
|
|
207
238
|
serving.later do
|
|
208
|
-
serving
|
|
239
|
+
hooked(serving, call, :on_end, serving.world)
|
|
209
240
|
serving.stops[:log]&.call
|
|
241
|
+
# After every line the hook logged, whether it ran or raised: the gateway seals the call on this
|
|
242
|
+
# word, and a line sent after the seal is refused.
|
|
243
|
+
call.done
|
|
210
244
|
end
|
|
211
245
|
serving.finish
|
|
212
246
|
end
|
|
@@ -215,27 +249,48 @@ module Pinecall
|
|
|
215
249
|
# the provider's prompt cache. Unsent blocks count as empty.
|
|
216
250
|
def sync(serving, call)
|
|
217
251
|
line = { channel: call.channel || "web", from: call.from, claimed: serving.world.claimed }
|
|
218
|
-
|
|
252
|
+
begin
|
|
253
|
+
rendered = Prompt.render(serving.agent, remembered: serving.remembered, line:)
|
|
254
|
+
visible = serving.agent.visible_tools
|
|
255
|
+
rescue StandardError => e
|
|
256
|
+
return failed_to_render(call, e)
|
|
257
|
+
end
|
|
219
258
|
rendered.blocks.each do |block|
|
|
220
259
|
next if block.text == serving.sent.fetch(block.name, "")
|
|
221
260
|
|
|
222
261
|
serving.sent[block.name] = block.text
|
|
223
262
|
call.set_prompt(block.name, block.text)
|
|
224
263
|
end
|
|
225
|
-
visible = serving.agent.visible_tools
|
|
226
264
|
return if visible == serving.tools_shown
|
|
227
265
|
|
|
228
266
|
serving.tools_shown = visible
|
|
229
267
|
call.set_tools(visible)
|
|
230
268
|
end
|
|
231
269
|
|
|
270
|
+
# A view that fails fails its call, never the process: the model keeps the view it had, and the log says why.
|
|
271
|
+
def failed_to_render(call, error)
|
|
272
|
+
call.log("render.failed", { error: error.class.to_s, message: error.message })
|
|
273
|
+
warn("pinecall: #{call.id} could not render its view and goes on with the one it had: #{error.message}")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# A hook that raises fails its call, never the process: the call goes on with what the hook wrote
|
|
277
|
+
# before it raised, and the log says which hook and why.
|
|
278
|
+
def hooked(serving, call, hook, *args, event: nil)
|
|
279
|
+
serving.agent.run_hook(hook, *args)
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
call.log("hook.failed", { hook: hook.to_s, event:, error: e.class.to_s, message: e.message }.compact)
|
|
282
|
+
warn("pinecall: #{call.id} #{hook} raised and the call goes on without it: #{e.message}")
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
232
286
|
def agent_class(serving) = serving.agent.class
|
|
233
287
|
|
|
234
|
-
# `call.log` data must be an object
|
|
288
|
+
# `call.log` data must be an object: a `Data` or a `Struct` is its fields, other values go under `value`.
|
|
235
289
|
def as_object(data)
|
|
236
|
-
|
|
290
|
+
fields = Wire::Codec.plain(data)
|
|
291
|
+
return {} if fields.nil?
|
|
237
292
|
|
|
238
|
-
|
|
293
|
+
fields.is_a?(Hash) ? fields : { value: fields }
|
|
239
294
|
end
|
|
240
295
|
end
|
|
241
296
|
end
|
data/lib/pinecall/call_world.rb
CHANGED
|
@@ -9,17 +9,26 @@ module Pinecall
|
|
|
9
9
|
# State is reduced from received entries; each verb is one wire command. No LiveKit access by
|
|
10
10
|
# design: a missing capability should become a new command of the wire.
|
|
11
11
|
class CallWorld
|
|
12
|
-
attr_reader :id, :contact, :from, :channel, :medium, :room, :turns, :today,
|
|
12
|
+
attr_reader :id, :contact, :from, :to, :channel, :direction, :caller, :metadata, :medium, :room, :turns, :today,
|
|
13
|
+
:claimed
|
|
13
14
|
|
|
14
15
|
# `send` puts one command on the wire for this call; `searching` asks the gateway to search for
|
|
15
16
|
# it. Both are supplied by the bridge. `today` is the day the call opened, YYYY-MM-DD. `medium` is
|
|
16
17
|
# spoken (`voice`) or written (`text`); a gateway that does not say gets it from the channel.
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
# `to` is the number called, or the agent a visitor reached; `direction` is `inbound` when the public
|
|
19
|
+
# reached the agent and `outbound` when it dialled; `caller` is who the platform knows is on the line
|
|
20
|
+
# (`id`, `name`, `phone`, `email`), nil when nobody is named; `metadata` is what the call was opened with,
|
|
21
|
+
# such as a token's, `{}` when none.
|
|
22
|
+
def initialize(id:, contact: nil, from: nil, to: nil, channel: nil, direction: nil, caller: nil, metadata: nil,
|
|
23
|
+
medium: nil, today: Time.now.strftime("%Y-%m-%d"), claimed: nil, searching: nil, &send)
|
|
19
24
|
@id = id
|
|
20
25
|
@contact = contact
|
|
21
26
|
@from = from
|
|
27
|
+
@to = to
|
|
22
28
|
@channel = channel
|
|
29
|
+
@direction = direction
|
|
30
|
+
@caller = caller
|
|
31
|
+
@metadata = metadata || {}
|
|
23
32
|
@medium = (medium || (channel.to_s == "whatsapp" ? "text" : "voice")).to_s
|
|
24
33
|
@today = today
|
|
25
34
|
@claimed = claimed
|
|
@@ -29,15 +38,11 @@ module Pinecall
|
|
|
29
38
|
@turns = []
|
|
30
39
|
@waiting = Waiting.new
|
|
31
40
|
@cause = nil
|
|
32
|
-
@events = 0
|
|
33
41
|
end
|
|
34
42
|
|
|
35
|
-
# The external event being handled, if any
|
|
43
|
+
# The external event being handled, if any (`name` and the entry's `seq`); the cause of the writes it makes.
|
|
36
44
|
attr_accessor :cause
|
|
37
45
|
|
|
38
|
-
# Next per-call event sequence number (not the wire's seq).
|
|
39
|
-
def numbered = @events += 1
|
|
40
|
-
|
|
41
46
|
# ── commands ─────────────────────────────────────────────────────────────
|
|
42
47
|
|
|
43
48
|
# Speak `text` verbatim now. Returns true once the turn arrives, false after `LANDS_WITHIN_S`.
|
|
@@ -110,7 +115,8 @@ module Pinecall
|
|
|
110
115
|
|
|
111
116
|
# Append an application entry to the call's log.
|
|
112
117
|
def log(name, data = {})
|
|
113
|
-
|
|
118
|
+
fields = Wire::Codec.plain(data)
|
|
119
|
+
@send.call("call.log", { name: name.to_s, data: fields.is_a?(Hash) ? fields : { value: fields } })
|
|
114
120
|
end
|
|
115
121
|
|
|
116
122
|
# The caller asked never to be called again: their number joins the org's do-not-call list, and no
|
|
@@ -20,9 +20,11 @@ module Pinecall
|
|
|
20
20
|
@client = client
|
|
21
21
|
@routes = options[:routes] || []
|
|
22
22
|
@takes_unclaimed = options.fetch(:takes_unclaimed, true)
|
|
23
|
+
# Sends `call.done` once a call's end hook ran: the gateway's seal waits for the hook's lines.
|
|
24
|
+
@says_done = options.fetch(:says_done, false)
|
|
23
25
|
@tools = {}
|
|
24
26
|
@waiting = []
|
|
25
|
-
@config = options.reject { |key, _| %i[routes tools takes_unclaimed].include?(key) }
|
|
27
|
+
@config = options.reject { |key, _| %i[routes tools takes_unclaimed says_done].include?(key) }
|
|
26
28
|
@app = nil
|
|
27
29
|
@running = []
|
|
28
30
|
@dev = nil
|
|
@@ -46,7 +48,8 @@ module Pinecall
|
|
|
46
48
|
|
|
47
49
|
# Replace the tools; sent with the next `configure`.
|
|
48
50
|
#
|
|
49
|
-
# A tool is `{ **spec, run: ->(arguments, call) { … } }
|
|
51
|
+
# A tool is `{ **spec, run: ->(arguments, call, call_id) { … } }`: `call_id` is the model's id for the
|
|
52
|
+
# call, which a state write it makes names as its cause.
|
|
50
53
|
def declare(tools)
|
|
51
54
|
@tools = tools.to_h { |tool| [tool[:name].to_s, tool] }
|
|
52
55
|
@config = @config.merge(tools: tools.map { |tool| tool.reject { |key, _| key == :run } })
|
|
@@ -62,7 +65,7 @@ module Pinecall
|
|
|
62
65
|
def open
|
|
63
66
|
registered = ask("agent.register", lands_as: "agent.registered", data: {
|
|
64
67
|
routes: @routes, sdk: @client.sdk, host: @client.host,
|
|
65
|
-
takes_unclaimed: @takes_unclaimed
|
|
68
|
+
takes_unclaimed: @takes_unclaimed, **(@says_done ? { says_done: true } : {})
|
|
66
69
|
})
|
|
67
70
|
# Each connection gets a new app id.
|
|
68
71
|
@app = registered[:app]
|
|
@@ -117,12 +120,17 @@ module Pinecall
|
|
|
117
120
|
def run_tool(event, call)
|
|
118
121
|
call_id, name, arguments = event.data.values_at(:call_id, :name, :arguments)
|
|
119
122
|
tool = @tools[name.to_s]
|
|
120
|
-
|
|
123
|
+
if tool.nil?
|
|
124
|
+
# The platform answers its own tool, and an answer from here would race it.
|
|
125
|
+
return if name == Wire::Enums::END_CALL
|
|
126
|
+
|
|
127
|
+
return call.tool_result({ call_id:, name:, error: "this app declares no tool called #{name}" })
|
|
128
|
+
end
|
|
121
129
|
|
|
122
130
|
running = Thread.new do
|
|
123
131
|
started = now
|
|
124
132
|
begin
|
|
125
|
-
output = tool[:run].call(arguments || {}, call)
|
|
133
|
+
output = tool[:run].call(arguments || {}, call, call_id)
|
|
126
134
|
call.tool_result({ call_id:, name:, output:, duration_s: now - started })
|
|
127
135
|
rescue StandardError => e
|
|
128
136
|
# Report the exception to the model as the tool's `error`.
|
data/lib/pinecall/client/call.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Pinecall
|
|
|
6
6
|
#
|
|
7
7
|
# Commands are not awaited; their effects arrive later as log entries.
|
|
8
8
|
class Call
|
|
9
|
-
attr_reader :id, :today, :claimed, :run, :medium
|
|
9
|
+
attr_reader :id, :today, :claimed, :run, :medium, :direction, :metadata
|
|
10
10
|
attr_accessor :status, :channel, :from, :to, :contact, :state
|
|
11
11
|
|
|
12
12
|
def initialize(id, agent, opened_at)
|
|
@@ -17,6 +17,8 @@ module Pinecall
|
|
|
17
17
|
@channel = nil
|
|
18
18
|
# Spoken or written, as `call.started` says; nil before it, and from a gateway that does not say.
|
|
19
19
|
@medium = nil
|
|
20
|
+
@direction = nil
|
|
21
|
+
@metadata = {}
|
|
20
22
|
@from = nil
|
|
21
23
|
@to = nil
|
|
22
24
|
@contact = nil
|
|
@@ -48,11 +50,13 @@ module Pinecall
|
|
|
48
50
|
# Set the tools currently visible to the model (a subset of the declared ones).
|
|
49
51
|
def set_tools(tools) = command("tools.set", { tools: })
|
|
50
52
|
|
|
51
|
-
# Send the full state; answered with `state.changed`.
|
|
52
|
-
|
|
53
|
+
# Send the full state; answered with `state.changed`. `cause` says who wrote it, in the wire's
|
|
54
|
+
# `StateCause`: a tool, an outside event or a lifecycle hook.
|
|
55
|
+
def set_state(state, changed = nil, cause = nil)
|
|
53
56
|
@state = state
|
|
54
57
|
wire = { state: }
|
|
55
58
|
wire[:changed] = Array(changed).map(&:to_s) unless changed.nil?
|
|
59
|
+
wire[:cause] = cause unless cause.nil?
|
|
56
60
|
command("state.set", wire)
|
|
57
61
|
end
|
|
58
62
|
|
|
@@ -67,6 +71,9 @@ module Pinecall
|
|
|
67
71
|
# Append an application entry to the call's log, as a `custom` entry.
|
|
68
72
|
def log(name, data = {}) = command("call.log", { name: name.to_s, data: })
|
|
69
73
|
|
|
74
|
+
# Say the end hook ran and what it wrote was sent: the gateway seals the call on this word.
|
|
75
|
+
def done = command("call.done", {})
|
|
76
|
+
|
|
70
77
|
# Send any call-scoped command of the wire.
|
|
71
78
|
def command(type, data) = @agent.command(type, @id, data)
|
|
72
79
|
|
|
@@ -104,6 +111,8 @@ module Pinecall
|
|
|
104
111
|
def started(line)
|
|
105
112
|
the_line(line, "active")
|
|
106
113
|
@medium = line[:medium]
|
|
114
|
+
@direction = line[:direction]
|
|
115
|
+
@metadata = line[:metadata] || {}
|
|
107
116
|
# A run's call says its day, which a golden may have pinned; a person's runs on the clock's.
|
|
108
117
|
@today = line[:today] if line[:today]
|
|
109
118
|
end
|
|
@@ -32,6 +32,9 @@ module Pinecall
|
|
|
32
32
|
|
|
33
33
|
def open? = @open
|
|
34
34
|
|
|
35
|
+
# Never the key, nor the driver, which holds it in its headers.
|
|
36
|
+
def inspect = "#<#{self.class} #{@url} #{@open ? "open" : "closed"}>"
|
|
37
|
+
|
|
35
38
|
# Connect and return once `on_open` has run; reconnects are handled internally afterwards.
|
|
36
39
|
def start
|
|
37
40
|
@closed = false
|
|
@@ -70,11 +73,12 @@ module Pinecall
|
|
|
70
73
|
nil
|
|
71
74
|
end
|
|
72
75
|
|
|
73
|
-
# Called by websocket-driver.
|
|
76
|
+
# Called by websocket-driver. Once this side closed the socket, what the driver still writes (the
|
|
77
|
+
# answer to the gateway's close frame) has nowhere to go, and that is not a failure.
|
|
74
78
|
def write(bytes)
|
|
75
79
|
@socket.write(bytes)
|
|
76
80
|
rescue IOError, SystemCallError => e
|
|
77
|
-
@handlers.on_error.call(e)
|
|
81
|
+
@handlers.on_error.call(e) unless @closed
|
|
78
82
|
end
|
|
79
83
|
|
|
80
84
|
private
|
data/lib/pinecall/client.rb
CHANGED
|
@@ -58,6 +58,7 @@ module Pinecall
|
|
|
58
58
|
@errors = []
|
|
59
59
|
@entries = []
|
|
60
60
|
@stops = []
|
|
61
|
+
@kills = []
|
|
61
62
|
@connects = []
|
|
62
63
|
@listeners = Listeners.new { |error| on_error(error) }
|
|
63
64
|
@connection = Connection.new(
|
|
@@ -76,6 +77,9 @@ module Pinecall
|
|
|
76
77
|
@agents[slug] = Agent.new(slug, options, self)
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
# What a log line or an exception prints of the client: its door and its agents, never the key.
|
|
81
|
+
def inspect = "#<#{self.class} #{@url} env=#{@env.inspect} agents=#{@agents.keys.inspect}>"
|
|
82
|
+
|
|
79
83
|
# Declared agents by slug.
|
|
80
84
|
def agents = @agents.dup
|
|
81
85
|
|
|
@@ -123,6 +127,13 @@ module Pinecall
|
|
|
123
127
|
-> { @stops.delete(listener) }
|
|
124
128
|
end
|
|
125
129
|
|
|
130
|
+
# Called when an admin lets go of one of this client's agents: the listener takes its slug and the
|
|
131
|
+
# gateway's sentence. The socket stays open for the others. Returns a lambda that unsubscribes.
|
|
132
|
+
def on_killed(&listener)
|
|
133
|
+
@kills << listener
|
|
134
|
+
-> { @kills.delete(listener) }
|
|
135
|
+
end
|
|
136
|
+
|
|
126
137
|
# True while the socket is up.
|
|
127
138
|
def connected? = @connection.open?
|
|
128
139
|
|
|
@@ -180,7 +191,7 @@ module Pinecall
|
|
|
180
191
|
return if agent.nil?
|
|
181
192
|
|
|
182
193
|
guarded { agent.take(entry) }
|
|
183
|
-
|
|
194
|
+
killed(entry) if killed?(entry)
|
|
184
195
|
end
|
|
185
196
|
|
|
186
197
|
# An `error` coded `stopped` for no agent: a member of the org pressed Stop.
|
|
@@ -188,6 +199,12 @@ module Pinecall
|
|
|
188
199
|
|
|
189
200
|
def killed?(entry) = entry.type == "error" && entry.data[:code] == KILLED
|
|
190
201
|
|
|
202
|
+
# The agent is no longer declared, so a reconnect leaves it out; then whoever asked to know.
|
|
203
|
+
def killed(entry)
|
|
204
|
+
@agents.delete(entry.agent)
|
|
205
|
+
@kills.each { |listener| guarded { listener.call(entry.agent, entry.data[:message]) } }
|
|
206
|
+
end
|
|
207
|
+
|
|
191
208
|
# With no stop listener the stop is reported as an error, so it is never silent.
|
|
192
209
|
# Every agent registered again, then whoever asked to know.
|
|
193
210
|
def opened
|
data/lib/pinecall/serve/held.rb
CHANGED
|
@@ -8,21 +8,24 @@ module Pinecall
|
|
|
8
8
|
class Held
|
|
9
9
|
attr_reader :client, :mounted
|
|
10
10
|
|
|
11
|
-
def initialize(client, mounted)
|
|
11
|
+
def initialize(client, mounted, err: $stderr)
|
|
12
12
|
@client = client
|
|
13
13
|
@mounted = mounted
|
|
14
|
+
@err = err
|
|
14
15
|
@stopped = Thread::Queue.new
|
|
15
16
|
@lock = Mutex.new
|
|
16
17
|
@drained = nil
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
# Drain every agent,
|
|
20
|
+
# Drain every agent, close the socket and say on stderr where the calls went; asked twice, the
|
|
21
|
+
# first answer. Returns the drain.
|
|
20
22
|
def stop
|
|
21
23
|
@lock.synchronize do
|
|
22
24
|
return @drained unless @drained.nil?
|
|
23
25
|
|
|
24
26
|
@drained = @client.drain
|
|
25
27
|
close
|
|
28
|
+
@err.puts(Serve.drain_line(@drained))
|
|
26
29
|
@drained
|
|
27
30
|
end
|
|
28
31
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# What a served process prints of its agent's entries: the values the agent declared private, masked as the call's log masks them.
|
|
4
|
+
|
|
5
|
+
module Pinecall
|
|
6
|
+
module Serve
|
|
7
|
+
# One agent's private names and, as its tool calls pass, the private values they carried.
|
|
8
|
+
class Masking
|
|
9
|
+
MASK = "***"
|
|
10
|
+
|
|
11
|
+
# Shorter than this a value is never learned as a secret: "7", "ok" or "Al" cannot be told from a
|
|
12
|
+
# word, and hunting it in what else is written would mask every such digit or word. The argument is
|
|
13
|
+
# still masked by its name; only its value is not looked for.
|
|
14
|
+
LEAST_SECRET_LENGTH = 3
|
|
15
|
+
|
|
16
|
+
# What the agent wrote down, by entry: the fields of its data a private value may be in. What was
|
|
17
|
+
# said is the caller's and the model's words, and never masked.
|
|
18
|
+
WRITTEN_DOWN = {
|
|
19
|
+
"tool.call" => %i[arguments],
|
|
20
|
+
"tool.result" => %i[output error summary],
|
|
21
|
+
"state.changed" => %i[state],
|
|
22
|
+
"call.attached" => %i[state],
|
|
23
|
+
"custom" => %i[data]
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# `declared` is the agent's wire config: the arguments its tools list under `pii`, and the state
|
|
27
|
+
# fields declared `pii`.
|
|
28
|
+
def initialize(declared)
|
|
29
|
+
@arguments = (declared[:tools] || []).to_h { |tool| [tool[:name].to_s, Array(tool[:pii]).map(&:to_sym)] }
|
|
30
|
+
@fields = (declared[:state_fields] || []).filter_map { |field| field[:name].to_sym if field[:visibility].to_s == "pii" }
|
|
31
|
+
@values = Set.new
|
|
32
|
+
@pattern = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The entry with every private value masked: by name where the agent named it, by value wherever
|
|
36
|
+
# else it was written down. An entry with nothing private to hide is the entry itself.
|
|
37
|
+
def entry(entry)
|
|
38
|
+
fields = WRITTEN_DOWN[entry.type]
|
|
39
|
+
return entry if fields.nil?
|
|
40
|
+
|
|
41
|
+
named = @arguments.fetch(entry.data[:name].to_s, [])
|
|
42
|
+
return entry unless @values.any? || @fields.any? || (entry.type == "tool.call" && named.any?)
|
|
43
|
+
|
|
44
|
+
learn(named, entry.data) if entry.type == "tool.call"
|
|
45
|
+
data = entry.data.dup
|
|
46
|
+
fields.each { |field| data[field] = masked(data[field], names_in(entry, field, named)) if data.key?(field) }
|
|
47
|
+
entry.with(data:)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def names_in(entry, field, named)
|
|
53
|
+
return named if entry.type == "tool.call" && field == :arguments
|
|
54
|
+
|
|
55
|
+
field == :state ? @fields : []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def learn(named, data)
|
|
59
|
+
given = data[:arguments]
|
|
60
|
+
return unless given.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
known = @values.size
|
|
63
|
+
named.each { |name| @values.merge(leaves(given[name])) }
|
|
64
|
+
@pattern = whole_words if @values.size != known
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A hash is masked member by member; any other value is masked of the secrets alone.
|
|
68
|
+
def masked(value, names)
|
|
69
|
+
return scrubbed(value) unless value.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
value.to_h { |name, member| [name, names.include?(name.to_sym) ? MASK : scrubbed(member)] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A leaf that is a private value goes whole; a string that holds one as a whole word has it
|
|
75
|
+
# replaced, so the rest of what it said stays readable.
|
|
76
|
+
def scrubbed(value)
|
|
77
|
+
return value if @pattern.nil?
|
|
78
|
+
|
|
79
|
+
case value
|
|
80
|
+
when String then value.gsub(@pattern, MASK)
|
|
81
|
+
when Numeric then @values.include?(value.to_s) ? MASK : value
|
|
82
|
+
when Array then value.map { |item| scrubbed(item) }
|
|
83
|
+
when Hash then value.to_h { |name, member| [name, scrubbed(member)] }
|
|
84
|
+
else value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A value is a secret where it stands as a whole word: `Ana` is no part of `Banana` or `Anabel`, nor
|
|
89
|
+
# `7` of `1700`. The longest comes first, so a value that holds another is masked whole.
|
|
90
|
+
def whole_words
|
|
91
|
+
ordered = @values.sort_by { |secret| -secret.length }
|
|
92
|
+
Regexp.new("(?<![[:alnum:]_])(?:#{ordered.map { |secret| Regexp.escape(secret) }.join("|")})(?![[:alnum:]_])")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Every string and number inside a value, as text; a blank is no secret, it would mask everything.
|
|
96
|
+
def leaves(value)
|
|
97
|
+
case value
|
|
98
|
+
when String then secret_of(value.strip)
|
|
99
|
+
when Numeric then secret_of(value.to_s)
|
|
100
|
+
when Array then value.flat_map { |item| leaves(item) }
|
|
101
|
+
when Hash then value.values.flat_map { |member| leaves(member) }
|
|
102
|
+
else []
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def secret_of(text) = text.length >= LEAST_SECRET_LENGTH ? [text] : []
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/pinecall/serve.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "serve/loading"
|
|
4
4
|
require_relative "serve/held"
|
|
5
|
+
require_relative "serve/masking"
|
|
5
6
|
require_relative "serve/viewing"
|
|
6
7
|
|
|
7
8
|
module Pinecall
|
|
@@ -40,18 +41,18 @@ module Pinecall
|
|
|
40
41
|
def start(flags, out:, err:, env:, input:, signals:)
|
|
41
42
|
client = client_from(env, prod: flags.prod)
|
|
42
43
|
classes = flags.served.map { |served| [served, Loading.load_served(served)] }
|
|
43
|
-
said(client, out:, err:, events: flags.events)
|
|
44
44
|
mounted = classes.map do |served, klass|
|
|
45
45
|
held = Pinecall.mount(klass, client:, slug: served.slug, takes_unclaimed: !flags.console)
|
|
46
46
|
held.agent.on_dev { |verb, data| Viewing.answer(klass, served.slug, verb, data) }
|
|
47
47
|
held
|
|
48
48
|
end
|
|
49
|
-
|
|
49
|
+
said(client, out:, err:, events: flags.events, maskings: mounted.to_h { |one| [one.slug, Masking.new(one.options)] })
|
|
50
|
+
held = Held.new(client, mounted, err:)
|
|
50
51
|
asked = signals || trapped
|
|
51
52
|
client.on_stopped { |why| err.puts(why) || asked.push(:stopped) }
|
|
52
53
|
client.connect
|
|
53
54
|
Thread.new { ended(input, asked) }
|
|
54
|
-
leave(held, asked
|
|
55
|
+
leave(held, asked)
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
# Load the class, open it in the state the pairs name, and print its prompt. No gateway, no key.
|
|
@@ -123,16 +124,19 @@ module Pinecall
|
|
|
123
124
|
|
|
124
125
|
# Listening starts before connect, so `agent.registered` is the first line of a pipe. A pipe's
|
|
125
126
|
# $stdout is buffered: each line is flushed, or the CLI waiting on it sees it only at exit.
|
|
126
|
-
def said(client, out:, err:, events:)
|
|
127
|
+
def said(client, out:, err:, events:, maskings:)
|
|
127
128
|
client.on_entries do |entry|
|
|
128
129
|
if events
|
|
129
|
-
|
|
130
|
+
# What it prints is what the call's log keeps: a value the agent declared private never reaches a terminal.
|
|
131
|
+
shown = maskings[entry.agent]&.entry(entry) || entry
|
|
132
|
+
out.puts(JSON.generate({ type: shown.type, agent: shown.agent, call: shown.call, data: shown.data }))
|
|
130
133
|
elsif entry.type == "agent.registered"
|
|
131
134
|
out.puts("#{entry.agent} · answering as #{entry.data[:app]}")
|
|
132
135
|
end
|
|
133
136
|
out.flush
|
|
134
137
|
end
|
|
135
138
|
client.on_errors { |error| err.puts("#{error.class}: #{error.message}") }
|
|
139
|
+
client.on_killed { |slug, why| err.puts("#{slug} ✗ #{why}") }
|
|
136
140
|
end
|
|
137
141
|
|
|
138
142
|
# A trap only pushes: the main thread does the leaving, as a trap may not take a lock.
|
|
@@ -153,7 +157,7 @@ module Pinecall
|
|
|
153
157
|
|
|
154
158
|
# The first reason drains (unless the org already stopped it); only a second signal closes at
|
|
155
159
|
# once. The end of stdin is not one: the CLI passes a signal on and closes the pipe together.
|
|
156
|
-
def leave(held, asked
|
|
160
|
+
def leave(held, asked)
|
|
157
161
|
if asked.pop == :stopped
|
|
158
162
|
held.close
|
|
159
163
|
return 0
|
|
@@ -164,8 +168,7 @@ module Pinecall
|
|
|
164
168
|
nil until asked.pop == :signalled
|
|
165
169
|
outcome.push(:again)
|
|
166
170
|
end
|
|
167
|
-
|
|
168
|
-
drained == :again ? held.close : err.puts(drain_line(drained))
|
|
171
|
+
held.close if outcome.pop == :again
|
|
169
172
|
0
|
|
170
173
|
end
|
|
171
174
|
end
|
data/lib/pinecall/testing.rb
CHANGED
|
@@ -131,13 +131,14 @@ module Pinecall
|
|
|
131
131
|
attr_reader :errors
|
|
132
132
|
|
|
133
133
|
# Start a call and return its `Fake` handle; `state:` is the state its opener asked for,
|
|
134
|
-
# `today:` the day a golden pinned (YYYY-MM-DD).
|
|
135
|
-
def call_started(id: nil, channel: "web", from: "+34600000000", to: "+34910000000",
|
|
136
|
-
today: nil)
|
|
134
|
+
# `today:` the day a golden pinned (YYYY-MM-DD), `metadata:` what a token opened it with.
|
|
135
|
+
def call_started(id: nil, channel: "web", direction: "inbound", from: "+34600000000", to: "+34910000000",
|
|
136
|
+
caller: nil, state: nil, today: nil, metadata: nil)
|
|
137
137
|
id ||= "CA_#{next_seq}"
|
|
138
|
-
started = { channel:, direction
|
|
138
|
+
started = { channel:, direction:, from:, to:, caller:, started_at: Time.now.to_f }
|
|
139
139
|
started = started.merge(state:) unless state.nil?
|
|
140
140
|
started = started.merge(today:) unless today.nil?
|
|
141
|
+
started = started.merge(metadata:) unless metadata.nil?
|
|
141
142
|
deliver("call.started", started, call: id)
|
|
142
143
|
settle
|
|
143
144
|
Fake.new(self, id)
|
data/lib/pinecall/version.rb
CHANGED
data/lib/pinecall/wire/codec.rb
CHANGED
|
@@ -12,8 +12,8 @@ module Pinecall
|
|
|
12
12
|
# `error` naming the command's id.
|
|
13
13
|
Command = Data.define(:type, :agent, :call, :id, :data)
|
|
14
14
|
|
|
15
|
-
# An entry read as what it means: the wire type,
|
|
16
|
-
Event = Data.define(:type, :data)
|
|
15
|
+
# An entry read as what it means: the wire type, the payload that type names, and the entry's seq.
|
|
16
|
+
Event = Data.define(:type, :data, :seq)
|
|
17
17
|
|
|
18
18
|
# JSON encoding and decoding. Keys are symbols in Ruby and strings on the wire; both are
|
|
19
19
|
# snake_case, so unlike TypeScript there is no key renaming.
|
|
@@ -33,7 +33,7 @@ module Pinecall
|
|
|
33
33
|
raise WireError, "unknown event type: #{entry.type}" if shape.nil?
|
|
34
34
|
|
|
35
35
|
Validate.call!(shape, entry.data, where: entry.type)
|
|
36
|
-
Event.new(type: entry.type, data: entry.data)
|
|
36
|
+
Event.new(type: entry.type, data: entry.data, seq: entry.seq)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# Build a command frame, validating its data and then the whole envelope. Validating here
|
|
@@ -42,6 +42,7 @@ module Pinecall
|
|
|
42
42
|
shape = Registry::COMMANDS[type]
|
|
43
43
|
raise WireError, "unknown command type: #{type}" if shape.nil?
|
|
44
44
|
|
|
45
|
+
data = plain(data)
|
|
45
46
|
Validate.call!(shape, data, where: type)
|
|
46
47
|
frame = { type:, agent:, call:, data: }
|
|
47
48
|
frame[:id] = id unless id.nil?
|
|
@@ -49,6 +50,17 @@ module Pinecall
|
|
|
49
50
|
Command.new(type:, agent:, call:, id:, data:)
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
# A Ruby value as the JSON the wire carries: a `Data` or a `Struct` is its fields, wherever it sits in
|
|
54
|
+
# an array or a hash, since `JSON.generate` would send its `inspect`.
|
|
55
|
+
def plain(value)
|
|
56
|
+
case value
|
|
57
|
+
when Data, Struct then plain(value.to_h)
|
|
58
|
+
when Hash then value.to_h { |key, inner| [key, plain(inner)] }
|
|
59
|
+
when Array then value.map { |inner| plain(inner) }
|
|
60
|
+
else value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
52
64
|
# A frame as JSON text. A command without an id omits the key (the schema refuses null);
|
|
53
65
|
# `call` stays null, since a command about the agent itself belongs to no call.
|
|
54
66
|
def encode(frame)
|
data/lib/pinecall/wire/enums.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Every closed list of the wire this gem reads, as a frozen array.
|
|
3
|
+
# Every closed list of the wire this gem reads, as a frozen array, and the name the platform keeps for itself.
|
|
4
4
|
|
|
5
5
|
module Pinecall
|
|
6
6
|
module Wire
|
|
@@ -8,6 +8,9 @@ module Pinecall
|
|
|
8
8
|
# The door the public came through: a phone call over SIP, the browser widget over WebRTC,
|
|
9
9
|
# or WhatsApp text.
|
|
10
10
|
CHANNEL = %w[phone web whatsapp].freeze
|
|
11
|
+
# The tool the platform puts in front of a model that may hang up. Its tool.call and its
|
|
12
|
+
# tool.result are both the platform's, so an app answers none.
|
|
13
|
+
END_CALL = "end_call"
|
|
11
14
|
# Inbound: the public reached the agent. Outbound: the agent reached out (a dial).
|
|
12
15
|
DIRECTION = %w[inbound outbound].freeze
|
|
13
16
|
# How a call is had: spoken in a room, or written. A web call can be either.
|
|
@@ -101,6 +101,7 @@ module Pinecall
|
|
|
101
101
|
"call.callback" => "CallCallback",
|
|
102
102
|
"call.claim" => "CallClaim",
|
|
103
103
|
"call.dial" => "CallDial",
|
|
104
|
+
"call.done" => "CallDone",
|
|
104
105
|
"call.dtmf" => "CallDtmf",
|
|
105
106
|
"call.event" => "CallEvent",
|
|
106
107
|
"call.hangup" => "CallHangup",
|
|
@@ -129,7 +130,7 @@ module Pinecall
|
|
|
129
130
|
EVENT_TYPES = %w[agent.configured agent.detached agent.draining agent.registered agent.state agent.transcript app.failed attention.answered attention.requested call.attached call.claimed call.dialing call.ended call.line call.ringing call.score call.started call.summary call.transferred callback.requested code.claimed code.issued confirm.declined confirm.granted confirm.request credits.exhausted custom dev.request docs.sources dtmf.received error evals.spent event.received fleet.full judge.broke log.caught_up log.gap memory.ops message.taken message.waiting metrics.avatar metrics.eot metrics.eou metrics.interruption metrics.llm metrics.realtime metrics.stt metrics.tts metrics.vad participant.joined participant.left participant.speaking pong prompt.changed release.changed room.opened room.sent run.changed spend.unusual state.changed supervisor.ended supervisor.released supervisor.said supervisor.took_over supervisor.transferred supervisor.whispered tool.call tool.failed tool.ran tool.result tools.changed track.published track.unpublished turn.agent turn.user user.state user.transcript vendor.switched worker.changed].freeze
|
|
130
131
|
|
|
131
132
|
# Every command an app may send.
|
|
132
|
-
COMMAND_TYPES = %w[agent.configure agent.drain agent.register agent.reply agent.say call.attention call.callback call.claim call.dial call.dtmf call.event call.hangup call.hold call.log call.mute call.opt_out call.transfer call.unhold call.unmute dev.answer participant.mute participant.remove ping prompt.set room.invite room.send session.configure state.set supervisor.verb tool.result tools.set].freeze
|
|
133
|
+
COMMAND_TYPES = %w[agent.configure agent.drain agent.register agent.reply agent.say call.attention call.callback call.claim call.dial call.done call.dtmf call.event call.hangup call.hold call.log call.mute call.opt_out call.transfer call.unhold call.unmute dev.answer participant.mute participant.remove ping prompt.set room.invite room.send session.configure state.set supervisor.verb tool.result tools.set].freeze
|
|
133
134
|
|
|
134
135
|
# Entries a store may drop and a slow reader may miss without harm: the entry's
|
|
135
136
|
# own ephemeral flag defaults to this.
|
|
@@ -149,6 +150,7 @@ module Pinecall
|
|
|
149
150
|
"call.callback" => %w[callback.requested],
|
|
150
151
|
"call.claim" => %w[call.claimed],
|
|
151
152
|
"call.dial" => %w[call.dialing],
|
|
153
|
+
"call.done" => [],
|
|
152
154
|
"call.dtmf" => [],
|
|
153
155
|
"call.event" => %w[event.received],
|
|
154
156
|
"call.hangup" => %w[call.ended],
|
data/lib/pinecall/wire/shapes.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Pinecall
|
|
|
21
21
|
"ModelUsage" => { on: :type, members: %w[LLMModelUsage TTSModelUsage STTModelUsage InterruptionModelUsage EOTModelUsage] }.freeze,
|
|
22
22
|
"Turn" => { on: :role, members: %w[UserTurn AgentTurn] }.freeze,
|
|
23
23
|
"Verb" => { on: :verb, members: %w[SayVerb WhisperVerb TakeoverVerb ReleaseVerb TransferVerb EndVerb] }.freeze,
|
|
24
|
-
"StateCause" => { on: :kind, members: %w[StateCauseTool StateCauseEvent] }.freeze
|
|
24
|
+
"StateCause" => { on: :kind, members: %w[StateCauseTool StateCauseEvent StateCauseHook] }.freeze
|
|
25
25
|
}.freeze
|
|
26
26
|
|
|
27
27
|
# A map is keyed by names the app chose; every value is the one shape given here.
|
|
@@ -100,7 +100,8 @@ module Pinecall
|
|
|
100
100
|
worker: { kind: :str, null: true },
|
|
101
101
|
medium: { kind: :ref, ref: "Medium", null: true },
|
|
102
102
|
state: { kind: :json },
|
|
103
|
-
today: { kind: :str, null: true }
|
|
103
|
+
today: { kind: :str, null: true },
|
|
104
|
+
metadata: { kind: :json }
|
|
104
105
|
}.freeze,
|
|
105
106
|
"CallSummary" => {
|
|
106
107
|
reason: { kind: :ref, ref: "EndReason", required: true },
|
|
@@ -15,7 +15,8 @@ module Pinecall
|
|
|
15
15
|
sdk: { kind: :str },
|
|
16
16
|
host: { kind: :str },
|
|
17
17
|
takes_unclaimed: { kind: :bool, default: true },
|
|
18
|
-
answers_dev: { kind: :bool, default: false }
|
|
18
|
+
answers_dev: { kind: :bool, default: false },
|
|
19
|
+
says_done: { kind: :bool, default: false }
|
|
19
20
|
}.freeze,
|
|
20
21
|
"AgentReply" => {
|
|
21
22
|
instructions: { kind: :str, required: true },
|
|
@@ -50,6 +51,7 @@ module Pinecall
|
|
|
50
51
|
name: { kind: :str, required: true },
|
|
51
52
|
data: { kind: :json, required: true }
|
|
52
53
|
}.freeze,
|
|
54
|
+
"CallDone" => {}.freeze,
|
|
53
55
|
"CallHangup" => {
|
|
54
56
|
reason: { kind: :str }
|
|
55
57
|
}.freeze,
|
|
@@ -99,7 +101,8 @@ module Pinecall
|
|
|
99
101
|
}.freeze,
|
|
100
102
|
"StateSet" => {
|
|
101
103
|
state: { kind: :json, required: true },
|
|
102
|
-
changed: { kind: :list, items: { kind: :str } }
|
|
104
|
+
changed: { kind: :list, items: { kind: :str } },
|
|
105
|
+
cause: { kind: :ref, ref: "StateCause" }
|
|
103
106
|
}.freeze,
|
|
104
107
|
"SupervisorVerb" => {
|
|
105
108
|
by: { kind: :ref, ref: "Supervisor", required: true },
|
|
@@ -337,6 +337,10 @@ module Pinecall
|
|
|
337
337
|
name: { kind: :str, required: true },
|
|
338
338
|
seq: { kind: :int, required: true }
|
|
339
339
|
}.freeze,
|
|
340
|
+
"StateCauseHook" => {
|
|
341
|
+
kind: { kind: :const, const: "hook", required: true },
|
|
342
|
+
hook: { kind: :enum, values: %w[on_call on_memory], required: true }
|
|
343
|
+
}.freeze,
|
|
340
344
|
}.freeze
|
|
341
345
|
end
|
|
342
346
|
end
|
data/sig/pinecall.rbs
CHANGED
|
@@ -125,7 +125,7 @@ module Pinecall
|
|
|
125
125
|
def doc: () -> String?
|
|
126
126
|
def tools: () -> Array[spec]
|
|
127
127
|
def visible_tools: () -> Array[spec]
|
|
128
|
-
def run_tool: (String | Symbol, ?Hash[Symbol, untyped]) -> untyped
|
|
128
|
+
def run_tool: (String | Symbol, ?Hash[Symbol, untyped], ?String?) -> untyped
|
|
129
129
|
def snapshot: () -> state
|
|
130
130
|
def restore: (state) -> Agent
|
|
131
131
|
def start_in: (state) -> Agent
|
|
@@ -156,8 +156,10 @@ module Pinecall
|
|
|
156
156
|
|
|
157
157
|
module Author
|
|
158
158
|
KEY: Symbol
|
|
159
|
+
CALL: Symbol
|
|
159
160
|
def self?.current: () -> String?
|
|
160
|
-
def self?.
|
|
161
|
+
def self?.call_id: () -> String?
|
|
162
|
+
def self?.with: [T] (String, ?String?) { () -> T } -> T
|
|
161
163
|
end
|
|
162
164
|
|
|
163
165
|
module Doc
|
|
@@ -221,14 +223,18 @@ module Pinecall
|
|
|
221
223
|
attr_reader id: String
|
|
222
224
|
attr_reader contact: String?
|
|
223
225
|
attr_reader from: String?
|
|
226
|
+
attr_reader to: String?
|
|
224
227
|
attr_reader channel: String?
|
|
228
|
+
attr_reader direction: String?
|
|
229
|
+
attr_reader caller: Hash[Symbol, untyped]?
|
|
230
|
+
attr_reader metadata: Hash[Symbol, untyped]
|
|
225
231
|
attr_reader medium: String
|
|
226
232
|
attr_reader today: String
|
|
227
233
|
attr_reader claimed: String?
|
|
228
234
|
attr_accessor cause: Hash[Symbol, untyped]?
|
|
229
|
-
def initialize: (id: String, ?contact: String?, ?from: String?, ?
|
|
230
|
-
?
|
|
231
|
-
|
|
235
|
+
def initialize: (id: String, ?contact: String?, ?from: String?, ?to: String?, ?channel: String?, ?direction: String?,
|
|
236
|
+
?caller: Hash[Symbol, untyped]?, ?metadata: Hash[Symbol, untyped]?, ?medium: String?, ?today: String,
|
|
237
|
+
?claimed: String?, ?searching: (^(String, Integer?) -> Array[Hash[Symbol, untyped]])?) { (String, Hash[Symbol, untyped]) -> untyped } -> void
|
|
232
238
|
def say: (String, **untyped) -> bool
|
|
233
239
|
def reply: (String, **untyped) -> bool
|
|
234
240
|
def search: (String, ?k: Integer?) -> Array[Found]
|
|
@@ -271,6 +277,7 @@ module Pinecall
|
|
|
271
277
|
def drain: (?answer_s: Numeric, ?tools_s: Numeric) -> Drained
|
|
272
278
|
def on_entries: () { (untyped) -> void } -> ^() -> void
|
|
273
279
|
def on_stopped: () { (String) -> void } -> ^() -> void
|
|
280
|
+
def on_killed: () { (String, String) -> void } -> ^() -> void
|
|
274
281
|
def sdk: () -> String
|
|
275
282
|
def agent: (String, **untyped) -> Client::Agent
|
|
276
283
|
def agents: () -> Hash[String, Client::Agent]
|
|
@@ -314,6 +321,8 @@ module Pinecall
|
|
|
314
321
|
attr_reader claimed: String?
|
|
315
322
|
attr_reader run: String?
|
|
316
323
|
attr_reader medium: String?
|
|
324
|
+
attr_reader direction: String?
|
|
325
|
+
attr_reader metadata: Hash[Symbol, untyped]
|
|
317
326
|
attr_reader today: String
|
|
318
327
|
attr_accessor status: String
|
|
319
328
|
attr_accessor channel: String?
|
|
@@ -325,11 +334,12 @@ module Pinecall
|
|
|
325
334
|
def reply: (String, **untyped) -> untyped
|
|
326
335
|
def set_prompt: (String | Symbol, String) -> untyped
|
|
327
336
|
def set_tools: (Array[Hash[Symbol, untyped]]) -> untyped
|
|
328
|
-
def set_state: (Hash[Symbol, untyped], ?Array[Symbol | String]?) -> untyped
|
|
337
|
+
def set_state: (Hash[Symbol, untyped], ?Array[Symbol | String]?, ?Hash[Symbol, untyped]?) -> untyped
|
|
329
338
|
def tool_result: (Hash[Symbol, untyped]) -> untyped
|
|
330
339
|
def event: (String, Hash[Symbol, untyped]) -> untyped
|
|
331
340
|
def hangup: (?String?) -> untyped
|
|
332
341
|
def log: (String | Symbol, ?Hash[Symbol, untyped]) -> untyped
|
|
342
|
+
def done: () -> untyped
|
|
333
343
|
def command: (String, Hash[Symbol, untyped]) -> untyped
|
|
334
344
|
def take: (untyped) -> void
|
|
335
345
|
def on: (String) { (Hash[Symbol, untyped], Call) -> void } -> ^() -> void
|
|
@@ -381,10 +391,18 @@ module Pinecall
|
|
|
381
391
|
def initialize: (file: String, slug: String) -> void
|
|
382
392
|
end
|
|
383
393
|
|
|
394
|
+
class Masking
|
|
395
|
+
MASK: String
|
|
396
|
+
LEAST_SECRET_LENGTH: Integer
|
|
397
|
+
WRITTEN_DOWN: Hash[String, Array[Symbol]]
|
|
398
|
+
def initialize: (Hash[Symbol, untyped]) -> void
|
|
399
|
+
def entry: (untyped) -> untyped
|
|
400
|
+
end
|
|
401
|
+
|
|
384
402
|
class Held
|
|
385
403
|
attr_reader client: Client
|
|
386
404
|
attr_reader mounted: Array[Bridge::Mounted]
|
|
387
|
-
def initialize: (Client, Array[Bridge::Mounted]) -> void
|
|
405
|
+
def initialize: (Client, Array[Bridge::Mounted], ?err: IO) -> void
|
|
388
406
|
def stop: () -> Client::Drained
|
|
389
407
|
def close: () -> untyped
|
|
390
408
|
def join: () -> untyped
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pinecall
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.11.
|
|
4
|
+
version: 0.11.16
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Pinecall
|
|
@@ -66,6 +66,7 @@ files:
|
|
|
66
66
|
- lib/pinecall/serve.rb
|
|
67
67
|
- lib/pinecall/serve/held.rb
|
|
68
68
|
- lib/pinecall/serve/loading.rb
|
|
69
|
+
- lib/pinecall/serve/masking.rb
|
|
69
70
|
- lib/pinecall/serve/viewing.rb
|
|
70
71
|
- lib/pinecall/testing.rb
|
|
71
72
|
- lib/pinecall/version.rb
|