mistri 0.6.1 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1414bffbf49921cf9e575ea1a8998cfc435b77325d80d55ed76e6564d63311c5
4
- data.tar.gz: b707010a75d56d65a8c42b50a874807e0f529da6b5878246aa9b34239493c808
3
+ metadata.gz: 87b42895bd43efd658a542312836abdd62a3ee7b18ac9d2a3e289cef6b7752f3
4
+ data.tar.gz: 413e5fa28d123c5e8a6ec39e332149e4aaf5d6f0587a9c3947032a6a4bc83c2a
5
5
  SHA512:
6
- metadata.gz: b7781114db5365b8ce899af9ab50b8eaf2279979d8110a282d54a4fb3187a2576fc94821c3ddb550e9af3da8052ad64b14310c5136d3a064044aee57418c209b
7
- data.tar.gz: d87fb8db281d3c6cedaea995054723f4f15d62e5268384393e16d655dfd45ba714e4b276c73c378307e8b3314ecec1108b3d320578edd659d77e6d07a8e34e2f
6
+ metadata.gz: 36e337d37f8fa876bbb52ea4fcfe0d41f66bed3028acf84871e0fe9c6d743cb31a3916a1dea63235ff03fe1170731e134abb76f206e335b2f63e63f064e7086f
7
+ data.tar.gz: 45fb19f616d8fd5f4f90220eee76d807ce17ebd371bd4b36741c61f35156309664d986b3f020e36d1329748015250136406aa869d539e02339af1c3529c9cac0
data/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.0] - 2026-07-30
9
+
10
+ - `Mistri.logger` and `Mistri::Sinks::Logger`: assign any Logger-compatible
11
+ object and every run logs its story as one scannable line per beat, tagged
12
+ by session (sub-agents get their worker label): the input, each tool call
13
+ and result with a short pairing id, arguments, duration, and result size,
14
+ each turn's token usage with cached prompt tokens called out, retries,
15
+ compaction, approvals (carrying the full call id `Session#approve` takes),
16
+ worker reports wherever a sink watched the spawn, and a closing line with
17
+ the run's outcome, elapsed time, turn count, and dollar cost whenever
18
+ pricing is known, a known $0.0000 included. `task` logs one frame around
19
+ all its fix passes, and the frame covers the whole public method, so store
20
+ and audit failures get a crash line too. Payloads log in full by default;
21
+ `content: false` keeps the same story as metadata only, error, crash, and
22
+ retry free text included, since exception messages can echo payloads. `level:` floors
23
+ the ordinary lines (warnings and errors keep their own levels, and
24
+ expected stops such as cancels and budget ceilings log calmly), `color:`
25
+ and `truncate:` are per-sink options, and a preconfigured `Sinks::Logger`
26
+ (subclasses included) can be assigned directly; wrong assignments and
27
+ options raise at configuration time. Sinks the Agent builds skip origin-tagged events so every agent, in
28
+ any process, logs its own events exactly once; a sink composed directly
29
+ per run renders forwarded child events with their origin instead. Logging
30
+ never breaks a run: sink construction and every write are contained,
31
+ failures warn once per run and the run's sink goes quiet, host exceptions
32
+ are never replaced (invalid byte sequences included), and hidden bytes in
33
+ payloads, names, ids, and labels render as visible escapes so untrusted
34
+ output cannot forge lines or steer a terminal. With no logger assigned
35
+ the event path is untouched (one nil check per run, nothing per event);
36
+ with one assigned, delta events short-circuit without allocating,
37
+ formatting is skipped when the floor level is disabled, and per-line
38
+ string work, tool argument keys and values included, is bounded by the
39
+ truncation limit, not the payload.
40
+
8
41
  ## [0.6.1] - 2026-07-21
9
42
 
10
43
  - The ActiveRecord store and workspace read past the host's query cache.
@@ -882,7 +915,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
882
915
 
883
916
  - Reserved the gem name.
884
917
 
885
- [Unreleased]: https://github.com/mcheemaa/mistri/compare/v0.6.1...HEAD
918
+ [Unreleased]: https://github.com/mcheemaa/mistri/compare/v0.7.0...HEAD
919
+ [0.7.0]: https://github.com/mcheemaa/mistri/compare/v0.6.1...v0.7.0
886
920
  [0.6.1]: https://github.com/mcheemaa/mistri/compare/v0.6.0...v0.6.1
887
921
  [0.6.0]: https://github.com/mcheemaa/mistri/compare/v0.5.0...v0.6.0
888
922
  [0.5.0]: https://github.com/mcheemaa/mistri/compare/v0.4.1...v0.5.0
data/README.md CHANGED
@@ -231,6 +231,64 @@ The same event stream works in Sinatra, Rack, a WebSocket server, a background
231
231
  job, or a test. Event types form an extensible union; consumers should handle
