mistri 0.4.0 → 0.5.0

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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # How a background child actually executes. The spawn tool hands every
5
+ # dispatcher two things: the serializable spec (name, session_id,
6
+ # parent_session_id, type, instructions, tool_names, model, workspace,
7
+ # task) and a runner closure that executes the child in this process with
8
+ # everything already reconstructed.
9
+ #
10
+ # In-process dispatchers just call the runner. A queue dispatcher ignores
11
+ # the runner, enqueues the spec, and its job reconstructs the pieces from
12
+ # host registries and calls SubAgent.run_dispatched:
13
+ #
14
+ # dispatcher: ->(spec, _runner) { ChildRunJob.perform_async(spec) }
15
+ #
16
+ # The runner closes over the spawn-time event sink, so in-process
17
+ # children keep streaming to whoever watched the spawn; that sink must
18
+ # outlive the parent's turn (a broadcast lambda does, a request-scoped
19
+ # object may not).
20
+ module Dispatchers
21
+ # Runs the child synchronously inside the spawn call and still answers
22
+ # in receipt form: background degrades gracefully where no concurrency
23
+ # exists (consoles, tests), and the receipt stays truthful because it
24
+ # reads the child's status after dispatch.
25
+ class Inline
26
+ def call(_spec, runner) = runner.call
27
+ end
28
+
29
+ # Real in-process background: the child runs on its own thread and the
30
+ # spawn call returns immediately. Enough for development and for hosts
31
+ # whose children are short; queue-backed hosts plug their own lambda.
32
+ class Thread
33
+ def call(spec, runner)
34
+ ::Thread.new do
35
+ runner.call
36
+ rescue StandardError => e
37
+ # The runner writes the child's failed terminal before re-raising;
38
+ # a thread must not die loudly into the process, but it must not
39
+ # die silently either, or there is no trace to debug from.
40
+ warn "mistri: background runner for #{spec["name"].inspect} crashed: " \
41
+ "#{e.class}: #{e.message}"
42
+ end
43
+ nil
44
+ end
45
+ end
46
+ end
47
+ end
data/lib/mistri/event.rb CHANGED
@@ -15,11 +15,18 @@ module Mistri
15
15
  # events; nil where nothing ran (denials, interruptions).
16
16
  class Event < Data.define(:type, :content_index, :delta, :content, :tool_call,
17
17
  :reason, :message, :error_message, :partial, :origin,
18
- :duration)
18
+ :duration, :attempt, :max_attempts, :delay,
19
+ :agent, :session_id, :status)
19
20
  # The stream types come from a provider mid-turn; the loop adds
20
21
  # :tool_result after it runs each tool, :approval_needed when a gated
21
- # call parks for a human, and :compacting/:compaction around a context
22
- # compaction, so one subscription sees the whole exchange.
22
+ # call parks for a human, :compacting/:compaction around a context
23
+ # compaction, and :retry (with attempt, max_attempts, delay) before it
24
+ # waits out a transient failure, so one subscription sees the whole
25
+ # exchange. :done and :error are loop-owned and terminal: only the
26
+ # accepted attempt's terminal event reaches the subscriber.
27
+ # :subagent_report announces a background child's terminal outcome
28
+ # (agent, session_id, status; content carries the report), so a UI that
29
+ # watched the spawn can settle the child's lane the moment it ends.
23
30
  TYPES = %i[
24
31
  start
25
32
  text_start text_delta text_end
@@ -29,11 +36,13 @@ module Mistri
29
36
  tool_result approval_needed
30
37
  compacting compaction
31
38
  retry
39
+ subagent_report
32
40
  ].freeze
33
41
 
34
42
  def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
35
43
  reason: nil, message: nil, error_message: nil, partial: nil, origin: nil,
36
- duration: nil)
44
+ duration: nil, attempt: nil, max_attempts: nil, delay: nil,
45
+ agent: nil, session_id: nil, status: nil)
37
46
  raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
38
47
 
39
48
  super
@@ -48,7 +57,8 @@ module Mistri
48
57
  # Partials are ephemeral streaming state and stay out of serialization.
49
58
  def to_h
