Skip to content

Commit 66aab8d

Browse files
authored
Merge pull request #2969 from square/debug-follow-along
Log the full agent traffic, and which way in each message came
2 parents 9cb24da + e672801 commit 66aab8d

21 files changed

Lines changed: 1456 additions & 141 deletions

File tree

.claude/skills/shark-dive/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ was built debuggable or the whole device build is; `list_devices` says which.
5454
```
5555

5656
An investigation somebody already ran is either the answer or the half of the dump not worth doing again.
57+
Adding `session=<id>` reads one of them call by call, each with the exact text it sent and read back — which
58+
is how you tell a step that read an answer from one that misread it, and a long answer for that reason, so
59+
reach for it when a run went wrong rather than as a matter of course.
5760

5861
## The command line
5962

docs/shark-dive-changelog.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,18 @@ uses, without the one for a newly recognized library leak:
8080
one is everything that agent did — what each call did, which object it did it to, and the sentence it gave
8181
for making it, with the refusals in red. A row leads where the call went, so reading what an agent did and
8282
going to look at it are one move — including a row about a heap dump this window hasn't got, which opens
83-
that dump, and whose link is there to copy like every other. Kept in `~/.shark-dive/agents/sessions`,
84-
one file per session and the newest hundred kept, so a session outlives the window it was worked in.
83+
that dump, and whose link is there to copy like every other. **And every row unfolds onto the call itself**,
84+
what was sent and what came back, as the text each of them was and never a first line of it: a reason is
85+
what an agent *said* it was doing, and a step taken on an answer that said nothing reads exactly like one
86+
taken on an answer that said everything until you open both. The `▸ {}` under a row is what opens it, and
87+
what was sent names the tool the agent called as well as what it sent to it — `describe_object` beside the
88+
window's own word for it, which is the pair worth having when a step doesn't follow. **The full traffic, not
89+
only the calls that worked**: the handshake, a call naming a tool that doesn't exist and a line that wasn't
90+
JSON are rows too, a refusal and an error are under `answered:` as the agent was handed them, and each row
91+
says whether it came in over MCP or from `--agent` at a shell — because what this screen gets opened for is
92+
often why *nothing* happened. `agent_log` hands an agent the same text, so one agent can work out where
93+
another went wrong. Kept in `~/.shark-dive/agents/sessions`, one file per session and the newest hundred
94+
kept, so a session outlives the window it was worked in.
8595
See [Hand it to an agent](shark-dive.md#hand-it-to-an-agent).
8696
***The chain marks the faulty reference**: the one step going from an `Expected` object straight to a
8797
`Stuck` one reads `Holder.activity · faulty reference`, which is the leak itself rather than one of the

docs/shark-dive.md

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ press, because a surface with less than that is one whose answer is "ask your hu
371371
| --- | --- |
372372
| `open_heap_dumps` | Every heap dump open, by the file name the other tools take, with the method to follow. |
373373
| `list_leaks` | The **Leaks** screen: what this heap dump says shouldn't be there. |
374-
| `agent_log` | The **Agent logs** screen: what has already been tried on this dump, and what it came to. |
374+
| `agent_log` | The **Agent logs** screen: what has already been tried on this dump, and what it came to — and, for one session, every call it made with the text it sent and read back. |
375375
| `chain_from_gc_root` | One chain, every step with its labels and its verdict. |
376376
| `describe_object` | What an object is: its class, fields, labels, size. |
377377
| `ways_held` | Every way an object is held, rather than the one chain — the *X ways from here* list. |
@@ -419,11 +419,14 @@ unexplained steps cannot report a root cause, however sure it is, and what it ge
419419
objects to go and read.
420420

421421
**What it did is on the *Agent logs* screen**, one row per agent that has connected to the app. Open a row
422-
and there is every call that agent made, in order and in words — what it did, which object it did it to, and
423-
the sentence it gave for doing it:
422+
and there is everything that agent sent, in order and in words — what each call did, which object it did it
423+
to, and the sentence it gave for doing it:
424424

425425
```
426-
08:23:04 ▸ Asked which heap dumps are open
426+
08:23:01 Connected
427+
08:23:01 Sent the notification notifications/initialized
428+
08:23:02 Asked what the tools are
429+
08:23:04 Asked which heap dumps are open
427430
because: Seeing what there is to read before asking anything about it.
428431
08:23:11 Listed the leaks
429432
because: Starting from what the heap dump already says shouldn't be here.
@@ -440,8 +443,8 @@ the sentence it gave for doing it:
440443
*0x12d368b8* on *Read the chain to 0x12d368b8* and the window opens that object, so reading what an agent did
441444
and going to look at it are one move. A call that named nothing went somewhere all the same — *leaks* on
442445
*Listed the leaks* is the leaks screen, and *dominator tree* on *Read the dominator tree* is the tree from its
443-
root. The one row that leads to several places unfolds instead: *Asked which heap dumps are open* opens into
444-
the dumps that were open, each of them a window away.
446+
root. The one row that leads to several places keeps them behind its fold instead: *Asked which heap dumps are
447+
open* opens into the dumps that were open, each of them a window away.
445448

446449
**A refused call is a row too**, in red, under the reason the agent gave for making it — and those are the
447450
half of a session worth reading, since a refusal is where the method sent an agent back to the heap dump
@@ -454,10 +457,81 @@ rather than on to an answer:
454457
reference: the rules can only name one once something below it is known not to belong. […]
455458
```
456459

460+
**And so is every other line that arrived** — the three at the top of that session are the handshake. A call
461+
naming a tool that doesn't exist, a line that wasn't JSON at all, a read that failed: each is a row, and the
462+
ones nothing could answer say *Failed* rather than *Refused*, which is the opposite claim. A refusal is the
463+
method working; this is Shark Dive not working.
464+
465+
```
466+
08:24:02 Called solve_the_leak 0x12d368b8
467+
because: Trying my luck.
468+
Failed: There is no tool called "solve_the_leak". This server has open_heap_dumps, […]
469+
08:24:09 Sent something this app could not read
470+
Failed: That is not JSON: Unexpected JSON token at offset 0
471+
```
472+
473+
Which is the point of keeping them: what this screen gets opened for is often why *nothing* happened, and a
474+
screen holding only the calls that worked is the one screen that can't answer that. It shows in the shape of a
475+
session — one sent from a shell is a *Connected* per call, `--agent` being a process per call — and the
476+
`n call(s)` above the rows counts the calls rather than the lines.
477+
478+
**And every row unfolds onto the call itself** — the `▸ {}` under a row opens what the agent sent and what it
479+
read back, as the text each of them was, so a step you don't follow is one question rather than a dead end:
480+
481+
```
482+
11:37:31 Looked at 0x12d368b8
483+
because: The one App leak: a MainActivity the app watched. Reading what it is before the chain.
484+
▾ {}
485+
sent over MCP:
486+
describe_object {
487+
"object": "0x12d368b8",
488+
"reason": "The one App leak: a MainActivity the app watched. Reading what it is
489+
before the chain."
490+
}
491+
answered:
492+
{
493+
"object": "0x12d368b8",
494+
"label": "MainActivity",
495+
"className": "com.example.leakcanary.MainActivity",
496+
"kind": "INSTANCE",
497+
"strength": "STRONG",
498+
"shallowBytes": 214,
499+
"retainedBytes": 210978,
500+
"verdict": "STUCK",
501+
"verdictReason": "ObjectWatcher was watching this and Activity#mDestroyed is true",
502+
[…]
503+
}
504+
```
505+
506+
What it sent is the tool's own name and then the arguments under it, which is the call as the model wrote it:
507+
*Looked at* is this window's word for `describe_object`, and the pair is worth having side by side exactly
508+
when a step doesn't follow from the one before it. And *over MCP* is which way in it came — a client holding a
509+
connection open, or `--agent` typed at this window — which is the one thing about a call that nothing else on
510+
the screen can tell you, since by the time anything answers one the two are the same protocol on the same
511+
socket.
512+
513+
Whole, never a first line of it: what you open a call for is the part the sentence left out, so an answer cut
514+
to fit would be one where the field that misled the agent is the part that got cut. And a session recorded by
515+
an older Shark Dive says so rather than opening onto a gap.
516+
517+
**Including the calls that came to nothing**, which is what this is most worth opening for. A refusal is
518+
under `answered:` as the agent was handed it, as well as in red on the row; so is an error, if a read failed
519+
or this app has a bug; so is the answer to a call naming a tool that doesn't exist. The only row with nothing
520+
under `answered:` is a notification, which is the one kind of message the protocol forbids answering.
521+
522+
The mark is under the row rather than on the verb because a row is a sentence with one link in it, and a fold
523+
over its first words made hovering light up the half that isn't the link.
524+
525+
**Reading it is what makes the reasons worth anything**, since a reason is what an agent *said* it was doing:
526+
a step that reads as sound and was taken on an answer that said nothing looks exactly like a step taken on
527+
one that said everything, until you open both. Select and copy either half — into an issue, into a message to
528+
whoever handed you the dump, into a diff of two runs.
529+
457530
A session is kept in `~/.shark-dive/agents/sessions`, one file per agent that connected and the newest
458-
hundred kept, a line of JSON per call — so it outlives the window and can be read by something other than
459-
this app. **And the reads each call cost are in the run's log**, in `~/.shark-dive/logs`, where the reason
460-
it gave is followed by the work it caused:
531+
hundred kept, a line of JSON per message, each carrying that message's `input` and `output` — so it outlives
532+
the window and can be read by something other than this app. `agent_log` hands an agent the same text, which
533+
is how one agent works out where another went wrong. **And the reads each call cost are in the run's log**, in
534+
`~/.shark-dive/logs`, where the reason it gave is followed by the work it caused:
461535

462536
```
463537
18:19:48.035 [shark-dive-agents] An agent called chain_from_gc_root(object=0x12d368b8, window=zvphq4r3)

