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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ This format follows [Keep a Changelog](https://keepachangelog.com/) and adheres
## [Unreleased]

### Fixed
- **Prompt-agent deploy: `stage` no longer fails with `Required properties ["kind"] are not present` against `azure-ai-projects` 2.x.**
`_copy_definition` previously called `.copy()` on the typed
`PromptAgentDefinition` returned by `get_version`. In SDK 1.x that
preserved the typed model so the body serialized as a flat
`{"kind": "prompt", "model": ..., "instructions": ...}`. In SDK 2.x
the same `.copy()` returns a stripped base `Model` whose JSON shape
is `{"_data": {"kind": "prompt", ...}}`, and `.get("kind")` returns
`None` — so the request body that reached the Foundry Agents service
contained `definition: {"_data": {...}}` with no top-level `kind`,
and the service rejected it with `invalid_payload`. This regression
only fired on the `created` action path (i.e. when the user's prompt
differed from the seed); the `reused` and bootstrap paths were
unaffected because they don't round-trip the typed model through
`.copy()`. `_copy_definition` now normalizes any SDK definition
object to a plain `dict` before mutation, and `_create_agent_version`
no longer puts a root-level `kind` on the request body (the new API
treats `kind` strictly as the discriminator inside `definition`).
- **Tutorial: prompt-agent step 13 now shows the steady-state `foundry-agent.json` (action: reused) instead of the bootstrap edge case.**
The example JSON in step 13 previously showed `action: bootstrapped`
with `candidate_agent: "travel-agent:1"` and a "the two numbers are
Expand Down
46 changes: 36 additions & 10 deletions src/agentops/pipeline/prompt_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,9 @@ def _create_agent_version(
description: str,
) -> Any:
client = _project_client(endpoint)
# The current Foundry Agents create_version endpoint validates both a root
# `kind` discriminator and the nested `definition` payload.
body = {
"kind": _get_definition_value(definition, "kind"),
"definition": definition,
definition_dict = _definition_to_dict(definition)
body: Dict[str, Any] = {
"definition": definition_dict,
"metadata": metadata,
"description": description,
}
Expand Down Expand Up @@ -380,13 +378,41 @@ def _get_mapping_value(value: Any, key: str) -> Any:
return None


def _copy_definition(definition: Any) -> Any:
if hasattr(definition, "copy"):
def _copy_definition(definition: Any) -> Dict[str, Any]:
"""Return a deep copy of ``definition`` as a plain dict.

The Foundry SDK's typed definition models (e.g. ``PromptAgentDefinition``)
expose ``.copy()``, but in ``azure-ai-projects`` 2.x that returns a stripped
base ``Model`` whose JSON shape is ``{"_data": {...}}`` instead of the
flattened payload the service expects. To stay compatible across SDK
versions we always normalize to a plain dict here.
"""

return copy.deepcopy(_definition_to_dict(definition))


def _definition_to_dict(definition: Any) -> Dict[str, Any]:
"""Best-effort conversion of an SDK definition object into a plain dict."""

if isinstance(definition, dict):
return dict(definition)
data = getattr(definition, "_data", None)
if isinstance(data, dict):
return dict(data)
if hasattr(definition, "items"):
try:
return definition.copy()
except TypeError:
return {key: value for key, value in definition.items()}
except Exception: # noqa: BLE001 — fall through to attribute scrape
pass
return copy.deepcopy(definition)
if hasattr(definition, "as_dict"):
try:
return dict(definition.as_dict())
except Exception: # noqa: BLE001
pass
raise TypeError(
f"Cannot convert Foundry agent definition of type {type(definition).__name__} "
"to a dict; expected a mapping-compatible object."
)


def _deployment_metadata(*, environment: str, prompt_hash: str) -> Dict[str, str]:
Expand Down
80 changes: 80 additions & 0 deletions tests/unit/test_prompt_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,3 +374,83 @@ def test_is_not_found_error_handles_404_and_rejects_others() -> None:
assert not prompt_deploy._is_not_found_error(_make_not_found(403))
assert not prompt_deploy._is_not_found_error(_make_not_found(500))
assert not prompt_deploy._is_not_found_error(Exception("no status"))


def test_copy_definition_returns_plain_dict_from_sdk_typed_model() -> None:
"""Regression: ``azure-ai-projects`` 2.x typed definition models expose a
``.copy()`` method that returns a stripped base ``Model`` whose JSON shape
is ``{"_data": {...}}`` instead of the flat fields the Foundry service
expects. ``_copy_definition`` must normalize to a plain dict so the
``definition`` payload reaches the wire as a flat object with ``kind`` at
the top level.
"""

class _FakeTypedModel:
"""Mimics the surface of ``PromptAgentDefinition`` from SDK 2.x."""

def __init__(self, data: dict) -> None:
self._data = dict(data)

def get(self, key, default=None):
return self._data.get(key, default)

def items(self):
return self._data.items()

def copy(self):
stripped = _FakeTypedModel.__new__(_FakeTypedModel)
stripped._data = dict(self._data)
return stripped

typed = _FakeTypedModel(
{"kind": "prompt", "model": "gpt-4o-mini", "instructions": "hi"}
)

copied = prompt_deploy._copy_definition(typed)

assert isinstance(copied, dict)
assert copied == {
"kind": "prompt",
"model": "gpt-4o-mini",
"instructions": "hi",
}
assert "_data" not in copied


def test_create_agent_version_body_uses_flat_definition_dict(monkeypatch) -> None:
"""The body sent to ``client.agents.create_version`` must contain a flat
``definition`` dict (not the SDK's ``{"_data": {...}}`` shape) and must
not include a body-root ``kind`` — the new Foundry API treats ``kind`` as
the polymorphic discriminator inside ``definition``.
"""

captured: dict = {}

class _FakeAgents:
def create_version(self, agent_name, *, body):
captured["agent_name"] = agent_name
captured["body"] = body
return SimpleNamespace(id="agent-version-9", version="9")

class _FakeClient:
agents = _FakeAgents()

monkeypatch.setattr(prompt_deploy, "_project_client", lambda endpoint: _FakeClient())

definition = {"kind": "prompt", "model": "gpt-4o-mini", "instructions": "hi"}

result = prompt_deploy._create_agent_version(
"https://example/api/projects/p",
"travel-agent",
definition,
metadata={"agentops.env": "dev"},
description="desc",
)

assert result.version == "9"
body = captured["body"]
assert "kind" not in body, "body-root 'kind' is no longer valid in SDK 2.x"
assert body["definition"] == definition
assert body["definition"]["kind"] == "prompt"
assert body["metadata"] == {"agentops.env": "dev"}
assert body["description"] == "desc"
Loading