50
59
  { type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
51
- reason:, message: message&.to_h, error_message:, origin:, duration: }.compact
60
+ reason:, message: message&.to_h, error_message:, origin:, duration:,
61
+ attempt:, max_attempts:, delay:, agent:, session_id:, status: }.compact
52
62
  end
53
63
  end
54
64
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Opt-in, exactly like Stores::ActiveRecord: the gem never requires Rails.
4
+ # require "mistri/locks/rails_cache".
5
+ module Mistri
6
+ module Locks
7
+ # Leases and flags over an ActiveSupport::Cache::Store (Redis-backed in
8
+ # any real deployment), so every process in the fleet reads the same
9
+ # truth. Pass any cache store duck; defaults to Rails.cache.
10
+ class RailsCache
11
+ def initialize(cache: nil, namespace: "mistri:locks")
12
+ @cache = cache || (defined?(Rails) && Rails.cache) ||
13
+ raise(ConfigurationError, "no cache store; pass cache: or configure Rails.cache")
14
+ @namespace = namespace
15
+ end
16
+
17
+ def acquire(key, ttl:)
18
+ @cache.write(scoped(key), true, unless_exist: true, expires_in: ttl)
19
+ end
20
+
21
+ def renew(key, ttl:)
22
+ @cache.write(scoped(key), true, expires_in: ttl)
23
+ nil
24
+ end
25
+
26
+ def release(key)
27
+ @cache.delete(scoped(key))
28
+ nil
29
+ end
30
+
31
+ def held?(key)
32
+ @cache.exist?(scoped(key))
33
+ end
34
+
35
+ def set_flag(key, ttl: 300)
36
+ renew(key, ttl: ttl)
37
+ end
38
+
39
+ def flag?(key) = held?(key)
40
+
41
+ def clear_flag(key) = release(key)
42
+
43
+ private
44
+
45
+ def scoped(key) = "#{@namespace}:#{key}"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Mistri
6
+ # Cross-process coordination for hosts that run loops in more than one
7
+ # process: leases that say "this is alive right now" and flags that carry
8
+ # one-bit requests (stop) between processes. Everything expires on its
9
+ # own, so a crashed holder never wedges anything; a live holder renews on
10
+ # a heartbeat, so only dead ones expire.
11
+ #
12
+ # Configure once at boot:
13
+ #
14
+ # Mistri.locks = Mistri::Locks::Memory.new # single process
15
+ # Mistri.locks = Mistri::Locks::RailsCache.new # requires opt-in file
16
+ #
17
+ # An adapter implements six methods: acquire(key, ttl:) -> bool,
18
+ # renew(key, ttl:), release(key), held?(key), set_flag(key, ttl:),
19
+ # flag?(key), clear_flag(key). With no adapter configured, everything
20
+ # lease-aware degrades gracefully: children read :running instead of
21
+ # :interrupted, and holds are no-ops.
22
+ module Locks
23
+ LEASE_TTL = 180
24
+ HEARTBEAT = 60
25
+
26
+ module_function
27
+
28
+ # Hold a lease for the duration of a block of work: acquire, renew on a
29
+ # heartbeat from a background thread, release on the way out. Returns a
30
+ # Hold to release in an ensure, or nil when no adapter is configured or
31
+ # the lease is already held elsewhere: a caller that was refused must
32
+ # never renew or delete the real holder's key. Callers that need to
33
+ # tell refusal apart from no-adapter use the adapter's acquire directly.
34
+ #
35
+ # With stop_key: and signal:, the same thread watches the flag and trips
36
+ # the signal within a tick, so a stop request written in another process
37
+ # becomes this run's cooperative abort.
38
+ def hold(key, ttl: LEASE_TTL, heartbeat: HEARTBEAT, stop_key: nil, signal: nil)
39
+ adapter = Mistri.locks
40
+ return nil unless adapter
41
+ return nil unless adapter.acquire(key, ttl: ttl)
42
+
43
+ Hold.new(adapter, key, ttl, heartbeat, stop_key: stop_key, signal: signal)
44
+ end
45
+
46
+ # A held lease: one thread renews it on the heartbeat and watches the
47
+ # stop flag between renewals. Renewal runs on a monotonic deadline (the
48
+ # last sleep before it is the remaining fraction, never a full tick, so
49
+ # the cadence is exact and a lease can never lapse waiting for a tick to
50
+ # round up). release stops the thread (and joins it, so a mid-renewal
51
+ # tick can never re-stamp a lease that was just released) and deletes
52
+ # the key.
53
+ class Hold
54
+ def initialize(adapter, key, ttl, heartbeat, stop_key: nil, signal: nil)
55
+ @adapter = adapter
56
+ @key = key
57
+ @stopping = false
58
+ @thread = Thread.new do
59
+ deadline = now + heartbeat
60
+ until @stopping
61
+ sleep (deadline - now).clamp(0.01, 1.0)
62
+ break if @stopping
63
+
64
+ signal.abort!("stopped by user") if stop_key && signal && adapter.flag?(stop_key)
65
+ if now >= deadline
66
+ adapter.renew(key, ttl: ttl)
67
+ deadline = now + heartbeat
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ def release
74
+ @stopping = true
75
+ @thread.kill
76
+ @thread.join(2)
77
+ @adapter.release(@key)
78
+ end
79
+
80
+ private
81
+
82
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
83
+ end
84
+
85
+ # The in-process adapter: real TTL semantics over a hash, for tests,
86
+ # development, and the thread dispatcher. Monitor-synchronized; expiry
87
+ # is judged at read time, so nothing needs a sweeper.
88
+ class Memory
89
+ def initialize
90
+ @entries = {}
91
+ @lock = Monitor.new
92
+ end
93
+
94
+ def acquire(key, ttl:)
95
+ @lock.synchronize do
96
+ return false if live?(key)
97
+
98
+ @entries[key] = deadline(ttl)
99
+ true
100
+ end
101
+ end
102
+
103
+ def renew(key, ttl:)
104
+ @lock.synchronize { @entries[key] = deadline(ttl) }
105
+ nil
106
+ end
107
+
108
+ def release(key)
109
+ @lock.synchronize { @entries.delete(key) }
110
+ nil
111
+ end
112
+
113
+ def held?(key)
114
+ @lock.synchronize { live?(key) }
115
+ end
116
+
117
+ def set_flag(key, ttl: 300)
118
+ renew(key, ttl: ttl)
119
+ end
120
+
121
+ def flag?(key) = held?(key)
122
+
123
+ def clear_flag(key) = release(key)
124
+
125
+ private
126
+
127
+ def live?(key)
128
+ deadline = @entries[key]
129
+ return false unless deadline
130
+ return true if deadline > now
131
+
132
+ @entries.delete(key)
133
+ false
134
+ end
135
+
136
+ def deadline(ttl) = now + ttl
137
+
138
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
139
+ end
140
+ end
141
+ end
@@ -24,7 +24,7 @@ module Mistri
24
24
  # One client serializes its calls; parallel tool calls against one
