From 6c4666c8984d08efdb17966ff57f0981f2a07bce Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sat, 10 Oct 2026 12:29:38 -0700 Subject: [PATCH 1/3] feat(application): read bounded source-local SDK clones --- STABILITY.md | 7 + docs/application-comparison.md | 39 ++- docs/distribution-surfaces.md | 7 + src/agents_shipgate/cli/application_diff.py | 77 ++++- .../cli/application_summary.py | 2 + src/agents_shipgate/core/domain.py | 5 + .../inputs/agent_construction_identity.py | 16 +- .../inputs/list_expressions.py | 83 +++++- .../inputs/openai_sdk_static.py | 165 +++++++++-- src/agents_shipgate/inputs/python_imports.py | 11 + .../inputs/sdk_agent_clones.py | 84 ++++++ tests/shard_seconds.json | 14 +- tests/test_application_diff_unobserved.py | 11 +- tests/test_sdk_agent_clones.py | 274 ++++++++++++++++++ 14 files changed, 735 insertions(+), 60 deletions(-) create mode 100644 src/agents_shipgate/inputs/sdk_agent_clones.py create mode 100644 tests/test_sdk_agent_clones.py diff --git a/STABILITY.md b/STABILITY.md index f2a7d184..01973100 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -1,5 +1,12 @@ # Stability Contract · 1.2.0 +Unreleased #911: application comparison `0.4` adds optional `agent_settings` +on a clone's binding side. Recognized source literals can change a row's +compared declaration; they add no binding, release verdict or merge authority. +Exact source-local SDK clones have their own bindings. Unknown receivers, +constructor/handle changes and unread lists retain explicit uncertainty. +See [the application contract](docs/application-comparison.md). + What agents and CI integrations can rely on across versions of Agents Shipgate. Unreleased, runtime contract v42: an empty preflight plan no longer mints diff --git a/docs/application-comparison.md b/docs/application-comparison.md index 8cd1eee3..d5e104bd 100644 --- a/docs/application-comparison.md +++ b/docs/application-comparison.md @@ -301,7 +301,8 @@ agents' rows in the file stand: - a copy that passes its own `tools`, `handoffs` or `mcp_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. A copy passing only `**` is a limit only on a + 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; @@ -669,6 +670,42 @@ 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](https://openai.github.io/openai-agents-python/ref/agent/#agents.agent.Agent.clone). + +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, diff --git a/docs/distribution-surfaces.md b/docs/distribution-surfaces.md index a8083b5b..3653ef68 100644 --- a/docs/distribution-surfaces.md +++ b/docs/distribution-surfaces.md @@ -214,3 +214,10 @@ renders that sentence into the adoption prompts beside the pin, and the See also [`CONTRIBUTING.md` § Surface discipline](../CONTRIBUTING.md#surface-discipline), which governs whether a *new* surface should exist at all. This document governs what an existing one is allowed to say. + +Unreleased #911: the application reader gives exact source-local SDK clones +independent bindings, through the existing constructor/list/handle proofs. +Optional `agent_settings` on a binding records recognized source literals and +is part of the compared declaration. It is never a tool, effect, authority or +runtime assertion. Unknown receiver/settings forms keep their documented +limits. See [application comparison](application-comparison.md). diff --git a/src/agents_shipgate/cli/application_diff.py b/src/agents_shipgate/cli/application_diff.py index bd002665..8924dc49 100644 --- a/src/agents_shipgate/cli/application_diff.py +++ b/src/agents_shipgate/cli/application_diff.py @@ -49,7 +49,10 @@ assess_tool_semantics, ) from agents_shipgate.core.verification_identity import build_engine_requirement -from agents_shipgate.inputs.agent_construction_identity import source_construction_label +from agents_shipgate.inputs.agent_construction_identity import ( + source_binding_label, + source_construction_label, +) from agents_shipgate.inputs.google_adk import adk_agent_subclasses, load_google_adk_artifacts from agents_shipgate.inputs.object_tools import object_display, reading_object_bindings from agents_shipgate.inputs.openai_sdk_static import ( @@ -93,6 +96,7 @@ class Observations: handoff_only: set[tuple[str, str]] = field(default_factory=set) #: Internal source labels, used only to refuse ambiguous identity matching. identity_fallbacks: set[tuple[str, str]] = field(default_factory=set) + construction_labels: dict[tuple[str, str], set[str]] = field(default_factory=dict) bindings: dict[tuple[str, str, str], dict[str, Any]] = field(default_factory=dict) limits: list[str] = field(default_factory=list) sources: list[dict[str, str]] = field(default_factory=list) @@ -844,6 +848,10 @@ def _observe_source(result: Observations, root: Path, source: ToolSourceConfig) constructed.add((path, observation.agent)) if observation.source_identity_fallback: result.identity_fallbacks.add((path, observation.agent)) + if observation.source_construction_labels: + result.construction_labels.setdefault((path, observation.agent), set()).update( + observation.source_construction_labels + ) handoff_targets.update((path, name) for name in observation.handoff_names) if not observation.tools_complete or not observation.handoffs_complete: for message in observation.issues or ["Incomplete observed binding list."]: @@ -1140,6 +1148,28 @@ def _observe_source(result: Observations, root: Path, source: ToolSourceConfig) if len(listed) > 1: result.bindings[key]["construction_sites"] = listed + settings_by_agent: dict[tuple[str, str], dict[str, str] | None] = {} + settings_issues: dict[tuple[str, str], set[str]] = {} + for item in loaded: + for observation in item.binding_observations: + key = (_source_path(root, observation.source), observation.agent) + settings_issues.setdefault(key, set()).update(observation.agent_settings_issues) + if key in settings_by_agent and settings_by_agent[key] != observation.agent_settings: + settings_by_agent[key] = None # Conflicting constructions supply no selected setting. + else: + settings_by_agent[key] = observation.agent_settings + for key, binding in result.bindings.items(): + for message in sorted(settings_issues.get(key[:2], ())): + result.gap(message, source=key[0], agent=key[1], tool=key[2]) + settings = settings_by_agent.get(key[:2]) + if key[:2] in settings_by_agent and settings is None: + result.gap( + "Conflicting constructions supply different declared agent settings.", + source=key[0], agent=key[1], tool=key[2], + ) + if settings: + binding["agent_settings"] = dict(settings) + def _object_binding( key: tuple[str, str], name: str, payload: dict[str, Any], pointer: str | None @@ -1359,6 +1389,7 @@ def compare( if target in unmatched: uncertainty.setdefault(missing_side, []).append(identity_reason) only_condition = False + only_settings = False if before is None: reasons = base.absence_gaps(key, target_moves) if reasons: @@ -1386,6 +1417,9 @@ def compare( only_condition = before_meaning != _meaning(after) and ( {**before_meaning, "bound_when": None} == {**_meaning(after), "bound_when": None} ) + only_settings = before_meaning != _meaning(after) and ( + {**before_meaning, "agent_settings": None} == {**_meaning(after), "agent_settings": None} + ) if only_condition: # A part of the list that was not read may hold the tool some # other way, so the condition is not established either. @@ -1432,7 +1466,9 @@ def compare( + (f" (it was bound {_bound(before)})" if before and before.get("bound_when") else "") + "; check scope limits for relocation.", "not_established": "This candidate change cannot be established from the affected inputs; it is not a no-change result.", - "changed": "What the bound tool object is changed (its `object`); authority direction is not established." + "changed": "The declared agent settings changed; its tools remain bound and runtime behavior is not verified." + if only_settings + else "What the bound tool object is changed (its `object`); authority direction is not established." if (before or {}).get("object") and (after or {}).get("object") else "The bound callable's interface or implementation changed; authority direction is not established.", }[kind], @@ -1527,7 +1563,10 @@ def _align_exact_moves( ] -def _still_named_at(root: Path, path: str, name: str, *, source_label_path: str | None = None) -> int | None: +def _still_named_at( + root: Path, path: str, name: str, *, source_label_path: str | None = None, + construction_labels: set[str] | None = None, +) -> int | None: """First line at which ``path`` still binds ``name`` or passes ``name=name``. Those are the two agent identities the readers key on: the SDK's bound @@ -1563,8 +1602,19 @@ def _still_named_at(root: Path, path: str, name: str, *, source_label_path: str scopes = ScopeIndex(tree) lines.extend( node.lineno for node in ast.walk(tree) - if isinstance(node, ast.Call) - and source_construction_label(node, scopes, source_label_path) == name + if (isinstance(node, ast.alias) or ( + isinstance(node, ast.Name | ast.Attribute) and isinstance(node.ctx, ast.Store) + )) + and source_binding_label(node, scopes, source_label_path) in (construction_labels or {name}) + ) + lines.extend( + node.lineno for node in ast.walk(tree) + if (isinstance(node, ast.Call | ast.alias) or ( + isinstance(node, ast.expr) + and isinstance(scopes.parents.get(node), ast.Assign | ast.AnnAssign) + and scopes.parents[node].value is node + )) + and source_construction_label(node, scopes, source_label_path) in (construction_labels or {name}) ) return min(lines, default=None) @@ -1587,12 +1637,16 @@ def _unobserved_agent_gaps( } old_fallbacks = {(moves.get(path, path), name): path for path, name in old.identity_fallbacks} new_fallbacks = {key: key[0] for key in new.identity_fallbacks} - for side, root, agents, bindings, to_side, source_labels in ( - (new, head_root, new.agents.keys() - new.handoff_only, old.bindings, lambda path: path, old_fallbacks), - (old, base_root, old_agents, new.bindings, lambda path: unmoves.get(path, path), new_fallbacks), + old_labels = {(moves.get(path, path), name): (path, labels) for (path, name), labels in old.construction_labels.items()} + new_labels = {key: (key[0], labels) for key, labels in new.construction_labels.items()} + for side, root, agents, bindings, to_side, source_labels, labels in ( + (new, head_root, new.agents.keys() - new.handoff_only, old.bindings, lambda path: path, old_fallbacks, old_labels), + (old, base_root, old_agents, new.bindings, lambda path: unmoves.get(path, path), new_fallbacks, new_labels), ): for path, name in sorted({key[:2] for key in bindings} - agents): - line = _still_named_at(root, to_side(path), name, source_label_path=source_labels.get((path, name))) + label_path, construction_labels = labels.get((path, name), (source_labels.get((path, name)), None)) + line = _still_named_at(root, to_side(path), name, source_label_path=label_path, + construction_labels=construction_labels) if line is not None: side.gap( f"{to_side(path)}:{line} still names agent {name!r}, but no supported " @@ -2256,6 +2310,11 @@ def _print_rows(payload: dict[str, Any], _one_line: Any) -> None: f" {side}: {_one_line(value.get('signature') or value['tool'])} at {_one_line(value.get('binding_location'))}" + (f" (also listed at {_one_line(', '.join(also))})" if also else "") ) + if value.get("agent_settings"): + settings = value["agent_settings"] + typer.echo(" declared settings: " + ", ".join(f"{key}={item}" for key, item in settings.items())) + if settings.get("tool_choice") == "none": + typer.echo(" tools remain declared; this tool_choice setting disallows tool use.") if value.get("bound_when"): typer.echo(f" bound {_one_line(_bound(value))}") if value.get("object"): diff --git a/src/agents_shipgate/cli/application_summary.py b/src/agents_shipgate/cli/application_summary.py index 15071f3d..3d9129de 100644 --- a/src/agents_shipgate/cli/application_summary.py +++ b/src/agents_shipgate/cli/application_summary.py @@ -133,6 +133,8 @@ def _what_changed(before: dict[str, Any], after: dict[str, Any]) -> list[str]: changes.append("signature changed") if before.get("bound_when") != after.get("bound_when"): changes.append(f"bound {bound_phrase(before)} → {bound_phrase(after)}") + if before.get("agent_settings") != after.get("agent_settings"): + changes.append("declared agent settings changed") if before.get("object") != after.get("object"): identity_before, identity_after = before.get("object") or {}, after.get("object") or {} keys = sorted( diff --git a/src/agents_shipgate/core/domain.py b/src/agents_shipgate/core/domain.py index 19cd95cb..49662646 100644 --- a/src/agents_shipgate/core/domain.py +++ b/src/agents_shipgate/core/domain.py @@ -732,6 +732,11 @@ class AgentBindingObservation(BaseModel): constructor_issues: dict[str, str] = Field(default_factory=dict, exclude=True) #: Internal source-label provenance; never a deployed agent-name assertion. source_identity_fallback: bool = Field(default=False, exclude=True) + #: Qualified construction labels, independent of a clone's display identity. + source_construction_labels: list[str] = Field(default_factory=list, exclude=True) + #: Source-read clone settings; not bindings or runtime behavior assertions. + agent_settings: dict[str, str] = Field(default_factory=dict, exclude=True) + agent_settings_issues: list[str] = Field(default_factory=list, exclude=True) class SourceSurfaceOmission(BaseModel): diff --git a/src/agents_shipgate/inputs/agent_construction_identity.py b/src/agents_shipgate/inputs/agent_construction_identity.py index d4f36e07..92ffcd60 100644 --- a/src/agents_shipgate/inputs/agent_construction_identity.py +++ b/src/agents_shipgate/inputs/agent_construction_identity.py @@ -30,7 +30,7 @@ def construction_identities( return identities, refusals -def source_construction_label(call: ast.Call, scopes: ScopeIndex, source: str) -> str: +def source_construction_label(call: ast.expr | ast.alias, scopes: ScopeIndex, source: str) -> str: """Label syntax only; the caller must prove SDK identity separately.""" parent = scopes.parents.get(call) target = None @@ -38,8 +38,20 @@ def source_construction_label(call: ast.Call, scopes: ScopeIndex, source: str) - targets = parent.targets if isinstance(parent, ast.Assign) else [parent.target] if len(targets) == 1: target = reference_spelling(targets[0]) + elif isinstance(call, ast.alias) and isinstance(parent, ast.Import | ast.ImportFrom): + target = call.asname or call.name.split(".", 1)[0] + return _source_label(call, target, scopes, source) + + +def source_binding_label(target: ast.Name | ast.Attribute | ast.alias, scopes: ScopeIndex, source: str) -> str: + """A qualified stored/imported target, used only to protect uncertain absence.""" + spelling = target.asname or target.name.split(".", 1)[0] if isinstance(target, ast.alias) else reference_spelling(target) + return _source_label(target, spelling, scopes, source) + + +def _source_label(node: ast.AST, target: str | None, scopes: ScopeIndex, source: str) -> str: ancestors = [] - current = parent + current = scopes.parents.get(node) while current is not None: if isinstance(current, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef): ancestors.append(current) diff --git a/src/agents_shipgate/inputs/list_expressions.py b/src/agents_shipgate/inputs/list_expressions.py index a3b6d0b2..50061401 100644 --- a/src/agents_shipgate/inputs/list_expressions.py +++ b/src/agents_shipgate/inputs/list_expressions.py @@ -57,6 +57,12 @@ replaces_builtin_namespace, ) from agents_shipgate.inputs.python_static import dotted_name +from agents_shipgate.inputs.sdk_agent_clones import ( + ClonePlan, + clone_plan, + is_clone, + literal_name_filter, +) if TYPE_CHECKING: from agents_shipgate.inputs.builder_calls import BuilderCalls, Invocation @@ -501,6 +507,15 @@ def _direct_agent_instance(view: _View, value: ast.Call) -> bool: ) + +def _clone_plan(view: _View, call: ast.Call) -> ClonePlan | None: + lookup = view.lookup or bindings_at(view.scopes, view.bindings) + def receiver_call(receiver: ast.Name) -> ast.Call | None: + found = lookup(receiver.id, receiver) + value = getattr(found[0][1], "value", None) if len(found) == 1 else None + return value if isinstance(value, ast.Call) else None + return clone_plan(call, receiver_call, lambda owner: _direct_agent_instance(view, owner)) + def _returned_agent_instance(view: _View, value: ast.Call) -> bool: """Only a bound source-local builder's direct Agent return owns this role.""" if id(value) in view.returned_instances: @@ -768,9 +783,13 @@ def _constructor_family(view: _View, call: ast.Call) -> str | None: lookup = view.lookup or bindings_at(view.scopes, view.bindings) found = lookup(receiver.id, receiver) owner = getattr(found[0][1], "value", None) if len(found) == 1 else None - if not isinstance(owner, ast.Call) or not _direct_agent_instance(view, owner): + if not isinstance(owner, ast.Call): return None - qualified = _namespace_call_reference(view, owner) + plan = _clone_plan(view, owner) if is_clone(owner) else None + root = plan.root if plan is not None else owner + if not _direct_agent_instance(view, root): + return None + qualified = _namespace_call_reference(view, root) return next((family for family in ("agents", "openai_agents", "google.adk") if qualified in _external_constructor_paths(family)), None) @@ -1552,9 +1571,10 @@ def refused(phase: str) -> bool: lookup = view.lookup or bindings_at(view.scopes, view.bindings) found = lookup(call.func.value.id, call.func.value) owner = getattr(found[0][1], "value", None) if len(found) == 1 else None - if not isinstance(owner, ast.Call) or not _direct_agent_instance(view, owner): + plan = _clone_plan(view, call) + if plan is None or not isinstance(owner, ast.Call): return False - constructor = _namespace_call_reference(view, owner) + constructor = _namespace_call_reference(view, plan.root) if constructor is None or not self._reads_agent(view, owner, keyword): return False qualified = constructor + ".clone" @@ -2019,7 +2039,8 @@ def _agent_result_changed_inner( if not entered and (not isinstance(parent, ast.Assign | ast.AnnAssign) or parent.value is not call): return not isinstance(parent, ast.Expr) and not ( strict_container and ( - self._field_data_read(view, call) if keyword == "field_data" + self._settings_data_read(view, call) if keyword == "settings_data" + else self._field_data_read(view, call) if keyword == "field_data" else self._toolset_data_read(view, call) if keyword == "toolset_data" else self._instance_data_read(view, call) if keyword == "instance_data" else self._container_read(view, call) @@ -2059,8 +2080,7 @@ def _handle_use_changed( return False # This exact operand loads the function before wrapping it. use = view.scopes.parents.get(node) if (keyword in {"__class__", *CAPABILITY_FIELDS} and strict_container - and self._checking_constructor_namespaces and not initial_field - and self._agent_copy_receiver_read(view, node, keyword)): + and self._agent_copy_receiver_read(view, node, keyword, initial_field=initial_field)): return False if isinstance(use, ast.Return): # Follow ``agent = Agent(...); return agent`` with its actual @@ -2085,6 +2105,9 @@ def _handle_use_changed( return True finally: self._checking_returns.remove(key) + elif keyword == "settings_data": + if not self._settings_data_read(view, node): + return True elif keyword == "field_data": if not self._field_data_read(view, node): return True @@ -2281,7 +2304,7 @@ def scope(at: ast.AST) -> ast.AST: return False return stores > 0 - def _agent_copy_receiver_read(self, view: _View, node: ast.Name, keyword: str) -> bool: + def _agent_copy_receiver_read(self, view: _View, node: ast.Name, keyword: str, *, initial_field: bool = False) -> bool: """Retain an exact SDK copy receiver only through its owned result.""" parent = view.scopes.parents.get(node) if isinstance(parent, ast.Attribute) and parent.value is node and parent.attr == "clone": @@ -2298,12 +2321,23 @@ def _agent_copy_receiver_read(self, view: _View, node: ast.Name, keyword: str) - # must also retain its class safely through every use of its result. return ( self._reads_agent(view, call, "tools") - and not self._agent_result_changed(view, call, "__class__", strict_container=True) + and not self._agent_result_changed(view, call, "__class__", strict_container=True, initial_field=initial_field) and (keyword == "__class__" or not self._agent_result_changed( - view, call, keyword, strict_container=True, + view, call, keyword, strict_container=True, initial_field=initial_field, )) ) + def _settings_data_read(self, view: _View, node: ast.expr) -> bool: + """Only inline literal SDK tool-choice settings enter a proved agent.""" + parent = view.scopes.parents.get(node) + destination = view.scopes.parents.get(parent) + return bool( + isinstance(node, ast.Call) and isinstance(parent, ast.keyword) and parent.arg == "model_settings" + and isinstance(destination, ast.Call) and _constructor_unchanged(view, node, family="agents") + and self._reads_agent(view, destination, "tools") + and not self._agent_result_changed(view, destination, "__class__", strict_container=True) + ) + @staticmethod def _field_data_read(view: _View, node: ast.expr) -> bool: """FieldInfo is data carrying a shared class, never a builtin container.""" @@ -2357,6 +2391,19 @@ def _toolset_data_read(self, view: _View, node: ast.expr) -> bool: # -- resolution -------------------------------------------------------- + def clone_fields_unchanged(self, plan: ClonePlan) -> bool: + return self._clone_fields_unchanged(self.entry, plan) + + def _clone_fields_unchanged(self, view: _View, plan: ClonePlan) -> bool: + """The source and each retained copy keep the fields the plan read.""" + for construction in (plan.root, *plan.chain): + for role in ("tools", "handoffs", "mcp_servers"): + if (not self._reads_agent(view, construction, role) + or self._agent_result_changed(view, construction, role, + strict_container=True, initial_field=True)): + return False + return True + def constructor_changed(self, call: ast.Call, invocation: Invocation | None) -> bool: """A bound construction must retain its constructor even for literals.""" self._constructor_change_context = None @@ -2927,7 +2974,15 @@ def _container_read(self, view: _View, node: ast.expr, *, dictionary: bool = Fal return self._callee_leaves_alone(view, call, position, keyword, strict_container=True) # Iteration may retain the mutable projected list, and an alias may # subsequently change it. Neither is a read-only ownership proof. - if isinstance(parent, ast.For | ast.AsyncFor | ast.comprehension): + if isinstance(parent, ast.comprehension): + expression = view.scopes.parents.get(parent) + if (parent.iter is node and isinstance(expression, ast.ListComp) + and literal_name_filter(expression) is not None): + # The exact predicate only reads the tool name; members reach + # the same proved agent destination, never a callback or alias. + return self._container_read(view, expression) + return False + if isinstance(parent, ast.For | ast.AsyncFor): return False return read_only_use(node, view.scopes.parents, self._call_reads(view)) @@ -3056,6 +3111,12 @@ def _member_agent_field( if self._reads_agent(view, call, field): if initial_field and self._agent_result_changed(view, call, field, strict_container=True, initial_field=True): return None + plan = _clone_plan(view, call) if is_clone(call) else None + if plan is not None: + if initial_field and not self._clone_fields_unchanged(view, plan): + return None + value = plan.fields.get(field) + return (view, value, invocation) if value is not None else None values = [keyword.value for keyword in call.keywords if keyword.arg == field] return (view, values[0], invocation) if len(values) == 1 else None from agents_shipgate.inputs.builder_calls import CallLimit, CallSite, single_return diff --git a/src/agents_shipgate/inputs/openai_sdk_static.py b/src/agents_shipgate/inputs/openai_sdk_static.py index 63469a49..abcc7b76 100644 --- a/src/agents_shipgate/inputs/openai_sdk_static.py +++ b/src/agents_shipgate/inputs/openai_sdk_static.py @@ -15,7 +15,10 @@ ToolkitScopeBound, ) from agents_shipgate.core.errors import InputParseError -from agents_shipgate.inputs.agent_construction_identity import construction_identities +from agents_shipgate.inputs.agent_construction_identity import ( + construction_identities, + source_construction_label, +) from agents_shipgate.inputs.builder_calls import ( BuilderCalls, ConstructionContext, @@ -71,6 +74,7 @@ function_signature, parse_python_file, ) +from agents_shipgate.inputs.sdk_agent_clones import clone_plan, is_clone, literal_name_filter from agents_shipgate.inputs.sdk_guard_dependencies import ( guard_module_metadata, read_guard_dependency, @@ -314,12 +318,48 @@ def identity_of( return literal or fallback_identities.get(id(call)) return target or literal or fallback_identities.get(id(call)) sdk_calls = [node for node in ast.walk(tree) if isinstance(node, ast.Call) and _denotes_agent(sdk_names, node)] + lookup = _bindings_at(scopes, module.bindings if module is not None else _module_bindings(tree)[0]) + + def receiver_call(receiver: ast.Name, lookup=lookup) -> ast.Call | None: + found = lookup(receiver.id, receiver) + value = getattr(found[0][1], "value", None) if len(found) == 1 else None + return value if isinstance(value, ast.Call) else None + + clone_plans = { + id(call): plan for call in ast.walk(tree) + if object_rule is not None and isinstance(call, ast.Call) and is_clone(call) + if (plan := clone_plan(call, receiver_call, lambda owner, names=sdk_names: _denotes_agent(names, owner))) is not None + } + clone_calls = [call for call in ast.walk(tree) if id(call) in clone_plans] + clone_labels, clone_refusals = construction_identities( + clone_calls, scopes, source_ref, set(), + ) + clone_identities: dict[int, str] = {} + for call in clone_calls: + plan = clone_plans[id(call)] + source_identity = identity_of(plan.root) + label = clone_labels.get(id(call)) + if _literal_agent_name(call) is not None: + clone_identities[id(call)] = _literal_agent_name(call) + elif source_identity is not None and label is not None: + clone_identities[id(call)] = f"{source_identity} clone at {label}" unnamed = [call for call in sdk_calls if identity_of(call) is None] recovered, identity_refusals = construction_identities( unnamed, scopes, source_ref, {identity for call in sdk_calls if (identity := identity_of(call)) is not None}, ) fallback_identities.update(recovered) + for call in clone_calls: + plan = clone_plans[id(call)] + if id(call) not in clone_identities and id(call) in clone_labels and identity_of(plan.root) is not None: + clone_identities[id(call)] = f"{identity_of(plan.root)} clone at {clone_labels[id(call)]}" + direct_identity_of = identity_of + + def identity_of(call: ast.Call, direct=direct_identity_of, clones=clone_identities, plans=clone_plans) -> str | None: + return clones.get(id(call)) if id(call) in plans else direct(call) + + def field(call: ast.Call, name: str, plans=clone_plans) -> ast.AST | None: + return plans[id(call)].fields.get(name) if id(call) in plans else _keyword(call, name) module_bindings = module.bindings if module is not None else _module_bindings(tree)[0] #: Every agent identity this file constructs: a handoff from another #: module's list spelled the same would be read as this file's agent. @@ -350,6 +390,39 @@ def identity_of( #: ``list binding -> identities`` of agents constructed with that list. list_holders: dict[object, set[str]] = {} copies: list[ast.Call] = [] + if clone_plans: + # The SDK shallow copy retains the actual omitted list object, + # including a constructor literal that has no bound list name. + def shared_clone_lists( + expression: ast.AST | None, seen: frozenset[int] = frozenset(), *, + lookup=lookup, sdk_names=sdk_names, clone_plans=clone_plans, + ) -> set[object]: + if expression is None or id(expression) in seen or len(seen) >= 32: + return set() + seen = seen | {id(expression)} + if isinstance(expression, ast.List): + return {("clone_list", id(expression))} + if isinstance(expression, ast.Name): + found = lookup(expression.id, expression) + value = getattr(found[0][1], "value", None) if len(found) == 1 else None + return shared_clone_lists(value, seen) + if (isinstance(expression, ast.Attribute) and expression.attr in {"tools", "handoffs", "mcp_servers"} + and isinstance(expression.value, ast.Name)): + owner = receiver_call(expression.value) + if owner is not None and (_denotes_agent(sdk_names, owner) or id(owner) in clone_plans): + return shared_clone_lists(field(owner, expression.attr), seen) + if isinstance(expression, ast.IfExp | ast.BoolOp): + members = (expression.body, expression.orelse) if isinstance(expression, ast.IfExp) else expression.values + return set().union(*(shared_clone_lists(member, seen) for member in members)) + return set() + + for holder in (*sdk_calls, *clone_calls): + identity = identity_of(holder) + if identity is not None: + for role in ("tools", "handoffs", "mcp_servers"): + expression = field(holder, role) + for shared in shared_clone_lists(expression): + list_holders.setdefault(shared, set()).add(identity) def unread(reason: str, pointer: str, kind: str, path: str = source_ref) -> None: # A construction the reader saw but cannot establish is a named @@ -406,7 +479,7 @@ def unread_agent( ] for node, context in construction_sites: pointer = f"{source_ref}:{node.lineno}" - if _copy_shape(node) is not None: + if id(node) not in clone_plans and _copy_shape(node) is not None: # Whether the receiver is an agent is known only once every # construction in the file has been read. copies.append(node) @@ -422,13 +495,13 @@ def unread_agent( file_observations, ) continue - if not _denotes_agent(sdk_names, node): + if not _denotes_agent(sdk_names, node) and id(node) not in clone_plans: continue call = node target = identity_of(call) if target is None: unread( - identity_refusals.get(id(call)) or ( + clone_refusals.get(id(call)) or identity_refusals.get(id(call)) or ( f"OpenAI Agents SDK agent constructed at {pointer} has no literal " "name, so it cannot be identified; its tools are not read." ), @@ -442,7 +515,7 @@ def unread_agent( for shared in shared_lists( scopes, module_bindings, - [value for value in (_keyword(call, "tools"), _keyword(call, "handoffs")) if value is not None], + [value for value in (field(call, "tools"), field(call, "handoffs")) if value is not None], ): list_holders.setdefault(shared, set()).add(target) opaque = _opaque_arguments(call) @@ -460,14 +533,23 @@ def unread_agent( ) ) continue - tools_expr = _keyword(call, "tools") + tools_expr = field(call, "tools") listed = lists.resolve(tools_expr, invocation=context.invocation) + filtered_name = literal_name_filter(tools_expr) if id(call) in clone_plans else None + if filtered_name is not None: + condition = f"the filter `{ast.unparse(tools_expr.generators[0].ifs[0])}` keeps it" + listed = replace(listed, members=tuple( + replace(member, conditions=tuple(item for item in member.conditions if item != condition)) + for member in listed.members + )) issues: list[str] = list(context.limits) warnings.extend(context.limits) tools_complete = not context.limits constructor_changed = lists.constructor_changed(call, context.invocation) constructor_context = lists.constructor_refusal_context(call, context.invocation) - constructor_issue = builder_calls.constructor_issue(module, call) if module is not None else None + constructor_issue = builder_calls.constructor_issue(module, call) if module is not None and id(call) not in clone_plans else None + if id(call) in clone_plans and not lists.clone_fields_unchanged(clone_plans[id(call)]): + constructor_issue = "the clone's source, method, inherited field or retained handle is not established" if constructor_issue is None and constructor_changed: constructor_issue = "its builder's constructor or imports may change its capability lists" if constructor_context: @@ -602,6 +684,13 @@ def bind_object( if isinstance(object_found, ObjectBinding): if id(member.expr) in candidate_nodes: continue # This initializer fallback covers function references only. + if filtered_name is not None: + reason = "The clone filter cannot establish this object tool's runtime name; its display name is not that proof." + tools_complete = False + issues.append(reason) + warnings.append(reason) + if object_found.name is not None: + tool_issues[object_found.name] = reason if not bind_object(object_found, member.conditions, member.invocation): tools_complete = False continue @@ -702,6 +791,8 @@ def bind_object( names.append(import_aliases.get(reference, reference)) when.add(names[-1], member.conditions) continue + if filtered_name is not None and tool.name != filtered_name: + continue locator = f"{tool.source_ref}#{tool.name}" if tool.source_ref else None if tool.name in duplicated: continue @@ -748,7 +839,7 @@ def bind_object( f"{constructor_issue}; {unread_list_reason('OpenAI Agents SDK', target, pointer, listed)}" ) tool_issues[tool.name] = "; ".join(filter(None, (tool_issues.get(tool.name), reason))) - handoff_list = lists.resolve(_keyword(call, "handoffs"), invocation=context.invocation) + handoff_list = lists.resolve(field(call, "handoffs"), invocation=context.invocation) handoffs_complete = not context.limits and constructor_issue is None if lists.construction_changed(context.invocation, "handoffs"): reason = f"OpenAI Agents SDK agent {target!r}: the caller's returned agent handle may change its handoffs." @@ -798,7 +889,7 @@ def bind_object( continue handoff_names.append(identity) handoff_when.add(identity, member.conditions) - if objects_reader is not None and module is None and _keyword(call, "mcp_servers") is not None: + if objects_reader is not None and module is None and field(call, "mcp_servers") is not None: reason = ( f"OpenAI Agents SDK agent {target!r} at {pointer} has MCP servers this " "reader does not read outside the selected scope." @@ -810,7 +901,7 @@ def bind_object( # ``mcp_servers=[...]``: each server is a binding of the agent's # (#910). Read only for the comparison; ``scan`` does not read # the keyword. - servers = lists.resolve(_keyword(call, "mcp_servers"), invocation=context.invocation) + servers = lists.resolve(field(call, "mcp_servers"), invocation=context.invocation) if servers.unresolved: reason = ( f"OpenAI Agents SDK agent {target!r} at {pointer} has MCP servers it reads " @@ -843,6 +934,24 @@ def bind_object( f"the constructor identity is not established: {constructor_issue}"])) for name in (*names, *objects)}) tool_conditions = when.only_when(duplicated) + settings: dict[str, str] = {} + settings_issues: list[str] = [] + if id(call) in clone_plans and constructor_issue is None: + if clone_plans[id(call)].settings_unread: + settings_issues.append("A model override may change inherited model settings; that SDK decision was not read.") + behavior = field(call, "tool_use_behavior") + if isinstance(behavior, ast.Constant) and behavior.value in {"run_llm_again", "stop_on_first_tool"}: + settings["tool_use_behavior"] = behavior.value + elif behavior is not None: + settings_issues.append("The clone's tool_use_behavior setting is outside the recognized literal values.") + model_settings = field(call, "model_settings") + if (isinstance(model_settings, ast.Call) + and sdk_names.denotes(dotted_name(model_settings.func), model_settings, "ModelSettings", {"ModelSettings"})): + choice = _keyword(model_settings, "tool_choice") + if isinstance(choice, ast.Constant) and choice.value in {"required", "none", "auto"}: + settings["tool_choice"] = choice.value + if model_settings is not None and "tool_choice" not in settings: + settings_issues.append("The clone's model settings do not supply a recognized inline SDK tool_choice literal.") file_observations.append( AgentBindingObservation( agent=target, @@ -863,6 +972,10 @@ def bind_object( handoffs_complete=handoffs_complete, issues=issues, constructor_issues=constructor_issues, + agent_settings=settings, + agent_settings_issues=settings_issues, + source_construction_labels=[label] if id(call) in clone_plans + and (label := source_construction_label(call, scopes, source_ref)) is not None else [], ) ) for node in copies: @@ -949,6 +1062,8 @@ def bind_object( tuple( sorted((k, tuple(v)) for k, v in observation.handoff_conditions.items()) ), + tuple(sorted(observation.agent_settings.items())), + tuple(observation.agent_settings_issues), observation.tools_complete, observation.handoffs_complete, tuple(observation.issues), @@ -963,6 +1078,9 @@ def bind_object( first = next(o for o in file_observations if o.agent == identity) for other in file_observations: if other.agent == identity: + first.source_construction_labels = list(dict.fromkeys( + (*first.source_construction_labels, *other.source_construction_labels) + )) for name, locations in other.tool_sites.items(): first.tool_sites[name] = list(dict.fromkeys((*first.tool_sites.get(name, []), *locations))) for name, locations in other.handoff_sites.items(): @@ -988,7 +1106,8 @@ def bind_object( observation.handoffs_complete = False if reason not in observation.issues: observation.issues.append(reason) - fallback_names = set(fallback_identities.values()) + fallback_names = set(fallback_identities.values()) | {identity for call in clone_calls + if _literal_agent_name(call) is None and (identity := clone_identities.get(id(call))) is not None} for observation in file_observations: observation.source_identity_fallback = observation.agent in fallback_names observations.extend(file_observations) @@ -1169,21 +1288,13 @@ def _agent_reads( return False if sdk_names.denotes(dotted_name(call.func), call, "Agent", DEFAULT_AGENT_CONSTRUCTORS): return True - if isinstance(call.func, ast.Attribute) and call.func.attr == "clone": - receiver = call.func.value - if not isinstance(receiver, ast.Name): - return False - local = scopes.enclosing_bindings(receiver, receiver.id) - if local: - statements = [scopes.statement_of(local[0])] if len(local) == 1 else [] - else: - found = module_bindings.get(receiver.id, []) - statements = [found[0].statement] if len(found) == 1 else [] - return any( - isinstance(getattr(statement, "value", None), ast.Call) - and _denotes_agent(sdk_names, statement.value) - for statement in statements - ) + if is_clone(call): + lookup = _bindings_at(scopes, module_bindings) + def receiver_call(receiver: ast.Name) -> ast.Call | None: + found = lookup(receiver.id, receiver) + value = getattr(found[0][1], "value", None) if len(found) == 1 else None + return value if isinstance(value, ast.Call) else None + return clone_plan(call, receiver_call, lambda owner: _denotes_agent(sdk_names, owner)) is not None name = dotted_name(call.func) if name not in {"replace", "dataclasses.replace", "copy.replace"}: return False @@ -1231,7 +1342,7 @@ def _handoff_identity( if ( isinstance(found[0] if found else None, ast.Name) and isinstance(value, ast.Call) - and _denotes_agent(sdk_names, value) + and (_denotes_agent(sdk_names, value) or is_clone(value)) ): identity = identity_of(value) if identity is not None: diff --git a/src/agents_shipgate/inputs/python_imports.py b/src/agents_shipgate/inputs/python_imports.py index c4e9a1ef..ed8d583c 100644 --- a/src/agents_shipgate/inputs/python_imports.py +++ b/src/agents_shipgate/inputs/python_imports.py @@ -4258,6 +4258,17 @@ def possible_holder(bindings: list[ast.AST], depth: int = 0, seen: frozenset[int # Agent capability fields or builtin-container method allowances. resolver._constructor_namespace_owners[(family, id(parent), "field_data")] = (module, parent, "field_data") continue + if (family == "agents" and allow_owner_routes and isinstance(parent, ast.Call) and parent.func is node + and resolver._constructor_reference(module, node, scopes).get("external_constructor") in { + "agents.ModelSettings", "agents.model_settings.ModelSettings", "openai_agents.ModelSettings", + } and not parent.args and len(parent.keywords) == 1 + and parent.keywords[0].arg == "tool_choice" + and isinstance(parent.keywords[0].value, ast.Constant) + and parent.keywords[0].value.value in {"auto", "none", "required"}): + # A narrow data role still carries a shared SDK class. Its owner + # checks the inline destination and every result use separately. + resolver._constructor_namespace_owners[(family, id(parent), "settings_data")] = (module, parent, "settings_data") + continue decorated = parent if isinstance(parent, ast.Call) and parent.func is node: decorated = scopes.parents.get(parent) diff --git a/src/agents_shipgate/inputs/sdk_agent_clones.py b/src/agents_shipgate/inputs/sdk_agent_clones.py new file mode 100644 index 00000000..f5fe6273 --- /dev/null +++ b/src/agents_shipgate/inputs/sdk_agent_clones.py @@ -0,0 +1,84 @@ +"""Source-only clone chains: unique local receivers, no evaluated code.""" + +from __future__ import annotations + +import ast +from collections.abc import Callable +from dataclasses import dataclass + +FIELDS = frozenset({"tools", "handoffs", "mcp_servers", "tool_use_behavior", "model_settings"}) +MAX_CLONES = 8 + + +def is_clone(call: ast.Call) -> bool: + return isinstance(call.func, ast.Attribute) and call.func.attr == "clone" + + +@dataclass(frozen=True) +class ClonePlan: + root: ast.Call + chain: tuple[ast.Call, ...] + fields: dict[str, ast.expr] + settings_unread: bool = False + + +def clone_plan( + call: ast.Call, + lookup: Callable[[ast.Name], ast.Call | None], + denotes: Callable[[ast.Call], bool], +) -> ClonePlan | None: + """Follow at most eight uniquely bound SDK clones to a direct Agent call. + + This is syntax correspondence only. The list reader must separately prove + the constructor, method, retained handle and inherited field unchanged. + """ + current = call + chain: list[ast.Call] = [] + seen: set[int] = set() + while is_clone(current): + assert isinstance(current.func, ast.Attribute) + names = [item.arg for item in current.keywords] + if (id(current) in seen or len(chain) >= MAX_CLONES or current.args + or None in names or len(set(names)) != len(names) + or not isinstance(current.func.value, ast.Name)): + return None + seen.add(id(current)) + chain.append(current) + owner = lookup(current.func.value) + if owner is None: + return None + current = owner + if not denotes(current): + return None + fields = {item.arg: item.value for item in current.keywords if item.arg in FIELDS} + settings_unread = False + for clone in reversed(chain): + names = {item.arg for item in clone.keywords} + if "model" in names and "model_settings" not in names: + # The SDK can reset implicit model defaults on a model override. + # Do not assert inherited settings without reading that decision. + settings_unread |= "model_settings" in fields + fields.pop("model_settings", None) + elif "model_settings" in names: + settings_unread = False + fields.update({item.arg: item.value for item in clone.keywords if item.arg in FIELDS}) + return ClonePlan(current, tuple(reversed(chain)), fields, settings_unread) + + +def literal_name_filter(expression: ast.AST | None) -> str | None: + """Only one literal equality on the unchanged member's SDK tool name.""" + if not isinstance(expression, ast.ListComp) or len(expression.generators) != 1: + return None + generator = expression.generators[0] + if (generator.is_async or not isinstance(generator.target, ast.Name) + or not isinstance(expression.elt, ast.Name) or expression.elt.id != generator.target.id + or len(generator.ifs) != 1): + return None + test = generator.ifs[0] + if (not isinstance(test, ast.Compare) or len(test.ops) != 1 or not isinstance(test.ops[0], ast.Eq) + or len(test.comparators) != 1 or not isinstance(test.left, ast.Attribute) + or not isinstance(test.left.value, ast.Name) or test.left.value.id != generator.target.id + or test.left.attr != "name" or not isinstance(test.comparators[0], ast.Constant) + or not isinstance(test.comparators[0].value, str)): + return None + return test.comparators[0].value diff --git a/tests/shard_seconds.json b/tests/shard_seconds.json index ad64acf9..6cdf1dd5 100644 --- a/tests/shard_seconds.json +++ b/tests/shard_seconds.json @@ -430,7 +430,8 @@ "tests/test_action_application.py": 43.7, "tests/test_application_source_identity.py": 20.7, "tests/test_agent_field_lists.py": 7.0, - "tests/test_builder_construction_conditions.py": 3.1 + "tests/test_builder_construction_conditions.py": 3.1, + "tests/test_sdk_agent_clones.py": 53.0 }, "measured_at": "cbff1ef0b101", "measurement": { @@ -485,6 +486,17 @@ "case_count": 48, "command": "python -m pytest tests/test_agent_field_lists.py tests/test_builder_construction_conditions.py --junitxml=flow.xml", "time_basis": "JUnit testcase time; original full-suite measurements retained" + }, + { + "source": "junit", + "measured_at": "bef21d5ac7bf with issue #911 bounded clone increment", + "report_sha256": "cb22c6baa687109af43ffe44cf33d6de5751877e69473868c3648d9d5d31f684", + "files": { + "tests/test_sdk_agent_clones.py": 53.0 + }, + "case_count": 81, + "command": "python -m pytest tests/test_sdk_agent_clones.py tests/test_application_source_identity.py tests/test_application_diff_unobserved.py -n 4 --junitxml=clones-identity.xml", + "time_basis": "JUnit testcase time; original full-suite measurements retained" } ] } diff --git a/tests/test_application_diff_unobserved.py b/tests/test_application_diff_unobserved.py index 53095219..b9a2871b 100644 --- a/tests/test_application_diff_unobserved.py +++ b/tests/test_application_diff_unobserved.py @@ -78,11 +78,6 @@ def test_every_agent_construction_is_observed(repo, body, agent): 'class Custom(Agent):\n pass\nhelper = Custom(name="c", tools=[quote])\n', "built from the subclass 'Custom'", ), - ( - 'base = Agent(name="base", tools=[quote])\n' - 'copy = base.clone(tools=[quote, send_image])\n', - "agent copy at agent.py:", - ), ( "from shared import base_agent\n" "copy = base_agent.clone(tools=[quote, send_image])\n", @@ -407,11 +402,9 @@ def test_an_unused_literal_clone_preserves_the_original_membership_comparison(re result = _compare( repo, body.replace("TOOLS", "[quote]"), body.replace("TOOLS", "[quote, send_image]") ) - # #876 treats a name/instructions-only copy with an unused result as no - # explicit capability override. Its direct receiver still needs complete - # constructor/method/result ownership; other copy/escape tests pin refusal. + # #911 gives the identified copy its own inherited binding as well. assert result["comparison_status"] == "compared" - assert _pairs(result) == [("pirate_agent", "send_image", "added")] + assert _pairs(result) == [("Robot", "send_image", "added"), ("pirate_agent", "send_image", "added")] def test_unused_sdk_subclass_hooks_limit_constructor_identity(repo): diff --git a/tests/test_sdk_agent_clones.py b/tests/test_sdk_agent_clones.py new file mode 100644 index 00000000..35014847 --- /dev/null +++ b/tests/test_sdk_agent_clones.py @@ -0,0 +1,274 @@ +"""Copies have independent bindings only through proved SDK receivers.""" + +import pytest + +from tests.test_imported_tool_bindings import _commit, _compare, _git, _write +from tests.test_imported_tool_bindings import _sdk as _plain_sdk + + +def _sdk(root, path): + from agents_shipgate.inputs.object_tools import reading_object_bindings + with reading_object_bindings(lambda path: False): + return _plain_sdk(root, path) + + +PREFIX = """from agents import Agent, function_tool +@function_tool +def read(): + return None +@function_tool +def write(): + return None +source = Agent(name='Source', tools=[read, write]) +""" + + +def fixture(clone): + return {"agent.py": PREFIX + clone + "\n"} + + +@pytest.mark.parametrize("clone,tools", [ + ("copy = source.clone()", ["read", "write"]), + ("copy = source.clone(tools=[read])", ["read"]), + ("copy = source.clone(tools=[t for t in source.tools if t.name == 'read'])", ["read"]), + ("first = source.clone(tools=[read]); copy = first.clone()", ["read"]), + ("first = source.clone(); copy = first.clone(tools=[t for t in first.tools if t.name == 'read'])", ["read"]), +]) +def test_clone_bindings(tmp_path, clone, tools): + _write(tmp_path, fixture(clone)) + loaded = _sdk(tmp_path, "agent.py") + copy = loaded.binding_observations[-1] + assert loaded.warnings == [] + assert copy.agent.startswith("source clone at ") and copy.tools_complete + assert copy.tool_names == tools + + +def test_only_clone_is_narrowed(tmp_path): + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, fixture("copy = source.clone(tools=[read, write])")) + head = _commit(tmp_path, fixture("copy = source.clone(tools=[read])")) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "compared" + assert len(result["rows"]) == 1 + assert result["rows"][0]["change"] == "removed" + assert result["rows"][0]["agent"].startswith("source clone at ") + + +@pytest.mark.parametrize("change", [ + "source.tools = []\n", "source.tools.append(read)\n", "opaque(source)\n", + "Agent.clone = replacement\n", "source.clone = replacement\n", +]) +def test_unproved_source_or_method_stays_partial(tmp_path, change): + _write(tmp_path, fixture(change + "copy = source.clone(tools=[read])")) + loaded = _sdk(tmp_path, "agent.py") + copy = loaded.binding_observations[-1] + assert loaded.warnings and not copy.tools_complete + + +def test_explicit_clone_name_and_inherited_handoff(tmp_path): + _write(tmp_path, {"agent.py": PREFIX.replace("source = Agent(name='Source', tools=[read, write])", "worker = Agent(name='Worker', tools=[write])\nsource = Agent(name='Source', tools=[read], handoffs=[worker])") + "copy = source.clone(name='Copy')\n"}) + loaded = _sdk(tmp_path, "agent.py") + copy = next(item for item in loaded.binding_observations if item.agent == "Copy") + assert not loaded.warnings and copy.tool_names == ["read"] and copy.handoff_names == ["worker"] + + +def test_unknown_clone_source_keeps_a_named_limit(tmp_path): + _write(tmp_path, fixture("copy = unknown.clone(tools=[read])")) + loaded = _sdk(tmp_path, "agent.py") + assert loaded.warnings + assert any(not item.tools_complete for item in loaded.binding_observations) + + +def test_clone_identity_survives_line_movement(tmp_path): + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, fixture("copy = source.clone(tools=[read])")) + head = _commit(tmp_path, fixture("marker = 1\ncopy = source.clone(tools=[read])")) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "compared" and result["rows"] == [] + + +def test_clone_settings_are_facts_and_tools_remain_bound(tmp_path): + def configured(choice): + result = fixture(f"copy = source.clone(tool_use_behavior='stop_on_first_tool', model_settings=ModelSettings(tool_choice='{choice}'))") + result["agent.py"] = result["agent.py"].replace("from agents import Agent,", "from agents import Agent, ModelSettings,") + return result + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, configured("required")) + head = _commit(tmp_path, configured("none")) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "compared" and len(result["rows"]) == 2 + assert all(row["change"] == "changed" and row["after"]["agent_settings"] == {"tool_use_behavior":"stop_on_first_tool", "tool_choice":"none"} for row in result["rows"]) + + +@pytest.mark.parametrize("change", [ + "copy.tools.append(write)", "copy.tools = []", "alias = copy; opaque(alias)", + "read.name = 'other'", "source.__class__ = Fake", +]) +def test_mutable_shared_members_and_copy_handles_stay_unestablished(tmp_path, change): + _write(tmp_path, fixture("copy = source.clone()\n" + change)) + loaded = _sdk(tmp_path, "agent.py") + copy = next(item for item in loaded.binding_observations if "clone at" in item.agent) + assert loaded.warnings and not copy.tools_complete + + +@pytest.mark.parametrize("replacement", [False, True]) +def test_clone_inherits_and_overrides_mcp_servers(tmp_path, replacement): + from agents_shipgate.inputs.object_tools import reading_object_bindings + files = fixture("copy = source.clone()") + files["agent.py"] = files["agent.py"].replace("source = Agent(name='Source', tools=[read, write])", "from agents.mcp import MCPServerStdio\nserver = MCPServerStdio(params={'command':'npx'})\nsource = Agent(name='Source', tools=[read], mcp_servers=[server])") + if replacement: + files["agent.py"] = files["agent.py"].replace("copy = source.clone()", "copy = source.clone(mcp_servers=[])") + _write(tmp_path, files) + with reading_object_bindings(lambda path: False): + loaded = _sdk(tmp_path, "agent.py") + copy = next(item for item in loaded.binding_observations if "clone at" in item.agent) + assert not loaded.warnings and copy.tools_complete + assert bool(copy.object_bindings) != replacement + + +def test_filtered_clone_narrows_only_the_clone(tmp_path): + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, fixture("copy = source.clone(tools=[t for t in source.tools if t.name == 'read'])")) + head = _commit(tmp_path, fixture("copy = source.clone(tools=[t for t in source.tools if t.name == 'write'])")) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "compared" + assert sorted((row["tool"],row["change"]) for row in result["rows"]) == [("read","removed"),("write","added")] + assert all("clone at" in row["agent"] for row in result["rows"]) + + +@pytest.mark.parametrize("settings", [ + "ModelSettings(tool_choice=choice)", "Other(tool_choice='none')", "ModelSettings(tool_choice='none', extra=opaque())", +]) +def test_unread_settings_do_not_acquire_known_sdk_facts(tmp_path, settings): + files = fixture(f"copy = source.clone(model_settings={settings})") + files["agent.py"] = files["agent.py"].replace("Agent, function_tool", "Agent, ModelSettings, function_tool") + _write(tmp_path, files) + loaded = _sdk(tmp_path, "agent.py") + copy = next(item for item in loaded.binding_observations if "clone at" in item.agent) + assert copy.agent_settings == {} + + +@pytest.mark.parametrize("named", [False, True]) +@pytest.mark.parametrize("replacement", ["factory()", "wrapper(source)", "source.copy()", "source.clone(*arguments)", "provided_agent", "module.agent"]) +def test_unread_replacement_at_clone_site_is_not_a_deletion(tmp_path, named, replacement): + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, fixture("copy = source.clone(" + ("name='Copy'" if named else "") + ")")) + head = _commit(tmp_path, fixture("copy = " + replacement)) + result = _compare(tmp_path, base, head, "--scope", ".") + copy_rows = [row for row in result["rows"] if row["agent"] == "Copy" or "clone at" in row["agent"]] + assert len(copy_rows) == 2 + assert all(row["change"] == "not_established" and row["uncertainty"].get("head") for row in copy_rows) + + +@pytest.mark.parametrize("named", [False, True]) +@pytest.mark.parametrize("replacement", [ + "from provider import agent as copy", "import provider as copy", + "copy = other = provided_agent", "copy, other = provided_pair", + "(copy,) = provided_pair", "if (copy := provided_agent):\n pass", + "for copy in provided_agents:\n pass", "copy: Agent", +]) +def test_import_at_clone_site_is_not_a_deletion(tmp_path, named, replacement): + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, fixture("copy = source.clone(" + ("name='Copy'" if named else "") + ")")) + head = _commit(tmp_path, fixture(replacement)) + result = _compare(tmp_path, base, head, "--scope", ".") + copy_rows = [row for row in result["rows"] if row["agent"] == "Copy" or "clone at" in row["agent"]] + assert len(copy_rows) == 2 + assert all(row["change"] == "not_established" for row in copy_rows) + + +@pytest.mark.parametrize("named", [False, True]) +def test_deleting_clone_site_removes_its_bindings(tmp_path, named): + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, fixture("copy = source.clone(" + ("name='Copy'" if named else "") + ")")) + head = _commit(tmp_path, fixture("")) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "compared" + assert len(result["rows"]) == 2 + assert all(row["change"] == "removed" for row in result["rows"]) + + +@pytest.mark.parametrize("mutation", ["append(write)", "extend([write])", "insert(0, write)"]) +@pytest.mark.parametrize("override", ["", "tools=source.tools", "tools=source.tools if enabled else []", "tools=source.tools or []", "tools=alias"]) +def test_clone_in_place_additions_make_the_source_list_incomplete(tmp_path, mutation, override): + files = fixture(("alias = source.tools\n" if override == "tools=alias" else "") + + "copy = source.clone(" + override + ")\ncopy.tools." + mutation) + files["agent.py"] = files["agent.py"].replace("tools=[read, write]", "tools=[read]") + _write(tmp_path, files) + loaded = _sdk(tmp_path, "agent.py") + assert all(not item.tools_complete for item in loaded.binding_observations) + assert all(item.issues for item in loaded.binding_observations) + + +@pytest.mark.parametrize("override", ["[read]", "[*source.tools]", "list(source.tools)"]) +def test_clone_new_literal_list_does_not_share_source_list(tmp_path, override): + files = fixture("copy = source.clone(tools=" + override + ")\ncopy.tools.append(write)") + files["agent.py"] = files["agent.py"].replace("tools=[read, write]", "tools=[read]") + _write(tmp_path, files) + loaded = _sdk(tmp_path, "agent.py") + assert loaded.binding_observations[0].tools_complete + assert not loaded.binding_observations[-1].tools_complete + + +def test_model_override_does_not_assert_inherited_model_defaults(tmp_path): + files = fixture("first = source.clone(model_settings=ModelSettings(tool_choice='none'))\ncopy = first.clone(model='other-model')") + files["agent.py"] = files["agent.py"].replace("Agent, function_tool", "Agent, ModelSettings, function_tool") + _write(tmp_path, files) + loaded = _sdk(tmp_path, "agent.py") + assert loaded.binding_observations[-1].agent_settings == {} + assert loaded.binding_observations[-1].agent_settings_issues + + +def test_unread_model_override_is_not_a_confirmed_setting_change(tmp_path): + def configured(model): + files = fixture("first = source.clone(model_settings=ModelSettings(tool_choice='none'))\ncopy = first.clone(" + model + ")") + files["agent.py"] = files["agent.py"].replace("Agent, function_tool", "Agent, ModelSettings, function_tool") + return files + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, configured("")) + head = _commit(tmp_path, configured("model='other-model'")) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "partial" + assert all(row["change"] == "not_established" for row in result["rows"]) + assert all(row["uncertainty"].get("head") for row in result["rows"]) + + +@pytest.mark.parametrize("collision", ["clone", "source"]) +def test_conflicting_settings_are_not_merged_or_selected(tmp_path, collision): + def configured(choice): + text = "from agents import ModelSettings\n" + name = "source" if collision == "source" else "Copy" + if collision == "clone": + text += "a = source.clone(name='Copy', model_settings=ModelSettings(tool_choice='none'))\n" + text += f"b = source.clone(name='{name}', model_settings=ModelSettings(tool_choice='{choice}'))\n" + return fixture(text) + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, configured("none")) + head = _commit(tmp_path, configured("required")) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "partial" + assert all(row["change"] == "not_established" for row in result["rows"]) + assert any("constructed more than once" in limit for limit in result["head"]["limits"]) + assert all("agent_settings" not in row["after"] for row in result["rows"] if row["after"]) + + +@pytest.mark.parametrize("name", ["web_search", "WebSearchTool"]) +def test_object_display_name_is_not_a_filter_membership_proof(tmp_path, name): + def hosted(clone): + files = fixture(clone) + files["agent.py"] = files["agent.py"].replace("Agent, function_tool", "Agent, WebSearchTool, function_tool").replace("tools=[read, write]", "tools=[WebSearchTool()]") + return files + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, hosted("copy = source.clone()")) + head = _commit(tmp_path, hosted(f"copy = source.clone(tools=[t for t in source.tools if t.name == '{name}'])")) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "partial" + assert all(row["change"] == "not_established" for row in result["rows"]) + assert any("runtime name" in limit for limit in result["head"]["limits"]) + + +def test_default_scan_does_not_expand_application_clones(tmp_path): + _write(tmp_path, fixture("copy = source.clone(tools=[read])")) + loaded = _plain_sdk(tmp_path, "agent.py") + assert any("agent copy" in warning for warning in loaded.warnings) + assert not any("clone at" in observation.agent for observation in loaded.binding_observations) From ed379f090739cb9947a140f31222055594241b44 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sat, 10 Oct 2026 12:45:19 -0700 Subject: [PATCH 2/3] fix(application): preserve clone absence across binding scopes --- src/agents_shipgate/cli/application_diff.py | 4 +- .../inputs/agent_construction_identity.py | 16 +++++-- tests/shard_seconds.json | 8 ++-- tests/test_sdk_agent_clones.py | 47 +++++++++++++++++++ 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/agents_shipgate/cli/application_diff.py b/src/agents_shipgate/cli/application_diff.py index 8924dc49..23d5ccf4 100644 --- a/src/agents_shipgate/cli/application_diff.py +++ b/src/agents_shipgate/cli/application_diff.py @@ -50,7 +50,7 @@ ) from agents_shipgate.core.verification_identity import build_engine_requirement from agents_shipgate.inputs.agent_construction_identity import ( - source_binding_label, + source_binding_labels, source_construction_label, ) from agents_shipgate.inputs.google_adk import adk_agent_subclasses, load_google_adk_artifacts @@ -1605,7 +1605,7 @@ def _still_named_at( if (isinstance(node, ast.alias) or ( isinstance(node, ast.Name | ast.Attribute) and isinstance(node.ctx, ast.Store) )) - and source_binding_label(node, scopes, source_label_path) in (construction_labels or {name}) + and source_binding_labels(node, scopes, source_label_path) & (construction_labels or {name}) ) lines.extend( node.lineno for node in ast.walk(tree) diff --git a/src/agents_shipgate/inputs/agent_construction_identity.py b/src/agents_shipgate/inputs/agent_construction_identity.py index 92ffcd60..cc687453 100644 --- a/src/agents_shipgate/inputs/agent_construction_identity.py +++ b/src/agents_shipgate/inputs/agent_construction_identity.py @@ -43,10 +43,20 @@ def source_construction_label(call: ast.expr | ast.alias, scopes: ScopeIndex, so return _source_label(call, target, scopes, source) -def source_binding_label(target: ast.Name | ast.Attribute | ast.alias, scopes: ScopeIndex, source: str) -> str: - """A qualified stored/imported target, used only to protect uncertain absence.""" +def source_binding_labels(target: ast.Name | ast.Attribute | ast.alias, scopes: ScopeIndex, source: str) -> set[str]: + """Stored/imported target labels, used only to protect uncertain absence.""" spelling = target.asname or target.name.split(".", 1)[0] if isinstance(target, ast.alias) else reference_spelling(target) - return _source_label(target, spelling, scopes, source) + labels = {_source_label(target, spelling, scopes, source)} + if isinstance(target, ast.Name | ast.alias) and spelling: + # A global/nonlocal store belongs to the scope it binds, rather than + # the function containing its syntax. This protects absence only; + # construction identities above keep their existing source labels. + bindings = scopes.enclosing_bindings(target, spelling) + if not bindings: + labels.add(f"{spelling}@{source}") + else: + labels.add(_source_label(bindings[0], spelling, scopes, source)) + return labels def _source_label(node: ast.AST, target: str | None, scopes: ScopeIndex, source: str) -> str: diff --git a/tests/shard_seconds.json b/tests/shard_seconds.json index 6cdf1dd5..9ba56c4c 100644 --- a/tests/shard_seconds.json +++ b/tests/shard_seconds.json @@ -431,7 +431,7 @@ "tests/test_application_source_identity.py": 20.7, "tests/test_agent_field_lists.py": 7.0, "tests/test_builder_construction_conditions.py": 3.1, - "tests/test_sdk_agent_clones.py": 53.0 + "tests/test_sdk_agent_clones.py": 73.3 }, "measured_at": "cbff1ef0b101", "measurement": { @@ -490,11 +490,11 @@ { "source": "junit", "measured_at": "bef21d5ac7bf with issue #911 bounded clone increment", - "report_sha256": "cb22c6baa687109af43ffe44cf33d6de5751877e69473868c3648d9d5d31f684", + "report_sha256": "75e56025f6bf6cede09ebf85baba23c8e68e8e197817e2a540b417d6b707d9d1", "files": { - "tests/test_sdk_agent_clones.py": 53.0 + "tests/test_sdk_agent_clones.py": 73.3 }, - "case_count": 81, + "case_count": 92, "command": "python -m pytest tests/test_sdk_agent_clones.py tests/test_application_source_identity.py tests/test_application_diff_unobserved.py -n 4 --junitxml=clones-identity.xml", "time_basis": "JUnit testcase time; original full-suite measurements retained" } diff --git a/tests/test_sdk_agent_clones.py b/tests/test_sdk_agent_clones.py index 35014847..ed8f12db 100644 --- a/tests/test_sdk_agent_clones.py +++ b/tests/test_sdk_agent_clones.py @@ -177,6 +177,53 @@ def test_import_at_clone_site_is_not_a_deletion(tmp_path, named, replacement): assert all(row["change"] == "not_established" for row in copy_rows) +@pytest.mark.parametrize("named", [False, True]) +@pytest.mark.parametrize("replacement", ["copy = provided_agent", "from provider import agent as copy"]) +def test_global_replacement_at_clone_site_is_not_a_deletion(tmp_path, named, replacement): + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, fixture("copy = source.clone(" + ("name='Copy'" if named else "") + ")")) + head = _commit(tmp_path, fixture("def update():\n global copy\n " + replacement + "\nupdate()")) + result = _compare(tmp_path, base, head, "--scope", ".") + copy_rows = [row for row in result["rows"] if row["agent"] == "Copy" or "clone at" in row["agent"]] + assert len(copy_rows) == 2 + assert all(row["change"] == "not_established" for row in copy_rows) + + +@pytest.mark.parametrize("named", [False, True]) +def test_unrelated_local_replacement_does_not_hide_clone_deletion(tmp_path, named): + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, fixture("copy = source.clone(" + ("name='Copy'" if named else "") + ")")) + head = _commit(tmp_path, fixture("def update():\n copy = provided_agent\nupdate()")) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "compared" + assert len(result["rows"]) == 2 + assert all(row["change"] == "removed" for row in result["rows"]) + + +@pytest.mark.parametrize("named", [False, True]) +def test_global_clone_construction_keeps_its_syntax_label_for_uncertain_absence(tmp_path, named): + _git(tmp_path, "init", "-q", "-b", "main") + before = "def update():\n global copy\n copy = source.clone(" + ("name='Copy'" if named else "") + ")\nupdate()" + base = _commit(tmp_path, fixture(before)) + head = _commit(tmp_path, fixture("def update():\n global copy\n copy = other = provided_agent\nupdate()")) + result = _compare(tmp_path, base, head, "--scope", ".") + copy_rows = [row for row in result["rows"] if row["agent"] == "Copy" or "clone at" in row["agent"]] + assert len(copy_rows) == 2 + assert all(row["change"] == "not_established" for row in copy_rows) + + +@pytest.mark.parametrize("declaration,expected", [("global copy", "copy@agent.py"), ("nonlocal copy", "outer.copy@agent.py"), ("", "outer.update.copy@agent.py")]) +def test_absence_label_uses_the_declared_binding_scope(declaration, expected): + import ast + + from agents_shipgate.inputs.agent_construction_identity import source_binding_labels + from agents_shipgate.inputs.python_imports import ScopeIndex + + tree = ast.parse("def outer():\n copy = first\n def update():\n " + (declaration + "\n " if declaration else "") + "copy = provided_agent\n") + target = next(node for node in ast.walk(tree) if isinstance(node, ast.Assign) and isinstance(node.value, ast.Name) and node.value.id == "provided_agent").targets[0] + assert source_binding_labels(target, ScopeIndex(tree), "agent.py") == {expected, "outer.update.copy@agent.py"} + + @pytest.mark.parametrize("named", [False, True]) def test_deleting_clone_site_removes_its_bindings(tmp_path, named): _git(tmp_path, "init", "-q", "-b", "main") From 5daa9a061904614e36de231a649c0c371f187ddc Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sat, 10 Oct 2026 13:21:22 -0700 Subject: [PATCH 3/3] test(application): verify supported clone comparisons and refusals --- tests/test_imported_tool_review.py | 36 ++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/tests/test_imported_tool_review.py b/tests/test_imported_tool_review.py index 72c7c4a3..631f5e1c 100644 --- a/tests/test_imported_tool_review.py +++ b/tests/test_imported_tool_review.py @@ -894,17 +894,35 @@ def test_sdk_list_passed_to_a_helper_is_dynamic_only_when_it_can_change(repo, he if dynamic: assert result["comparison_status"] == "partial" elif "base_agent.clone(tools=TOOLS)" in helper: - # The unread copy owns its limit; passing TOOLS does not mutate the - # list or erase the two independently declared agents' tool changes. - assert result["comparison_status"] == "partial" + # The source-local SDK clone reads TOOLS without mutating it; each + # agent retains the imported function's actual implementation change. + clone = "base_agent clone at variant@agent.py" + assert result["comparison_status"] == "compared" assert _rows(result) == [("agent", "lookup", "changed"), - ("base_agent", "lookup", "changed")] + ("base_agent", "lookup", "changed"), + (clone, "lookup", "changed")] + assert all(row["uncertainty"] == {} for row in result["rows"]) for side in ("base", "head"): - gaps = result[side]["coverage_gaps"] - assert len(gaps) == 1 - assert gaps[0]["agent"] == "variant" - assert "agent copy at agent.py:6 (clone) is not read" in gaps[0]["reason"] - assert gaps[0]["affects"] == "binding_presence" + assert result[side]["coverage_gaps"] == [] + field = "before" if side == "base" else "after" + assert all(row[field]["definition"]["source"] == "billing.py" for row in result["rows"]) + + for refused in ( + agent.replace("variant =", "base_agent.clone = replacement\nvariant ="), + agent.replace("agent = Agent(name='app'", "opaque(variant)\nagent = Agent(name='app'"), + ): + base = commit(repo, {"agent.py": refused, "billing.py": BILLING_SDK}) + head = commit(repo, {"billing.py": BILLING_SDK.replace("'billing'", "q.upper()")}) + limited = run(repo, base, head) + assert limited["comparison_status"] == "partial" + row, = [row for row in limited["rows"] if row["agent"] == clone] + assert row["change"] == "not_established" + assert row["candidate_change"] == "changed" + for side in ("base", "head"): + assert row["uncertainty"][side] + assert any(gap.get("agent") == clone + and "constructor identity is not established" in gap["reason"] + for gap in limited[side]["coverage_gaps"]) else: # These consumers can retain a callable's constructor namespace even # when the old membership-only proof regarded them as reads.