Skip to content

Dashboard: find the agents an application runs, and draw them - #169

Open
benh wants to merge 11 commits into
mainfrom
dashboard-agents
Open

benh wants to merge 11 commits into
mainfrom
dashboard-agents

Conversation

@benh

@benh benh commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Summary

The dashboard's call graph stopped at the agent boundary. A workflow that hands its work to a model showed up as one unremarkable method, and everything the model could then do to the application — every tool call back into a state type — was invisible, even though those are ordinary Reboot calls running under the same workflow. On the agent-wiki example the graph drew 5 calls between 4 state types and said nothing about the librarian, its 5 tools, or the calls they make; with this change it draws 11 calls between 4 state types and 1 agent.

How the analysis finds agents

It extends the existing static analysis in reboot/dashboard/backend/code_watcher.py and works the same way that analysis already recognizes Reboot calls: by asking pyright where a call's definition is, not by how the call is spelled.

  • Runs. A call whose definition lands on one of Agent's entry points (run, iter, run_stream, run_stream_events) in reboot/agents/pydantic_ai/_agent.py is a run. Which entry point isn't recorded, since it doesn't change what the agent can reach. Runs are recorded on the servicer method, flattened through followed helpers.
  • Only literally named, top-level agents, for now. The receiver has to resolve to an Agent(...) (or Agent.wrap(Agent(...))) with a string literal name=, bound to a name at the top level of a module, in the file running it or imported from another. That gives its name, model, system_prompt/instructions and description; only literals are read. The name, which the runtime requires to be unique, is the agent's identity: a run records it, and per-file records join on it. Anything else — a computed or missing name=, a factory, Agent.wrap of anything but a construction, held on self, bound inside a function, constructed where it's run, an alias, a parameter, an element of a collection, or a name bound twice at the top level — isn't resolved, and the run is a hazard of the method. Supporting more shapes can come later.
  • Tools. A function whose decorator resolves to Agent.tool/tool_plain, the one way of giving an agent a tool that can always be followed. Each tool's body goes through the same analysis as a servicer method, so its Reboot calls, the runs it makes (agent-to-agent delegation), and its own method hazards are recorded.
  • Everything that isn't followed is a typed hazard. Each place that can record a hazard has its own nested message with a oneof of the specific hazards possible there, each carrying what's written (rendered by ast.unparse), with optional fields where something may be absent: Servicer.Method.Hazard (run_on_unresolved_agent, override_on_unresolved_agent, ambiguous_call, each with the callee), Agent.Hazard (constructed_with for tools=/toolsets=/prepare_tools=, tool_registered_by_call, overridden), Agent.Run.Hazard (run_arguments for toolsets=/model=/instructions=, overridden when written inside an override's with), Agent.Tool.Hazard (prepared), and UnattributedHazard (tool_on_unresolved_agent, recorded per file). These are for a future ATTENTION section to show and toggle by case. builtin_tools= is left out, since those run in the model provider.
  • ambiguous is a hazard too. Servicer.Method.ambiguous (and Agent.Tool.ambiguous) become Servicer.Method.Hazard.ambiguous_call, so everything the analysis didn't follow in a method is in one list.
  • No positions. Nothing the analysis records is a line or column any more — including Servicer.line/character, which nothing read — so a file's records don't change when only the code around them moves.
  • Per-file records. Like servicers, agents are recorded per file, because the watcher's cache is keyed by file. An agent met in several files has a record in each, joined on name. Reading Reboot's agents module is recorded as an external dependency, so upgrading reboot reanalyzes exactly the files that read it.

A fix to stored analyses (its own commit)

A restarted dashboard carries a file forward, unanalyzed, when its bytes and its dependencies' are unchanged, and the state recorded nothing about which analysis produced its results. After upgrading to a Reboot whose analysis records something new (agents, in this PR), unchanged files would go on showing what the older analysis found. The second commit, independent of agents, makes the state record code_analysis_version; a restart that finds a different one analyzes every file again, keeping the old records so the changelog doesn't report everything as added.

What the page shows

A card per agent, marked with 🤖 and tinted in the workflow colour, since an agent only ever runs inside a workflow: name, model, the first lines of the prompt, and a row per decorated tool. A run lands on the card's head as a runs arrow, folding into one counted arrow when the calling package is collapsed. Tools are drawn as workflow-coloured rings (they run in the workflow's context, but are called by the agent and declared by no API, which is also why unknown methods are rings), their calls leave their own rows dashed like a workflow's, and the cones light through the agent. Clicking the head opens the agent in the right-hand pane with the full prompt as written, its description, and each tool with its docstring and what it calls.

