terret-core 0.1.0 → 0.1.1
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/lib/terret/approvals.rb +267 -0
- data/lib/terret/compactor.rb +116 -0
- data/lib/terret/credentials.rb +172 -0
- data/lib/terret/llm.rb +90 -2
- data/lib/terret/loop.rb +538 -26
- data/lib/terret/redactor.rb +101 -0
- data/lib/terret/sessions.rb +325 -22
- data/lib/terret/store.rb +79 -0
- data/lib/terret/subagents.rb +99 -0
- data/lib/terret/titler.rb +58 -0
- data/lib/terret/tools.rb +280 -11
- data/lib/terret.rb +18 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f36dbce70ed5b476d44ca902a7a261d7ae71c0f019109f9ec02052f8c7900714
|
|
4
|
+
data.tar.gz: c149aa846c4ea730562fa0e2f3e0539edc12ba533f468bac578f19617ef903ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 83921151858e5edf9df27f6a7a6064d2936f5059fc504e6ffd27e469ed345c516504c88f7c7104309ba238063bf403a718d1cbbf33f64ea85313e656e618c3f5
|
|
7
|
+
data.tar.gz: 64f2129e36d07a74d1cbd4a0caf49763519992992398f634e2383c3d0439abb8c51119be93fbb4399c5823305ca97270f0884d1072d2cf9a2a20fdc1be15a30f
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terret
|
|
4
|
+
module Tools
|
|
5
|
+
# ctx[:approvals] — durable human-in-the-loop gating (plan §6.3, §12 M6).
|
|
6
|
+
# An execute-stage middleware parks calls whose definition demands a
|
|
7
|
+
# decision, appends durable approval/requested, and resumes when a
|
|
8
|
+
# matching approval/resolved lands in the log (the socket's approve/deny
|
|
9
|
+
# frames append exactly that). Both sides are durable, so a parked call
|
|
10
|
+
# survives a process death: on resume the gate finds the recorded verdict
|
|
11
|
+
# and never parks. It gates on tools/execute rather than tools/pre_execute
|
|
12
|
+
# so pre_execute vetoes (the per-agent AllowList) settle a call before a
|
|
13
|
+
# human is ever asked.
|
|
14
|
+
class Approvals < Hames::Service
|
|
15
|
+
service_key :approvals
|
|
16
|
+
inject :sessions, :tools, :loop
|
|
17
|
+
config_schema({}) # opt-in per tool; the service itself takes no config
|
|
18
|
+
|
|
19
|
+
def start(ctx)
|
|
20
|
+
@ctx = ctx
|
|
21
|
+
# A unique per-park token => { session_id:, call_id:, queue: }. Keyed by
|
|
22
|
+
# the token rather than by [session_id, call_id] because provider
|
|
23
|
+
# tool-call ids are NOT contractually unique: two calls of one parallel
|
|
24
|
+
# batch can arrive sharing an id, and keying by it let the second park
|
|
25
|
+
# overwrite the first's queue so a single verdict woke only the survivor
|
|
26
|
+
# and the first fiber parked forever, hanging the barrier. Each park now
|
|
27
|
+
# owns its own entry and its own wake. The DURABLE correlation
|
|
28
|
+
# (approval/requested and /resolved, matched in the log on call_id +
|
|
29
|
+
# name + args) is unchanged; only this in-memory map needed unique keys.
|
|
30
|
+
@waiting = {}
|
|
31
|
+
@waiting_mutex = Mutex.new # tests resolve from another thread; wake_one scans
|
|
32
|
+
@park_seq = 0
|
|
33
|
+
|
|
34
|
+
ctx.on("tools/execute") do |call, next_|
|
|
35
|
+
gate(call, next_)
|
|
36
|
+
end
|
|
37
|
+
ctx.on("session/event") do |ev|
|
|
38
|
+
next unless ev.type == "approval/resolved"
|
|
39
|
+
|
|
40
|
+
wake_one(ev.session_id, ev.payload[:call_id], ev.payload)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Log-derived: requested without a matching resolved, within the OPEN
|
|
45
|
+
# turn. The in-memory waiter map is never consulted — after a restart it
|
|
46
|
+
# is empty while the log still knows what is owed. Nothing is pending
|
|
47
|
+
# once the turn that asked has closed: a request its turn outlived was
|
|
48
|
+
# settled by that turn ending, and a provider is free to reuse the call
|
|
49
|
+
# id afterwards.
|
|
50
|
+
def pending(session_id)
|
|
51
|
+
events = open_turn(session_id)
|
|
52
|
+
resolved = events.filter_map { |e| e.payload[:call_id] if e.type == "approval/resolved" }
|
|
53
|
+
events.filter_map do |e|
|
|
54
|
+
next unless e.type == "approval/requested"
|
|
55
|
+
next if resolved.include?(e.payload[:call_id])
|
|
56
|
+
|
|
57
|
+
e.payload[:call_id]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def pending?(session_id, call_id) = pending(session_id).include?(call_id)
|
|
62
|
+
|
|
63
|
+
# Cancel's escape hatch: deny everything parked for a session, durably.
|
|
64
|
+
# Each denial is an ordinary approval/resolved append, so an in-process
|
|
65
|
+
# parked fiber unparks through the same listener as a socket verdict,
|
|
66
|
+
# and a restart-orphaned request settles for good.
|
|
67
|
+
def deny_pending!(session_id, reason: "cancelled")
|
|
68
|
+
pending(session_id).each do |call_id|
|
|
69
|
+
@ctx[:sessions].append(session_id, "approval/resolved",
|
|
70
|
+
{ call_id: call_id, verdict: "denied", reason: reason })
|
|
71
|
+
end
|
|
72
|
+
# The durable denials above wake one waiter per unique pending id
|
|
73
|
+
# through the resolved listener. A provider that reused a call id within
|
|
74
|
+
# one parallel batch parked more than one fiber on that id, though, and
|
|
75
|
+
# a cancel means all of them: sweep whatever is still parked for this
|
|
76
|
+
# session onto the denial they share, so no fiber is left holding the
|
|
77
|
+
# barrier open. Waiters the appends already woke are gone from the map,
|
|
78
|
+
# so this touches only the ones a single durable denial could not reach.
|
|
79
|
+
drain_session_waiters(session_id, { verdict: "denied", reason: reason })
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def gate(call, next_)
|
|
85
|
+
d = begin
|
|
86
|
+
@ctx[:tools].fetch(call.name)
|
|
87
|
+
rescue KeyError
|
|
88
|
+
nil # vanished tool: fall through — the base renders its recoverable error
|
|
89
|
+
end
|
|
90
|
+
return next_.(call) unless d && requires_approval?(d)
|
|
91
|
+
|
|
92
|
+
# Order is the contract: a verdict already in the log settles the call
|
|
93
|
+
# however it got there, so a resume inside a child honors the decision
|
|
94
|
+
# a human really made. Only a call with no answer at all reaches the
|
|
95
|
+
# unattended check, and only then does it fail closed.
|
|
96
|
+
verdict = recorded_verdict(call) || unattended_verdict(call) || park(call)
|
|
97
|
+
if verdict[:verdict] == "approved"
|
|
98
|
+
next_.(call)
|
|
99
|
+
else
|
|
100
|
+
reason = verdict[:reason] || "no reason given"
|
|
101
|
+
Result.new(id: call.id, content: nil, error: "#{call.name} denied: #{reason}")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Fail closed instead of deadlocking. A subagent's session cannot be
|
|
106
|
+
# reached by any approver: the parent's log never names it, so no socket
|
|
107
|
+
# is bound to it and no operator can answer a request they were never
|
|
108
|
+
# shown. Parking there would wait on a verdict that cannot arrive and
|
|
109
|
+
# would take the parent's turn — and the fiber running it — with it, so
|
|
110
|
+
# the call is denied with a reason the model can act on and report.
|
|
111
|
+
#
|
|
112
|
+
# Nothing durable is appended for the refusal. It is not a decision
|
|
113
|
+
# anybody made, and the tool/result the loop writes is already the
|
|
114
|
+
# permanent record of what happened to the call.
|
|
115
|
+
UNATTENDED_DENIAL = { verdict: "denied",
|
|
116
|
+
reason: "no approver can reach a subagent session" }.freeze
|
|
117
|
+
|
|
118
|
+
def unattended_verdict(call)
|
|
119
|
+
agent = @ctx[:loop].agent_for_session(call.session_id)
|
|
120
|
+
agent&.unattended ? UNATTENDED_DENIAL : nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# :always asks every time; :policy asks when the tool mutates (plan
|
|
124
|
+
# §13's spirit: mutation is what needs a human under policy); :never
|
|
125
|
+
# (the default) passes through.
|
|
126
|
+
def requires_approval?(d)
|
|
127
|
+
d.approval == :always || (d.approval == :policy && d.mutating)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Everything appended after the last turn/start, and nothing at all once
|
|
131
|
+
# that turn has closed. Approvals are per-turn state.
|
|
132
|
+
def open_turn(session_id)
|
|
133
|
+
events = @ctx[:sessions].fetch(session_id).events
|
|
134
|
+
opened = events.rindex { |e| e.type == "turn/start" }
|
|
135
|
+
return [] unless opened
|
|
136
|
+
|
|
137
|
+
turn = events[(opened + 1)..]
|
|
138
|
+
turn.any? { |e| e.type == "turn/end" } ? [] : turn
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A verdict already in the log (replay after a restart, or a decision
|
|
142
|
+
# that raced ahead of execution) settles the call without parking. The
|
|
143
|
+
# match is bound to content, not just to the call id: the latest request
|
|
144
|
+
# in the open turn naming this id, this tool, and these args, with a
|
|
145
|
+
# verdict for that id recorded after it. Provider tool call ids are not
|
|
146
|
+
# contractually unique, so an id reused in a later turn — or reused
|
|
147
|
+
# within one turn for a different call — must never inherit an old
|
|
148
|
+
# decision.
|
|
149
|
+
def recorded_verdict(call)
|
|
150
|
+
events = open_turn(call.session_id)
|
|
151
|
+
# Both compared in stored form, and hoisted out of the scan: a tool
|
|
152
|
+
# name is content like its args (the model chooses it), so a scrubber
|
|
153
|
+
# rewrites it on the way into the log and a raw comparison would never
|
|
154
|
+
# match again.
|
|
155
|
+
name = stored_form(call.name)
|
|
156
|
+
args = stored_form(call.args)
|
|
157
|
+
asked = events.rindex do |e|
|
|
158
|
+
e.type == "approval/requested" && e.payload[:call_id] == call.id &&
|
|
159
|
+
e.payload[:name] == name && e.payload[:args] == args
|
|
160
|
+
end
|
|
161
|
+
return nil unless asked
|
|
162
|
+
|
|
163
|
+
events[(asked + 1)..].find do |e|
|
|
164
|
+
e.type == "approval/resolved" && e.payload[:call_id] == call.id
|
|
165
|
+
end&.payload
|
|
166
|
+
rescue NonPrimitivePayload
|
|
167
|
+
# A value the log refuses has no stored form to compare against — a
|
|
168
|
+
# Time or some other object a plugin synthesized into args, which the
|
|
169
|
+
# JSON round trip this used to do coerced silently. That is a
|
|
170
|
+
# comparison this method cannot make, not a verdict it found, so it
|
|
171
|
+
# answers nil and the call parks. Park re-uses a standing request where
|
|
172
|
+
# there is one; with no standing request, park's own append of these
|
|
173
|
+
# same args raises there instead — which is what this path has always
|
|
174
|
+
# done with a value the log cannot store, and is left alone. Provider
|
|
175
|
+
# args are JSON primitives, so a model's own call never reaches here.
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Args reach the log through Sessions' primitives contract (symbol keys,
|
|
180
|
+
# symbols in value position stringified) AND through any registered
|
|
181
|
+
# scrubber, so the comparison above is against what was really stored.
|
|
182
|
+
# Asking Sessions rather than round-tripping JSON here is what keeps a
|
|
183
|
+
# redacted argument from making an approved call ask a second time:
|
|
184
|
+
# every rewrite the append applies has to be applied to this side too.
|
|
185
|
+
def stored_form(args) = @ctx[:sessions].stored_form(args)
|
|
186
|
+
|
|
187
|
+
def park(call)
|
|
188
|
+
q = Thread::Queue.new
|
|
189
|
+
token = nil
|
|
190
|
+
# waiter first, then the durable request: a verdict can never land in
|
|
191
|
+
# the gap between the append's fan-out and the waiter existing
|
|
192
|
+
@waiting_mutex.synchronize do
|
|
193
|
+
token = (@park_seq += 1)
|
|
194
|
+
@waiting[token] = { session_id: call.session_id, call_id: call.id, queue: q }
|
|
195
|
+
end
|
|
196
|
+
# ...and the gate's own lookup happened before that waiter existed, so
|
|
197
|
+
# a verdict landing in between signalled nothing. Look again now that
|
|
198
|
+
# a signal has somewhere to land, or the pop below waits forever.
|
|
199
|
+
if (raced = recorded_verdict(call))
|
|
200
|
+
return raced
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
agent = @ctx[:loop].agent_for_session(call.session_id)
|
|
204
|
+
unless pending?(call.session_id, call.id) # a resume re-parks on the standing request
|
|
205
|
+
@ctx[:sessions].append(call.session_id, "approval/requested",
|
|
206
|
+
{ call_id: call.id, name: call.name, args: call.args })
|
|
207
|
+
end
|
|
208
|
+
agent&.status = :waiting_approval
|
|
209
|
+
# cooperative under the fiber scheduler (parks the fiber); blocks the
|
|
210
|
+
# thread under plain minitest, where tests resolve from another thread
|
|
211
|
+
q.pop
|
|
212
|
+
ensure
|
|
213
|
+
@waiting_mutex.synchronize { @waiting.delete(token) } if token
|
|
214
|
+
restore(agent, call.session_id)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Wake exactly one waiter parked on this (session, call_id). One durable
|
|
218
|
+
# verdict is one decision, so it releases one park; a second verdict for a
|
|
219
|
+
# reused id releases the next. FIFO by insertion, so the call that parked
|
|
220
|
+
# first is the first one a verdict frees.
|
|
221
|
+
def wake_one(session_id, call_id, payload)
|
|
222
|
+
entry = @waiting_mutex.synchronize do
|
|
223
|
+
token, e = @waiting.find do |_t, w|
|
|
224
|
+
w[:session_id] == session_id && w[:call_id] == call_id
|
|
225
|
+
end
|
|
226
|
+
@waiting.delete(token) if token
|
|
227
|
+
e
|
|
228
|
+
end
|
|
229
|
+
entry && entry[:queue].push(payload)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Release every waiter still parked for a session onto one payload — the
|
|
233
|
+
# cancel path's escape hatch (deny_pending!), so a reused call id cannot
|
|
234
|
+
# leave a fiber holding the barrier open after the durable denials ran.
|
|
235
|
+
def drain_session_waiters(session_id, payload)
|
|
236
|
+
entries = @waiting_mutex.synchronize do
|
|
237
|
+
matched = @waiting.select { |_t, w| w[:session_id] == session_id }
|
|
238
|
+
matched.each_key { |t| @waiting.delete(t) }
|
|
239
|
+
matched.values
|
|
240
|
+
end
|
|
241
|
+
entries.each { |e| e[:queue].push(payload) }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# What the agent goes back to when a parked call comes out, derived from
|
|
245
|
+
# the LOG rather than from the label this park overwrote.
|
|
246
|
+
#
|
|
247
|
+
# A parallel run can park two calls at once (docs/subagents.md §5), and
|
|
248
|
+
# the fiber that unparks first must not announce a turn that is still
|
|
249
|
+
# waiting on a human: the socket reads this status to decide whether a
|
|
250
|
+
# cancel also has to deny_pending!, so a premature :running is a turn
|
|
251
|
+
# nobody can cancel and a sibling parked forever. While anything is
|
|
252
|
+
# still pending for the session, :waiting_approval stays true.
|
|
253
|
+
#
|
|
254
|
+
# And the restore that does happen is decided rather than left to
|
|
255
|
+
# whichever assignment runs last: a cancel requested while the call was
|
|
256
|
+
# parked has not stopped being true just because a verdict landed, so
|
|
257
|
+
# the fiber unparks into a turn that already knows it is stopping and
|
|
258
|
+
# the status says the same thing (docs/subagents.md §8).
|
|
259
|
+
def restore(agent, session_id)
|
|
260
|
+
return unless agent && agent.status == :waiting_approval
|
|
261
|
+
return unless pending(session_id).empty?
|
|
262
|
+
|
|
263
|
+
agent.status = agent.cancelled? ? :stopping : :running
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terret
|
|
4
|
+
# ctx[:compactor] — turns a long history into a short one without breaking
|
|
5
|
+
# "model-visible means logged" (§2.5): the summary is itself a durable
|
|
6
|
+
# event, and derive_messages projects it in place of everything at or
|
|
7
|
+
# before its boundary. The §12 contract: upto_seq is ALWAYS the seq
|
|
8
|
+
# immediately preceding the compaction event — the projection prepends the
|
|
9
|
+
# summary, so any gap would interleave it among events that predate it.
|
|
10
|
+
# The boundary is computed at append time, after the summarizer returns:
|
|
11
|
+
# only projection-invisible events can land during summarization (the
|
|
12
|
+
# agent is still mid-turn, so nothing model-visible can interleave).
|
|
13
|
+
#
|
|
14
|
+
# Summary GENERATION is a seam: ctx[:summarizer] (sole provider, like the
|
|
15
|
+
# session store). A summarizer may decline by returning nil/empty —
|
|
16
|
+
# compaction is an optimization, so a decline warns and the next
|
|
17
|
+
# overweight turn retries. A summarizer that raises inside the trigger is
|
|
18
|
+
# isolated by emit dispatch; a manual compact! raises through.
|
|
19
|
+
class Compactor < Hames::Service
|
|
20
|
+
service_key :compactor
|
|
21
|
+
inject :sessions, :summarizer
|
|
22
|
+
config_schema budget: { type: Integer,
|
|
23
|
+
doc: "token budget that triggers compaction; unset or falsy disables it" }
|
|
24
|
+
|
|
25
|
+
def start(ctx)
|
|
26
|
+
@ctx = ctx
|
|
27
|
+
@budget = config[:budget]
|
|
28
|
+
# Always registered, gated at fire time: a hot-set budget (reconfigure)
|
|
29
|
+
# arms the trigger without a remount.
|
|
30
|
+
ctx.on("session/event") do |ev|
|
|
31
|
+
next unless @budget && ev.type == "turn/end"
|
|
32
|
+
|
|
33
|
+
compact!(ev.session_id) if overweight?(ev.session_id)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reconfigure(config)
|
|
38
|
+
@budget = config[:budget]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Everything derive_messages projects. Anything else may land under a
|
|
42
|
+
# boundary without being summarized; these may not.
|
|
43
|
+
MODEL_VISIBLE = %w[user/message context/injected assistant/message
|
|
44
|
+
tool/result session/compacted].freeze
|
|
45
|
+
|
|
46
|
+
# Summarize the whole projected history and append the boundary event.
|
|
47
|
+
# Returns the appended SessionEvent, or nil when the summarizer declined.
|
|
48
|
+
def compact!(session_id)
|
|
49
|
+
sessions = @ctx[:sessions]
|
|
50
|
+
history = sessions.derive_messages(session_id)
|
|
51
|
+
raise ArgumentError, "nothing to compact in #{session_id}" if history.empty?
|
|
52
|
+
|
|
53
|
+
base_seq = sessions.fetch(session_id).events.last.seq
|
|
54
|
+
summary = @ctx[:summarizer].summarize(history)
|
|
55
|
+
unless summary.is_a?(String) && !summary.strip.empty?
|
|
56
|
+
warn "terret: compaction skipped for #{session_id}: summarizer declined"
|
|
57
|
+
return nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Summarizing is a round trip; the boundary covers the whole prefix, so
|
|
61
|
+
# model-visible history arriving while it ran would be swept under a
|
|
62
|
+
# summary that never read it. Decline instead — the next overweight turn
|
|
63
|
+
# retries, and nothing is lost in the meantime.
|
|
64
|
+
raced = sessions.fetch(session_id).events
|
|
65
|
+
.count { |e| e.seq > base_seq && MODEL_VISIBLE.include?(e.type) }
|
|
66
|
+
unless raced.zero?
|
|
67
|
+
warn "terret: compaction skipped for #{session_id}: " \
|
|
68
|
+
"#{raced} model-visible event(s) landed while summarizing"
|
|
69
|
+
return nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
sessions.append(session_id, "session/compacted",
|
|
73
|
+
{ upto_seq: sessions.fetch(session_id).events.last.seq,
|
|
74
|
+
summary: summary })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# The last step/end's prompt_tokens is what the next request will roughly
|
|
80
|
+
# cost before compaction; over budget means compact now, while idle.
|
|
81
|
+
def overweight?(session_id)
|
|
82
|
+
last = @ctx[:sessions].fetch(session_id).events.reverse_each
|
|
83
|
+
.find { |e| e.type == "step/end" }
|
|
84
|
+
tokens = last&.payload&.dig(:usage, :prompt_tokens)
|
|
85
|
+
!!(tokens && tokens >= @budget)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ctx[:summarizer], the no-signup default: one model call through the
|
|
90
|
+
# :compactor role (a config row away on any adapter). Raises KeyError when
|
|
91
|
+
# the role is unconfigured — set the role or mount a provider that doesn't
|
|
92
|
+
# need one (terret-morph).
|
|
93
|
+
class RoleSummarizer < Hames::Service
|
|
94
|
+
service_key :summarizer
|
|
95
|
+
inject :llm
|
|
96
|
+
config_schema role: { type: [String, Symbol], default: :compactor,
|
|
97
|
+
doc: "llm role a summary is produced under" }
|
|
98
|
+
|
|
99
|
+
PROMPT = <<~TEXT
|
|
100
|
+
Summarize the conversation so far for your own future context. Preserve
|
|
101
|
+
user goals and constraints, decisions made, tool results that still
|
|
102
|
+
matter, and open questions. Reply with one compact briefing only.
|
|
103
|
+
TEXT
|
|
104
|
+
|
|
105
|
+
def start(ctx)
|
|
106
|
+
@ctx = ctx
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def reconfigure(_config); end # :role is read per call — already live
|
|
110
|
+
|
|
111
|
+
def summarize(history)
|
|
112
|
+
request = LLM::Request.new(model: nil, system: PROMPT, messages: history, tools: [])
|
|
113
|
+
@ctx[:llm].stream(@ctx, role: config[:role] || :compactor, request: request) { |_ev| }.text
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
7
|
+
module Terret
|
|
8
|
+
# ctx[:credentials] — plan §6.9. The one place a provider's secret is
|
|
9
|
+
# resolved, and its security point: every value it resolves is fed to the
|
|
10
|
+
# session scrubber (Sessions#register_scrubber), so a resolved credential can
|
|
11
|
+
# never reach the durable log even if a tool echoes it back into a result.
|
|
12
|
+
# That is what makes this more than a lookup table — it closes the loop the
|
|
13
|
+
# config-pattern redactor (docs/exec.md §6) leaves open, catching a secret by
|
|
14
|
+
# its exact bytes rather than by a shape a deployment had to name in advance.
|
|
15
|
+
#
|
|
16
|
+
# Resolution order is ENV-first by convention (`<PROVIDER>_API_KEY`), then an
|
|
17
|
+
# optional encrypted file store. ENV ALWAYS wins; the file is consulted only
|
|
18
|
+
# when ENV is silent (unset or empty), and a file present with no master key
|
|
19
|
+
# REFUSES rather than falling back to anything unprotected.
|
|
20
|
+
#
|
|
21
|
+
# On-disk format (a deployment writes it; a `trt credentials set` CLI is
|
|
22
|
+
# future work, plan §14 — only the format is promised here): JSON at the
|
|
23
|
+
# configured `file:` path, mapping each provider name to
|
|
24
|
+
#
|
|
25
|
+
# Base64.strict_encode64( iv(12 bytes) || auth_tag(16 bytes) || ciphertext )
|
|
26
|
+
#
|
|
27
|
+
# each entry AES-256-GCM under a 32-byte master key. The master key is ENV
|
|
28
|
+
# `TERRET_CREDENTIALS_KEY`, itself Base64 of exactly 32 bytes — generate one
|
|
29
|
+
# with `openssl rand -base64 32`. Both the file and the key are optional: with
|
|
30
|
+
# neither, ENV `<PROVIDER>_API_KEY` resolves on its own and the file store is
|
|
31
|
+
# inert, which is the shipped default.
|
|
32
|
+
#
|
|
33
|
+
# Deferred (plan §14): an OS-keychain backend, and the writer CLI above.
|
|
34
|
+
class Credentials < Hames::Service
|
|
35
|
+
service_key :credentials
|
|
36
|
+
inject :sessions
|
|
37
|
+
config_schema file: { type: String,
|
|
38
|
+
doc: "path to an AES-256-GCM encrypted credential store, provider => " \
|
|
39
|
+
"base64(iv+tag+ciphertext); the master key is ENV " \
|
|
40
|
+
"TERRET_CREDENTIALS_KEY (base64 of 32 bytes) and the per-provider " \
|
|
41
|
+
"ENV <PROVIDER>_API_KEY always wins — neither is config, and with " \
|
|
42
|
+
"no file only ENV resolves" }
|
|
43
|
+
|
|
44
|
+
class Error < StandardError; end
|
|
45
|
+
|
|
46
|
+
IV_LEN = 12 # AES-GCM standard nonce
|
|
47
|
+
TAG_LEN = 16 # AES-GCM authentication tag
|
|
48
|
+
KEY_LEN = 32 # AES-256
|
|
49
|
+
|
|
50
|
+
# Below this an exact-string scrub would match far too much: the empty
|
|
51
|
+
# string inserts the token between every character, and a two-character
|
|
52
|
+
# value paints ordinary prose. A real resolved credential dwarfs this
|
|
53
|
+
# floor, so a value under it is still resolved and returned — it just does
|
|
54
|
+
# not become an active scrub pattern (fail-safe: never corrupt the log to
|
|
55
|
+
# chase a value too short to be a secret worth catching by its bytes).
|
|
56
|
+
MIN_SCRUB_LENGTH = 8
|
|
57
|
+
REPLACEMENT = "[REDACTED]"
|
|
58
|
+
|
|
59
|
+
def start(ctx)
|
|
60
|
+
@secrets = []
|
|
61
|
+
@mutex = Mutex.new
|
|
62
|
+
# ONE scrubber over the growing set rather than a fresh registration per
|
|
63
|
+
# resolve: re-resolving a provider must not stack duplicate scrubbers, and
|
|
64
|
+
# a scrubber reaches every append whether it was registered early or late.
|
|
65
|
+
ctx[:sessions].register_scrubber(method(:scrub))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reconfigure(_config); end # the file path is read per resolve
|
|
69
|
+
|
|
70
|
+
# The credential for a provider by name, or nil. ENV wins; then the file
|
|
71
|
+
# store when configured. A non-trivial resolved value is registered as an
|
|
72
|
+
# exact-string scrub pattern before it is returned, so anything that later
|
|
73
|
+
# echoes it into the log is caught.
|
|
74
|
+
def resolve(provider)
|
|
75
|
+
name = provider.to_s
|
|
76
|
+
env = ENV["#{env_name(name)}_API_KEY"]
|
|
77
|
+
value = (env unless env.nil? || env.empty?) || from_file(name)
|
|
78
|
+
remember(value) if value
|
|
79
|
+
value
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Exact-string, block-form gsub: never a Regexp a value's own metacharacters
|
|
85
|
+
# could turn into an over-match, and never gsub's STRING replacement (a `\0`
|
|
86
|
+
# inside a secret would paste the secret back in). Reads a snapshot under
|
|
87
|
+
# the lock so a concurrent resolve cannot mutate the set mid-fold.
|
|
88
|
+
def scrub(text)
|
|
89
|
+
@mutex.synchronize { @secrets.dup }.reduce(text) do |acc, secret|
|
|
90
|
+
acc.gsub(secret) { REPLACEMENT }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def remember(value)
|
|
95
|
+
return unless value.length >= MIN_SCRUB_LENGTH
|
|
96
|
+
|
|
97
|
+
@mutex.synchronize { @secrets << value unless @secrets.include?(value) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# openrouter => OPENROUTER; a hyphen or dot in a provider name becomes an
|
|
101
|
+
# underscore so the env var is a legal shell identifier.
|
|
102
|
+
def env_name(name) = name.upcase.gsub(/[^A-Z0-9]/, "_")
|
|
103
|
+
|
|
104
|
+
# nil when no file is configured or it does not exist; RAISES when the file
|
|
105
|
+
# exists but no master key is set — never a plaintext or unprotected
|
|
106
|
+
# fallback. Read fresh each resolve, so a deployment can rewrite the store
|
|
107
|
+
# without a remount.
|
|
108
|
+
def from_file(name)
|
|
109
|
+
store = file_store or return nil
|
|
110
|
+
entry = store[name] or return nil
|
|
111
|
+
|
|
112
|
+
blob = begin
|
|
113
|
+
Base64.strict_decode64(entry.to_s)
|
|
114
|
+
rescue ArgumentError
|
|
115
|
+
raise Error, "a credential store entry is not valid Base64 " \
|
|
116
|
+
"(a truncated or corrupted store); refusing to resolve"
|
|
117
|
+
end
|
|
118
|
+
decrypt(blob)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def file_store
|
|
122
|
+
path = config[:file]
|
|
123
|
+
return nil unless path && File.exist?(path)
|
|
124
|
+
|
|
125
|
+
unless master_key
|
|
126
|
+
raise Error, "credential store #{path.inspect} exists but TERRET_CREDENTIALS_KEY is not " \
|
|
127
|
+
"set; refusing to resolve credentials without the master key"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
parsed = JSON.parse(File.read(path))
|
|
131
|
+
raise Error, "credential store #{path.inspect} is not a JSON object" unless parsed.is_a?(Hash)
|
|
132
|
+
|
|
133
|
+
parsed
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# nil only when the key is absent/empty; a present-but-malformed key RAISES
|
|
137
|
+
# rather than reading as absent, so a typo refuses instead of silently
|
|
138
|
+
# skipping to a fallback.
|
|
139
|
+
def master_key
|
|
140
|
+
raw = ENV["TERRET_CREDENTIALS_KEY"]
|
|
141
|
+
return nil if raw.nil? || raw.empty?
|
|
142
|
+
|
|
143
|
+
key = begin
|
|
144
|
+
Base64.strict_decode64(raw)
|
|
145
|
+
rescue ArgumentError
|
|
146
|
+
raise Error, "TERRET_CREDENTIALS_KEY is not valid Base64"
|
|
147
|
+
end
|
|
148
|
+
unless key.bytesize == KEY_LEN
|
|
149
|
+
raise Error, "TERRET_CREDENTIALS_KEY must be Base64 of exactly #{KEY_LEN} bytes (an AES-256 key)"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
key
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def decrypt(blob)
|
|
156
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
|
|
157
|
+
cipher.key = master_key
|
|
158
|
+
cipher.iv = blob.byteslice(0, IV_LEN)
|
|
159
|
+
cipher.auth_tag = blob.byteslice(IV_LEN, TAG_LEN)
|
|
160
|
+
plaintext = cipher.update(blob.byteslice(IV_LEN + TAG_LEN..) || "") + cipher.final
|
|
161
|
+
plaintext.force_encoding(Encoding::UTF_8)
|
|
162
|
+
rescue OpenSSL::Cipher::CipherError, ArgumentError, TypeError => e
|
|
163
|
+
# CipherError is the wrong-key/tampered case; ArgumentError and TypeError
|
|
164
|
+
# are what a TRUNCATED blob raises before decryption even begins — an iv or
|
|
165
|
+
# auth tag of the wrong length, or a nil slice past the end of the bytes.
|
|
166
|
+
# All three mean the entry cannot be decrypted, and all three fail closed
|
|
167
|
+
# through the friendly Error rather than a raw crypto backtrace.
|
|
168
|
+
raise Error, "a credential store entry failed to decrypt " \
|
|
169
|
+
"(wrong master key, or a truncated or tampered store): #{e.message}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|