232
232
  the types they use and ignore the rest.
233
233
 
234
+ ## Logging
235
+
236
+ One assignment makes every run tell its story in your log:
237
+
238
+ ```ruby
239
+ Mistri.logger = Rails.logger # or any Logger-compatible object
240
+ ```
241
+
242
+ ```text
243
+ [mistri 0a1b2c3d] run "What is 2 plus 3?" (gpt-5.6, 3 tools)
244
+ [mistri 0a1b2c3d] turn 1 done tool_use (312 in / 48 out)
245
+ [mistri 0a1b2c3d] tool add#7f3a {"a":2,"b":3}
246
+ [mistri 0a1b2c3d] tool add#7f3a ok 12ms "5"
247
+ [mistri 0a1b2c3d] text "The sum is 5."
248
+ [mistri 0a1b2c3d] turn 2 done stop (410 in / 22 out)
249
+ [mistri 0a1b2c3d] done completed in 1.2s, 2 turns, 722 in / 70 out, $0.0042
250
+ ```
251
+
252
+ A turn line lands when the model finishes speaking, so it precedes the tool
253
+ executions that turn requested. The short `#id` on tool lines pairs
254
+ concurrent same-name calls; an `approval needed` line carries the full call
255
+ id, which is what `session.approve` takes. Failed tools and retries log at
256
+ warn and provider errors at error, while expected stops (a cancel, a budget
257
+ ceiling) log calmly. Cached prompt tokens count toward "in" and are called
258
+ out, dollar cost appears whenever pricing is known (including a known
259
+ $0.0000), and hidden bytes in any value render as visible escapes.
260
+ Sub-agents log under their own worker label (`[mistri researcher#89bb20de]`)
261
+ exactly once, whichever process runs them; a `worker` report line appears
262
+ wherever a sink watched the spawn. Interleaved lines stay whole as long as
263
+ the logger serializes writes, which stdlib `Logger` and Rails' do. A `task`
264
+ logs one frame around all its fix passes.
265
+
266
+ By default the lines carry payloads: inputs, thinking, arguments, results.
267
+ That is the point in development, and it is fine in production too when
268
+ your log pipeline is where you want the story (shipping these lines to your
269
+ log platform gives you agent observability for free). When payloads must
270
+ stay out of a log, `content: false` keeps the whole story as metadata:
271
+ names, ids, durations, sizes, tokens, cost, and statuses, and error, crash,
272
+ and retry text reduces to its byte size while classes and reasons stay. `level: :debug`
273
+ floors the ordinary lines, `truncate` bounds rendered values, and a wrong
274
+ assignment raises at assignment time:
275
+
276
+ ```ruby
277
+ Mistri.logger = Mistri::Sinks::Logger.new(logger, content: false, level: :debug)
278
+ ```
279
+
280
+ Composing per run (`agent.run(input, &Mistri::Sinks::Logger.new(logger))`)
281
+ is deliberately smaller: event lines under a bare `[mistri]` tag, no
282
+ framing, and forwarded child events rendered with their origin, since no
283
+ child sink exists to log them. Logger calls are synchronous on
284
+ the run's own threads, so a slow logger slows the run: keep the destination
285
+ local (stdout, a file) and let your log shipper make the network hop, or
286
+ buffer at the logger layer. Logging never breaks a run: construction and
287
+ every write are contained, and a failing logger warns once per run and that
288
+ run's sink goes quiet. With no logger assigned the cost is one nil check
289
+ per run; with one assigned, delta events short-circuit without allocating
290
+ and per-line work is bounded by the truncation limit, not the payload.
291
+
234
292
  ## Long conversations and structured tasks
235
293
 
236
294
  Compaction is on by default for catalogued models. When a session enters the
data/lib/mistri/agent.rb CHANGED
@@ -39,10 +39,12 @@ module Mistri
39
39
  # when an approved call finally executes, so a decision that aged days
40
40
  # still passes current policy); after_tool(call, result, context) may
41
41
  # return a replacement result, or nil to keep the original.
42
+ # log_label tags this agent's log lines (sub-agents
43
+ # get their worker label); nil falls back to the session id.
42
44
  def initialize(provider:, session: nil, system: nil, tools: [], budget: nil,
43
45
  max_concurrency: 4, transform_context: nil, compaction: Compaction.new,
44
46
  retries: RetryPolicy.new, skills: [], before_tool: nil, after_tool: nil,
45
- context: nil)
47
+ context: nil, log_label: nil)
46
48
  @provider = provider
47
49
  @session = session || Session.new(store: Stores::Memory.new)
48
50
  skills = skills.is_a?(String) ? Skills.load(skills) : Array(skills)
@@ -62,6 +64,8 @@ module Mistri
62
64
  @before_tool = before_tool
