Skip to content

fix(diff --application): an unobserved agent is not "no change" (#876) - #880

Merged
pengfei-threemoonslab merged 8 commits into
mainfrom
claude/github-issue-876-review-ca3249
Sep 28, 2026
Merged

pengfei-threemoonslab merged 8 commits into
mainfrom
claude/github-issue-876-review-ca3249

Conversation

@pengfei-threemoonslab

@pengfei-threemoonslab pengfei-threemoonslab commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Closes #876.

This PR now carries a different implementation from the one it opened with. The original commits (cbc82a8, 2892a3c) conflicted with main after #864 (#879), and a review found two P2 regressions in them: a conditionally defaulted tools=None builder parameter and a global tools rebinding. Each let a real SDK tool addition print compared with no rows.

Three candidates were compared against main 1b1f646, each rebased onto main:

  • A: this PR's original.
  • B: the second implementation on claude/issue-876-silent-compared.
  • R: B after 25 further local review rounds. It was unpushed; it is now preserved at claude/issue-876-round25-preserved.

This branch carries B, plus fixes (F). Why not the others:

  • A still printed compared with no rows on 11 probes that B catches, among them dataclasses.replace/copy.replace with tools=, a .clone(tools=…) of an imported agent, a type()-made class, and tools changed after construction.

  • R catches about 145 more changed-after-construction shapes. But:

    • It is about 3× the code (+3,540 source lines against about 1,120).
    • It is 4.5× slower on a scope with no agents.
    • A very common pattern, client.chat.completions.create(..., tools=req.tools) in any handler in scope, turns every agent's rows into not_established.

    Those shapes can return as a follow-up once R's precision and cost are fixed.

Problem

On kkmiecik-coder/CRM#5, diff --application answered compared with no rows, although the Wycena agent gained wyslij_obraz:

  • The SDK reader skipped return Agent(...) builders.
  • A test double established the scope.

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.

    • Read, by their literal name=: return Agent(...), self.agent = Agent(...), inline agents, Agent[Ctx](...) and positional names.
    • Named as a limit on their file:
      • **/extra positional arguments;
      • clone/replace copies with their own tools;
      • tools changed after construction;
      • SDK and Google ADK agent subclasses that something instantiates or imports;
      • one identity built twice with different tools.

    A compared result 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 unreleased 0.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_diff row in docs/distribution-surfaces.md names excluded_tests and the unread-construction limits. docs/application-comparison.md and CHANGELOG are updated.

  • scan impact (intended; stated in CHANGELOG):

    • A Google ADK agent subclass in use, or one agent name built twice with different bindings, moves an ADK module from passed to insufficient_evidence.
    • SDK verdicts do not move: they are already insufficient_evidence on main, because the SDK reader's confidence is capped at medium. What changes is the selected gap.

What it does not do

  • Still compared with no rows (a follow-up is suggested):
    • ADK: 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.
    • SDK: functools.partial(Agent, tools=…), an Agent re-exported through a project module, getattr(agents, "Agent")(…), and changes made through importlib.import_module(...).
  • Changes after construction through helpers, namespaces (vars(a), __dict__, object.__setattr__), computed names (setattr(a, key, …)), or handles moved out of view are neither read nor named. R catches these.
  • It can say partial too readily. x.tools = … in an application module that imports from the scope is a limit even when x is not an agent, for example an OpenAI request object.
  • CRM's imported list stays an unresolved-tool limit on Wycena under --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.
  • Corpus numbers not re-measured. This PR's original numbers (134 open + 48 modify-existing: compared 3 → 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.
  • 23 scan-side suites: 711 passed.
  • tests/test_latency_budget.py -m perf on 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 . and git diff --check: clean.
  • 150 probe cases against main: 53 → 25 silent cases, and the remaining set is a subset of main's.
  • Timing matches main at --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

pengfei-threemoonslab added a commit that referenced this pull request Sep 26, 2026
- 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 pengfei-threemoonslab left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +432 to +437
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Comment on lines +212 to +214
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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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>
@pengfei-threemoonslab
pengfei-threemoonslab force-pushed the claude/github-issue-876-review-ca3249 branch from 2892a3c to 4d76558 Compare September 27, 2026 20:38
# Conflicts:
#	docs/distribution-surfaces.md
@pengfei-threemoonslab
pengfei-threemoonslab merged commit f29616f into main Sep 28, 2026
12 checks passed
pengfei-threemoonslab added a commit that referenced this pull request Sep 29, 2026
…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>
pengfei-threemoonslab added a commit that referenced this pull request Sep 29, 2026
…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 .`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Application diff reports 'compared, no changes' when an SDK agent built by 'return Agent(...)' gains a tool

1 participant