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 +4 -4
- data/CHANGELOG.md +35 -1
- data/README.md +58 -0
- data/lib/mistri/agent.rb +106 -43
- data/lib/mistri/sinks/logger.rb +401 -0
- data/lib/mistri/sub_agent/execution.rb +2 -2
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri.rb +18 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87b42895bd43efd658a542312836abdd62a3ee7b18ac9d2a3e289cef6b7752f3
|
|
4
|
+
data.tar.gz: 413e5fa28d123c5e8a6ec39e332149e4aaf5d6f0587a9c3947032a6a4bc83c2a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
111
|
+
end
|
|
103
112
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/mistri/version.rb
CHANGED
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.
|
|
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.
|
|
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:
|