Availability: new in 1.2.0, the newest published release; see the
CHANGELOG entry.
An older install has no --application: pipx upgrade agents-shipgate.
For an OpenAI Agents SDK or Google ADK application, compare committed PR refs:
agents-shipgate diff --application --workspace /path/to/repository \
--base BASE_SHA --head HEAD_SHA --scope backendNo init, manifest, saved baseline or authored declaration is required. The
command writes no files in the subject repository and runs no application code.
Fetch the refs first; the command never fetches. Without --scope the scope
is derived from the change (below); --scope . selects the repository root.
--head defaults to committed HEAD, excluding dirty edits.
The base is the requested base ref's merge base with the selected head.
The result identifies each observed agent object and its added, removed or changed tool bindings. It shows before/after signatures, binding and function locations, and implementation digests. Existing reader observations supply the binding edges; unrelated tool definitions are not agent capabilities. A missing deployment-root selection does not hide known per-agent edges.
ADDED synthesizer_agent → python_exec
before: no observed binding
after: python_exec(code, dataset_json) -> str at backend/app/agents/synthesizer.py:38
A reviewer can inspect the new callable and decide whether that agent should receive it. A body change is a request to review the implementation; it does not establish widening, narrowing, business impact or runtime behavior.
The text opens with one finding per changed agent, then the rows. A finding
names the tools the change added (+), removed (-) or changed (~); a
leading ? marks a candidate the comparison could not establish. Its first line
stands alone. For jpka/attest#3:
Findings: 1 changed agent, 2 rows (2 added); effect evidence 2 write (provisional: unknown effect).
attest_orchestrator [partial]: +recall_firm_memory, +remember_firm_finding (reach: none established; not read beyond agents/attest_orchestrator/memory_bank.py:531)
Review: Should attest_orchestrator be able to call recall_firm_memory, remember_firm_finding? What they reach was not read in full.
Detail:
and for MIS_TALENT#7, whose first finding line names a new database read before the rows that carry it:
Finance_Agent_Preflight [partial]: +load_service_catalog, ~load_and_validate (new reach: database read; not read beyond app/tools/FinanceAgent/tools.py:39)
The committed goldens in
benchmark/application-q2/goldens/
hold the whole text of six real answers, among them these two and the 234-row
O.R.I.O.N#126,
which reads in 24 lines.
What a finding states, and from where:
- Capability. What the tool's code reaches (#872, #913) and what an object
tool is (#910): the outbound call or library effect, its host, the
credentials it sends by name, and the parameters the model controls. For a
changed tool only a difference is new reach (
now also reaches); the same call sending something new ischanged reach; a reach the change left alone isstill reaches. - The first unresolved hop. A finding is
partial, nevercompared, when any of its tools has a reach the reader stopped short of, a binding it could not establish or an agent whose binding graph is incomplete. It names the first place reading stopped (not read beyond …) and, per tool, how many more there are.comparison_statusis unchanged and still speaks of bindings only, so acomparedanswer can carry apartialfinding. - Effect evidence is counted once for the whole answer instead of repeated per row.
- Shared causes.
not_establishedrows with the same reason are one line with a count, one example and the rows' number, however many tools and agents share it. Two reasons are one cause when they differ only in the names and places they quote. Limits no row states are one line, by agent. - A question specific to the change: a new write, process, network or
database reach ("should
agentreach …?") with the credentials it sends and the arguments the model controls; the same call newly sending a credential or taking a model-controlled argument; a removed tool; or a changed one and what changed ("arguments +units").
The reading is presentation. It changes no row, status, direction, gap, scope
or exit code, and states nothing a row does not: every finding, tool, cause and
question names the rows it summarizes by index. The rows follow under
Detail: for up to 10 of them. Above that the text says how many it did not
print: --json always carries every row, with every reach limit.
summary in the JSON is the content the text renders, and nothing else:
| Field | Meaning |
|---|---|
counts |
total, added, removed, changed, not_established over rows. |
findings[] |
One per agent with a row, in row order: agent, agent_source, status (compared or partial), rows (indexes into rows), counts, first_unresolved (kind binding, agent or reach; text, at, row; null when nothing was left unread), capabilities (new reach), changed_capabilities, unchanged_capabilities, tools[], causes (indexes into summary.causes), questions[] (question, rows) and, when the agent's graph has limits no row states, agent_limits. |
findings[].tools[] |
row, tool, change, candidate_change, facts[], unresolved (at, why, count) and effect. A fact is change (what differs), object, or one of reaches, reaches_new, reaches_dropped, reaches_same, reaches_changed, each with target, short, at and, when present, host, credentials and model_supplied (for reaches_changed, what is new, and dropped what is gone). |
effects[] |
effect, status and count over established rows. |
causes[] |
One per shared reason: pattern, an example, how many distinct reasons share it, the rows, sides and agents. |
not_read[] |
Limits no row states, by agent (null when no single agent): count, sides, first. |
question |
Present when a row is not_established. |
summary is derived from the sanitized rows and digested into comparison_id
with them. With several applications (comparisons) it covers the combined
top-level rows; each comparison keeps its own detail without one. benchmark/application-q2/summarize.py leaves it out of
answer_id, so a hand score keeps judging the answer it read.
Without --scope, the comparison does not read the whole repository, nor only
the directory the change touched. Each changed Python file is related to the
agent files — files discovery scores as OpenAI Agents SDK or Google ADK sources
that construct an agent class (under any name they import it as), subclass
one or copy one with capabilities of its own, and a module that changes an
agent's capabilities after construction (support.tools.append(x)) and
imports one of those, or that builds its agent through one's factory
(root_agent = make("bot", [tool]) with make returning an Agent); a
module that only defines tools is not one — that
import it, or that it imports, up to six import hops away, on each side of the
change. A module under a directory discovery skips (build/, fixtures/) is
related like any other, never an agent file. Importing pkg.impl runs pkg/__init__.py
first, so a package's imports count, and so does a literal
importlib.import_module("app.tools"). A file named like a test is related
when an agent imports it; it is never an agent file, and a changed test that
imports an agent does not widen a scope. A changed Python file not related to
a module that builds an agent — no import path, one longer than six hops, only
a module that rewires an agent, a file too large to read — is named in
scope_selection.limits with why, and makes the result partial, even when a
compared scope holds it: its consumer may be elsewhere. So is a changed link
to a module or a directory, or a changed submodule, whose content is not read,
and so is an agent outside the compared scopes whose imports go past the hop
bound. A module that imports the change — directly, through a package's
re-export, an agent file's, or other modules, up to six levels — and imports
within six hops an agent builder that builds on request — one that
constructs an agent whose tools, handoffs, MCP servers or sub-agents are not a
fixed list of the module's own names (tools=tools, list(REGISTRY.items),
self.tools, **config) or gives it such capabilities afterwards (a
rewire, or a copy such as BASE.clone(tools=tools)), or subclasses an agent
class — is named too,
however it builds its agent from them, when it builds, copies
(clone(tools=...), clone(update={"tools": ...})) or rewires an agent
itself, calls the repository's code with arguments of its own, or sets the
repository's module state (factory.TOOLS = [...],
setattr(factory, ...)), unless
one compared scope holds it, the change and that builder. A module that only
imports another module's agent, or only calls an entry point (main()), is
not one. A module that imports the change and imports onward past six hops
without reaching a builder is named as not established, and so is the search
itself when modules outside the compared scopes still import onward after six
levels. A rename's two paths are one file.
scope: backend/app (derived: backend/app: backend/app/services/gemini_tools.py is
related to agent file backend/app/agents/support.py through …)
Each changed file is compared in the outermost package holding it and every
agent that reaches it — with what those agents import from the repository and
the path entry a namespace package is imported through (src for
myapp.tools in src/myapp/) — so the reader can follow each chain. A
change to backend/app/services/gemini_tools.py that
backend/app/agents/support.py imports is compared in backend/app. When only
the repository root holds them all — a library module its own agents and an
example app elsewhere both import — the change is compared where its nearest
agents are, the others are named in outside and in scope_selection.limits,
and the answer is partial, never a silent compared.
Independent applications of a monorepo are separate comparisons, never the
repository root; --json then holds each one under comparisons, with every
path spelled from the repository root, and comparison_status is compared
only when each one is. A Python module moved inside one package is one
comparison of that package; an application directory moved whole — its old
path gone from the head, its new one absent from the base — is one relocation,
compared old path to new path as --base-scope/--scope would, with every
scope inside it; a renamed
non-Python file joins nothing. A change that touches no supported agent — a
README, or a script no agent imports — is not_established with the reason
naming the files considered; nothing is compared, and that is an answer, not a
failure. An absolute import is found where the importer's own path would find
it, or at one path entry holding a package of that name (libs/shared); a
standard-library name is the standard library unless a module on the importer's
path shadows it.
Everything is read from the two commits' objects, never run. The reading is
bounded per side: six import hops, 2000 files read to relate the change, and
5000 files or 64 MB read to find the agent files. A bound reached is named in
scope_selection.limits and makes the result partial — so does a
no-agent answer whose import following stopped at the hop bound — and it never
falls back to the root. --json records scope_selection: mode (derived
or explicit), scopes, the reason, the changed files considered and each
scope's relations (base_scope when it moved, outside agents). An explicit
--scope always wins, and --base-scope needs it.
For an application directory moved by the PR, select its old location separately:
agents-shipgate diff --application --base BASE_SHA --head HEAD_SHA \
--base-scope old/application --scope new/application --jsonThe two scopes are discovered independently. Exact Git blob renames supply file correspondence, preserving original evidence locations; file identity is not a claim about deployed agent identity. An absent directory is absence at that Git path, not proof that no agent exists anywhere else. Missing manifests do not supply an empty base.
An absent side names the missing scope and suggests --base-scope/--scope
for relocation. If neither selected directory exists, the command refuses with
exit 2. A removal describes the selected source path, not the entire repository.
--json emits application_comparison_schema_version: "0.4", engine identity,
requested and compared refs/tree IDs, per-side scope/coverage, rows, source
correspondence, scope_selection, comparisons when a derived change spans
more than one application, and a deterministic comparison_id. Version 0.2 adds
reach, effect_evidence and construction_sites to a row's sides (see
What a bound tool reaches); version 0.3 adds
bound_when (see Tools lists built by an expression);
version 0.4 adds object and object_evidence (see
Tools bound as objects), reach.effects (see
What a bound tool reaches beyond HTTP)
and the top-level summary (see Findings first); 0.4 is not
yet released, so summary extends it rather than adding 0.5.
This is a separate advisory
artifact from the existing host diff JSON and verifier receipt.
compared: the selected supported source observations were compared. An unchanged submodule may still be named inlimits(see below); it cannot carry a change, so it does not make the comparisonpartial.partial: a parse/discovery/binding gap remains. Gaps identify their source and agent where known. Only affected candidates becomechange: not_establishedrows, carryingcandidate_changeand per-sideuncertainty; independent known additions/removals remain visible. Unknown implementation evidence cannot hide an observed binding addition. Unattributed discovery bounds still cover the scope.not_established: neither side established a supported application agent.- Exit 2: refs/materialization/input could not be read. This is not no change.
Every OpenAI Agents SDK Agent(...) construction in a file the scope reads is
either an observed agent or a named limit. An agent assigned to a plain name
is identified by that name (assistant = Agent(...) is assistant), as
before, so renaming its name= or moving it into a builder changes nothing.
Only a variable name assigned in more than one function or class body — two
builders' local agent — gives way to each agent's literal name or source
label, and a handoff to such a
variable is named by the agent it holds. Any other construction is identified
by its literal name: return Agent(name="Quote", ...) inside a factory
function, self.agent = Agent(name="Held", ...), an agent inline in a list,
or Agent("Positional"). Agent[Context](...) is the same construction.
When that construction has no literal name, a unique source label identifies
it. A returned construction in build is build@agent.py; two builders'
local agent variables are first.agent@agent.py and second.agent@agent.py.
A class-held attribute is Helper.self.agent@agent.py, so moving its
construction between that class's methods keeps its identity. Other labels
use the enclosing functions and classes, the single assignment target when
present, and the file. An unassigned module construction is Agent@agent.py.
These are source identities; the reader does not evaluate a computed name.
No label uses a line number or an ordinal. If two constructions would receive
one label, or a label would collide with an existing identity in that file,
their correspondence is a named limit and their tools are not attributed.
If a source identity disappears and another identity appears in the same file,
their correspondence is ambiguous: affected rows are not_established,
not established additions and removals. A file move can also change a label's
file component. The same qualified source label at two unpaired paths names
that ambiguity even when the file moved with edits; it does not prove agent
correspondence.
What the reader cannot read is a named limit on the agent it concerns, so other agents' rows in the file stand:
- one identity constructed at more than one site in a file with different
tools or handoffs (two
return Agent(name="Quote", ...)branches, or a literalnameequal to another agent's variable) — the binding graph would merge them, so which construction binds which tool is not established; constructions that bind exactly the same tools and handoffs are one agent. A tool every such construction binds identically is still a row of that agent, beside the limit. The same holds for a Google ADK agent name: its constructions are one agent only when each binds the same tool definitions and handoffs and was read cleanly (no toolset, no**, no warning, no unresolved or non-literalsub_agents), asroot_agentand a builder returning its twin do; - a construction with
**keyword unpacking or positional arguments after its name, which can carrytoolsorhandoffs; - an agent whose
tools,handoffsormcp_serversare changed after construction: assigned, extended or sliced (agent.tools.append(...),agent.tools = [...],agent.tools[:] = ...),setattr/delattrby name, or a handle taken on them (t = agent.tools) that is itself changed later — a handle only read, likelen(request.tools), is nothing. The changed value is read in the scope of the change (a change in place reaches every agent built from the same list object): an agent the reader constructed — directly, throughself.agent, or through a module function that returns it — carries the limit; a value proven not to be an agent (Settings(), a literal,type(...)(), the instance's ownself.tools) carries nothing; anything else is a limit on the file; - a copy that passes its own
tools,handoffsormcp_servers(agent.clone(tools=...),dataclasses.replace(agent, tools=...),copy.replace(...)), wherever the original came from, unless the original is proven not to be an agent or the exact SDK clone route described below reads it. A copy passing only**is a limit only on a value known to be an agent. A copy that passes none of them keeps the original's tools, which the original's rows already compare, and is not a limit; - an agent built from a subclass of the SDK's
Agentdefined in the same module, whose tools arrive through its constructor, including one made withtype("X", (Agent,), {}).
A computed name alone does not prevent identification. Tool-list, constructor,
and handle checks still apply. A template method such as self.get_tools()
is still a named limit; this source-label step does not instantiate a base
class once for each concrete subclass or read its overridden method returns.
A module that is not read as an SDK source is still read for what can change
an agent without it: a capability-passing copy, and — once the module imports
anything from the scope, or is a Google ADK source, whose reader does not
follow a change after construction — a change to any object's tools, handoffs, MCP
servers or sub-agents, reached by an alias, a loop, a parameter or a call's
result as in an SDK file, unless the object is plainly not an agent (a literal,
or an instance of a class the scope defines that is not an Agent subclass).
A module that imports nothing from the scope has another library's .tools.
In any module, importing an SDK Agent subclass from the scope — from core import Assistant, core.Assistant after import core, or through a package
that re-exports it with from app.core import * — is a limit; a subclass
nothing imports, a vendor class of the same name, or a name that only appears
in a string is not. Each is a limit on the module where it appears. The scope's
own package path counts as the scope (from svc.app.x import … under
--scope svc/app).
The Google ADK reader reads each Agent(...) / LlmAgent(...) call, not a
subclass's constructor. A class deriving from an ADK agent class (class Helper(LlmAgent), its base imported from google.adk, or a class deriving
from that one) is therefore a named limit where it is used: on the module that
defines it when that module names it again — a call, functools.partial, any
other reference, or a decorator that could build it — and that module's tool
surface is then not reported as enumerated to scan either; and on every other
module in the scope that imports it, as for an SDK subclass. A subclass nothing
uses, even one another unused subclass derives from, is not a limit. scan
reads one declared module, so a subclass used only in another module does not
affect it.
The census of copies, changes and subclasses runs only when some side's
discovery found an OpenAI Agents SDK or Google ADK source. Without one, no
agent is read on either side, and the result stays not_established rather
than turning partial over the repository's own .tools (a report builder's
artifacts.tools.append(record)). Every module is still parsed, and one that
cannot be is still a gap.
Not read at all: an Agent re-exported through a project module,
functools.partial(Agent, ...), or a subclass defined outside the scope.
An agent one side observes is absent from the other only when that side's
file no longer names it. If the file still assigns or imports the agent's name
(from factory import agent, agent = build()), or passes it as name=,
through a construction the reader does not read (an Agent subclass, a
.clone()), that side records a gap for the agent and its rows are
not_established, never removed or added. A factory's own return Agent(name="assistant", ...) is read under that literal name in the module
that builds it; it is a construction site of its own, not evidence about the
name the factory's result is assigned to. An agent referenced only in another
agent's handoffs is not an observed construction.
Test files never establish the application. A file under a test or tests
directory, a test_*.py or *_test.py module, conftest.py, test.py or
tests.py — matched case-sensitively and relative to the selected scope, the
convention discovery uses — is listed in each side's excluded_tests, printed
in the text output, and not read as an agent source: a test double's agent is
not the application's agent, so it cannot make a scope count as established,
and a test file's own defects (a tool defined twice, an unsupported framework,
a parse failure) are not the application's gaps. A product module that only
looks like a test is excluded too, which the printed list makes visible; the
import resolver still follows a tool the application imports from such a file.
A file that defines one tool name twice is a named limit on that name in that
file: no agent binds either definition, so every binding of the name there is
not_established, while the file's other tools, and every other file in the
scope, are still compared.
Framework identity follows the import, not the spelling. Agent and
function_tool are the OpenAI Agents SDK's only when imported from the absolute
agents/openai_agents package or not imported at all, resolved in the scope
that uses them, so an import in another function does not decide it; LiveKit's
livekit.agents exports the same names and is not read as the SDK, and a
relative .agents import is the project's own package.
Discovery is bounded by --max-python-files (default 1000) and a 2 MB per-Python
file limit. Partial discovery remains visible. The readers follow tools imported
from other modules inside the selected scope (next section) and identify tools
bound as objects (below); dynamic factories and
imports they cannot follow remain explicit reader limitations. It
does not support other application frameworks yet. Indirect helper effects,
runtime loading, deployed reachability and business authority are outside this
comparison. It grants no release or merge permission and cannot stand in for a
reviewed verifier base or qualification evidence.
An agent often binds a function defined elsewhere in the repository:
from . import memory_bank
from ..tools.shop import add_to_cart
root_agent = Agent(name="orchestrator", tools=[memory_bank.remember_firm_finding])
checkout_agent = Agent(name="checkout", tools=[add_to_cart])Both readers follow such a reference to its definition: a name imported from a
sibling module or re-exported by a package's __init__.py, a module-qualified
module.function, a plain alias = function, and, for Google ADK,
FunctionTool(imported_function) / LongRunningFunctionTool(...), including a
wrapper built in the imported module. The row then
shows the definition's own signature, location and implementation digest, and
adds import_path: each module read, the line of the binding followed, and that
module's SHA-256. import_path is evidence, not compared meaning — moving an
import is not a change. An OpenAI Agents SDK definition must carry the SDK's
@function_tool.
A Google ADK tool built by a factory is the function the factory wraps. A
factory call — tool = create_tool() bound once and unconditionally in the
agent's function or at a module's top level, or tools=[create_tool()] — is
read, never run, to the factory's one unconditional return:
FunctionTool(inner) / LongRunningFunctionTool(inner), a plain function, a
name bound once to one of those, or another factory's call, up to four
factories deep. The function is one nested in the factory and defined before
the return, or one the factory's module binds; import_path records the
factory as a step, and the factory's code and the values each of this
agent's calls gives its parameters — what the tool's closure holds — are
part of its implementation digest: a literal, or a name or module attribute
bound once to one (a list, dict or set — at any depth, or through another
name — only when nothing else in its scope uses it, never a module's), a
function by its own code (not the helpers it calls, as for any tool), or a
parameter of the factory the call is made in, with the factory's defaults for
the rest. So
make_sql_tool(readonly=False) in place of readonly=True is a changed
tool, while spelling the same call otherwise (an alias, a keyword, the
default written out), a docstring, or another agent's call is not. A value
this read cannot name — a builder's parameter, a computed value — is named
as a limit on that agent's tool, with the arguments and where they are
given; a row appears, not_established, only when the calling module or the
factory changed — a value set from another module leaves only the limit. A
module constant the factory's own body reads is not part of it, as for any
function. A factory that returns from more than one place or
under a condition, calls itself, is decorated, a generator or async, a
wrapped function that is decorated, a parameter, defined more than once in its
file (a tool is known by its name there), or changed or handed on in the
factory (inner.__name__ = ... renames the tool, for a module function,
impl.search or a function-local import too), a tool changed or handed on
where it is bound (reading tool.name, listing it in a tools list, or
wrapping it in FunctionTool(func=...) is not), and a factory the repository
holds outside the scope stay named with why; any other call — a third-party
package, a class, a name bound twice — is what it was. A tools list built in
the agent's function — tools = [a, b] with tools.append(c),
.extend([...]), .insert(i, c) or tools += [...] — is read member by
member up to the statement that builds the agent, which copies it; an addition
under a condition or in a loop before it is named on the agent, never read as
bound. A list used any other way is read as
a tools list built by an expression is.
The boundary is narrow. Only regular .py files inside the selected scope are
read, parsed with ast and never imported or run. Symbolic links are not
followed, and a module name must match a file's exact spelling. An absolute
module name is looked up from the importing file's directory and each parent up
to the scope, and — when the scope is itself a package — from the scope's
parent for names starting with the scope's own package name. A name has to be
bound exactly once, directly in the module body, in every module on the way; a
package's own from . import submodule, even under if TYPE_CHECKING:, names
that submodule. A module-level __getattr__ is not evaluated: a tool reached
past one is, for scan, not counted as proven. The comparison takes the
submodule only when the hook is undecorated, never rebinds its parameter, and
every return it can reach for that name (one under if name == "other":
cannot) gives importlib.import_module(f".{name}", __name__) — with
importlib bound only by importing it — or the package's own from . import <name>: the lazy-loading idioms. Otherwise the tool stays named with its row
not_established.
import a.b followed by a.b.f reads the submodule a/b.py, which is what
the import system guarantees after a/__init__.py runs, even when the package
binds a b of its own; several import a.x statements bind one package and
are not a rebinding. A reference is read where it is used: a function that
builds the agent and imports the name itself (from support import lookup
inside the builder) is followed through that import, like a module-level one,
and nonlocal follows the outer function's binding. A function defined inside
the builder, when it is the only definition of its name, is that nested
definition. A module-level agent binds what the module binds at top level (its
def, its import, its wrapper assignment), never a same-named def or wrapper
nested in some function. A parameter, another local assignment, or a name the
builder binds more than once is a named stop, never the module's binding. A
factory's own toolset = McpToolset(...) or tool = FunctionTool(...) is read
like a module-level one. Code that runs before the name is used is checked
for a reassignment: every module on the chain (the agent's own file included),
the __init__.py of every package enclosing one of them, and every in-scope
module those import (import patches in the agent's file, from . import impl in a package). tools.lookup = tools.dangerous, setattr(tools, ...),
or a reassignment there of an attribute named like a step of the chain on an
imported module makes that reference a named stop, and so does such a module
that cannot be read (a link, or a missing relative module not imported under
except ImportError). The defining module's own registry.lookup = lookup
hands the definition on and does not count (a reassignment through its own
import still does); an import under typing's if TYPE_CHECKING: never runs
and is skipped; every location an ambiguous import could mean is read. Code
there that runs but is not read — a relative import above the scope, an
absolute import of a module the repository holds outside the scope (from svc.patches import … or from common.patches import … with scope
svc/app), a relative module no file provides — keeps the tool named, with its
row not_established and that import in the reason. Whether an absolute import
is the repository's own code is read from the compared commit's tree (for
scan, from the checkout, or from the three directories above the scope when
there is none): a module or regular package at the repository root, under
src/, or under any directory between the root and the scope (backend/common
for scope backend/app) — except a module the interpreter loads before any
application code (os, sys), a standard-library name at a root that is
itself a regular package (a plain directory on the path does shadow it:
backend/calendar.py is the calendar that scope backend/app imports), and
the scope's own package on the way to it, which counts only when it holds the name imported
(from agents import Agent beside app/agents/support is the SDK) — a linked
or submodule entry the import spells, or a
directory without __init__.py that holds the submodule named — agents/support/
holding SDK apps is not the agents that from agents import Agent imports. The
scope spelled from one of those roots through a regular package
(svc.app.tools with scope svc/app) is read inside the scope. The
__init__.py of every package between the repository root and the scope, and
the modules each imports — every package on the way to one (import svc.lib.util runs svc/lib/__init__.py) and each submodule named (from .hooks import patches) — run before the scope's modules and are read for the
same reassignments. There a reassignment counts unless it sets one attribute
of another module file outside the scope (registry.tools = [] with
registry a submodule its package binds nothing else under); a longer path
(registry.tools.lookup), a name imported from a module, or an alias of a
module (_t = tools) still counts. A module of the scope one of them imports
(from .app import bootstrap) is read like the chain's own. A change to
__path__ there is a caveat, except pkgutil.extend_path. One of those
modules that cannot be read — a link included — is a caveat unless its import
is guarded or generated, and past 1024 modules the rest are one caveat. What
they import in turn, or import by name at run time
(importlib.import_module("svc.patches")), is not followed, and neither is a
change to sys.path or sys.meta_path that makes a same-named module elsewhere
the one imported. A module file
wins over a directory without __init__.py of the same name, as the import
system prefers it. sys.modules and
globals() are read by allow-list: a subscript, get, a membership test, a
comparison, iteration, a spread ([*globals()], **globals()), a read-only
builtin, pkgutil.iter_modules(__path__) or a namespace keyword
(get_type_hints(fn, globalns=globals())) reads them; a store ([...] =,
setdefault, update, patch.dict, setitem, an attribute of
sys.modules[...]) is a named stop when its key names a module on the chain, a package above one, or
the framework's own modules — __name__ plus a literal is that module's own
name — and nothing when it names another module; any other use (mods = sys.modules, |=, operator.setitem, a computed key) is a caveat. A
module rebinding its own name through globals() or its own module object is
a reassignment of that name, whichever way it spells the object
(sys.modules[__name__], sys.modules.get(__name__),
importlib.import_module(__name__), an alias of one, globals under another
name) or the store (an attribute, __dict__[...], vars(...)[...]); a
computed setattr, the module object anywhere but an attribute, a plain alias,
a comparison or a reader (a container, a return, an annotated or conditional
alias, a walrus, a function), a store through __dict__.update, a frame's
f_globals, builtins.globals under another name, a function of the module's
own named like a reader, and a change to __path__ are caveats. A local named
globals or vars is a variable, unless it rebinds the builtin itself. A package
hook is not trusted when the package rebinds __name__, __getattr__ or
__path__, patches importlib, or stores into sys.modules or globals()
other than the idiom's own cache. A generated
*_pb2 or _version module, an optional import, and an absolute import the
repository does not hold (a third-party package) are the read's boundary, and a
rebinding in a module none of these import is not looked for.
When the module binds a name more than once or only inside an if, the Google
ADK reader still names the same-named def or wrapper it found, for scan,
but that binding is never established: its row is not_established on
whichever side it is present, and so is the row of every tool the module's
bindings of that name could give the agent instead, each followed to its
definition (a function imported from outside the scope by its imported name) —
every one of the agent's rows when one of those cannot be followed or a
wildcard import could bind the name. x = FunctionTool(func=x) right after
def x wraps that def; it is not a guess.
An OpenAI Agents SDK tools=NAME or handoffs=NAME is read through the scope
that binds NAME where the agent is constructed: a builder's own list, a class
body's own list, or the module's. It is read only when that scope binds it
once, unconditionally, to a list the expression reader
reads, and every use of that binding in the module that binds it only reads
it: iterated, indexed, compared, tested, formatted, spread ([*TOOLS, x]),
handed to a read-only builtin, a standard-library reader (json.dumps) or a
logger's method — each proven by its binding, so a print imported from the
application or an .info() on its own object is not one — to an agent's (or a copy's)
own tools=, or to a function whose every use of that parameter is such a
read. A list method, +=, a global or nonlocal rebinding, a subscript
store, a second name (also through x or y), a tuple, a return, *args, or anything
in the module that reaches its names without spelling them (globals(),
vars(), sys.modules, importing the module by __name__) leaves it unread,
named with why. The names in a module-level list are read at module level, whatever
the function that builds the agent imports.
A reference that does not reach one definition stays an unresolved tool, named
with its reason (Not resolved because … in the gap) and scoped to each agent
that lists it: a module the scope does not contain, a relative import above the
scope, more than one matching module location, a name bound twice or only
inside an if/try, a wildcard import, an import cycle, a class or other
value, a parameter or local assignment of the enclosing scope, a symbolic link, a module that
does not parse, or more than 64 modules read. Two agents binding same-named
functions from different modules keep two tools. One agent binding two
different functions under one name binds neither, whatever their order in the
list; its rows for that name are not_established. For scan, a definition an
import reaches and another configured source also reads is one catalog tool,
when both spell the module's path the same way; a Google ADK source configured
as a directory records no file for its tools and is not matched. A source an
inventory completes keeps its own observation, so the completion joins it.
A row compares a definition's signature and implementation digest, not the module it lives in: moving a function is not a change. So retargeting a binding between two functions whose definitions are the same text in different modules shows no row, even when the modules differ in what the function body refers to.
An agent's tools are often not written out in its construction:
tools=[*FINANCE_TOOLS, *([prepare_handoff] if want_handoff else [])],
tools=base_tools + (extra_tools or []), or a filter over another module's
list. An OpenAI Agents SDK or Google ADK agent's tools=, an SDK agent's
handoffs= and an ADK agent's sub_agents= are read member by member when
built from (#909):
- a list or tuple literal, each
*spread spliced in; a + b;a if c else banda or b: every branch that can be the value, each member conditional on the condition that selects it, or only the branch a constant condition, or an operand known to be empty or not, selects;- a comprehension that keeps its elements (
[t for t in TOOLS if keep(t)]) andfilter(f, TOOLS): the members ofTOOLS, each shown as held only when the filter keeps it. The filter is not evaluated, so the list may hold fewer; list(...),tuple(...)andsorted(...)of one of these;- a name bound once to one of these — in the builder or at module level, and
through a repository-local import to the module that builds the list, whose
members are then read by that module's names. A binding inside a branch, a
loop or an
exceptis read only by code in the same block; one insidewithor atrybody is read as unconditional.
A name is looked up where Python evaluates it: a default, decorator or
annotation in the scope around its definition, a comprehension's first
iterable around the comprehension. It is read only while nothing can change
the list after it is built: no use in its own module but a read, by the rule
for tools=NAME above; no change through an agent built with it
(helper.tools.append(x)); no wildcard import after it; and no change in a
module the import passes through or a package above the list's module, which
run first. A change made from a module the import never passes through is not
looked for. Anything else is named where it is, on the agent it belongs to,
with why — a call (get_tools()), a parameter whose closed caller set is not
established, self.tools, a comprehension that builds new elements, a name
bound twice or changed in place, nesting deeper than 16 levels, or more than
1,000 members:
OpenAI Agents SDK agent 'finance' at agent.py:4 has a tools list it reads only in part;
its binding graph is incomplete. Not read: a call to `plugin_tools`, whose result is not
read (agent.py:4).
The agent stays incomplete, so the answer is never compared, but the members
that were read are still compared: a tool both sides bind keeps its changed
row, and nothing a part not read holds is reported as removed. A member another
module's list names is read by that module's names, and never bound by its
spelling alone: an unresolved one is a named limit, and a handoff spelled like
an agent this module builds is not taken for that agent. Nothing is imported or
run. A Google ADK tools= or sub_agents= list that came through a name, or
from another module, is not counted by scan as a proven surface, because
another module could change it; a literal spread into a literal is.
For diff --application only, OpenAI Agents SDK clones with uniquely bound
local source agents are read as
independent agents. A literal name= override identifies the clone; otherwise
its identity combines the source identity with a qualified construction label,
without a line number. Competing labels or identities remain ambiguous. The
reader follows at most eight clone hops to a direct SDK Agent construction.
An imported, custom-instance or otherwise unidentified receiver remains unread.
A clone inherits tools, handoffs and mcp_servers when it omits the field,
and reads an explicit override through the same list resolver. A single literal
filter [t for t in source.tools if t.name == "read"] selects only tool names
the SDK reader established for function tools. Object-tool display names are
not runtime names; such a filter remains unread. Other filters retain their
source condition.
Source, method, shared members and returned handles must retain their existing
constructor and ownership proofs. Replacing a field, changing a shared list or
handing a handle to unread code refuses the read. The SDK makes a shallow copy;
these agents can still share the same list and entries. See the
SDK clone reference.
A clone row can also carry agent_settings: a recognized literal
tool_use_behavior, and tool_choice from an inline SDK
ModelSettings(tool_choice="auto"|"required"|"none"). These are facts read from
the source, not bindings or verified runtime behavior. tool_choice="none"
keeps tools declared and states that this setting disallows their use. A
setting change can produce a changed row without adding or removing tools.
Unknown settings acquire no inferred value. These optional fields extend the
application comparison 0.4 document; they do not add merge authority.
An explicit model override without model_settings supplies no inherited
tool_choice fact, because SDK model defaults can change; its setting remains
uncertain on the affected rows. Conflicting
constructions keep their settings uncertain. A clone's construction label is
retained separately from its name: replacing the clone at that site with an
unread factory does not establish that its bindings were removed.
The same guard covers an assigned object or import bound at that source site.
Caller arguments can reach a module-level builder's construction inside
if or else. The row carries both the caller's condition and the
construction's condition as source text. A statically unreachable branch,
loop, handler, nested builder or method remains a named limit. Calls from
different sites retain their own lists and construction locations; no
condition is evaluated as a runtime result.
A capability field of an already identified agent (base.tools, SDK
base.handoffs, or ADK base.sub_agents) can provide another construction's
list. The reader uses the existing strict instance, constructor and retained
handle proofs before following the original constructor argument. A changed
field or an escaping handle is refused. An ordinary object's field, an
unknown custom instance's self.tools, and template-method overrides remain
named limits. This step does not expand a base class once per subclass.
A member held only under a condition carries it on its row's side as
bound_when, one entry per way it gets in, each built from the conditions'
source text (`wanted`, not `wanted`, `extra` is empty,
the filter `keep(t)` keeps it, joined by and):
ADDED finance → prepare_finance_handoff
before: no observed binding
after: prepare_finance_handoff(note) at app/Agent/financeAgent.py:100
bound only when `want_handoff_tool`
A conditional member is never shown as unconditional, and a tool the list also
holds unconditionally has no bound_when. A condition is compared whole, never
shortened. When only the condition changed (if handoffs → if want_handoff_tool, or a conditional member made unconditional), the row is
changed, and its why names both conditions: they are read, never evaluated,
so whether the agent now holds the tool more or less often is not established.
When a part of the agent's list was not read, or the agent is constructed more
than once differently, the row is not_established instead: the unread part
may hold the tool another way.
Some capabilities are bound as objects, not as functions (#910): a remote MCP server, another agent exposed as a tool, a tool the framework hosts. Each is a binding of its agent, identified by what it is:
| Kind | Read from | object |
|---|---|---|
| MCP server or toolset | Google ADK McpToolset / MCPToolset; OpenAI Agents SDK MCPServerStdio, MCPServerSse, MCPServerStreamableHttp in mcp_servers= |
transport; the URL's host or the command's file name; credential_sources; tool_filter; endpoint_sha256 |
| Agent as a tool | SDK agent.as_tool(...); ADK AgentTool(agent=...) |
the wrapped agent and the tool name; agent_class when ADK wraps another kind of agent (RemoteA2aAgent, a workflow agent) |
| Hosted or built-in tool | SDK WebSearchTool, FileSearchTool, CodeInterpreterTool, ComputerTool; ADK google_search, built_in_code_execution, load_memory |
the tool; a hosted tool's argument names and a digest of their values |
| Function wrapped as a value | SDK function_tool(f) (ADK's FunctionTool(func=f) above) |
none: the row is the function's, as for a decorated one; the wrapper's other arguments (needs_approval=) are part of its implementation digest |
ADDED cinescout_phase1 → parallel_search
before: no observed binding
after: parallel_search at app/agent.py:31
MCP server (McpToolset, streamable_http); host env PARALLEL_MCP_URL or search.parallel.ai; tool filter ["web_fetch", "web_search"]; endpoint digest e262dcaf6404 at app/agent.py:21
credential: env PARALLEL_API_KEY → header Authorization
The row's name is the tool's own name (generate_blog_content,
load_memory, WebSearchTool), or for an MCP server its name=, the variable
it is bound to, the helper call it comes from (create_toolset()), or its
class and place in the list (MCPServerStdio#1). Its object is the compared
meaning: a different host, command, transport, credential name, filter, wrapped
agent or tool name is a changed row, and adding one is added. Where it is
built is evidence (definition), so moving it, or importing the same class
from another module of the framework, is not a change. A tool's description,
which tells the model about it, is not part of it.
Nothing credential-shaped is printed. A host is shown, never a URL's path or
query; a header, query key or a stdio server's env entry with a secret's name
is a credential_sources entry naming the environment variables its value is
made from (#872's rules), or literal: true with no value. A path, query,
header value or command argument changes endpoint_sha256, a digest, and
nothing else; a command's file name shaped like a key is withheld, as a URL's
path piece is. Values are read as a tool's reach reads them — module constants
through imports, os.getenv with its default, a repository helper's return —
and an attribute of a plain class instance built with no arguments
(settings.url after settings = Settings(), a dataclass with no base, no
constructor of its own and nothing storing into that attribute) is the class
body's default. A pydantic settings class is not read: it reads the
environment by field name. A value from a module-level dict is not taken as
written, as for a tool's reach.
Identity is the import. A name is a built-in, and a call one of these classes,
only when its one binding where it is used is an absolute import of the
framework's package that no file in the read scope or the repository provides
and nothing in the scope stores into. A same-named local function, a shadowed
or reassigned import, a module-level import under if TYPE_CHECKING: or try:, a vendored
google/adk package and another package's load_memory are not. A repository
helper that returns one of these from its one unconditional return (its last
statement; not decorated, async or a generator) is followed, up to eight
steps deep, with its parameters bound to the caller's arguments: OpenCMO's
_multi_channel_tool(blog_expert, tool_name="generate_blog_content", ...)
is an agent tool wrapping blog_expert. Any other helper stays what it was, a
named limit. An object changed after it is built — an attribute set
(toolset.tool_filter = [...]) or changed through a method
(toolset.tool_filter.append(...)), or its name rebound from a nested
function — is a named limit.
What is not read is never guessed. An .as_tool whose receiver is not an
agent the reader identifies, or whose tool_name is not a literal, an
AgentTool whose agent has no literal name, and an mcp_servers= member that is
not an MCP server are named on the agent, which stays incomplete. A part of an
object's identity the read cannot name — a host from a builder's parameter,
headers built elsewhere, a computed filter — leaves the binding present and its
addition or removal established, but names the part in a limit (the comparison
is partial), and a change to that object is not_established, never
changed. An SDK agent's mcp_servers= is read as its tools= is, member by
member; a part it cannot read is named.
These are read for diff --application only. scan reads what it read
before, so no catalog, check or report changes.
A signature says what the model may pass, not what the call does. Each
before/after side of a function tool also carries reach: the outbound
HTTP calls the tool's own code makes, and what it reaches beyond HTTP (see
below). It is read statically from
the function and the repository helpers it calls, up to three helper calls
deep.
ADDED tensorflow_pr_review_agent → submit_pr_code_review
after: submit_pr_code_review(pr_number, overall_assessment, summary_comment, inline_comments) -> dict[str, Any] at pr_review_agent/agent/agent.py:591
implementation: pr_review_agent/agent/agent.py:140 (f4919a519638)
reaches: POST https://api.github.com/repos/{env OWNER}/{env REPO}/pulls/{pr_number}/reviews at pr_review_agent/agent/utils.py:172 via pr_review_agent/agent/agent.py:190 post_pull_request_review, and 1 more call site
field event ∈ {APPROVE, COMMENT, REQUEST_CHANGES}, decided by overall_assessment
model-supplied: inline_comments → field comments; overall_assessment → field event; pr_number → url; summary_comment → field body; inline_comments → field body
credential: env GITHUB_TOKEN → header Authorization
effect: write (structural evidence: outbound call at pr_review_agent/agent/utils.py:172)
reach limit: pr_review_agent/agent/agent.py:165 calls ….get ('_PREFETCHED_PR_DETAILS' is bound more than once in agent.py (lines 51, 55)), which is not read
What each call records:
- The method and the URL. Calls through
requests,httpx, anaiohttpsession andurllib.request.urlopenare read, including arequests.Session()orhttpx.Client(base_url=...)built in the function or at module level. The URL is a template. Each part is a literal, a tool parameter ({pr_number}), an environment variable ({env OWNER}), or{…}for a part the read cannot name. Module constants are followed through imports. - Request fields (
json=,data=). A field is one of:- a literal value, printed only when it is a number or a word without
digits and the field's name does not suggest a secret (
pass,token,key, …); any other literal isliteral: true; - the literals it is chosen from, with the parameters that decide
(
field event ∈ {…}, decided by overall_assessment); - the parameters or environment variables it is made from.
- a literal value, printed only when it is a number or a word without
digits and the field's name does not suggest a secret (
model_supplied. Which parameters the model supplies flow into the URL, method, query, fields or headers. A parameter the framework injects, such as a tool or run context, is not model input, and neither is a parameter the function overwrites before reading it (pr_number = int(os.environ[...])).credential_sources. For a credential-named header (Authorization,X-Api-Key,Cookie, …),auth=, a secret-named query key or field, or a value spliced into a URL's userinfo (names are matched by whole word, somax_tokensandIdempotency-Keyare not credentials), these are the environment variables its value is made from, by name only. A hard-coded credential, or a literal default (os.getenv("KEY", "sk-test")), is reported asliteral: true, and its value is never printed. A value fetched at run time iscomputed. In a printed URL, a query value is shown only when it is a short lowercase word or a number (or a date, or a list of words). A token-shaped path piece after the host is withheld: a run of 20 characters, or of 7 with letters and at least two digits, sous-central1stays readable. So is the whole path of a webhook or bot URL except its method name (hooks.slack.com/services/[REDACTED:sensitive_field]/…). These are shapes, not proof: a secret spelled like an ordinary word in an ordinary place can still print, as it already appears in the source.- GraphQL. For a call to a GraphQL endpoint (the URL, or the variable it
comes from, says
graphql), this records whether the literalquerydocument defines a query or a mutation. Transport is not effect: a query sent over POST reads. Aqueryfield elsewhere (SQL, LogQL) is a plain POST.
Each side also carries effect_evidence: the existing semantic assessment of
the tool (see effect evidence), with the reach as one
more structural source, source_http_call. It gives the conservative effect,
the evidence status and the claims.
-
Write or destructive. A POST, PUT or PATCH call, or a GraphQL mutation, supports
write; a DELETE supportsdestructive. It does so whatever else the tool does. -
Read. A tool is said to read only when all of these hold:
- it makes at least one outbound call, and every outbound call reads (GET, HEAD, OPTIONS or a GraphQL query);
- every call the tool makes was followed, and none of them is a limit.
-
Limits. The read passes over only calls with no effect outside the process:
- a closed list of builtins, and functions such as
json,re,os.pathandlogging; - read-only methods (
get,strip,json, …) on plain data (an environment value, a model argument with a JSON type, or a list or dict holding only those) or on an object one of those libraries returned (an HTTP response, a regex match, a date). What such a method returns is plain data only when what it reads is:CLIENTS.values()hands back clients; - list and dict changes (
append,update, …) on the function's own containers or on plain data.
Every other call is a
reach.limits[]entry with its location, and the tool is then not said to read. That includes:- a library function,
open, and a method on an object the read cannot name (a repository class'sgetmay POST); - a helper beyond the bound or outside the scope;
- a decorator the read cannot see into;
- a store into an object it cannot name (
cache[key] = valueon a redis client), or an attribute set on something the function did not build (r.method = "DELETE"on a request passed in).
A repository function handed to another call (
sorted(items, key=helper)) is read as called. So is one set as a request's or client's hook or auth (hooks={"response": [audit]}, httpxevent_hooks,session.auth = sign), since it runs on every request. Any other function that a passed-over call runs is a limit:map(es.delete, ids), akey=that is not a builtin,iter(queue.pop, None). A request's changes after it is built are read: itsdata, its headers, and a method set on it, which leaves the method unread. Setting anything on a client other than its transport settings (headers, auth, timeouts, proxies, …) —session.request = send— is a limit. Options spread into a call (requests.get(url, **opts)) are read one by one.Five rules bound what
readcan rest on:-
A call through a client or request built elsewhere is a limit. A module-level, imported or factory-made client (
SESSION.get(...)), or a module-levelurllibrequest (urlopen(PURGE)), can be configured anywhere. So only a direct library call (requests.get), or a client or request the same function constructs, can supportread. Its hooks and auth are still read, so a write they make is still found. -
A value read out of a module-level dict is never taken as written. Any code may change such a dict, through routes a static read cannot bound: a helper two calls away,
*args, an accessor, a loop. SoCONFIG["method"],QUERIES["viewer"]orHANDLERS["drop"]is unknown, and cannot supportread. So is a value read out of a copy of one ({**DEFAULTS},config.update(DEFAULTS)). A dict the tool's own function builds from literals is read as written. -
A module-level constant or function rebound elsewhere is unknown. Every attribute and
setattrname the scope stores under is collected (agent_config.METHOD = "DELETE",helpers.fetch = purge), whatever the store goes through: an import in a function, a dotted import, an alias or a parameter. So is every module whose namespace is changed under names the read cannot see. That covers a computedsetattr,vars(m),m.__dict__,globals(),execoreval, andsys.modules[...](replaced, or its__class__swapped). It also covers a namespace handed to a call that is not a known reader (code.interact(local=vars(m))). What the changed object may be is followed:- through names, closures,
globalandnonlocal; - through parameters and their defaults, however many helpers deep;
- through what a function returns, and through displays, loops and
.values(); - back to an
import,sys.modules[...],importlib.import_module,__import__orglobals().
Any other object is taken as not a module: a call's result, an ORM row, an instance attribute. So
setattr(user, field, value)withholds nothing. A module is also tracked when it is kept where it is not followed, in a container, an attribute, a class attribute, or a call the scope does not define (Holder(config),SimpleNamespace(m=config)). If any namespace change in the scope goes to an object the scan does not follow, every module kept that way is unknown. Every value from module scope is unknown in these cases:- a namespace change the scan cannot tie to one module, such as a
computed
sys.modules[name]store, or a frame'sf_globals; - a lambda, or a function passed on as a value (a callback,
functools.partial, an import hook), that changes its argument's namespace. A library may hand it any module. A function used only as a decorator (@tag, or returned by a factory used only as@tag(...)) is handed definitions, never a module; - a file the scan cannot read, or a scope past its 2000-file bound.
A module-level name another file imports is followed to what it holds: an alias (
import config as settings_module,CONFIG = config, a tuple) is that module, and so is a name a star import may bind. A module held in a container there (SETTINGS_MODULES = [config]), or returned by a module's__getattr__, is also kept. Any other imported name that is no module file of the scope is an object the scan does not follow.del sys.modules[name]only makes the next import read the module again. A module registered lazily under its own name and spec (module_from_spec(find_spec(name))) is itself. A builtin rebound anywhere in the scope (builtins.print = send_log,builtins.__dict__["print"] = …) is not read as the builtin. A library function the read takes as pure and replaced anywhere in the scope (json.dumps = audited,logging.Logger.info = …,setattr(re, "sub", …)) is a limit where the tool calls it, however the module was reached (sys.modules["json"],import_module("json"), an alias, a helper's parameter). So is a method replaced on a library class (pathlib.Path.read_text = …) where the tool calls it on such an object.A value the tool takes from module scope under such a name, or from such a module, is unknown. Stores through a method's own
selfchange an instance and are not collected. Two unrelated uses of one name only ever withhold a value. - through names, closures,
-
State a closure shares is not taken as written. A dict the tool reads from an enclosing function (an ADK factory's
state = {...}) outlives one call, so what is read out of it is unknown. A name a function nested in that enclosing function rebinds (nonlocal method) or stores into (state["method"] = "DELETE") is unknown too, whichever of its closures is the tool. -
A patch to an HTTP library anywhere in the scope is a limit on every tool that sends: a store into
requests,httpx,urllib3,http,socketor the like (requests.get = logged_get,setattr(requests, …),sys.modules["requests"].get = …, or through an alias or a helper's parameter, a client class's method included), and a call that installs or instruments the stack (install_opener,patch_all,ddtrace.patch(…),instrument_requests()), in any file outside tests, inside a function or not. A setting stored as a plain value (DEFAULT_RETRIES = 3, a class'stimeoutormax_retries) and a retrying transport or adapter are not patches. A class's other attributes are, even as a plain value (urllib.request.Request.method = "DELETE"). So is any attribute but a tuning one stored on a class reached through an object or its bases (req.__class__.method = …,type(req),Sub.__mro__[1], through an alias, a loop or a helper too), unless it is the method's own class (type(self)). A patch is followed however its library was reached: through another module's attribute or a class's (agent.requests.get = …,clients.Http.lib.get = …, an import in a class body, re-exported relatively or with*), kept on an object,selfor one built around it and patched by name (http.module.get = …,self.http.get = …,Holder(urllib.request).module.Request.method = …, a method the stack sends through such asSession.prepare_request, one of its classes however reached), kept in a container, under another attribute or a property, set withsetattror__dict__, or passed through a call (typing.cast(type, requests.Session).request = …), or throughmock.patch.multiple,patch.objectunder any alias andwrapt's wrappers. A function of the stack handed along (asyncio.to_thread(requests.get, url)), a constant and an exception are not the stack kept. A chain of re-exports too long to follow counts as any module. A file the scan cannot read is named. A patch outside the compared scope is not seen, nor is code run from a string (exec,eval): 13 of the 99 corpus scopes run an interpreter or a calculator that way, so failing closed there would limit most of them. A recursive call with other arguments is followed twice as called, then once with every parameter unnamed. Past 24 outbound calls, the rest are not listed but still count for the effect.
- a closed list of builtins, and functions such as
-
Unread method or document. When a request method or a GraphQL document is not a literal, it is a limit, and that call supports no effect.
Most tools do their work somewhere other than an HTTP endpoint. reach.effects
names what the tool's own code (and the same helpers, within the same bound)
reaches through a recognised library, by its import, never by a name alone:
ADDED Finance_Agent_Preflight → load_service_catalog
after: load_service_catalog() -> dict at app/Agent/financeAgent.py:117
implementation: app/tools/FinanceAgent/tools.py:48 (418c97358c98)
reaches: database read SELECT (postgresql, psycopg2 cursor.execute) at app/database/repository.py:80 via app/tools/FinanceAgent/tools.py:56 get_services → app/tools/FinanceAgent/finance_data.py:71 _fetch → app/tools/FinanceAgent/finance_data.py:40 query_db
host: aws-0-ap-southeast-1.pooler.supabase.com, env SUPABASE_DB_HOST
credential: env SUPABASE_DB_PASSWORD, env SUPABASE_PASSWORD → keyword password
effect: write (provisional: unknown effect)
reach limit: app/tools/FinanceAgent/tools.py:56 calls asyncio.to_thread, which is not read
reach limit: app/database/repository.py:73 calls init_db_pool, more than 3 helper calls from the tool; not read
reach limit: app/database/repository.py:80 the SELECT statement splices in a value the read does not name; a further statement in it is not read
The query reads Postgres; the tool is still not said to read, because other calls stay unread and the statement splices in a table name from a module-level dict.
| Family | Recognised | Operation |
|---|---|---|
database |
sqlite3, psycopg2 (and its pools), psycopg, asyncpg, pymysql, SQLAlchemy engines, connections, sessions and queries, pymongo, redis |
A literal SQL statement decides: SELECT (and WITH, VALUES, SHOW, EXPLAIN) reads, INSERT/UPDATE/DELETE/DDL writes. SQLAlchemy's select()/insert()/update()/delete() decide as their statement; session.add and query.delete write. MongoDB and Redis methods by their table (find reads, insert_one writes, an aggregation with $out or $merge writes). |
process |
subprocess.run/call/check_call/check_output/Popen, os.system, os.popen, os.exec*, os.spawn*, os.posix_spawn*, asyncio.create_subprocess_* |
execute, naming the literal program by its file name. |
filesystem |
open (and io.open, Path.open), pathlib.Path reads and writes, shutil copies, moves and rmtree, os.remove and the like, os.listdir/walk/scandir/stat |
A literal mode with w, a, x or + writes, any other reads; Path.write_*, touch, unlink, rename write; read_*, glob read. |
cloud |
boto3 clients and resources, google.cloud storage, firestore and bigquery, Vertex AI Memory Bank (vertexai.Client(...).agent_engines.memories, ADK's VertexAiMemoryBankService, the synchronous google.cloud.aiplatform_v1beta1.MemoryBankServiceClient) |
boto3 by the operation's verb (get_, list_, describe_, head_ read; put_, create_, delete_, update_, upload_ write); the Google clients and Memory Bank by their tables (retrieve reads, generate writes). |
messaging |
smtplib, slack_sdk (WebClient, WebhookClient), twilio |
Sending writes; a Slack read method (conversations_history) reads. |
The synchronous aiplatform_v1beta1 Memory Bank client is recognised at its
root export and services.memory_bank_service export. Its selected table
reads retrieve_memories, get_memory and list_memories, and writes
generate_memories, create_memory, update_memory and delete_memory.
The table follows the Google client reference.
Resource targets come from a literal string in parent, name, or an update's memory.name,
either as explicit keywords or an inline request dictionary with literal keys.
An update's nested memory dictionary must also be inline. Dictionary keys
follow Python's last-value rule. Conflicting call arguments, dictionary
spreads, request aliases, dynamic resource values and unread requests do not
supply a target and retain a reading limit. Methods outside
this table stay unknown. A repository wrapper named MemoryBank.from_env()
is not identified by its name and remains subject to the existing helper and
object-state limits. This increment claims no corpus score or complete wrapper
coverage.
Each entry has family, operation (read, write, execute or unknown),
library, call (the function, or the object's role and method:
cursor.execute), at and via, and, where they are known:
target: the program, the table, the bucket, collection or Redis key, or a path. A path is printed only relative: an absolute or home path, or one that climbs out (..), is withheld, and so is any piece shaped like a key. A name is printed only when it is plainly a name.statement: a SQL statement's leading keyword. The statement itself is never printed:value_sha256digests it, and digests a command's arguments and any withheld target.service: the database engine (postgresql, read off the library or a SQLAlchemy URL's dialect), the boto3 service,storage,memory_bank,smtp,slack.hostandcredential_sources: read off the client's construction as a request's are. A connection string's password isliteral: true, never printed.model_supplied: the parameters that flow into the command, the statement, its parameters, the path or the call's arguments.
What an effect supports:
- An effect that writes supports
write; running a process supportscode_execution; sending a message supportsexternal_communication. Each is asource_library_callclaim ineffect_evidence. readstill needs everything the tool reaches, HTTP calls and effects alike, to read, and nothing unresolved, exactly as above.- A library call outside the tables is a limit, as before. A method of a
recognised object outside its table is an effect with operation
unknown, and a limit: the family is established, the direction is not. So is a SQL statement or file mode that is not a literal. - A
SELECTreads only when nothing after its keyword could write: no writing word (SELECT … INTOcreates a table), one statement, and no function call outside a list of SQL's own read functions (SELECT setval(…)writes). A statement that splices in a value the read does not name is a read, and a limit: a further statement could be spliced in. - A database, cloud or messaging object built outside the function that reads
through it, or built with an argument the read does not see into (a factory
class), is named as a limit on that read, as an HTTP client built elsewhere
is: its configuration can add hooks. A file or process opened elsewhere (a
module's log file) has its writes named where the tool makes them, and a
print(..., file=…)writes to its file. - A module global set only by its own module's functions, each time to
Noneor the same kind of recognised object (the lazy pool pattern), holds that object; nothing else in the scope may store into that name. - What a helper returns is read one call past the helper bound, so a pool a fourth helper builds is still named; the call itself stays a limit.
- A name-alike is not the library: a repository module or function spelled
subprocess,openorconnect, another library'srunorWebClient, or a library function replaced anywhere in the scope.
A repository class that wraps one of these libraries is not followed into: a method called on its instance stays a named limit.
reach and effect_evidence are evidence, not compared meaning. A helper's
changed endpoint does not make a binding changed on its own, and neither
field is a verdict or a risk score. The read never runs the code or fetches, so
a URL built from a response stays {…}.
When one Google ADK agent name is constructed more than once in a module, each
row's binding_location names the first construction, in line order, that
lists the tool, and construction_sites lists every construction that does.
When the constructions differ, which one runs is not established, so the agent
stays a named limit; identical constructions are one agent.
coverage_gaps records each gap's source/agent/tool and whether it affects
binding presence or only the implementation. Locations are relative to that
side's selected scope. A partial result with no rows is not a no-change answer.
Reader gaps are scoped to their input file unless typed agent evidence narrows
them further; this does not infer cross-module bindings by matching names — a
cross-module binding exists only where an import chain reaches its definition.
Implementation digests hash the resolved function's AST, including defaults
and decorators, excluding source positions and its leading docstring. Empty
AST fields are retained on Python 3.13+ to match 3.12. Digests are qualified by
the emitted engine/Python identity, not promised across arbitrary future AST
schema changes. comparison_id is SHA-256 over the sanitized published
object with that key removed, encoded as UTF-8 JSON with sorted keys,
separators=(",", ":") and ensure_ascii=True; readers can recompute it.
Each side materializes only its selected scope through the existing verified Git materializer, which retains symlinks and containment checks. The default root scope goes through the same scoped materializer, so it packs the tree rather than the history.
A symlink anywhere in the tree is recreated as the link it is, never refused and never read through. Every link under the scope is censused, including one that resolves to nothing, and what it can hide decides what it is:
- A link Python discovery would not read changes nothing, as in a checkout:
CLAUDE.md -> AGENTS.md, or a linked.claude/skills/…directory whose target the scope already reads at its own path. - A
*.pylink whose target is a Python input the scope already reads is compared at that target's path. Any other*.pylink — dangling, leaving the scope or the repository, or landing on something that is not a Python input — is a coverage gap over the link's own path (Linked Python input: agent.py), so replacingagent.pywith a dangling link isnot_established, never a removal. - A link to a directory outside the scope that holds Python is a gap over the
link's path (
Linked directory holds Python outside the scope: lib). - A link that resolves to nothing in the repository (dangling, absolute, or
leaving the tree) is a gap only where the other side reads source at or
beneath its path (
Linked input resolves outside the tree: tools). An unchangedagent/VERSION -> ../../VERSIONbeside the application changes nothing.
A selected scope that is itself a link is refused (exit 2).
A submodule's content is in another repository. The comparison never fetches
it and materializes its gitlink as the empty directory a checkout without
--recurse-submodules leaves. The same gitlink commit on both sides is the
same content, so it is named in both sides' limits —
Submodule content is not read (unchanged commit 85b71d7ecd4f): vendor/core —
and nothing more. An added, removed or moved gitlink is a coverage gap over its
path on each side that has it, so the comparison is partial, and a binding
the other side holds at that path is not_established rather than added or
removed. A gitlink at the selected scope itself covers the whole scope
(…: the selected scope).
Partial clones
with unfetched objects exit 2 with objects_missing, name the affected side,
and provide the existing git fetch --refetch --no-filter <remote> recovery.
The comparison never runs that fetch. Other configuration/materialization errors
use config_error; malformed reader inputs use input_parse_error in agent mode.