Conversation
Current Aviator status
This pull request is currently open (not queued). How to mergeTo merge this PR, comment
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
581b2c7 to
15eb421
Compare
| <TypeLink | ||
| className="type-link" | ||
| id={`${call.stateTypeName}.${call.methodName}`} | ||
| key={`${call.stateTypeName}.${call.methodName}`} |
There was a problem hiding this comment.
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:
reboot/reboot/dashboard/web/src/callgraph.ts
Lines 130 to 132 in 15eb421
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.
| 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.
e31fe8b to
12180e0
Compare
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
a1f77d1 to
c882cbc
Compare
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
c882cbc to
0c7387f
Compare
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
0c7387f to
dc95458
Compare
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
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
dc95458 to
6c2c59e
Compare
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-wikiexample 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.pyand 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.Agent's entry points (run,iter,run_stream,run_stream_events) inreboot/agents/pydantic_ai/_agent.pyis 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.Agent(...)(orAgent.wrap(Agent(...))) with a string literalname=, bound to a name at the top level of a module, in the file running it or imported from another. That gives itsname,model,system_prompt/instructionsanddescription; only literals are read. Thename, 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 missingname=, a factory,Agent.wrapof anything but a construction, held onself, 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.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.oneofof the specific hazards possible there, each carrying what's written (rendered byast.unparse), withoptionalfields where something may be absent:Servicer.Method.Hazard(run_on_unresolved_agent,override_on_unresolved_agent,ambiguous_call, each with thecallee),Agent.Hazard(constructed_withfortools=/toolsets=/prepare_tools=,tool_registered_by_call,overridden),Agent.Run.Hazard(run_argumentsfortoolsets=/model=/instructions=,overriddenwhen written inside anoverride'swith),Agent.Tool.Hazard(prepared), andUnattributedHazard(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.ambiguousis a hazard too.Servicer.Method.ambiguous(andAgent.Tool.ambiguous) becomeServicer.Method.Hazard.ambiguous_call, so everything the analysis didn't follow in a method is in one list.Servicer.line/character, which nothing read — so a file's records don't change when only the code around them moves.name. Reading Reboot's agents module is recorded as an external dependency, so upgradingrebootreanalyzes 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
runsarrow, 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.
Agents: test that override(tools=...) reaches a run and is memoizedDashboard: analyze every file again when the analysis changes—CODE_ANALYSIS_VERSIONDashboard: stop recording where a servicer is written— dropsServicer.line/characterDashboard: record where a method runs an agent— every run is aServicer.Method.Hazard.run_on_unresolved_agentDashboard: find the agents a method runs—Agent,Agent.Run, literally named top-level agents,Agent.Run.HazardDashboard: read an agent adopted with Agent.wrap where it is madeDashboard: find the tools an agent has, and what they do— decorated tools, runs made by tools,Agent.Hazard,Agent.Tool.Hazard,UnattributedHazardDashboard: say what an override changes about an agentDashboard: draw the agents an application runs— the pageDashboard: record an ambiguous call as a method's hazardDashboard: spell out Servicer.Method.Call rather than alias itTesting
agent_tests.py: a tool given withagent.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 owntools=[], and nothing covered it.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 anoverrideof 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.wrapof a construction, and of anything else; per-run andoverridechanges; tool registrations that aren't followed; the analysis version; and reconstitution from stored state.//tests/reboot/dashboard:code_watcher_tests_pypasses 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,ImplementationWatcherTestneeded Envoy 1.38.4 onPATH, because the dev container still ships 1.38.2.reboot/examples/agent-wikiin the browser (before the analysis was narrowed to top-level agents and hazards were restructured;agent-wiki'slibrarianis a top-level agent, and the page reads none of the changed fields): the card, therunsedge fromWiki.ingest, the tool edges, both cones, the collapsed-package fold, and the pane.🤖 Generated with Claude Code
https://claude.ai/code/session_01LRFggUcgVpLqhgb7h1cK6H