63
65
  @after_tool = after_tool
64
66
  @context = context
67
+ @log_label = log_label
68
+ @log_depth = 0
65
69
  end
66
70
 
67
71
  attr_reader :session
@@ -73,18 +77,22 @@ module Mistri
73
77
  # schema, natively where the provider supports it. task adds validation
74
78
  # on top; run alone does not validate.
75
79
  def run(input, images: [], signal: nil, output_schema: nil, &emit)
76
- if refresh_tool_control.any?
77
- raise ConfigurationError,
78
- "session is awaiting approvals; call resume"
79
- end
80
- if input.to_s.empty? && Array(images).empty?
81
- raise ArgumentError,
82
- "run needs input text or images"
83
- end
80
+ # The frame covers the whole public method, so an audit rejection or
81
+ # a store failure gets a crash line too, not just loop failures.
82
+ logged(emit, verb: "run", input: input) do |subscriber|
83
+ if refresh_tool_control.any?
84
+ raise ConfigurationError,
85
+ "session is awaiting approvals; call resume"
86
+ end
87
+ if input.to_s.empty? && Array(images).empty?
88
+ raise ArgumentError,
89
+ "run needs input text or images"
90
+ end
84
91
 
85
- fold_inbox # anything queued while this session sat idle arrived first; keep that order
86
- @session.append_message(Message.user_with_images(input, images))
87
- loop_turns(signal, output_schema, &emit)
92
+ fold_inbox # anything queued while this session sat idle arrived first; keep that order
93
+ @session.append_message(Message.user_with_images(input, images))
94
+ loop_turns(signal, output_schema, &subscriber)
95
+ end
88
96
  end
89
97
 
90
98
  # Continue a suspended run. Undecided approvals return immediately, still
@@ -93,25 +101,27 @@ module Mistri
93
101
  # carries on as if it never stopped, unless a settled call's tool ends
94
102
  # the turn, in which case its execution was the run's last word.
95
103
  def resume(signal: nil, &emit)
