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.
@@ -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
- @ctx.effect do
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
- def execute(call)
41
- admitted = @ctx.waterfall("tools/pre_execute", call)
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
- result = @ctx.waterfall("tools/execute", admitted) do |c|
45
- d = fetch(c.name)
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
- Result.new(id: c.id, content: d.handler.call(**c.args), error: nil)
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
- @ctx.waterfall("tools/post_execute", result)
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.0"
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.0
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: