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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terret
|
|
4
|
+
# ctx[:subagents] — the delegation seam, sole-provider like
|
|
5
|
+
# ctx[:session_store] and ctx[:summarizer]. This is the fork provider; the
|
|
6
|
+
# seam, the other two providers plan §6.4 names, and the lifecycle below are
|
|
7
|
+
# docs/subagents.md §§1-3.
|
|
8
|
+
#
|
|
9
|
+
# ctx[:subagents].run(prompt:, ctx:) # => Result(text:, session_id:, usage:, status:)
|
|
10
|
+
#
|
|
11
|
+
# `ctx:` is the CALLING AGENT's context and it is an explicit argument
|
|
12
|
+
# rather than a service ivar for one reason: it is what makes the
|
|
13
|
+
# no-escalation guarantee structural. No path through this provider can
|
|
14
|
+
# build a child from the root, so a child inherits the caller's roster and
|
|
15
|
+
# its install-time policy FLOOR — an agent whose FLOOR is Read and Grep
|
|
16
|
+
# cannot ask a child to run Bash. The qualifier matters (docs/subagents.md
|
|
17
|
+
# §3): a policy hot-*narrowed* mid-session does not carry to children spawned
|
|
18
|
+
# after it, because the child's session is fresh and holds no policy/updated,
|
|
19
|
+
# so it runs at the floor rather than at the parent's live, narrowed set. A
|
|
20
|
+
# narrowing that must reach children belongs in the floor (a config row).
|
|
21
|
+
class Subagents < Hames::Service
|
|
22
|
+
service_key :subagents
|
|
23
|
+
inject :loop, :sessions
|
|
24
|
+
config_schema({}) # spawns delegated agents; takes no config of its own
|
|
25
|
+
|
|
26
|
+
# `status` is the child's TURN status — :completed, :cancelled, :rejected
|
|
27
|
+
# or :empty (a failure raises instead). A caller rendering the child's text
|
|
28
|
+
# needs it to tell "had nothing to say" from "was stopped", and re-reading
|
|
29
|
+
# the child's log for a fact the turn already returned is a worse seam.
|
|
30
|
+
Result = Data.define(:text, :session_id, :usage, :status)
|
|
31
|
+
|
|
32
|
+
def start(ctx)
|
|
33
|
+
@ctx = ctx
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Nothing is captured from config, so a swapped row governs the very next
|
|
37
|
+
# delegation with nothing to re-derive.
|
|
38
|
+
def reconfigure(_config); end
|
|
39
|
+
|
|
40
|
+
# Spawn a child inside a fork of the caller's context, run one turn to
|
|
41
|
+
# completion on a fresh durable session, and hand back what it said, where
|
|
42
|
+
# it said it, and what it cost.
|
|
43
|
+
#
|
|
44
|
+
# The child's session is FRESH, not `Sessions#fork`ed: a subagent inherits
|
|
45
|
+
# its parent's capabilities, not its parent's transcript.
|
|
46
|
+
#
|
|
47
|
+
# AgentCapExceeded raises straight through rather than being wrapped: a
|
|
48
|
+
# refused spawn is the caller's answer, not something that happened inside
|
|
49
|
+
# a child.
|
|
50
|
+
def run(prompt:, ctx:)
|
|
51
|
+
sessions = @ctx[:sessions]
|
|
52
|
+
loop_service = @ctx[:loop]
|
|
53
|
+
session = sessions.create
|
|
54
|
+
agent = loop_service.spawn_agent(session_id: session.id,
|
|
55
|
+
id: "subagent-#{session.id}", parent: ctx)
|
|
56
|
+
# Marked before the turn can start: nothing routes an approval request
|
|
57
|
+
# for this session to a human, so the gate must deny rather than park on
|
|
58
|
+
# a verdict that can never arrive. A parked child would hold the parent's
|
|
59
|
+
# fiber forever and there is no one to unstick it.
|
|
60
|
+
agent.unattended = true
|
|
61
|
+
begin
|
|
62
|
+
# The ordinary Loop: same steps, same MAX_STEPS ceiling, same pipeline,
|
|
63
|
+
# same approvals gate, same allow list. run_turn's own input path is
|
|
64
|
+
# what appends the prompt as a durable user/message.
|
|
65
|
+
status = loop_service.run_turn(agent, prompt)
|
|
66
|
+
Result.new(text: final_text(sessions, session.id), session_id: session.id,
|
|
67
|
+
usage: sessions.usage(session.id), status: status)
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
# A stack trace in a tool result is context the parent's model cannot
|
|
70
|
+
# act on and pays for on every subsequent request. The session id is
|
|
71
|
+
# the pointer to where the whole story actually is.
|
|
72
|
+
raise Tools::Failure,
|
|
73
|
+
"the subagent turn failed (#{e.class}); its session #{session.id} has the story"
|
|
74
|
+
ensure
|
|
75
|
+
dispose(loop_service, agent)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# The child's last word, projected from its log like every other
|
|
82
|
+
# model-visible fact. A turn that closed without one (rejected, empty, or
|
|
83
|
+
# cancelled before a first reply) answers nil, and the caller renders it.
|
|
84
|
+
def final_text(sessions, session_id)
|
|
85
|
+
sessions.derive_messages(session_id).reverse_each
|
|
86
|
+
.find { |m| m.role == :assistant }&.text
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Unconditional, including when the turn raised: the fork goes with the
|
|
90
|
+
# agent, so every tool, listener, and effect the child installed dies at
|
|
91
|
+
# the same moment. A disposal that itself fails must not replace the
|
|
92
|
+
# failure the caller is already being told about.
|
|
93
|
+
def dispose(loop_service, agent)
|
|
94
|
+
loop_service.dispose_agent(agent.id)
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
warn "terret: subagent #{agent.id} would not dispose: #{e.class}: #{e.message}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terret
|
|
4
|
+
# ctx[:titler] — one durable title per session (plan §6.2's titler role,
|
|
5
|
+
# §12 M6). Rides the first turn/end: generates through the :titler model
|
|
6
|
+
# role when the roles map has one, else falls back to the first user line
|
|
7
|
+
# truncated to 40 chars. session/titled is metadata — projection-invisible,
|
|
8
|
+
# like the approval and policy events — so titling can never disturb
|
|
9
|
+
# derived context. The :role knob is read per call, so reconfigure is live
|
|
10
|
+
# by construction.
|
|
11
|
+
class Titler < Hames::Service
|
|
12
|
+
service_key :titler
|
|
13
|
+
inject :sessions, :llm
|
|
14
|
+
config_schema role: { type: [String, Symbol], default: :titler,
|
|
15
|
+
doc: "llm role a title is generated under" }
|
|
16
|
+
|
|
17
|
+
PROMPT = "Title this conversation in at most six words. Reply with the title only."
|
|
18
|
+
|
|
19
|
+
def start(ctx)
|
|
20
|
+
@ctx = ctx
|
|
21
|
+
ctx.on("session/event") do |ev|
|
|
22
|
+
next unless ev.type == "turn/end"
|
|
23
|
+
next if @ctx[:sessions].title(ev.session_id)
|
|
24
|
+
|
|
25
|
+
title!(ev.session_id)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reconfigure(_config); end
|
|
30
|
+
|
|
31
|
+
# Append a title now (re-titling is the caller's explicit choice; the
|
|
32
|
+
# listener above only ever titles once). Returns the event, or nil when
|
|
33
|
+
# there is nothing to title.
|
|
34
|
+
def title!(session_id)
|
|
35
|
+
sessions = @ctx[:sessions]
|
|
36
|
+
history = sessions.derive_messages(session_id)
|
|
37
|
+
return if history.empty?
|
|
38
|
+
|
|
39
|
+
title = (generate(history) || fallback(sessions.fetch(session_id).events)).to_s.strip
|
|
40
|
+
return if title.empty?
|
|
41
|
+
|
|
42
|
+
sessions.append(session_id, "session/titled", { title: title[0, 80] })
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def generate(history)
|
|
48
|
+
request = LLM::Request.new(model: nil, system: PROMPT, messages: history, tools: [])
|
|
49
|
+
@ctx[:llm].stream(@ctx, role: config[:role] || :titler, request: request) { |_ev| }.text
|
|
50
|
+
rescue KeyError
|
|
51
|
+
nil # no :titler role configured — the fallback carries it
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fallback(events)
|
|
55
|
+
events.find { |e| e.type == "user/message" }&.payload&.[](:text)&.[](0, 40)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/terret/tools.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Terret
|
|
4
4
|
module Tools
|
|
5
5
|
Definition = Data.define(:name, :description, :params, :handler,
|
|
6
|
-
:mutating, :approval) do
|
|
6
|
+
:mutating, :approval, :concurrency) do
|
|
7
7
|
def schema = { name:, description:, parameters: params }
|
|
8
8
|
end
|
|
9
9
|
|
|
@@ -17,42 +17,311 @@ module Terret
|
|
|
17
17
|
# (truncate / redact).
|
|
18
18
|
class Registry < Hames::Service
|
|
19
19
|
service_key :tools
|
|
20
|
+
config_schema({}) # the tool registry takes no config
|
|
20
21
|
|
|
21
22
|
def start(ctx)
|
|
22
23
|
@ctx = ctx
|
|
23
24
|
@defs = {}
|
|
25
|
+
@floor = nil
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
# Returns the registration's disposer. The roster itself stays global
|
|
29
|
+
# (visibility is the AllowList's job, not this method's) — but the
|
|
30
|
+
# effect that puts a Definition in the roster is recorded on `ctx`,
|
|
31
|
+
# which defaults to the registry's own root and so preserves every
|
|
32
|
+
# existing call site. A caller that passes its forked agent ctx ties
|
|
33
|
+
# OWNERSHIP to that fork: disposing the agent reaps the registration,
|
|
34
|
+
# closing the M6-recorded bleed where an agent-registered tool (one
|
|
35
|
+
# that can carry filesystem authority) outlived the agent that made it.
|
|
26
36
|
def register(name:, description:, params: {}, mutating: false,
|
|
27
|
-
approval: :never, &handler)
|
|
37
|
+
approval: :never, concurrency: :serial, ctx: @ctx, &handler)
|
|
28
38
|
d = Definition.new(name: name.to_s, description:, params:, handler:,
|
|
29
|
-
mutating:, approval:)
|
|
30
|
-
|
|
39
|
+
mutating:, approval:, concurrency:)
|
|
40
|
+
ctx.effect do
|
|
31
41
|
@defs[d.name] = d
|
|
32
42
|
-> { @defs.delete(d.name) }
|
|
33
43
|
end
|
|
34
|
-
d
|
|
35
44
|
end
|
|
36
45
|
|
|
37
46
|
def schemas = @defs.values.map(&:schema)
|
|
38
47
|
def fetch(name) = @defs.fetch(name.to_s)
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
# Execution runs the three-waterfall pipeline: pre_execute (validate /
|
|
50
|
+
# veto / rewrite) -> execute (a provider may replace execution
|
|
51
|
+
# wholesale) -> post_execute (truncate / redact). Waterfalls dispatch
|
|
52
|
+
# on `ctx`, which callers set to the AGENT's forked context so
|
|
53
|
+
# per-agent policy listeners ride the fork (root listeners still run
|
|
54
|
+
# first — fork dispatch chains parent-first). ctx is required — a
|
|
55
|
+
# forgotten kwarg must fail loudly, not silently skip per-agent policy.
|
|
56
|
+
#
|
|
57
|
+
# One thing a handler is given beyond its arguments: a handler that
|
|
58
|
+
# declares a `session_id:` keyword receives the executing call's
|
|
59
|
+
# session. It is injected here and never model-supplied — it is not a
|
|
60
|
+
# property of any tool's params schema, and the merge in #handler_args
|
|
61
|
+
# puts the Call's own value LAST so an argument carrying that name
|
|
62
|
+
# cannot name somebody else's session.
|
|
63
|
+
def execute(call, ctx:)
|
|
64
|
+
admitted = ctx.waterfall("tools/pre_execute", call)
|
|
42
65
|
return Result.new(id: call.id, content: nil, error: admitted.reason) if admitted.is_a?(Veto)
|
|
43
66
|
|
|
44
|
-
|
|
45
|
-
|
|
67
|
+
# The deny-by-default floor is AUTHORITATIVE. It runs here — on the
|
|
68
|
+
# exact call the pre_execute waterfall admitted, rewrites included —
|
|
69
|
+
# not as a waterfall listener a sibling could register ahead of and
|
|
70
|
+
# short-circuit past. A pre_execute listener can make policy stricter
|
|
71
|
+
# (a Veto above), never looser: it cannot admit a tool the floor
|
|
72
|
+
# denies, because that admission never reaches execution. This is the
|
|
73
|
+
# autonomous safety mechanism (docs/security.md); a listener from a
|
|
74
|
+
# third-party bundle must not be able to defeat it.
|
|
75
|
+
if @floor && (veto = @floor.call(admitted)).is_a?(Veto)
|
|
76
|
+
return Result.new(id: call.id, content: nil, error: veto.reason)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
result = ctx.waterfall("tools/execute", admitted) do |c|
|
|
46
80
|
begin
|
|
47
|
-
|
|
81
|
+
d = fetch(c.name)
|
|
82
|
+
Result.new(id: c.id, content: d.handler.call(**handler_args(d, c)), error: nil)
|
|
83
|
+
rescue Failure => e
|
|
84
|
+
Result.new(id: c.id, content: nil, error: e.message)
|
|
48
85
|
rescue => e
|
|
49
86
|
Result.new(id: c.id, content: nil, error: "#{e.class}: #{e.message}")
|
|
50
87
|
end
|
|
51
88
|
end
|
|
52
|
-
|
|
89
|
+
ctx.waterfall("tools/post_execute", result)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Install the authoritative deny-by-default floor. The floor is a single
|
|
93
|
+
# predicate #execute consults on the admitted call, deliberately NOT a
|
|
94
|
+
# tools/pre_execute listener: a listener is a peer another row's listener
|
|
95
|
+
# can register ahead of and short-circuit past (the mount-pass bypass
|
|
96
|
+
# that defeated the floor), whereas this gate sees the call that will
|
|
97
|
+
# actually run and its Veto is final. The predicate answers a Veto to
|
|
98
|
+
# deny and anything else to admit. Recorded as an effect of the mounting
|
|
99
|
+
# row (ctx defaults to the Registry's own root), so unloading that row
|
|
100
|
+
# removes the floor; a second install replaces the first and disposal
|
|
101
|
+
# restores whatever it replaced. Only one floor is active — the
|
|
102
|
+
# deny-by-default policy is one floor, with per-session and per-agent
|
|
103
|
+
# variation layered above it (policy/updated and per-fork AllowLists).
|
|
104
|
+
def install_floor(ctx = @ctx, &predicate)
|
|
105
|
+
ctx.effect do
|
|
106
|
+
previous = @floor
|
|
107
|
+
# The floor is the autonomous safety mechanism, so a second install
|
|
108
|
+
# silently swapping it out is worth surfacing. Not refused — a
|
|
109
|
+
# legitimate re-mount or hot reconfigure disposes the old floor first
|
|
110
|
+
# (restoring @floor to nil) and then re-installs, so that path sees no
|
|
111
|
+
# previous floor and stays quiet; only a genuine replace-while-active
|
|
112
|
+
# warns.
|
|
113
|
+
if previous
|
|
114
|
+
warn "terret: install_floor replaced an active tool floor; the " \
|
|
115
|
+
"deny-by-default safety floor is now the one just installed"
|
|
116
|
+
end
|
|
117
|
+
@floor = predicate
|
|
118
|
+
-> { @floor = previous }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# A handler that declares a `session_id:` keyword is asking which
|
|
125
|
+
# session it is running for. That is the one fact about a call that is
|
|
126
|
+
# not in its args and cannot be recovered from anywhere else: a forked
|
|
127
|
+
# agent scope is an anonymous Context, so a tool owning per-session
|
|
128
|
+
# state — a persistent shell, a named terminal — has no other way to
|
|
129
|
+
# keep one agent's state out of another's.
|
|
130
|
+
#
|
|
131
|
+
# Only handlers that declare it are handed it, so every registration
|
|
132
|
+
# that does not care keeps its exact signature (an unknown keyword
|
|
133
|
+
# would raise). And the call's own session is merged LAST: a model that
|
|
134
|
+
# writes `session_id` into its arguments is trying to name somebody
|
|
135
|
+
# else's shell, and here that argument simply loses.
|
|
136
|
+
#
|
|
137
|
+
# Both keyword shapes count. A handler written as a block reports an
|
|
138
|
+
# optional keyword as `:key` and a required one as `:keyreq`, and a
|
|
139
|
+
# lambda handler reports `:keyreq` too — the tool that needs its
|
|
140
|
+
# session must not depend on which of the three forms it was written
|
|
141
|
+
# in.
|
|
142
|
+
def handler_args(definition, call)
|
|
143
|
+
return call.args unless wants_session?(definition.handler)
|
|
144
|
+
|
|
145
|
+
call.args.merge(session_id: call.session_id)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def wants_session?(handler)
|
|
149
|
+
handler.parameters.any? { |kind, name| name == :session_id && %i[key keyreq].include?(kind) }
|
|
53
150
|
end
|
|
54
151
|
end
|
|
55
152
|
|
|
56
153
|
Veto = Data.define(:reason)
|
|
154
|
+
|
|
155
|
+
# A domain failure whose message is the whole story: handlers raise it
|
|
156
|
+
# when the error is the tool's outcome, not a bug. Registry#execute logs
|
|
157
|
+
# it message-only; any other exception keeps its class name, because a
|
|
158
|
+
# crash's class is diagnostics, not noise.
|
|
159
|
+
Failure = Class.new(StandardError)
|
|
160
|
+
|
|
161
|
+
# Deny-by-default allow list (plan §6.3), hot-reloadable (§12 M6): the
|
|
162
|
+
# ACTIVE pattern set is the last durable policy/updated event in the
|
|
163
|
+
# call's session, falling back to the install-time patterns as the floor.
|
|
164
|
+
# update is an ordinary durable append — it takes effect on the very next
|
|
165
|
+
# call with no reinstall, and replay rebuilds it, so a hot-reloaded
|
|
166
|
+
# policy survives a restart while the floor only governs sessions that
|
|
167
|
+
# never updated. Patterns are File.fnmatch globs; matching is
|
|
168
|
+
# case-sensitive and "*" does not match dotfiles — both fail closed.
|
|
169
|
+
module AllowList
|
|
170
|
+
# A per-agent (or per-context) allow list as a tools/pre_execute listener.
|
|
171
|
+
# This is the right shape for an agent's OWN policy: it rides the agent's
|
|
172
|
+
# fork and can only make the effective policy STRICTER (a veto here stops
|
|
173
|
+
# the call). It is deliberately NOT the authoritative floor — a listener
|
|
174
|
+
# is a peer another listener can order itself ahead of. For the
|
|
175
|
+
# deny-by-default floor that no row's listener may bypass, see
|
|
176
|
+
# #install_floor.
|
|
177
|
+
def self.install(ctx, patterns)
|
|
178
|
+
floor = Array(patterns).map(&:to_s)
|
|
179
|
+
cache = new_cache
|
|
180
|
+
pre = ctx.on("tools/pre_execute") do |call, next_|
|
|
181
|
+
if admits?(ctx, call, floor, cache)
|
|
182
|
+
next_.(call)
|
|
183
|
+
else
|
|
184
|
+
Veto.new(reason: "#{call.name} is not on the allow list")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
inval = install_invalidation(ctx, cache)
|
|
188
|
+
|
|
189
|
+
# Composite: tear the gate and its invalidation down together. Both are
|
|
190
|
+
# already recorded as effects of this context (so fork.dispose! reaps
|
|
191
|
+
# them); this is the handle a caller pulls to remove its list early.
|
|
192
|
+
lambda do
|
|
193
|
+
pre.call
|
|
194
|
+
inval.call
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The authoritative deny-by-default floor (docs/composition.md §6,
|
|
199
|
+
# docs/security.md). It runs the SAME per-session, hot-reloadable decision
|
|
200
|
+
# as #install, but wired into ctx[:tools] as the Registry's floor gate
|
|
201
|
+
# rather than as a tools/pre_execute listener. That placement is the whole
|
|
202
|
+
# point: the floor mounts in a later loader pass than a no-inject row, so
|
|
203
|
+
# as a listener it sat BEHIND that row's listener in the waterfall and a
|
|
204
|
+
# listener that admitted a call without delegating short-circuited past
|
|
205
|
+
# it. As the gate, it runs after the waterfall on the call that will
|
|
206
|
+
# actually execute, so no listener any row registers can bypass it.
|
|
207
|
+
def self.install_floor(ctx, patterns)
|
|
208
|
+
floor = Array(patterns).map(&:to_s)
|
|
209
|
+
cache = new_cache
|
|
210
|
+
gate = ctx[:tools].install_floor(ctx) do |call|
|
|
211
|
+
Veto.new(reason: "#{call.name} is not on the allow list") unless admits?(ctx, call, floor, cache)
|
|
212
|
+
end
|
|
213
|
+
inval = install_invalidation(ctx, cache)
|
|
214
|
+
|
|
215
|
+
lambda do
|
|
216
|
+
gate.call
|
|
217
|
+
inval.call
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The shared decision, used by both the per-agent listener and the floor
|
|
222
|
+
# gate: does the ACTIVE policy for this call's session admit its tool
|
|
223
|
+
# name? Patterns are File.fnmatch globs; matching is case-sensitive and
|
|
224
|
+
# "*" does not match dotfiles — both fail closed.
|
|
225
|
+
def self.admits?(ctx, call, floor, cache)
|
|
226
|
+
active = active_patterns(ctx, call.session_id, cache) || floor
|
|
227
|
+
active.any? { |p| File.fnmatch(p, call.name) }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Per-install, never global: a fresh cache is a closure local of THIS
|
|
231
|
+
# install, so a forked agent scope, a hot policy swap, and the floor each
|
|
232
|
+
# get their own. Two installs sharing one would leak one agent's policy
|
|
233
|
+
# into another's — the cross-agent bleed this milestone closed. Keyed by
|
|
234
|
+
# session id; the value is the patterns from that session's last
|
|
235
|
+
# policy/updated, or nil for "no policy yet, fall to the floor" (nil is
|
|
236
|
+
# cached too, so a never-updated session also stops rescanning the log).
|
|
237
|
+
def self.new_cache = {}
|
|
238
|
+
|
|
239
|
+
# Log-first invalidation. The cache is a read-through of the durable log,
|
|
240
|
+
# never a second source of truth, so the ONLY write besides a miss is a
|
|
241
|
+
# policy/updated landing in the log. session/event is emitted on the
|
|
242
|
+
# context that mounts Sessions — the root of the fork chain, NOT a forked
|
|
243
|
+
# ctx — and a fork-registered listener would never see it, so we listen on
|
|
244
|
+
# root. Lifetime still follows the caller: wrapping root.on in ctx.effect
|
|
245
|
+
# records the teardown as an effect of THIS context, so disposing the
|
|
246
|
+
# agent (Loop#dispose_agent -> fork.dispose!) reaps the root listener too,
|
|
247
|
+
# and it also rides the composite disposer the callers return. Fan-out is
|
|
248
|
+
# synchronous and in seq order, so update's append has refreshed the entry
|
|
249
|
+
# before the next call reads it.
|
|
250
|
+
def self.install_invalidation(ctx, cache)
|
|
251
|
+
root = ctx
|
|
252
|
+
root = root.parent while root.parent
|
|
253
|
+
ctx.effect do
|
|
254
|
+
root.on("session/event") do |ev|
|
|
255
|
+
cache[ev.session_id] = ev.payload[:patterns] if ev.type == "policy/updated"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Hot update: durable, per-session, last one wins.
|
|
261
|
+
def self.update(ctx, session_id, patterns)
|
|
262
|
+
ctx[:sessions].append(session_id, "policy/updated",
|
|
263
|
+
{ patterns: Array(patterns).map(&:to_s) })
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Read-through cache over the log projection. A hit returns the cached
|
|
267
|
+
# patterns-or-nil without touching the log; a miss derives once and
|
|
268
|
+
# stores the result. Concurrency: under the fiber scheduler a fiber
|
|
269
|
+
# yields only at an await, and neither this read/write nor the
|
|
270
|
+
# session/event writer awaits between touching the Hash — so same-sid
|
|
271
|
+
# operations cannot interleave and distinct sids are independent; a plain
|
|
272
|
+
# Hash needs no lock. An unknown session raises KeyError out of the
|
|
273
|
+
# derivation before any scan and before anything is stored, so it is NOT
|
|
274
|
+
# cached: the call re-derives (and re-warns) each time, and a deny-all
|
|
275
|
+
# never ossifies into an allow.
|
|
276
|
+
def self.active_patterns(ctx, session_id, cache)
|
|
277
|
+
cache.fetch(session_id) { cache[session_id] = current_patterns(ctx, session_id) }
|
|
278
|
+
rescue KeyError
|
|
279
|
+
warn "terret: no policy readable for session #{session_id.inspect}; denying every tool call"
|
|
280
|
+
[]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The pure log derivation the cache reads through: the patterns of the
|
|
284
|
+
# last durable policy/updated in the session, or nil if it never updated.
|
|
285
|
+
# Raises KeyError for a session this context cannot read (handled in
|
|
286
|
+
# active_patterns), which is why the rescue lives there and not here.
|
|
287
|
+
def self.current_patterns(ctx, session_id)
|
|
288
|
+
ctx[:sessions].fetch(session_id).events.reverse_each
|
|
289
|
+
.find { |e| e.type == "policy/updated" }
|
|
290
|
+
&.payload&.[](:patterns)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# AllowList as a config row, so a bundle can ship the deny-by-default
|
|
295
|
+
# floor the way it ships everything else (docs/composition.md §6). The
|
|
296
|
+
# module above is still the mechanism and still installable by hand; this
|
|
297
|
+
# is only the mounting. Its registrations are already effects of the
|
|
298
|
+
# mounting row, so unloading the row takes the gate with it.
|
|
299
|
+
#
|
|
300
|
+
# `patterns:` is the floor — the policy governing sessions that never
|
|
301
|
+
# issued a policy/updated of their own. Unconfigured means an empty floor,
|
|
302
|
+
# which denies every tool call.
|
|
303
|
+
class AllowListFloor < Hames::Service
|
|
304
|
+
service_key :allow_list
|
|
305
|
+
inject :tools, :sessions
|
|
306
|
+
config_schema patterns: { type: Array, default: [],
|
|
307
|
+
doc: "tool-name globs the deny-by-default floor permits" }
|
|
308
|
+
|
|
309
|
+
def start(ctx)
|
|
310
|
+
@ctx = ctx
|
|
311
|
+
@disposer = AllowList.install_floor(ctx, config[:patterns] || [])
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Hot-reconfigure the floor. The base Service#reconfigure only warns, so a
|
|
315
|
+
# config/updated that TIGHTENS the floor (drops a pattern) would silently
|
|
316
|
+
# keep the looser patterns start captured — the deny-by-default policy
|
|
317
|
+
# left looser than the operator asked for. Tear the old gate and its
|
|
318
|
+
# invalidation down and re-install with the new patterns; this runs under
|
|
319
|
+
# with_owner(id), so the new registrations are owned by this row exactly
|
|
320
|
+
# as start's were, and take effect on the next call.
|
|
321
|
+
def reconfigure(config)
|
|
322
|
+
@disposer&.call
|
|
323
|
+
@disposer = AllowList.install_floor(@ctx, config[:patterns] || [])
|
|
324
|
+
end
|
|
325
|
+
end
|
|
57
326
|
end
|
|
58
327
|
end
|
data/lib/terret.rb
CHANGED
|
@@ -7,7 +7,7 @@ rescue LoadError
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
module Terret
|
|
10
|
-
VERSION = "0.1.
|
|
10
|
+
VERSION = "0.1.1"
|
|
11
11
|
|
|
12
12
|
# Event vocabulary. Durable events land in the session log; the rest are
|
|
13
13
|
# live extension points. Modes are the public contract (see docs/events.md).
|
|
@@ -28,7 +28,16 @@ module Terret
|
|
|
28
28
|
e.("tool/call", :emit, durable: true, doc: "tool invocation requested by model")
|
|
29
29
|
e.("tool/result", :emit, durable: true, doc: "tool outcome")
|
|
30
30
|
e.("context/injected", :emit, durable: true, doc: "agent.inject landed in a request")
|
|
31
|
+
e.("session/compacted", :emit, durable: true,
|
|
32
|
+
doc: "history up to upto_seq replaced by summary (still model-visible)")
|
|
33
|
+
e.("approval/requested", :emit, durable: true, doc: "tool call parked awaiting a decision")
|
|
34
|
+
e.("approval/resolved", :emit, durable: true, doc: "parked call decided (approve/deny)")
|
|
35
|
+
e.("policy/updated", :emit, durable: true,
|
|
36
|
+
doc: "agent allow-list replaced (metadata; projection-invisible)")
|
|
37
|
+
e.("session/titled", :emit, durable: true, doc: "session titled (metadata; projection-invisible)")
|
|
31
38
|
# live extension points
|
|
39
|
+
e.("agent/disposed", :emit, doc: "an agent was disposed (session_id); reap its session-keyed runtime state")
|
|
40
|
+
e.("config/updated", :emit, doc: "a row's config was hot-swapped (id, config)")
|
|
32
41
|
e.("session/event", :emit, doc: "fan-out of every durable append")
|
|
33
42
|
e.("agent/pre_step", :waterfall, doc: "rewrite or reject the claimed messages")
|
|
34
43
|
e.("agent/request", :waterfall, doc: "rewrite the outbound model request")
|
|
@@ -36,13 +45,21 @@ module Terret
|
|
|
36
45
|
e.("tools/pre_execute", :waterfall, doc: "validate, veto, or rewrite a call")
|
|
37
46
|
e.("tools/execute", :waterfall, doc: "a provider may replace execution")
|
|
38
47
|
e.("tools/post_execute", :waterfall, doc: "truncate/redact the result")
|
|
48
|
+
e.("fs/authorize", :waterfall, doc: "veto or admit an fs operation")
|
|
39
49
|
e.("agent/turn_stopping", :serial, doc: "ordered veto point before turn/end")
|
|
40
50
|
end
|
|
41
51
|
end
|
|
42
52
|
|
|
43
53
|
require_relative "terret/llm"
|
|
54
|
+
require_relative "terret/store"
|
|
44
55
|
require_relative "terret/sessions"
|
|
45
56
|
require_relative "terret/tools"
|
|
57
|
+
require_relative "terret/redactor"
|
|
58
|
+
require_relative "terret/credentials"
|
|
59
|
+
require_relative "terret/approvals"
|
|
60
|
+
require_relative "terret/compactor"
|
|
61
|
+
require_relative "terret/titler"
|
|
46
62
|
require_relative "terret/loop"
|
|
63
|
+
require_relative "terret/subagents"
|
|
47
64
|
|
|
48
65
|
Terret.declare_events!
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: terret-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Obie Fernandez
|
|
@@ -34,9 +34,16 @@ extensions: []
|
|
|
34
34
|
extra_rdoc_files: []
|
|
35
35
|
files:
|
|
36
36
|
- lib/terret.rb
|
|
37
|
+
- lib/terret/approvals.rb
|
|
38
|
+
- lib/terret/compactor.rb
|
|
39
|
+
- lib/terret/credentials.rb
|
|
37
40
|
- lib/terret/llm.rb
|
|
38
41
|
- lib/terret/loop.rb
|
|
42
|
+
- lib/terret/redactor.rb
|
|
39
43
|
- lib/terret/sessions.rb
|
|
44
|
+
- lib/terret/store.rb
|
|
45
|
+
- lib/terret/subagents.rb
|
|
46
|
+
- lib/terret/titler.rb
|
|
40
47
|
- lib/terret/tools.rb
|
|
41
48
|
homepage: https://terret.org
|
|
42
49
|
licenses:
|