25
25
  # server queue rather than interleave.
26
26
  class Client
27
- PROTOCOL_VERSION = "2025-06-18"
27
+ PROTOCOL_VERSION = "2025-11-25"
28
28
  SUPPORTED_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
29
29
  LOOPBACK = %w[localhost 127.0.0.1 ::1].freeze
30
30
 
data/lib/mistri/mcp.rb CHANGED
@@ -5,7 +5,7 @@ require "json"
5
5
  module Mistri
6
6
  # Bridge Model Context Protocol servers into Mistri tools: list a server's
7
7
  # tools, hand them to an agent, and everything the harness already does
8
- # composes approval gates on third-party write tools, retries, sub-agent
8
+ # composes: approval gates on third-party write tools, retries, sub-agent
9
9
  # pools, the ui channel.
10
10
  #
11
11
  # client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
@@ -67,18 +67,32 @@ module Mistri
67
67
  kind == :text ? Content::Text.new(text:) : Content::Thinking.new(thinking: text)
68
68
  end
69
69
 
70
+ # Arguments stream in chunks, and every delta's partial carries the
71
+ # in-progress call with the arguments parsed so far, the same shape a
72
+ # real assembler builds, so a consumer that renders tool input as it
73
+ # arrives (a page preview, a code block) is testable headless.
70
74
  def stream_tool_call(spec, position, blocks, emit)