shark/shark-dive/notes/agent-surface.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,36 @@ mitigations that shipped in 2026 (Anthropic's tool search, code execution over M
2525
**This surface is not where a context window goes to die**, and a per-tool cost of ~300 tokens is what buys
2626
descriptions that say when to reach for a tool. Re-measure it if the count doubles again.
2727

28+
## And what reading somebody else's session costs
29+
30+
`agent_log` without a session id is a line per session and cheap. With one, it is now every *message* of that
31+
session with what each one sent and read back, which is the only form that answers "why did it do that
32+
next" — and the numbers are worth knowing before reaching for it. Measured against a packaged run on
33+
`leak_asynctask_o.hprof`: four calls typed at `--agent` (two `list_leaks`, a refused `conclude`, a
34+
`solve_the_leak` that is no tool) and four messages pushed at the socket by hand (a line that is not JSON, a
35+
`resources/list`, a `tools/call` with no name, a notification):
36+
37+
| | Measured |
38+
| --- | --- |
39+
| The four tool-call rows | 8,783 + 8,775 + 1,750 + 987 characters — the answer is nearly all of each |
40+
| The four protocol rows | 37,313 characters, of which **32,976 is four `initialize` answers** |
41+
| The whole `agent_log session=…` answer | 57,693 characters, ≈14,400 tokens |
42+
43+
So **a tool call is one to two thousand tokens** and a thirty-call investigation read in full is most of a
44+
small context window. That is the tool being used for what it is for rather than a leak — it is the one call
45+
on this surface whose subject is somebody else's whole investigation.
46+
47+
**The handshakes are the surprise, and they are a command line's.** `initialize` answers with
48+
`AgentMethod.INSTRUCTIONS`, and `--agent` is a process per call, so a session of typed calls carries the
49+
method once per call: 57% of the answer above, handed to a reader that already has the same text in its own
50+
context. An MCP session pays it once. Nothing about that is a reason to record less — a session that keeps
51+
only what reached a tool cannot say why nothing did, which is the whole point of keeping the traffic — but if
52+
`agent_log` needs to be cheaper, **the handshake answers are where to look first**, and the shape to reach
53+
for is a way to ask for one message's exchange rather than a shorter version of every message's.
54+
55+
Nothing truncates it, deliberately: a session cut to fit is one where the answer that misled an agent is the
56+
part that got cut.
57+
2858
## What the command line costs, now that there is one
2959

3060
`--agent <tool> name=value …` is a process per call, and the thing to know is what that *doesn't* cost.

shark/shark-dive/shark-dive-agent/AGENTS.md

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,41 @@ heap dump read on every call to answer a question the reader already has the dum
8585
can open a second dump, and a call about one this window hasn't got is a row it leaves as the address, saying
8686
which file, and opens that dump when clicked.
8787

88+
**A call keeps the exchange as well as this app's reading of it.** `input` is the tool's own name and then
89+
the arguments as they arrived, formatted, and `output` is the answer as the text that reached the model — the
90+
same string `toolResult` puts in `content[0].text`, formatted once and then both answered with and written
91+
down, so that a session can be compared against a client's own transcript character for character. Everything
92+
else on a call is derived, and a derived field is the one thing that is no use when the question is why an
93+
investigation went wrong: a step made on an answer that said nothing reads exactly like a step made on one
94+
that said everything.
95+
96+
**`output` is what went back, whatever that was.** A refusal is in it as well as in `refused`, and an error as
97+
well as in `error`, and the two are not the same field said twice: `refused` and `error` are this app's
98+
reading — the method said no, this app could not answer — and `output` is the text the agent was handed. The
99+
first version left `output` null for a refusal on the grounds that the refusal was already written down, and
100+
what that looked like from outside was a call that got no answer at all. **Null on `output` means nothing went
101+
back**, which is a notification and nothing else.
102+
103+
**And a line goes down for every message, not only the ones that reached a tool.** The handshake, a
104+
`tools/list`, a ping, a notification, a method this build has never heard of, a `tools/call` with no name or a
105+
name nothing answers to, and a line that was not JSON: all of them. `tool` is null for the ones that reached
106+
no tool and `method` says what arrived instead, `AgentSession.toolCalls` is the subset that got as far as a
107+
tool, and **that distinction is not cosmetic** — the eval counts calls, and a run scored on lines sent would
108+
have a number that moved with the transport. `AgentSessionCall.over` is which way in a line came, told to
109+
`McpSession` at construction because a command line and an MCP client are indistinguishable from the moment
110+
the handshake is past, which is the point of them. The word travels as the last field of `AgentServer`'s
111+
handshake line, and a connection that says nothing is MCP.
112+
113+
The name is in `input` even though `tool` has it, and that is not an oversight: this field is read as one
114+
thing, and a set of arguments lifted away from what they are arguments *to* is the one form of a call nobody
115+
can read on its own.
116+
117+
Two things follow. The window's *Agent logs* screen unfolds **every** row onto this, not only the one that
118+
answers with a list, and does it from a mark under the row rather than from the verb, so a row stays a
119+
sentence with one link in it. And `agent_log` with a session id hands the same text over, which makes it the
120+
one expensive call on this surface — `notes/agent-surface.md` has the measurement. Neither of them truncates:
121+
a session cut to fit is one where the answer that misled an agent is the part that got cut.
122+
88123
**Two fields come off the answer instead.** What an agent asked is what it typed, and what it concluded is
89124
what the heap dump *agreed to* — so `outcomeOfTool` reads the reference out of `conclude`'s answer. Both
90125
readers need that one and neither can work it out: the screen's last row is what a session came to, and the
@@ -95,8 +130,10 @@ answer printed twice.
95130

96131
**The verbs are here rather than in the app.** `verbOfTool` is beside the tool names, so that a screen never
97132
spells them itself and drift is one list rather than two. `AgentSessionFileTest` asserts every tool in the
98-
registry has one; a tool added without a verb reads as its own name, which is the protocol showing through on
99-
the screen that exists to not show it.
133+
registry has one — **which is what makes the fallback mean something**: a name `verbOfTool` has no verb for is
134+
a name this build has no tool for, so a row reading `Called solve_the_leak` is a typo or a tool from a newer
135+
build, and the name is left exactly as it arrived because that string is what somebody is looking for. A
136+
message that reached no tool at all reads through `verbOfMethod` instead.
100137

101138
**A verb stops where the thing it was about starts**, which is why several of them end mid-sentence: a row of
102139
that screen is prose with one link in it, and the link is the thing. So `list_leaks` is "Listed the" and
@@ -133,10 +170,10 @@ to one adapter and not the other is the mistake this shape exists to make imposs
133170
`notes/agent-surface.md`, which also has what a call costs.
134171

135172
**A process per call would otherwise be a session per call**, and a session is what somebody reads afterwards.
136-
So the handshake is `token[ sessionName]` on one line, `AgentSessionFile.continuing` appends to the newest file
137-
whose name carries that id, and a command line defaults to `cli<the shell's pid>` — an agent's calls come out
138-
of one shell the way its MCP calls come out of one connection. A client that says nothing gets a session of
139-
its own, which is what every MCP client does.
173+
So the handshake is `token[ sessionName[ over]]` on one line, `AgentSessionFile.continuing` appends to the
174+
newest file whose name carries that id, and a command line defaults to `cli<the shell's pid>` — an agent's
175+
calls come out of one shell the way its MCP calls come out of one connection. A client that says nothing gets
176+
a session of its own, which is what every MCP client does, and its lines are recorded as MCP.
140177

141178
**The name is checked at both ends**, because it becomes part of a file name: the command line refuses one
142179
that isn't letters and digits before calling anything, and `AgentServer` serves the connection anyway with a

shark/shark-dive/shark-dive-agent/src/main/java/shark/dive/agent/AgentCommandLine.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,11 @@ object AgentCommandLine {
164164
): Int {
165165
val toApp = PrintWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8), true)
166166
val fromApp = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
167-
// The token, and then which session this call is one of: one line, because the alternative is a
168-
// handshake that has to be answered before the protocol can start. See [AgentServer].
169-
toApp.println("${run.token} $sessionName")
167+
// The token, then which session this call is one of, then that it was typed rather than sent by a
168+
// client: one line, because the alternative is a handshake that has to be answered before the protocol
169+
// can start. Saying so here is the only chance there is — from the next line on this is indistinguishable
170+
// from an MCP client, which is the point of it. See [AgentServer] and [AgentTransport].
171+
toApp.println("${run.token} $sessionName ${AgentTransport.CLI.recorded}")
170172
if (fromApp.readLine() != AgentServer.ACCEPTED) {
171173
say("Shark Dive run ${run.pid} refused the token in ${run.file}, so it is not the run that wrote it")
172174
return NOTHING_ANSWERED

0 commit comments

Comments
 (0)