fix(diff --application): an unobserved agent is not "no change" (#876) - #880
Conversation
- A tool name defined twice is dropped from the binding observations too, so only rows of that name in that file are uncertain; the same agent's other tools are still compared. - A `*.py` link onto test code is a gap: the link census now compares against application inputs only, since test code is never read. - The test rule is decided once for both sides: when either scope is inside a test directory, both read test code. - Test code is filtered once into `application_files` and one pass over discovery's candidates, instead of at six call sites. - Merged-construction lines print in line order; the construction guard counts only the census's own gaps, so a shared line cannot satisfy it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pengfei-threemoonslab
left a comment
There was a problem hiding this comment.
Reviewed commit 2892a3c. I recommend fixing the two P2 regressions in the inline comments before merging: both allow a real SDK tool addition to produce comparison_status: compared, rows: [], and no coverage gaps.
Validation: the application comparison suites (tests/test_application_diff*.py), Google ADK suite, SDK boolean-source and guard-dependency suites, and input-loader suite all passed locally. I also reproduced both findings through the public diff --application CLI against temporary Git repositories and compared the same inputs with main (6394ffd). The first case changes main's not_established into a false complete comparison; the second loses an added execute row that main correctly emits.
Agents Shipgate verification of the PR refs reports control_state: complete and release decision passed, refreshed after the required boundary-check route. That static gate result does not detect the behavioral regressions below. No source changes were made.
This is submitted as a comment review because the authenticated reviewer account is also the PR author.
| scope = sdk_names.binding_scope(value.id, value) | ||
| if scope is not None and (id(scope), value.id) in list_vars: | ||
| return list_vars[(id(scope), value.id)] | ||
| if scope is not None and (id(scope), value.id) in sdk_names.parameters: | ||
| # The caller supplies it; its value is not this module's to read. | ||
| return None |
There was a problem hiding this comment.
[P2] Keep conditionally defaulted parameters unresolved
The literal-list lookup runs before the parameter check, so a common builder pattern bypasses the new dynamic-parameter protection:
def build(tools=None):
if tools is None:
tools = [lookup]
return Agent(name='Built', tools=tools)
app = build([lookup, execute])With local @function_tool definitions for lookup and execute, changing the caller from [lookup] to [lookup, execute] yields compared, no rows, and no coverage gaps on this head. The observation incorrectly says tool_names=['lookup'], tools_complete=True; main returned not_established for this unsupported builder. The conditional fallback is not evidence of the caller-supplied list. Check parameter ownership before using a cached literal (or prove the assignment reaches the construction) and preserve a dynamic-tools gap when the passed value is unresolved.
| if target and isinstance(value, (ast.List, ast.Tuple)): | ||
| list_vars[target] = _literal_names(value, import_aliases) | ||
| for node in ast.walk(tree): | ||
| if not isinstance(node, (ast.Assign, ast.AnnAssign)): | ||
| continue | ||
| target = _assignment_target(node) | ||
| call = node.value if isinstance(node.value, ast.Call) else None | ||
| if ( | ||
| not target | ||
| or call is None | ||
| or not sdk_names.denotes( | ||
| dotted_name(call.func), call, "Agent", DEFAULT_AGENT_CONSTRUCTORS | ||
| ) | ||
| ): | ||
| continue | ||
| scope = id(sdk_names.scope_of[id(node)]) | ||
| list_vars[(scope, target)] = _literal_names(value, import_aliases) |
There was a problem hiding this comment.
[P2] Account for global/nonlocal writes when indexing literal lists
Assignments are now indexed by their lexical scope, whereas reads use binding_scope(), which honors global/nonlocal. Those keys disagree for a write to an outer list. For example, with local decorated tools:
tools = [lookup]
def build():
global tools
tools = [lookup, execute]
agent = Agent(name='Built', tools=tools)
return agent
app = build()Comparing an inner assignment of [lookup] against [lookup, execute], main emits ADDED agent -> execute; this PR instead returns compared, no rows, and no gaps. The inner assignment is stored under the function scope, then the constructor reads the stale module entry. Resolve assignment ownership consistently with reads, or mark such outer-scope writes unresolved rather than claiming the module initializer is the complete binding list.
…s compared (#876) On kkmiecik-coder/CRM#5 the quoting agent, built by `return Agent(...)` inside a function, gained a tool. The only agent the SDK reader observed was a test double, so the comparison said `compared` with no rows. - Every OpenAI Agents SDK construction is observed or named. - Observed: `return Agent(...)`, `self.agent = Agent(...)`, inline in a list, positional names, and `Agent[Ctx](...)`. A function-local agent variable is identified by its literal name. - Named limits, on the agent or the file: - one identity built at two sites that bind different tools; - `**` or extra positional arguments; - a capability-passing copy (`clone`, `dataclasses.replace`, `copy.replace`) of a value not proven to be a non-agent; - an instance of a same-module `Agent` subclass. - A capability change after construction (assign, extend, slice, `setattr`, or a handle `t = x.tools`) is read in its own scope. It is a limit on the agent it reaches, a limit on the file when unknown, and nothing when proven not to be an agent. - The census in `diff` names, on each module that is not read as an SDK source: a copy, a capability change, and the use of an SDK `Agent` subclass defined elsewhere. - Test files are not the application. They are listed in `excluded_tests`, never read as sources, and never refuse a comparison. A duplicate tool in application code limits only its own file. - A Google ADK agent name constructed twice in one file is a named limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…squash The #876 change was written on the pre-squash #864 commits, where `_leaves_arguments_alone(call)` took one argument. Main's squash of #864 made it `(call, bindings_at)`, so after the cherry-pick `_capability_changes` raised TypeError and `diff --application` exited 1 on any SDK file taking a handle `t = x.tools` that is later handed to a call. It now builds the file's `bindings_at` once and passes it, as the other callers do. `test_reading_another_objects_tools_is_not_a_change[handle-only-read]` crashed before this and passes after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ADK reader reads each `Agent(...)`/`LlmAgent(...)` call, never a subclass's constructor. With `class Helper(LlmAgent)` beside an established root agent, a tool `helper = Helper(..., tools=[...])` gained left the comparison `compared` with no rows, on main and on the first #876 pass. The reader now records a surface warning for each class whose base resolves, through an import only, to an ADK agent class, so the module is `partial` and its tool surface is no longer enumerated for `scan`. Ported from PR #880's ADK census. Also carries over #880's reproduction of the issue itself (an imported tool list under `--scope app` beside a test double) and its ADK subclass test; both answer `compared` on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ructions bind Two over-broad limits from the first #876 pass, both fail-closed but dropping true rows: - A tool name one application file defines twice dropped the whole file, so every other agent that file builds lost its rows. The name is now removed from that source's tools and binding observations and named as a gap on that name in that file (ported from PR #880); the file's other tools are still compared. - One agent name constructed at two sites (tensorflow#128063's root agent and builder) made every tool both sites bind an "Ambiguous tool identity", so the true ADDED row disappeared. A tool bound with the same meaning by each construction of an identity the reader already names as constructed more than once is now kept; a differing one is still ambiguous. Regression tests fail on the previous head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`test_many_module_lists_are_read_in_linear_time` (added by this #876 change, not by #864) asserted a wall-clock ratio inside the parallel correctness gate. Alone it reads 27-31x for ten times the lists (the same on main); under `-n 4` load it measured 51x against a 40x bound and failed, which would stall the serial merge train. It moves to `tests/test_latency_budget.py` under the `perf` marker, so it runs only in CI's serial "Latency budget" step and `-m "not perf"` never collects it. The bound becomes 60x with a 2 s floor: the per-list rescan it guards against is ~100x and ~18 s at 1,000 lists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `application_diff` registry row did not mention what #876 adds to the surface: the per-side `excluded_tests` field and the limits that name an agent construction the readers cannot read. The row now states both and why neither is a claim (a path fact about the comparison's own inputs, and limits that only turn `compared` into `partial`); the parity harness's `application_diff` entry records the same reasoning and still registers no claim. The ADK agent-subclass limit carried over from #880 is documented in `docs/application-comparison.md` and as a sub-bullet of the #876 CHANGELOG entry, including its effect on `scan` (the module's tool surface is no longer reported as enumerated). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tructions are one agent; no census without an agent source Three over-broad limits from the #876 review, each fail-closed but hiding true rows or turning `not_established` into `partial`: - An ADK agent subclass nothing uses limited its module. `class Helper(LlmAgent): pass` beside `root_agent` made the module `partial`, hid root's ADDED row, and dropped `scan` to insufficient_evidence. The reader now names a subclass only when its module uses it again (a call, `functools.partial`, any reference other than a deeper subclass's base, or a decorator); a subclass of a subclass counts too. Another module that imports it is named by the comparison's census, as for an SDK subclass, so `from agent import Helper; Helper(...)` limits that module. - Identical ADK constructions of one name were "constructed more than once". The duplicate check now runs after every construction is read and compares what each site bound: the tool definitions (name and locator) and resolved `sub_agents`. A site is comparable only when read cleanly (every tool a definition, no toolset, no warning, no new issue, no `**`, no unresolved or non-literal `sub_agents`); any other site differs from every site. `root_agent` plus a builder returning its twin is one agent again, and `scan` is `passed` as on main. - A scope with no SDK/ADK source on either side became `partial` from the census reading the repository's own `.tools` (`artifacts.tools.append` in `inputs/n8n/_tools.py` under `--scope src`). Both sides are now discovered before either is read, and the census runs only when some side found an agent source. Every module is still parsed, so a parse failure is still a gap. `--scope src` of this repository is `not_established` again, in main's time. Regression tests fail on the previous head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2892a3c to
4d76558
Compare
# Conflicts: # docs/distribution-surfaces.md
…ew 11) The derived scope decided whether a module rewires an agent with its own predicate, which missed spellings the SDK reader already counts: a slice store into an agent's tools list, and a change made through a handle to that list. It now asks #880's `_capability_changes`, so the reader and the scope derivation agree on what changes an agent. On the 48-case corpus, one extra changed file in alliance-genome #842 and #860 is named as unrelated (`prompt_builder.py` reads `agent.tools` into a local); nothing else moves. Of 156 fixtures, the four this round added move to partial. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is omitted (#875) (#904) * feat(diff --application): derive the comparison scope from the change when --scope is omitted (#875) Without --scope, each changed Python file is related to the OpenAI Agents SDK and Google ADK agent files that import it, or that it imports, within six import hops. It is read from the two commits' objects on each side. A package's __init__.py and a literal importlib.import_module count. An agent file does one of these: - constructs or subclasses an agent class; - copies an agent with capabilities of its own; - changes an agent's capabilities after construction; - builds its agent through another file's factory. Each related group is compared in the outermost package that holds the change, its agents and what they import. Vesta#58 derives backend/app. - Independent applications become separate comparisons under `comparisons`, never the repository root. - A relocated application is one comparison. - A change that touches no supported agent is an explicit not_established answer naming the files considered. - These are named in scope_selection.limits and make the result partial: - a changed file unrelated to an agent-building module, with why; - a changed link or submodule; - an agent outside the scopes that reaches the change; - a consumer: a module that imports the change and an on-request builder and builds, copies or rewires an agent, calls repository code with its own arguments, or sets its module state. It is exempt only when one compared scope holds all three. - any bound reached. `scope_selection` records the mode, the scopes and why. An explicit --scope always wins, `--scope .` keeps the root, and --base-scope needs --scope. Partial clones are refused with the established hydration message before anything reads the tree. On the pinned 48-PR corpus, derived scopes establish the same 296 rows as the root. The changed-wiring directory establishes 18. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Count a capability change the way the SDK reader counts it (#875 review 11) The derived scope decided whether a module rewires an agent with its own predicate, which missed spellings the SDK reader already counts: a slice store into an agent's tools list, and a change made through a handle to that list. It now asks #880's `_capability_changes`, so the reader and the scope derivation agree on what changes an agent. On the 48-case corpus, one extra changed file in alliance-genome #842 and #860 is named as unrelated (`prompt_builder.py` reads `agent.tools` into a local); nothing else moves. Of 156 fixtures, the four this round added move to partial. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: balance test shards by measured time per file The suite's three CI shards were balanced by collected item count. Item count is a poor proxy for time: a file of forty git-fixture tests costs more than a file of four hundred pure ones. - On `main`, shard 3 took 13 of its 15 minutes while shards 1 and 2 took 8. - Any new test file reshuffled most files. #904's one new file moved 298 of 363, which put shard 1 at 14 minutes and cancelled shard 3 at the cap. Shards are now balanced by measured seconds per file, in `tests/shard_seconds.json`. `scripts/measure_shard_seconds.py` writes that file from a `--junitxml` run. A file without a measurement costs its item count at the measured seconds per item. A stale measurement only unbalances, never drops a file. The union property, determinism and fail-loud rules are unchanged. On one full-suite measurement: - balancing by count gives main 25/30/45% of the work, matching CI's 6.8/6.8/11.6-minute shards; - balancing by time gives 33/33/33%, for main and for #904. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: compare the whole tree where #865's cases change only a README Three #865 cases change only `README.md` and assert what a full-tree comparison says about an untouched agent. With the scope derived from the change (#875), a README-only change touches no agent and is `not_established`, so these cases now pass `--scope .`.
Closes #876.
Problem
On kkmiecik-coder/CRM#5,
diff --applicationansweredcomparedwith no rows, although theWycenaagent gainedwyslij_obraz:return Agent(...)builders.Separately, one tool name defined twice in a test file made the whole comparison refuse (exit 2).
What changes
Every OpenAI Agents SDK construction is read, or named as a limit.
name=:return Agent(...),self.agent = Agent(...), inline agents,Agent[Ctx](...)and positional names.**/extra positional arguments;clone/replacecopies with their own tools;A
comparedresult therefore holds no unaccounted construction site.Identical Google ADK constructions of one name are one agent, as on main. Differing ones are a named limit.
Test files (discovery's test-path convention, relative to the scope) are not read as the application. Each side lists them in a new additive field,
excluded_tests, which the text output also prints. The application comparison result stays at the unreleased0.1.A tool name defined twice is a gap on that name in its file. It no longer refuses the comparison. A tool that both constructions of one identity bind the same way keeps its ADDED row, as in the tensorflow#128063 shape.
No agent source, no census. A scope where neither side has an SDK or ADK source stays
not_established.Surfaces. The
application_diffrow indocs/distribution-surfaces.mdnamesexcluded_testsand the unread-construction limits.docs/application-comparison.mdand CHANGELOG are updated.scanimpact (intended; stated in CHANGELOG):passedtoinsufficient_evidence.insufficient_evidenceon main, because the SDK reader's confidence is capped at medium. What changes is the selected gap.What it does not do
comparedwith no rows (a follow-up is suggested):root_agent.clone(update={"tools": …}),model_copy(update=…),LlmAgent(**CFG),LlmAgent.model_validate({...}), and an ADK list handed to a helper that appends to it.functools.partial(Agent, tools=…), anAgentre-exported through a project module,getattr(agents, "Agent")(…), and changes made throughimportlib.import_module(...).vars(a),__dict__,object.__setattr__), computed names (setattr(a, key, …)), or handles moved out of view are neither read nor named. R catches these.partialtoo readily.x.tools = …in an application module that imports from the scope is a limit even whenxis not an agent, for example an OpenAI request object.Wycenaunder--scope app. That is Application diff reports 'compared, no changes' when an SDK agent built by 'return Agent(...)' gains a tool #876's named fallback, not an ADDED row.compared3 → 0, exit 2 3 → 0) came from the pre-Google ADK: resolve repository-local imported functions and module-qualified tool bindings #864 implementation A, so they do not describe this tree.Validation (4d76558, on main 107878b)
tests/test_application_diff*.py,test_google_adk,test_imported_tool_review,test_python_import_resolution,test_imported_tool_bindings,test_distribution_surface_parity,test_docs_links: 831 passed, 1 skipped.tests/test_latency_budget.py -m perfon its own: 5 passed. This now includes the SDK reader scaling check, which was moved out of the xdist-loaded gate after it failed there under load.ruff check .andgit diff --check: clean.--scope src: 10.7s on both.Implementation selection, fixes and review by a coding agent (Claude Code), not an independent human approval.
🤖 Generated with Claude Code