71
75
  spec = spec.transform_keys(&:to_sym)
72
76
  call = ToolCall.new(id: spec[:id] || "call_#{position + 1}", name: spec[:name],
73
77
  arguments: (spec[:arguments] || {}).transform_keys(&:to_s))
74
78
  index = blocks.size
75
79
  emit_event(emit, :toolcall_start, blocks, content_index: index)
76
- emit_event(emit, :toolcall_delta, blocks, content_index: index,
77
- delta: JSON.generate(call.arguments))
80
+ built = +""
81
+ JSON.generate(call.arguments).scan(/.{1,#{@chunk_size}}/m) do |chunk|
82
+ built << chunk
83
+ emit_event(emit, :toolcall_delta, blocks + [in_progress(call, built)],
84
+ content_index: index, delta: chunk)
85
+ end
78
86
  blocks << call
79
87
  emit_event(emit, :toolcall_end, blocks, content_index: index, tool_call: call)
80
88
  end
81
89
 
90
+ def in_progress(call, json)
91
+ parsed = PartialJson.parse(json)
92
+ ToolCall.new(id: call.id, name: call.name,
93
+ arguments: parsed.is_a?(Hash) ? parsed : {})
94
+ end
95
+
82
96
  def finish(turn, blocks, emit)
83
97
  reason = turn[:stop_reason] ||
84
98
  (blocks.any?(ToolCall) ? StopReason::TOOL_USE : StopReason::STOP)
data/lib/mistri/result.rb CHANGED
@@ -12,8 +12,12 @@ module Mistri
12
12
  # output is a task's validated value, nil on plain runs. usage is the
13
13
  # run's own accounting: every persisted turn plus compaction calls, summed
14
14
  # (a resumed run counts from the resume; task sums across its fix passes).
15
- Result = Data.define(:message, :status, :pending, :output, :usage) do
16
- def initialize(message:, status:, pending: [], output: nil, usage: Usage.zero)
15
+ # handed_off marks a run that ended because an ends_turn tool executed:
16
+ # complete, but the final word was the tool's, and whatever comes next (a
17
+ # human's answer) arrives as the next run's input.
18
+ Result = Data.define(:message, :status, :pending, :output, :usage, :handed_off) do
19
+ def initialize(message:, status:, pending: [], output: nil, usage: Usage.zero,
20
+ handed_off: false)
17
21
  super
18
22
  end
19
23
 
@@ -22,6 +26,7 @@ module Mistri
22
26
  def aborted? = status == :aborted
23
27
  def stopped_by_budget? = status == :budget
24
28
  def errored? = status == :error
29
+ def handed_off? = handed_off
25
30
 
26
31
  def text = message&.text
27
32
  def stop_reason = message&.stop_reason
@@ -4,8 +4,8 @@ module Mistri
4
4
  # When a failed turn is worth retrying, and how long to wait. Transient
5
5
  # failures (rate limits, overload, server errors, timeouts, dropped or
6
6
  # truncated streams) retry with jittered exponential backoff, honoring the
7
- # provider's retry-after when it sent one. Everything else auth, invalid
8
- # requests, our own bugs fails fast.
7
+ # provider's retry-after when it sent one. Everything else (auth, invalid
8
+ # requests, our own bugs) fails fast.
9
9
  #
10
10
  # attempts counts retries, not calls: attempts 3 means up to four requests.
11
11
  class RetryPolicy
@@ -21,10 +21,25 @@ module Mistri
21
21
  @max_delay = max_delay
22
22
  end
23
23
 
24
+ # What retries see when a completion answers nothing at all.
25
+ EMPTY_COMPLETION = {
26
+ "type" => "EmptyCompletion",
27
+ "message" => "the provider returned an empty completion"
28
+ }.freeze
29
+
24
30
  def retry?(error, attempt)
25
31
  attempt <= attempts && retryable?(error)
26
32
  end
27
33
 
34
+ # The error a finished attempt carries: the provider's own error, or a
35
+ # synthesized one for a completion that answers nothing at all, which a
36
+ # retry usually clears.
37
+ def error_for(message)
38
+ return message.error if message.stop_reason == StopReason::ERROR
39
+
40
+ EMPTY_COMPLETION if empty?(message)
41
+ end
42
+
28
43
  # error is the ErrorData hash from an errored message. A status decides
29
44
  # when present; otherwise only known-transient types retry, so schema
30
45
  # violations and host bugs never loop.
@@ -43,5 +58,12 @@ module Mistri
43
58
  exponential = base * (2**(attempt - 1))
44
59
  (exponential * rand(0.5..1.0)).clamp(0.0, max_delay)
45
60
  end
61
+
62
+ private
63
+
64
+ def empty?(message)
65
+ message.stop_reason == StopReason::STOP &&
66
+ message.content.all? { |block| block.is_a?(Content::Text) && block.text.to_s.strip.empty? }
67
+ end
46
68
  end
47
69
  end
@@ -66,6 +66,11 @@ module Mistri
66
66
  entries.reverse_each.find { |entry| entry["type"] == "compaction" }
67
67
  end
68
68
 
69
+ # The inbox: entry types queued for the loop's next turn boundary, each
70
+ # mapped to the marker key its fold leaves on the consuming message
71
+ # entry.
72
+ INBOX = { "steer" => "steer_id", "subagent_report" => "report_id" }.freeze
73
+
69
74
  # Queue a message for a running exchange from any process. The loop folds
70
75
  # pending steers into the transcript at the next turn boundary, so the
71
76
  # model sees them mid-run; one that arrives as the model finishes cleanly
@@ -74,12 +79,64 @@ module Mistri
74
79
  append("steer", "id" => SecureRandom.uuid, "message" => Message.user(text).to_h)
75
80
  end
76
81
 
77
- # Steers not yet folded into the transcript, oldest first. The folding
78
- # message entry carries the steer id, so consumption is derived from the
79
- # log alone and reads the same from every process.
82
+ # A sub-agent's report, queued for this session the way a steer is: it
83
+ # folds into the transcript at the next turn boundary as a labeled block
84
+ # the model can react to ("[Magpie finished] <report>"), while the typed
85
+ # entry keeps name, status, and the raw text for hosts to render as a
86
+ # report card rather than a fake user message. One report per child,
87
+ # ever: a duplicate delivery (a redelivered queue job, a lease race) is
88
+ # dropped, and the return says which happened. Reports normally arrive
89
+ # via SubAgent.run_dispatched; call this directly only from a custom
90
+ # dispatch path.
91
+ def deliver_report(name:, session_id:, status:, text: nil) # rubocop:disable Naming/PredicateMethod
92
+ already = entries.any? do |entry|
93
+ entry["type"] == "subagent_report" && entry["session_id"] == session_id
94
+ end
95
+ return false if already
96
+
97
+ append("subagent_report", "id" => SecureRandom.uuid, "name" => name,
98
+ "session_id" => session_id, "status" => status, "report" => text,
99
+ "message" => Message.user(report_label(name, status, text)).to_h)
100
+ true
101
+ end
102
+
103
+ # Everything queued for the loop's next turn boundary (steers and
104
+ # sub-agent reports), oldest first, in arrival order. The folding
105
+ # message entry carries the source entry's id under its marker key, so
106
+ # consumption is derived from the log alone and reads the same from
107
+ # every process. A host that wakes an idle session when a steer arrives
108
+ # should watch this instead: a report deserves the same pickup.
109
+ def pending_inbox
110
+ log = entries
111
+ folded = log.flat_map { |entry| entry.values_at(*INBOX.values) }.compact.to_set
112
+ log.select { |entry| INBOX.key?(entry["type"]) && !folded.include?(entry["id"]) }
113
+ end
114
+
115
+ # The steer-only slice of the inbox, oldest first.
80
116
  def pending_steers
81
- folded = entries.filter_map { |entry| entry["steer_id"] }
82
- entries.select { |entry| entry["type"] == "steer" && !folded.include?(entry["id"]) }
117
+ pending_inbox.select { |entry| entry["type"] == "steer" }
118
+ end
119
+
120
+ # Sub-agents this session has spawned, in spawn order: one Child window
121
+ # per link entry, each reading the child's own session. Derived from the
122
+ # log alone, like everything else here.
123
+ def children
124
+ entries.filter_map do |entry|
125
+ next unless entry["type"] == "subagent"
126
+
127
+ Child.new(name: entry["name"], session_id: entry["session_id"], store: @store)
128
+ end
129
+ end
130
+
131
+ # The session as a reader renders it: entries in order with inline image
132
+ # bytes stripped, and, with include_children, every sub-agent's own
133
+ # transcript spliced in after its link entry. Spliced entries carry an
134
+ # "origin" key shaped exactly like the live stream's event origins
135
+ # ("Magpie#ab12cd34", nesting joined with ">"), so a UI that rebuilds
136
+ # from this sees what it saw live, lanes included, running children's
137
+ # progress-so-far included. The raw log stays available as #entries.
138
+ def transcript(include_children: false)
139
+ splice(entries, include_children: include_children, prefix: nil, seen: Set[@id])
83
140
  end
84
141
 
85
142
  # Record a human's decision on a parked tool call. Decisions are session
@@ -137,6 +194,39 @@ module Mistri
137
194
  Message.user("#{Compaction::SUMMARY_PREFACE}\n\n#{summary}")
138
195
  end
139
196
 
197
+ # Each link entry opens its child's log in place, depth first, the way
198
+ # nested lanes opened live. The seen set makes expansion idempotent per
199
+ # child (a repeated or self-referencing link renders but never expands
200
+ # twice), so a hostile log cannot loop this.
201
+ def splice(log, include_children:, prefix:, seen:)
202
+ log.flat_map do |entry|
203
+ rendered = Child.strip_images(entry)
204
+ rendered = rendered.merge("origin" => prefix) if prefix
205
+ next [rendered] unless include_children && expandable?(entry, seen)
206
+
207
+ seen << entry["session_id"]
208
+ origin = "#{entry["name"]}##{entry["session_id"][0, 8]}"
209
+ origin = "#{prefix}>#{origin}" if prefix
210
+ child_log = self.class.new(store: @store, id: entry["session_id"]).entries
211
+ [rendered, *splice(child_log, include_children: true, prefix: origin, seen: seen)]
212
+ end
213
+ end
214
+
215
+ def expandable?(entry, seen)
216
+ entry["type"] == "subagent" && !seen.include?(entry["session_id"])
217
+ end
218
+
219
+ # How a report reads to the model: labeled with the worker's name and
220
+ # fate, so the parent knows exactly who finished and how.
221
+ def report_label(name, status, text)
222
+ case status
223
+ when "done" then "[#{name} finished] #{text}"
224
+ when "failed" then "[#{name} failed] #{text}"
225
+ when "stopped" then "[#{name} was stopped]"
226
+ else "[#{name} ended: #{status}]"
227
+ end
228
+ end
229
+
140
230
  def decide(call_id, approved:, note:)
141
231
  unless open_approvals.any? { |open| open[:call].id == call_id }
142
232
  raise ConfigurationError, "no open approval for #{call_id.inspect}"
data/lib/mistri/skill.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Mistri
4
4
  # One expert playbook: a name the model selects by, a description that
5
5
  # earns the selection, and the full body it reads before acting. Build
6
- # them from anywhere Skills.load reads a directory, and a host with
6
+ # them from anywhere: Skills.load reads a directory, and a host with
7
7
  # skills in a database constructs these directly.
8
8
  Skill = Data.define(:name, :description, :body) do
9
9
  def initialize(name:, description: "", body: "")
data/lib/mistri/skills.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Mistri
4
4
  # Loads skills and wires them into an agent: their one-line descriptions
5
5
  # ride the system prompt, and the model pulls a full body on demand with
6
- # the read_skill tool so a large library costs almost nothing until a
6
+ # the read_skill tool, so a large library costs almost nothing until a
7
7
  # skill is actually used.
8
8
  module Skills
9
9
  module_function