Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
### Application comparison without prior setup

- Add `diff --application` for OpenAI Agents SDK and Google ADK source-observed per-agent wiring, with exact base/head refs and independently selected scopes. No manifest, saved baseline or authored declarations are needed. The advisory `application_comparison_schema_version: "0.1"` result records before/after evidence, scoped coverage gaps and explicit uncertain candidates; it supplies no release verdict or merge permission. Includes scoped Git materialization, partial-clone recovery, and definition lookup using reader-resolved Python symbols and locations. See [application comparison](docs/application-comparison.md). (#871)
- `diff --application` no longer reads another library's `Agent` or `function_tool` as the OpenAI Agents SDK's, and no longer reports an agent it stopped observing as removed. On speechmatics/speechmatics-academy#142, a LiveKit voice agent (`from livekit.agents import Agent, function_tool`) moved its tools into an `Agent` subclass that passes them through `super().__init__`. The comparison printed `compared` with four `REMOVED agent → …` rows although the same four tools were still bound. It now prints `not_established`: no supported application agent on either side. (#871 follow-up; related #580, #864, #865)
- **Framework identity.** Discovery and the SDK reader count `function_tool` and `Agent` as the SDK's only when the name is imported from the absolute `agents`/`openai_agents` package, or is not imported at all (the existing reading, unless a wildcard import from another module could supply it). The name is resolved in the scope that uses it, as Python resolves it, so an import in a sibling function never decides it, and a parameter or local assignment of that name is not the SDK's. A name imported from anywhere else — `livekit.agents`, a relative `.agents` package — is another library's. A LiveKit file is no longer an SDK candidate, and a file using both reads only the SDK's symbols. A relative import is no longer any framework's import signal. `scan` of a declared SDK source reads the same way.
- **Unobserved is not removed.** One side may observe an agent while the other side's same file still assigns or imports its name (`from factory import agent`), or passes it as `name=`, through a construction the reader does not support: an `Agent` subclass, a factory, `Agent[Context](...)` or `.clone()`. That side then records a coverage gap for the agent. The result is `partial`, and its rows are `not_established` with `candidate_change` `removed` or `added`. An agent referenced only in another agent's `handoffs` is not an observed construction. An agent whose name is gone from the file is still an established removal, and rows for other agents are unaffected. No schema changes.

### Changes

Expand Down
15 changes: 15 additions & 0 deletions docs/application-comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ artifact from the existing host diff JSON and verifier receipt.
- `not_established`: neither side established a supported application agent.
- Exit 2: refs/materialization/input could not be read. This is not no change.

An agent one side observes is absent from the other only when that side's
file no longer names it. If the file still assigns or imports the agent's name
(`from factory import agent`), or passes it as `name=`, through a construction the reader does not support (an `Agent`
subclass passing `tools` through `super().__init__`, a factory,
`Agent[Context](...)`, `.clone()`), that side records a gap for the agent and
its rows are `not_established`, never `removed` or `added`. An agent referenced
only in another agent's `handoffs` is not an observed construction.

Framework identity follows the import, not the spelling. `Agent` and
`function_tool` are the OpenAI Agents SDK's only when imported from the absolute
`agents`/`openai_agents` package or not imported at all, resolved in the scope
that uses them, so an import in another function does not decide it; LiveKit's
`livekit.agents` exports the same names and is not read as the SDK, and a
relative `.agents` import is the project's own package.

Discovery is bounded by `--max-python-files` (default 1000) and a 2 MB per-Python
file limit. Partial discovery remains visible. The first increment uses existing
SDK/ADK readers; unresolved imports, dynamic factories and built-ins remain
Expand Down
2 changes: 1 addition & 1 deletion docs/distribution-surfaces.md

Large diffs are not rendered by default.

81 changes: 80 additions & 1 deletion src/agents_shipgate/cli/application_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ class Observations:
scope: str
status: str = "complete"
agents: dict[tuple[str, str], dict[str, Any]] = field(default_factory=dict)
# Agents known only as a handoff target: their own construction was not read.
handoff_only: set[tuple[str, str]] = field(default_factory=set)
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)
Expand Down Expand Up @@ -266,8 +268,12 @@ def _observe_source(result: Observations, root: Path, source: ToolSourceConfig)
if artifacts is not None:
bag.set("google_adk", artifacts)
attributed = set()
constructed, handoff_targets = set(), set()
for item in loaded:
for observation in item.binding_observations:
path = _source_path(root, observation.source)
constructed.add((path, observation.agent))
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."]:
result.gap(
Expand All @@ -287,6 +293,7 @@ def _observe_source(result: Observations, root: Path, source: ToolSourceConfig)
if warning not in attributed:
result.gap(warning, source=source.path)
attributed.add(warning)
result.handoff_only |= handoff_targets - constructed
tools, warnings = _build_canonical_tools(loaded)
for warning in warnings:
result.gap(warning, source=source.path)
Expand Down Expand Up @@ -516,6 +523,74 @@ def _align_exact_moves(
]


def _still_named_at(root: Path, path: str, name: str) -> 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
variable and ADK's ``name=`` literal. An import binds as an assignment
does, so ``from factory import agent`` keeps the name here.
"""
file = root / path
if not path or not file.is_file() or not file.resolve().is_relative_to(root.resolve()):
return None
try:
tree = ast.parse(file.read_bytes())
except (SyntaxError, ValueError, RecursionError, OSError):
return None # observe() already recorded this file as a gap.
lines = [
node.lineno
for node in ast.walk(tree)
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id == name
Comment on lines +540 to +543

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] Treat an import binding as evidence that the agent still exists

Python imports bind names through ast.alias, not ast.Name(ctx=Store), so this check misses from agent_factory import agent (including from agent_factory import exported as agent). Reproduced with a base agent.py containing agent = Agent(..., tools=[lookup]); the head imports agent from a new agent_factory.py, where def build(): return Agent(..., tools=[lookup]) and agent = build() preserve the same wiring. diff --application still returns comparison_status: compared and lookup: removed, with no head coverage gap. The factory is intentionally unsupported, but the original module still binds the agent name, so absence has not been established. Include import-bound names in this check and add a regression asserting not_established / candidate removed for this refactor.

] + [
node.lineno
for node in ast.walk(tree)
if isinstance(node, (ast.Import, ast.ImportFrom))
for alias in node.names
if (alias.asname or alias.name.split(".", 1)[0]) == name
] + [
node.value.lineno
for node in ast.walk(tree)
if isinstance(node, ast.keyword)
and node.arg == "name"
and isinstance(node.value, ast.Constant)
and node.value.value == name
]
return min(lines, default=None)


def _unobserved_agent_gaps(
old: Observations, new: Observations, base_root: Path, head_root: Path, moves: dict[str, str]
) -> None:
"""An agent observed on one side is absent from the other only if it is gone.

Readers see only the constructions they support. An ``Agent`` subclass
passing ``tools`` through ``super().__init__``, a factory, ``Agent[Ctx]``
or ``.clone()`` keeps the agent while hiding its wiring. When the other
side's file still names the agent, its bindings there are unobserved,
never removed or newly added. Called after ``_align_exact_moves``, so
``old.bindings`` are keyed by head paths.
"""
unmoves = {head: base for base, head in moves.items()}
old_agents = {
(moves.get(path, path), name) for path, name in old.agents.keys() - old.handoff_only
}
for side, root, agents, bindings, to_side in (
(new, head_root, new.agents.keys() - new.handoff_only, old.bindings, lambda path: path),
(old, base_root, old_agents, new.bindings, lambda path: unmoves.get(path, path)),
):
for path, name in sorted({key[:2] for key in bindings} - agents):
line = _still_named_at(root, to_side(path), name)
if line is not None:
side.gap(
f"{to_side(path)}:{line} still names agent {name!r}, but no supported "
"agent construction was observed for it (for example an Agent "
"subclass, factory or clone); its bindings on this side are not "
"established.",
source=to_side(path),
agent=name,
)


def _location(scope: str, path: str | None) -> str | None:
if path is None:
return None
Expand Down Expand Up @@ -600,7 +675,11 @@ def in_scope(path: str, selected: str = selected_scope) -> bool:
f"base={old_scope!r}, head={scope!r}. Check --scope/--base-scope."
)
moves = _align_exact_moves(workspace, base_commit, head_commit, old, new)
rows = compare(old, new, target_moves={m["base_source"]: m["head_source"] for m in moves})
target_moves = {m["base_source"]: m["head_source"] for m in moves}
_unobserved_agent_gaps(
old, new, scratch / "base" / old_scope, scratch / "head" / scope, target_moves
)
rows = compare(old, new, target_moves=target_moves)
for row in rows:
row["before"] = _published_binding(row["before"], old_scope)
row["after"] = _published_binding(row["after"], scope)
Expand Down
35 changes: 33 additions & 2 deletions src/agents_shipgate/cli/discovery/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -936,7 +936,9 @@ def _parse_python_facts(path: Path, workspace: Path) -> _PyFacts | None:
alias.name if alias.asname else alias.name.split(".")[0]
)
elif isinstance(node, ast.ImportFrom):
if node.module:
# `from .agents import x` names this project's own package, never
# an installed framework, so only absolute imports are signals.
if node.module and not node.level:
facts.imports.add(node.module)
for alias in node.names:
facts.imports.add(f"{node.module}.{alias.name}")
Expand Down Expand Up @@ -1685,13 +1687,42 @@ def _score_python_signals_inner(
2.0, "strong", f"{fact.rel_path}: openai-agents import"
)
scores["openai_agents_sdk"].add_file(fact.rel_path)
if fact.decorators & OPENAI_AGENTS_SDK_DECORATORS:
if any(
_sdk_spelling(fact, decorator)
for decorator in fact.decorators & OPENAI_AGENTS_SDK_DECORATORS
):
scores["openai_agents_sdk"].add(
2.0, "strong", f"{fact.rel_path}: @function_tool decorator"
)
scores["openai_agents_sdk"].add_file(fact.rel_path)


def _sdk_spelling(fact: _PyFacts, spelling: str) -> bool:
"""Whether an SDK-shaped decorator spelling denotes the SDK's own.

LiveKit exports ``function_tool`` from ``livekit.agents`` too, so the
spelling alone proves nothing. Its head decides: imported from the
absolute ``agents``/``openai_agents`` package, or not imported at all
(the terminal-name reading, unless a wildcard could supply it), it is the
SDK's; imported only from anywhere else — ``livekit.agents``, a relative
``.agents`` — it is not.
"""
head = spelling.split(".", 1)[0]
origins = [
(module, level)
for (_scope, bound), (module, level, _original) in fact.constant_imports.items()
if bound == head
]
if head in fact.plain_imports:
origins.append((fact.plain_imports[head], 0))
if not origins:
return not fact.star_import
return any(
not level and module.split(".", 1)[0] in OPENAI_AGENTS_SDK_IMPORT_MODULES
for module, level in origins
)


def _collect_package_tokens(workspace: Path) -> list[str]:
tokens: list[str] = []
pyproject = workspace / "pyproject.toml"
Expand Down
Loading
Loading