96
- open = refresh_tool_control
97
- pending = open.select { |approval| approval[:decision].nil? }
98
- if pending.any?
99
- return Result.new(message: nil, status: :awaiting_approval,
104
+ logged(emit, verb: "resume") do |subscriber|
105
+ open = refresh_tool_control
106
+ pending = open.select { |approval| approval[:decision].nil? }
107
+ if pending.any?
108
+ next Result.new(message: nil, status: :awaiting_approval,
100
109
  pending: pending.map { |approval| approval[:call] },
101
110
  usage: Usage.zero)
102
- end
111
+ end
103
112
 
104
- executed = settle(open, signal, &emit)
105
- if signal&.aborted?
106
- last = @session.messages.reverse_each.find(&:assistant?)
107
- return finished(last, Usage.zero, signal)
108
- end
109
- if executed.any? { |call| ends_turn?(call) }
110
- last = @session.messages.reverse_each.find(&:assistant?)
111
- return finished(last, Usage.zero, signal, handed_off: true)
112
- end
113
+ executed = settle(open, signal, &subscriber)
114
+ if signal&.aborted?
115
+ last = @session.messages.reverse_each.find(&:assistant?)
116
+ next finished(last, Usage.zero, signal)
117
+ end
118
+ if executed.any? { |call| ends_turn?(call) }
119
+ last = @session.messages.reverse_each.find(&:assistant?)
120
+ next finished(last, Usage.zero, signal, handed_off: true)
121
+ end
113
122
 
114
- loop_turns(signal, nil, &emit)
123
+ loop_turns(signal, nil, &subscriber)
124
+ end
115
125
  end
116
126
 
117
127
  # Run an exchange that must end in a JSON value matching schema. Tools
@@ -126,7 +136,37 @@ module Mistri
126
136
  # belongs to whoever answers, and re-prompting for JSON would steal it
127
137
  # back. Ask again once the answer arrives.
128
138
  def task(input, schema:, images: [], signal: nil, fixes: 1, &emit)
129
- plan = Schema.task_plan(schema)
139
+ # One log frame for the whole task, fix passes included: the inner
140
+ # runs see the frame open and only tee, so the single done line
141
+ # reports the validated outcome, not a rejected intermediate answer.
142
+ logged(emit, verb: "task", input: input) do |subscriber|
143
+ plan = Schema.task_plan(schema)
144
+ validated_task(input, plan, images, signal, fixes, &subscriber)
145
+ end
146
+ end
147
+
148
+ # How full the context is: {tokens:, window:, fraction:}. Hosts render
149
+ # meters and near-limit warnings from this; window is nil for models the
150
+ # catalog does not know unless Compaction#window supplies one.
151
+ def context_usage
152
+ tokens = @session.context_tokens
153
+ window = context_window
154
+ { tokens: tokens, window: window,
155
+ fraction: window && (tokens.to_f / window).round(3) }
156
+ end
157
+
158
+ # Compact now (a UI button, a pre-flight trim before a big task). Returns
159
+ # the Compactor result, or nil when there is nothing worth compacting.
160
+ def compact(&)
161
+ Compactor.call(session: @session, provider: @provider,
162
+ settings: @compaction || Compaction.new, &)
163
+ end
164
+
165
+ private
166
+
167
+ # The body of task, extracted so its early returns close the log frame
168
+ # instead of skipping it.
169
+ def validated_task(input, plan, images, signal, fixes, &emit)
130
170
  result = run(
131
171
  TaskOutput.prompt(input, plan), images:, signal:, output_schema: plan, &emit
132
172
  )
@@ -146,24 +186,47 @@ module Mistri
146
186
  end
147
187
  end
148
188
 
149
- # How full the context is: {tokens:, window:, fraction:}. Hosts render
150
- # meters and near-limit warnings from this; window is nil for models the
151
- # catalog does not know unless Compaction#window supplies one.
152
- def context_usage
153
- tokens = @session.context_tokens
154
- window = context_window
155
- { tokens: tokens, window: window,
156
- fraction: window && (tokens.to_f / window).round(3) }
189
+ # When a host sets Mistri.logger, every public run tees its events into
190
+ # a fresh logging sink for this session alongside the caller's block,
191
+ # and the framing lines carry what the stream cannot: the input, the
192
+ # model, and the final Result. Nothing wraps when no logger is set, a
193
+ # nested public call (task's inner runs) reuses the open frame, and a
194
+ # caller's subscriber failures propagate exactly as before because the
195
+ # sink never raises.
196
+ def logged(emit, verb:, input: nil)
197
+ sink = log_sink
198
+ return yield(emit) unless sink
199
+
200
+ @log_depth += 1
201
+ begin
202
+ sink.run_started(verb: verb, input: input, model: @provider.model,
203
+ tool_count: @tools.length)
204
+ subscriber = if emit
205
+ lambda { |event|
206
+ sink.call(event)
207
+ emit.call(event)
208
+ }
209
+ else
210
+ sink.to_proc
211
+ end
212
+ begin
213
+ result = yield(subscriber)
214
+ rescue StandardError => e
215
+ sink.run_crashed(EventDelivery.original(e))
216
+ raise
217
+ end
218
+ sink.run_finished(result)
219
+ result
220
+ ensure
221
+ @log_depth -= 1
222
+ end
157
223
  end
158
224
 
159
- # Compact now (a UI button, a pre-flight trim before a big task). Returns
160
- # the Compactor result, or nil when there is nothing worth compacting.
161
- def compact(&)
162
- Compactor.call(session: @session, provider: @provider,
163
- settings: @compaction || Compaction.new, &)
164
- end
225
+ def log_sink
226
+ return nil if @log_depth.positive?
165
227
 
166
- private
228
+ Sinks::Logger.attach(@session.id, label: @log_label)
229
+ end
167
230
 
168
231
  def loop_turns(signal, output_schema = nil, &emit)
169
232
  turns = 0
@@ -0,0 +1,401 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ module Sinks
7
+ # Renders the event stream as one log line per meaningful beat, so a
8
+ # log tells each run's whole story: which tools ran with which
9
+ # arguments, how long each took, what every turn cost, and how the run
10
+ # ended. Deltas never log (whole blocks do).
11
+ #
12
+ # Assigning Mistri.logger turns this on for every run in one line:
13
+ #
14
+ # Mistri.logger = Rails.logger
15
+ # Mistri.logger = Mistri::Sinks::Logger.new(logger, color: true)
16
+ #
17
+ # Payloads (inputs, text, thinking, arguments, results, reports) log in
18
+ # full by default; content: false keeps the same story with metadata
19
+ # only, for logs whose payloads must stay out. level: floors the
20
+ # ordinary lines (warnings and errors keep their own levels).
21
+ #
22
+ # Sinks the Agent builds through for_session skip origin-tagged events,
23
+ # because every agent logs its own events under its own tag: exactly
24
+ # once, however deep the nesting and whichever process runs it. A sink
25
+ # composed directly (agent.run(input, &sink)) is the only logger its
26
+ # run has, so it renders forwarded child events instead, origin first;
27
+ # framing (run, done) still comes only from the Agent.
28
+ #
29
+ # Any object responding to info/warn/error works. Logging must never
30
+ # break a run: every entry point rescues, the first failure warns, and
31
+ # the sink goes quiet for the rest of its run. Tool events arrive from
32
+ # executor threads; the only mutable state (the turn counter) moves on
33
+ # loop-thread events, and interleaved lines stay whole as long as the
34
+ # host's logger serializes writes, which stdlib Logger and Rails do.
35
+ class Logger
36
+ SKIPPED = %i[start text_start text_delta thinking_start thinking_delta
37
+ toolcall_start toolcall_delta toolcall_end].to_h { |type| [type, true] }.freeze
38
+ LINES = { text_end: :text_line, thinking_end: :thinking_line,
39
+ tool_started: :tool_line, tool_result: :tool_result_line,
40
+ done: :turn_line, error: :error_line, retry: :retry_line,
41
+ approval_needed: :approval_line, compacting: :compacting_line,
42
+ compaction: :compaction_line, subagent_report: :report_line }.freeze
43
+ QUIET = %i[text_end thinking_end tool_started done approval_needed
44
+ compacting compaction subagent_report].to_h { |type| [type, true] }.freeze
45
+ COLORS = { bold: "1", dim: "2", red: "31", green: "32", yellow: "33" }.freeze
46
+ LEVELS = %i[debug info warn error].freeze
47
+ # C0 and C1 controls, DEL, Unicode line and paragraph separators, and
48
+ # bidi embedding controls: everything that could steer a terminal or
49
+ # forge a line while staying invisible.
50
+ HIDDEN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/
51
+ HEAD = 4096
52
+ private_constant :SKIPPED, :LINES, :QUIET, :COLORS, :LEVELS, :HIDDEN, :HEAD
53
+
54
+ # truncate bounds every rendered value (nil for full payloads).
55
+ # session is the line tag, verbatim, so concurrent runs stay
56
+ # distinguishable. Options are validated here so a bad configuration
57
+ # fails at assignment time, not silently at run time.
58
+ def initialize(logger, session: nil, truncate: 200, color: false, level: :info,
59
+ content: true, forwarded: :render)
60
+ raise ArgumentError, "level must be one of #{LEVELS.join(", ")}" unless
61
+ LEVELS.include?(level)
62
+ unless truncate.nil? || (truncate.is_a?(Integer) && truncate.positive?)
63
+ raise ArgumentError, "truncate must be a positive Integer or nil"
64
+ end
65
+ raise ArgumentError, "forwarded must be :render or :skip" unless
66
+ %i[render skip].include?(forwarded)
67
+
68
+ @logger = logger
69
+ @truncate = truncate
70
+ @color = color
71
+ @level = level
72
+ @content = content
73
+ @forwarded = forwarded
74
+ @session = session && field(session, limit: 48)
75
+ @turns = 0
76
+ @started = now
77
+ @warned = false
78
+ @tag = paint(@session ? "[mistri #{@session}]" : "[mistri]", :dim)
79
+ end
80
+
81
+ # A fresh sink for one run: same logger and options, this run's tag
82
+ # (a sub-agent's label, or the session id), its own turn count and
83
+ # clock. Runs framed this way skip forwarded events, because each
84
+ # agent in the tree logs its own.
85
+ def for_session(id, label: nil)
86
+ self.class.new(@logger, session: label || id.to_s[0, 8], truncate: @truncate,
87
+ color: @color, level: @level, content: @content, forwarded: :skip)
88
+ end
89
+
90
+ # The global hookup: builds this run's sink from Mistri.logger, or
91
+ # nil. Contained, so a broken assignment costs the log, never the run.
92
+ def self.attach(id, label: nil)
93
+ configured = Mistri.logger
94
+ return nil unless configured
95
+
96
+ sink = configured.is_a?(self) ? configured : new(configured)
97
+ sink.for_session(id, label: label)
98
+ rescue StandardError => e
99
+ warn "mistri: logging sink construction failed (#{e.class}: #{e.message}); run not logged"
100
+ nil
101
+ end
102
+
103
+ def call(event)
104
+ return if @warned
105
+
106
+ return if event.origin && (@forwarded == :skip)
107
+ return if SKIPPED[event.type]
108
+ return if QUIET[event.type] && !floor_enabled?
109
+
110
+ handler = LINES[event.type]
111
+ prefix = event.origin ? "#{field(event.origin)} " : ""
112
+ # Future event types degrade to a greppable line, never to silence.
113
+ line = handler ? send(handler, event) : "#{event.type} #{body_of(event.content)}".rstrip
114
+ write("#{prefix}#{line.first}", level: line.last) if line.is_a?(Array)
115
+ write("#{prefix}#{line}") if line.is_a?(String)
116
+ rescue StandardError => e
117
+ quiet(e)
118
+ end
119
+
120
+ def to_proc = method(:call).to_proc
121
+
122
+ # The framing the stream cannot carry: the Agent calls these around a
123
+ # run with the input, the model, and the final Result. Each is total
124
+ # for the same reason call is: a logging failure inside a rescue
125
+ # block must never replace the host's own exception.
126
+ def run_started(verb:, model:, tool_count:, input: nil)
127
+ @turns = 0
128
+ @started = now
129
+ return unless floor_enabled?
130
+
131
+ ask = input && @content ? %( "#{trim(input)}") : ""
132
+ write("#{paint(verb, :bold)}#{ask} (#{field(model)}, #{count(tool_count, "tool")})")
133
+ rescue StandardError => e
134
+ quiet(e)
135
+ end
136
+
137
+ def run_finished(result)
138
+ status, level = status_of(result)
139
+ return if level == :info && !floor_enabled?
140
+
141
+ write("#{paint("done", :bold)} #{status} in #{clock(now - @started)}, " \
142
+ "#{count(@turns, "turn")}#{summary(result.usage)}", level: level)
143
+ rescue StandardError => e
144
+ quiet(e)
145
+ end
146
+
147
+ def run_crashed(error)
148
+ write("#{paint("crashed", :red)} #{error.class}#{note(error.message)}", level: :error)
149
+ rescue StandardError => e
150
+ quiet(e)
151
+ end
152
+
153
+ private
154
+
155
+ def text_line(event)
156
+ body = body_of(event.content)
157
+ body.empty? ? nil : "text #{body}"
158
+ end
159
+
160
+ def thinking_line(event)
161
+ body = body_of(event.content)
162
+ body.empty? ? nil : "thinking #{body}"
163
+ end
164
+
165
+ def tool_line(event)
166
+ "tool #{title(event.tool_call)} #{arguments(event.tool_call)}".rstrip
167
+ end
168
+
169
+ def tool_result_line(event)
170
+ verdict = event.tool_error? ? paint("FAILED", :red) : "ok"
171
+ parts = ["tool #{title(event.tool_call)} #{verdict}", clock(event.duration),
172
+ presence(body_of(event.content))]
173
+ [parts.compact.join(" "), event.tool_error? ? :warn : :info]
174
+ end
175
+
176
+ def turn_line(event)
177
+ @turns += 1
178
+ "turn #{@turns} done #{event.reason}#{tokens(event.message&.usage)}"
179
+ end
180
+
181
+ # :error also carries expected stops, which log calmly; only real
182
+ # failures alarm. A budget stop is synthetic (no provider call), so
183
+ # it alone does not count as a turn.
184
+ def error_line(event)
185
+ case event.reason
186
+ when StopReason::BUDGET then ["stopped on budget", :warn]
187
+ when StopReason::ABORTED
188
+ @turns += 1
189
+ "aborted"
190
+ else
191
+ @turns += 1
192
+ ["#{paint("error", :red)} #{event.reason}#{note(event.error_message)}", :error]
193
+ end
194
+ end
195
+
196
+ def retry_line(event)
197
+ wait = event.delay ? " in #{event.delay.round(1)}s" : ""
198
+ ["#{paint("retry", :yellow)} #{event.attempt}/#{event.max_attempts}#{wait}" \
199
+ "#{note(event.content)}", :warn]
200
+ end
201
+
202
+ # The full call id rides the line: Session#approve takes exactly that
203
+ # id, and the short pairing suffix is not it.
204
+ def approval_line(event)
205
+ call = event.tool_call
206
+ parts = ["approval needed #{title(call)}", presence(arguments(call)),
207
+ "id #{field(call.id, limit: 128)}"]
208
+ parts.compact.join(" ")
209
+ end
210
+
211
+ def compacting_line(_event) = "compacting"
212
+
213
+ def compaction_line(event) = "compacted #{body_of(event.content)}"
214
+
215
+ def report_line(event)
216
+ "worker #{field(event.agent)} (#{field(event.session_id.to_s[0, 8], limit: 16)}) " \
217
+ "#{event.status} #{body_of(event.content)}".rstrip
218
+ end
219
+
220
+ def status_of(result)
221
+ case result.status
222
+ when :completed
223
+ [result.handed_off? ? "completed (handed off)" : "completed", :info]
224
+ when :awaiting_approval
225
+ ["suspended (#{count(result.pending.length, "approval")} pending)", :info]
226
+ when :aborted then ["aborted", :info]
227
+ when :budget then ["stopped on budget", :warn]
228
+ else [paint(result.status.to_s, :red), :error]
229
+ end
230
+ end
231
+
232
+ def summary(usage)
233
+ return "" unless usage
234
+
235
+ base = ", #{prompt(usage)} / #{usage.output} out"
236
+ cost = usage.cost
237
+ cost&.known? ? base + format(", $%.4f", cost.total) : base
238
+ end
239
+
240
+ def tokens(usage)
241
+ usage ? " (#{prompt(usage)} / #{usage.output} out)" : ""
242
+ end
243
+
244
+ # Cache traffic is real prompt volume; input alone under-reports it.
245
+ def prompt(usage)
246
+ cached = usage.cache_read + usage.cache_write
247
+ return "#{usage.input} in" unless cached.positive?
248
+
249
+ "#{usage.input + cached} in (#{cached} cached)"
250
+ end
251
+
252
+ # A short call id makes concurrent same-name calls pairable.
253
+ def title(tool_call)
254
+ return paint("?", :green) unless tool_call
255
+
256
+ name = paint(field(tool_call.name), :green)
257
+ id = field(tool_call.id, limit: 128)
258
+ ref = id.length > 4 ? id[-4, 4] : id
259
+ ref.empty? ? name : "#{name}##{ref}"
260
+ end
261
+
262
+ def arguments(tool_call)
263
+ return "[#{trim(tool_call.arguments_error)}]" if tool_call.arguments_error?
264
+ return "" unless @content
265
+
266
+ rendered = +""
267
+ preview(tool_call.arguments || {}, rendered)
268
+ trim(rendered)
269
+ end
270
+
271
+ # A bounded JSON preview: rendering stops once the budget is spent,
272
+ # so a payload near the 8 MiB argument ceiling never materializes as
273
+ # a whole serialized string on the emission path.
274
+ def preview(value, out)
275
+ return out << "..." if @truncate && out.length > @truncate
276
+
277
+ case value
278
+ when Hash
279
+ walk(value, out, "{", "}") do |(key, item)|
280
+ out << JSON.generate(snip(key.to_s)) << ":"
281
+ preview(item, out)
282
+ end
283
+ when Array
284
+ walk(value, out, "[", "]") { |item| preview(item, out) }
285
+ when String
286
+ out << JSON.generate(snip(value))
287
+ else
288
+ out << JSON.generate(value)
289
+ end
290
+ end
291
+
292
+ def snip(text) = @truncate ? text[0, @truncate] : text
293
+
294
+ def walk(items, out, open, close)
295
+ out << open
296
+ items.each_with_index do |item, index|
297
+ break if @truncate && out.length > @truncate && index.positive?
298
+
299
+ out << "," if index.positive?
300
+ yield(item)
301
+ end
302
+ out << close
303
+ end
304
+
305
+ # Diagnostics keep their class and reason in every mode; the free
306
+ # text obeys content:, because exception messages can echo payloads.
307
+ def note(text)
308
+ flat = presence(trim(text))
309
+ return "" unless flat
310
+
311
+ @content ? ": #{flat}" : " (#{bytes(text)})"
312
+ end
313
+
314
+ # content: false keeps the beat and the volume, never the words.
315
+ def body_of(text)
316
+ return "" if text.nil? || text.to_s.strip.empty?
317
+ return "(#{bytes(text)})" unless @content
318
+
319
+ quote(text)
320
+ end
321
+
322
+ def quote(text)
323
+ body, cut = clip(text)
324
+ cut ? %("#{body}" (#{bytes(text)})) : %("#{body}")
325
+ end
326
+
327
+ def trim(text) = clip(text).first
328
+
329
+ # Structural scalars (tags, names, ids, labels) are bounded and
330
+ # stripped of hidden bytes before they join a line, so untrusted
331
+ # values cannot forge lines or steer a terminal.
332
+ def field(value, limit: 64)
333
+ clip(value, limit: limit).first
334
+ end
335
+
336
+ # String work per line stays bounded: only a head of the payload is
337
+ # normalized, never all of it. scrub repairs invalid bytes (encode
338
+ # alone skips same-encoding validation), encode converts binary, and
339
+ # hidden bytes become visible escapes. The size suffix reports the
340
+ # whole payload.
341
+ def clip(text, limit: @truncate)
342
+ raw = text.to_s
343
+ head = limit ? raw[0, [(limit * 2) + 1, HEAD].max] : raw
344
+ flat = head.scrub.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
345
+ .gsub(/\s+/, " ").strip
346
+ .gsub(HIDDEN) { |hidden| format("\\u%04x", hidden.ord) }
347
+ cut = limit && (flat.length > limit || head.bytesize < raw.bytesize)
348
+ return [flat, false] unless cut
349
+
350
+ ["#{flat[0, limit].rstrip}...", true]
351
+ end
352
+
353
+ def clock(duration)
354
+ return nil unless duration
355
+
356
+ duration < 1 ? "#{(duration * 1000).round}ms" : "#{duration.round(1)}s"
357
+ end
358
+
359
+ def bytes(text)
360
+ size = text.to_s.bytesize
361
+ return "#{size}B" if size < 1024
362
+
363
+ size < 1_048_576 ? format("%.1fKB", size / 1024.0) : format("%.1fMB", size / 1_048_576.0)
364
+ end
365
+
366
+ def count(number, noun) = "#{number} #{number == 1 ? noun : "#{noun}s"}"
367
+
368
+ def presence(text) = text.nil? || text.empty? ? nil : text
369
+
370
+ def paint(text, color) = @color ? "\e[#{COLORS.fetch(color)}m#{text}\e[0m" : text
371
+
372
+ # Formatting is skipped entirely when the floor level is disabled on
373
+ # the host logger; warnings and errors always go through.
374
+ def floor_enabled?
375
+ query = :"#{@level}?"
376
+ !@logger.respond_to?(query) || @logger.public_send(query)
377
+ rescue StandardError
378
+ true
379
+ end
380
+
381
+ def write(line, level: :info)
382
+ return if @warned
383
+
384
+ level = @level if level == :info
385
+ @logger.public_send(level, "#{@tag} #{line}")
386
+ rescue StandardError => e
387
+ quiet(e)
388
+ end
389
+
390
+ def quiet(error)
391
+ return if @warned
392
+
393
+ @warned = true
394
+ warn "mistri: logging sink failed (#{error.class}: #{error.message}); " \
395
+ "logging disabled for this run"
396
+ end
397
+
398
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
399
+ end
400
+ end
401
+ end
@@ -58,9 +58,9 @@ module Mistri
58
58
 
59
59
  def run_child_agent(child:, label:, provider:, system:, tools:, task:, schema:,
60
60
  signal:, emit:, agent_options:)
61
- agent = Agent.new(provider: provider, session: child, system: system,
62
- tools: tools, **agent_options)
63
61
  origin = "#{label}##{child.id[0, 8]}"
62
+ agent = Agent.new(provider: provider, session: child, system: system,
63
+ tools: tools, log_label: origin, **agent_options)
64
64
  tagged = ->(event) { forward(event, origin, emit) }
65
65
  return agent.task(task, schema: schema, signal: signal, &tagged) if schema
66
66
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mistri
4
- VERSION = "0.6.1"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/mistri.rb CHANGED
@@ -51,6 +51,7 @@ require_relative "mistri/mcp"
51
51
  require_relative "mistri/sinks/action_cable"
52
52
  require_relative "mistri/sinks/sse"
53
53
  require_relative "mistri/sinks/coalesced"
54
+ require_relative "mistri/sinks/logger"
54
55
  require_relative "mistri/providers/fake"
55
56
  require_relative "mistri/providers/anthropic"
56
57
  require_relative "mistri/providers/openai"
@@ -66,8 +67,25 @@ module Mistri
66
67
  gemini: "GEMINI_API_KEY" }.freeze
67
68
 
68
69
  # The configured lock adapter, nil until a host sets one. See Locks.
70
+ # The run logger, nil until a host sets one: a Logger-compatible object
71
+ # (info/warn/error), or a Sinks::Logger for options. Every Agent run
72
+ # then logs its story. A wrong assignment raises here, where the typo
73
+ # is, instead of leaving every run's log silently empty.
69
74
  class << self
70
75
  attr_accessor :locks
76
+ attr_reader :logger
77
+
78
+ def logger=(value)
79
+ sink_like = value.is_a?(Sinks::Logger)
80
+ logger_like = %i[info warn error].all? { |severity| value.respond_to?(severity) }
81
+ unless value.nil? || sink_like || logger_like
82
+ raise ConfigurationError,
83
+ "Mistri.logger takes a Logger-compatible object (info/warn/error) " \
84
+ "or a Mistri::Sinks::Logger"
85
+ end
86
+
87
+ @logger = value
88
+ end
71
89
  end
72
90
 
73
91
  module_function
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mistri
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Muhammad Ahmed Cheema
@@ -90,6 +90,7 @@ files:
90
90
  - lib/mistri/session.rb
91
91
  - lib/mistri/sinks/action_cable.rb
92
92
  - lib/mistri/sinks/coalesced.rb
93
+ - lib/mistri/sinks/logger.rb
93
94
  - lib/mistri/sinks/sse.rb
94
95
  - lib/mistri/skill.rb
95
96
  - lib/mistri/skills.rb
@@ -134,7 +135,7 @@ metadata:
134
135
  changelog_uri: https://github.com/mcheemaa/mistri/blob/main/CHANGELOG.md
135
136
  allowed_push_host: https://rubygems.org
136
137
  rubygems_mfa_required: 'true'
137
- documentation_uri: https://github.com/mcheemaa/mistri/blob/v0.6.1/docs/README.md
138
+ documentation_uri: https://github.com/mcheemaa/mistri/blob/v0.7.0/docs/README.md
138
139
  bug_tracker_uri: https://github.com/mcheemaa/mistri/issues
139
140
  rdoc_options: []
140
141
  require_paths: