Class: Agentilda::Transcript

Inherits:
Object
  • Object
show all
Defined in:
lib/agentilda/transcript.rb

Overview

What an agent is doing, while it is still doing it.

claude -p prints nothing until it exits, so a fifteen-minute agent used to sit behind a spinner that said only its name. Asked for --output-format stream-json it emits one JSON object per line as it works, and this turns that stream into the short phrase a spinner line has room for: "editing spec.md", "reading plan.md", whatever the agent last said about itself.

Three things make this less trivial than parsing JSON.

Chunks arrive from readpartial, so a line is split across two of them as often as not. #push buffers and only ever parses whole lines.

Not every line is JSON. claude reports its own failures as prose on stdout, and the 401 that cost a whole run three minutes an agent was one of those. Those lines are kept in #plain so a failure can still be explained by what was actually printed.

The reader runs on its own thread, so #activity is written from one thread and read from another. It is a single assignment of an immutable string, which is why there is no lock here.

Every line is also kept verbatim in an optional trace file, written before anything is made of it, so a run that failed can be read back afterwards even where this parser made nothing of an event it had never seen. See Executor::TRACE_DIR for where those land and how to read one.

Defined Under Namespace

Classes: Progress

Constant Summary collapse

DELTA_NOISE =

The one kind of line the trace does not keep.

--include-partial-messages is what makes #meter possible, and it also emits one event per handful of generated characters. Those are the bulk of the stream and the only part of it nothing reads back: a fifteen-minute agent writes thousands of them, and they would bury the events a failed run is actually read for. Matched as a substring rather than parsed because the trace is written before a line is understood, which is what makes it complete when the process dies mid-invocation.

'"content_block_delta"'
LIMIT =

Longest phrase a spinner line can carry without wrapping into the next.

56
VERBS =

How each tool reads as something being done, rather than as a tool name. A spinner says what is happening; "Edit" is a noun and says nothing.

{
  "Read" => "reading", "Write" => "writing", "Edit" => "editing",
  "MultiEdit" => "editing", "NotebookEdit" => "editing",
  "Bash" => "running", "Grep" => "searching for", "Glob" => "looking for",
  "Task" => "delegating", "TodoWrite" => "planning",
  "WebFetch" => "fetching", "WebSearch" => "searching the web for"
}.freeze
SUBJECTS =

The input key worth naming, per tool, in the order we would rather say it. description outranks command deliberately: Claude's Bash tool carries a written summary of why it is running something, and "Read NOTES.md" is a better spinner line than sixty characters of absolute path.

%w[file_path pattern description command query url].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(trace: nil) {|progress| ... } ⇒ Transcript

Returns a new instance of Transcript.

Parameters:

  • trace (String, nil) (defaults to: nil)

    a file to keep the raw stream in, or nil to keep none. Every line is written before anything is made of it, so a run that failed can be read back afterwards even where this parser made nothing of an event it had never seen.

Yield Parameters:



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/agentilda/transcript.rb', line 90

def initialize(trace: nil, &on_progress)
  @on_progress = on_progress
  @buffer = +""
  @plain = []
  @activity = nil
  @result = nil
  @error = nil
  @trace_path = trace
  @trace = trace && File.open(trace, "a")
  @tools = 0
  @main = {up: 0, down: 0}
  @streamed = {}
  @tasks = {}
  @spawned = 0
end

Instance Attribute Details

#activityString? (readonly)

Returns the last thing the agent was seen doing.

Returns:

  • (String, nil)

    the last thing the agent was seen doing



149
150
151
# File 'lib/agentilda/transcript.rb', line 149

def activity
  @activity
end

#errorString? (readonly)

Returns what the agent said went wrong.

Returns:

  • (String, nil)

    what the agent said went wrong



161
162
163
# File 'lib/agentilda/transcript.rb', line 161

def error
  @error
end

#pidInteger?

The claude process this transcript is reading, set by the Executor once it has found the child, so the spinner line can name a pid someone can actually ps while the agent runs.

Returns:

  • (Integer, nil)


125
126
127
# File 'lib/agentilda/transcript.rb', line 125

def pid
  @pid
end

#plainArray<String> (readonly)

Returns lines that were not JSON, in order.

Returns:

  • (Array<String>)

    lines that were not JSON, in order



155
156
157
# File 'lib/agentilda/transcript.rb', line 155

def plain
  @plain
end

#resultString? (readonly)

Returns the final text, from the result event.

Returns:

  • (String, nil)

    the final text, from the result event



152
153
154
# File 'lib/agentilda/transcript.rb', line 152

def result
  @result
end

#spawnedInteger (readonly)

Returns sub-agents this invocation started.

Returns:

  • (Integer)

    sub-agents this invocation started



118
119
120
# File 'lib/agentilda/transcript.rb', line 118

def spawned
  @spawned
end

#toolsInteger (readonly)

Returns how many tool calls have gone past, which is the one honest measure of how much work an agent that says "done" actually did.

Returns:

  • (Integer)

    how many tool calls have gone past, which is the one honest measure of how much work an agent that says "done" actually did



143
144
145
# File 'lib/agentilda/transcript.rb', line 143

def tools
  @tools
end

#trace_pathString? (readonly)

Returns the file the raw stream is being kept in.

Returns:

  • (String, nil)

    the file the raw stream is being kept in



146
147
148
# File 'lib/agentilda/transcript.rb', line 146

def trace_path
  @trace_path
end

Instance Method Details

#delegatedInteger

Sub-agent totals arrive as one number with no split between what went up and what came back, so they are counted as #up. They are held here as well, unmixed, because a report that says "of which N could not be split" is honest and one that quietly rounds is not.

Returns:

  • (Integer)


133
134
135
136
# File 'lib/agentilda/transcript.rb', line 133

def delegated
  @tasks.reject { |_, task| @streamed.key?(task[:tool_use_id]) }
    .sum { |_, task| task[:total] }
end

#downInteger

Returns tokens the model generated, thinking included.

Returns:

  • (Integer)

    tokens the model generated, thinking included



115
# File 'lib/agentilda/transcript.rb', line 115

def down = @main[:down] + streamed[:down]

#failed?Boolean

Returns whether the stream reported its own failure.

Returns:

  • (Boolean)

    whether the stream reported its own failure



158
# File 'lib/agentilda/transcript.rb', line 158

def failed? = !@error.nil?

#finishvoid

This method returns an undefined value.

Whatever is left when the process exits, which may be a last line with no newline after it.



182
183
184
185
186
# File 'lib/agentilda/transcript.rb', line 182

def finish
  consume(@buffer.slice!(0..-1).to_s)
  @trace&.close
  @trace = nil
end

#progressAgentilda::Transcript::Progress

Returns a snapshot, safe to keep.

Returns:



139
# File 'lib/agentilda/transcript.rb', line 139

def progress = Progress.new(activity: @activity, up:, down:, subagents: @spawned, pid: @pid)

#push(chunk) ⇒ void

This method returns an undefined value.

Feed one chunk of stdout. Safe to call with a partial line, which is the normal case.

Parameters:

  • chunk (String, nil)


168
169
170
171
172
173
174
175
176
# File 'lib/agentilda/transcript.rb', line 168

def push(chunk)
  return if chunk.nil?

  @buffer << chunk
  while (index = @buffer.index("\n"))
    line = @buffer.slice!(0..index).chomp
    consume(line)
  end
end

#upInteger

Everything sent to the model, which on any turn after the first is mostly cache reads: a typical agent turn is two fresh input tokens against a quarter of a million read back from cache. Counting only the fresh ones would report a fifteen-minute agent as having said nothing.

Returns:

  • (Integer)


112
# File 'lib/agentilda/transcript.rb', line 112

def up = @main[:up] + streamed[:up] + delegated