Not covered

The page doesn't draw hazards yet, which is the follow-up UI work. Agent changes don't produce changelog entries yet.

Commits

Each commit builds and passes the dashboard tests on its own, and each analysis commit records as hazards whatever it doesn't follow yet.

  1. Agents: test that override(tools=...) reaches a run and is memoized
  2. Dashboard: analyze every file again when the analysis changesCODE_ANALYSIS_VERSION
  3. Dashboard: stop recording where a servicer is written — drops Servicer.line/character
  4. Dashboard: record where a method runs an agent — every run is a Servicer.Method.Hazard.run_on_unresolved_agent
  5. Dashboard: find the agents a method runsAgent, Agent.Run, literally named top-level agents, Agent.Run.Hazard
  6. Dashboard: read an agent adopted with Agent.wrap where it is made
  7. Dashboard: find the tools an agent has, and what they do — decorated tools, runs made by tools, Agent.Hazard, Agent.Tool.Hazard, UnattributedHazard
  8. Dashboard: say what an override changes about an agent
  9. Dashboard: draw the agents an application runs — the page
  10. Dashboard: record an ambiguous call as a method's hazard
  11. Dashboard: spell out Servicer.Method.Call rather than alias it

Testing

  • The first commit adds a regression test to agent_tests.py: a tool given with agent.override(tools=[...]) reaches a run inside the override and is memoized across a workflow retry. That depends on each entry point building its wrapped toolsets before installing its own tools=[], and nothing covered it.
  • Agent tests in code_watcher_tests.py: runs through all four entry points; decorated tools; an agent constructed in another file; delegation and cycles; runs whose agent can't be resolved (factory, alias, annotated and untyped parameters, collection element) and an override of one; only literally named top-level agents resolved (computed or missing names, factories, self, function-local, inline and twice-bound ones are hazards), with records unchanged when code moves down; Agent.wrap of a construction, and of anything else; per-run and override changes; tool registrations that aren't followed; the analysis version; and reconstitution from stored state. //tests/reboot/dashboard:code_watcher_tests_py passes on every commit from the third on, and every target under //tests/reboot/dashboard/... and //reboot/dashboard/... passes on the UI commit and the last, as does //tests/reboot/agents/pydantic_ai:agent_tests_py. Locally, ImplementationWatcherTest needed Envoy 1.38.4 on PATH, because the dev container still ships 1.38.2.
  • Checked by hand against reboot/examples/agent-wiki in the browser (before the analysis was narrowed to top-level agents and hazards were restructured; agent-wiki's librarian is a top-level agent, and the page reads none of the changed fields): the card, the runs edge from Wiki.ingest, the tool edges, both cones, the collapsed-package fold, and the pane.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LRFggUcgVpLqhgb7h1cK6H

@aviator-app

aviator-app Bot commented Sep 13, 2026

Copy link
Copy Markdown

Current Aviator status

Aviator will automatically update this comment as the status of the PR changes.
Comment /aviator refresh to force Aviator to re-examine your PR (or learn about other /aviator commands).

This pull request is currently open (not queued).

How to merge

To merge this PR, comment /aviator merge or add the mergequeue-ready label.


See the real-time status of this PR on the Aviator webapp.
Use the Aviator Chrome Extension to see the status of your PR within GitHub.

`Agent.override` rejects `toolsets=`, because the per-run override
each entry point installs would silently shadow it. The same entry
points also install `tools=[]`, which looks like it should shadow an
override's `tools=` the same way, and nothing tested it either way.

It does not. Each entry point builds its wrapped toolsets while the
caller's override is still in effect, so the overridden tools are
picked up and wrapped before its own `tools=[]` takes over: a tool
given with `override(tools=...)` reaches a run inside the override,
and a retried workflow gets its memoized result rather than calling
it again. The test pins that ordering down, so a change that stops
honoring it fails here rather than dropping the tools silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRFggUcgVpLqhgb7h1cK6H
<TypeLink
className="type-link"
id={`${call.stateTypeName}.${call.methodName}`}
key={`${call.stateTypeName}.${call.methodName}`}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These sibling TypeLinks can collide on their React key. countCalls folds on a key that includes how, so (stateType, method) is not unique within a GraphTool.calls array:

for (const call of calls ?? []) {
const key = `${call.stateType}|${call.method}|${call.how}`;
const already = counted.get(key);

A tool body that reaches the same method two ways — await Depot.ref(id).look(context) alongside Depot.ref(id).schedule(when=...).look(context) — yields two GraphCalls differing only in how (CALL vs SCHEDULE; the backend appends each call with no dedup), and both render here with key="shop.v1.Depot.look", which is a duplicate-key warning and unreliable reconciliation. This is the first place in the dashboard that renders GraphCalls as keyed siblings, so nothing on main already establishes this pattern — and features.ts guards the same ${stateTypeName}.${methodName} string with an explicit seen set for exactly this reason.

Suggested change
key={`${call.stateTypeName}.${call.methodName}`}
key={`${call.stateTypeName}.${call.methodName}|${call.how}`}

Worth noting separately: with the key fixed, the two entries still render as byte-identical links, since neither how nor count is shown. Deduplicating the list on (stateTypeName, methodName) instead, or surfacing how in the label, may be closer to what the pane wants.

@benh
benh force-pushed the dashboard-agents branch 4 times, most recently from e31fe8b to 12180e0 Compare September 14, 2026 12:39
Comment thread reboot/dashboard/backend/code_watcher.py Outdated
A restarted dashboard carries a file forward, unanalyzed, when its
bytes and its dependencies' are what they were when it was last
analyzed. The state recorded nothing about which analysis produced
what it holds, so a state written by an older analysis went on
showing what that analysis found: after upgrading to a Reboot whose
analysis records something new, nothing new would appear for a file
until the file itself was edited.

The state now records `code_analysis_version` beside what the
analysis recorded, and the watch writes `CODE_ANALYSIS_VERSION` with
every update. A restart that finds another version, including a state
written before the field existed, analyzes every file again. What was
recorded is kept, only without the digest that would carry it
forward, so the changelog still tells what changed from what was
always there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRFggUcgVpLqhgb7h1cK6H
@benh
benh force-pushed the dashboard-agents branch 2 times, most recently from a1f77d1 to c882cbc Compare September 14, 2026 13:45
A servicer recorded the line and column of its class, which nothing
reads. A position changes whenever anything above it does, so a file
whose servicer only moved down recorded something different, and a
record pointing into another file would go stale the moment that
file shifted -- which rules out ever carrying forward the analysis of
a function that did not change.

What the analysis records is now only what the code says, so moving
a servicer down its file records the same thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRFggUcgVpLqhgb7h1cK6H
@github-actions

Copy link
Copy Markdown
Contributor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

benh and others added 8 commits September 14, 2026 21:34
The call graph stopped at the agent boundary. A workflow that hands
its work to a model showed one unremarkable method, and nothing said
it runs an agent at all.

A run of an agent is now recognized by its definition rather than by
how it is spelled, which is how the analysis already recognizes a
Reboot call: a call whose own definition lands on one of `Agent`'s
entry points -- `run`, `iter`, `run_stream`, `run_stream_events` --
in the module Reboot writes them in, flattened through the helpers a
method calls. Reading Reboot's agents module is recorded as an
external dependency, so upgrading the installed `reboot` reanalyzes
exactly the files that read it.

Which agent a run is made on is not resolved yet, so each run is
recorded as what the analysis did not follow rather than guessed at
or dropped: a `Servicer.Method.Hazard`, a message whose `oneof` says
what went unseen and carries what is written there. Its one case so
far is `run_on_unresolved_agent`, with the callee of the run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRFggUcgVpLqhgb7h1cK6H
Every run of an agent was a hazard, since nothing said which agent a
run is made on.

Which agent is run is now what pyright resolves the receiver to,
whether the agent is used in the file constructing it or imported
from another. To keep this first version simple, only an
`Agent(...)` constructed with a string literal `name=` and bound to
a name at the top level of a module is resolved. The `name`, which
the runtime requires to be unique, is what tells it from every other
agent and what a run records; no position is recorded, so moving code
records nothing new. Its construction also says its model, prompt and
description; only literals are read. A method records its runs,
flattened through the helpers it calls, and the file records each
agent it runs, in `Agent`. Records are per file, the way servicers
are, because the watch's cache is keyed by file, so an agent run in
several files has a record in each.

An agent whose `name=` is computed or missing, one a factory builds,
one held on `self`, bound inside a function or constructed where it
is run, an alias, a parameter, an element of a collection, or a name
bound twice at the top level -- which pyright resolves to its first
binding whichever one a run holds -- is still not resolved, and its
runs are still `run_on_unresolved_agent` hazards.

What a resolved run is given on top of its agent is not followed,
but recorded as an `Agent.Run.Hazard`: `run_arguments`, for
`toolsets=`, `model=` and `instructions=` passed to the run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRFggUcgVpLqhgb7h1cK6H
`Agent.wrap(...)` is not `Agent(...)`, so an adopted agent was not
resolved, and every run of it was a hazard, even when the agent it
adopts is constructed right there with a literal `name=`.

`Agent.wrap(Agent(...))` is now read from the construction it wraps.
An agent that wraps anything else, e.g. `Agent.wrap(existing)`, is
still not resolved, and its runs still say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRFggUcgVpLqhgb7h1cK6H
What an agent can do to the application is what its tools do, and
nothing recorded them: every tool call back into a state type was
invisible, even though those calls are Reboot calls like any other
and run under the same workflow.

A tool is now found by its definition too: a function whose decorator
resolves to the `tool` or `tool_plain` a Reboot `Agent` registers
one with, the one way of giving an agent a tool that can always be
followed. It is recorded on the agent with the name and description
the model is told, and its body is analyzed exactly as a servicer
method's is: the Reboot calls it makes, the runs it makes, which is
where one agent handing work to another comes from, and what it does
that is not followed, in `Agent.Tool.method_hazards`. Two agents
handing work to each other terminate. The file registering a tool
records the agent too, wherever the agent is constructed.

Every other way of giving an agent tools can be written in more
shapes than can be followed reliably, so none is, and each is
recorded as a hazard instead:

- `Agent.Hazard.constructed_with`: `tools=`, `toolsets=` and
  `prepare_tools=` passed to its construction.
- `Agent.Hazard.tool_registered_by_call`: a tool registered by
  calling `tool` rather than decorating with it.
- `Agent.Tool.Hazard.prepared`: `prepare=`, which can hide the tool
  when the agent runs.
- `UnattributedHazard.tool_on_unresolved_agent`: a tool registered
  on an agent that cannot be resolved, which has no agent to be
  recorded on, so the file records it.

`builtin_tools=` is left out: those tools run in the model provider
and cannot reach the application's state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRFggUcgVpLqhgb7h1cK6H
`agent.override(...)` changes the agent for whatever runs inside it:
the tools it can reach, its model, its instructions, and a name that
does not change the one its runs are memoized under. None of that was
recorded, so a card drawn from the construction alone would read as
the whole of what those runs do.

What an `override` changes is not followed, but recorded as an
`Agent.Hazard.Overridden`, one per `override`, with each argument it
passes: on the agent, and, as `Agent.Run.Hazard.overridden`, on each
run written inside the override's `with`. Which runs are inside is
decided by where they are written, so a run in a function called from
inside the `with` is not said to be, though its agent still is. An
`override` of an agent that cannot be resolved is a
`Servicer.Method.Hazard.override_on_unresolved_agent`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRFggUcgVpLqhgb7h1cK6H
The analysis records every agent an application runs, the tools each
has, and the calls those tools make, but the page drew none of it: a
workflow handing its work to a model still showed one unremarkable
method.

The page grows a card per agent, marked with a robot, in the
workflow's colour, since an agent only runs inside a workflow: its
name, its model, the first lines of its prompt, and a row per tool,
whose dot is a ring, since a tool runs in the workflow but is called
by the agent. A run lands on the card's head as a labelled arrow,
folding into one counted arrow when the calling package is
collapsed, and each tool's own calls leave its row, dashed the way a
workflow's calls are. The card's head opens the agent in the types
pane, where the prompt is shown whole beside each tool's description
and what it calls. The page joins an agent's records on its name,
which the runtime requires to be unique, and does not show hazards
yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRFggUcgVpLqhgb7h1cK6H
A call the analysis cannot resolve -- one with no definition pyright
can say, or one whose definition is no function -- was recorded in a
list of its own, `ambiguous`, beside the hazards the agent analysis
records for exactly the same reason: something in the method's
implementation was not followed. Whoever shows what may be missing
from the graph had two places to look, shaped differently.

An ambiguous call is now `Servicer.Method.Hazard.ambiguous_call`,
with its callee as written, recorded among the method's other hazards
in the order the analysis met them, and among a tool's
`method_hazards` the same way. `Servicer.Method.ambiguous` and
`Agent.Tool.ambiguous` are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRFggUcgVpLqhgb7h1cK6H
The analysis aliased `Servicer.Method.Call` as `Call`, which reads
like one of `ast`'s nodes -- `ast.Call` is all over the same file --
and hides which message a call is recorded as. It is now spelled out
wherever it is used, the way the analysis spells out every agent
message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRFggUcgVpLqhgb7h1cK6H
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant