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
7 changes: 7 additions & 0 deletions STABILITY.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
39 changes: 38 additions & 1 deletion docs/application-comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions docs/distribution-surfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
77 changes: 68 additions & 9 deletions src/agents_shipgate/cli/application_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_labels,
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 (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."]:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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_labels(node, scopes, source_label_path) & (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)

Expand All @@ -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 "
Expand Down Expand Up @@ -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"):
Expand Down
2 changes: 2 additions & 0 deletions src/agents_shipgate/cli/application_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions src/agents_shipgate/core/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
26 changes: 24 additions & 2 deletions src/agents_shipgate/inputs/agent_construction_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,38 @@ 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
if isinstance(parent, ast.Assign | ast.AnnAssign) and parent.value is call:
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_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)
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:
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)
Expand Down
Loading
Loading