tools: cp_deploy_tarball waits for live card; new cp_refresh_agent
All checks were successful
build / build (push) Successful in 37s
All checks were successful
build / build (push) Successful in 37s
cp_deploy_tarball now blocks after the from-tarball POST, polling the new
agent's /.well-known/agent-card until version matches the deployed version
(180s timeout, exponential backoff capped at 10s). On success it returns
live_skills with each skill's input_schema so the builder LLM can verify
the advertised shape matches the code it wrote — catches the schema/code
skew that caused downstream 400s on call_agent.
cp_refresh_agent(name) is a new tool that forces the CP to re-fetch the
live card via GET /v1/agents/{name}. Use when a pod was redeployed out
of band and the CP cache is stale; cp_deploy_tarball handles the in-band
case automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,11 +6,13 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import tarfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -31,6 +33,41 @@ class ToolContext:
|
||||
cp_jwt: str | None = None
|
||||
|
||||
|
||||
async def _wait_for_live_card(
|
||||
url: str | None,
|
||||
expected_version: str | None,
|
||||
timeout_s: float = 180.0,
|
||||
) -> tuple[bool, dict[str, Any]]:
|
||||
"""Poll ``{url}/.well-known/agent-card`` until ``version`` matches
|
||||
``expected_version`` or ``timeout_s`` elapses. Returns
|
||||
``(matched, last_seen_card)``. ``last_seen_card`` is ``{}`` if the
|
||||
endpoint never responded with a parseable card.
|
||||
|
||||
Backoff: 2s → 1.5x per try, capped at 10s. Tolerates pod-not-ready
|
||||
(any HTTPError or 4xx/5xx) — just keeps polling.
|
||||
"""
|
||||
if not url or not expected_version:
|
||||
return False, {}
|
||||
deadline = time.monotonic() + timeout_s
|
||||
last_card: dict[str, Any] = {}
|
||||
interval = 2.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as c:
|
||||
r = await c.get(f"{url.rstrip('/')}/.well-known/agent-card")
|
||||
if r.status_code == 200:
|
||||
card = r.json() or {}
|
||||
if isinstance(card, dict):
|
||||
last_card = card
|
||||
if card.get("version") == expected_version:
|
||||
return True, card
|
||||
except (httpx.HTTPError, ValueError):
|
||||
pass
|
||||
await asyncio.sleep(interval)
|
||||
interval = min(interval * 1.5, 10.0)
|
||||
return False, last_card
|
||||
|
||||
|
||||
def _s3(ctx: ToolContext) -> Any:
|
||||
return boto3.client(
|
||||
"s3",
|
||||
@@ -180,7 +217,20 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
||||
@tool
|
||||
async def cp_deploy_tarball(name: str, version: str = "0.1.0", public: bool = True) -> str:
|
||||
"""Tar the agent at ``agents/<name>/`` and POST it to the control
|
||||
plane's ``/v1/agents/from-tarball`` endpoint on the user's behalf.
|
||||
plane's ``/v1/agents/from-tarball`` endpoint on the user's behalf,
|
||||
then BLOCK until the live ``.well-known/agent-card`` reports the
|
||||
new version (or 180s timeout).
|
||||
|
||||
Returns JSON with ``ok``, ``live``, ``url``, ``version``,
|
||||
``live_version``, and ``live_skills[{name, input_schema}]``. When
|
||||
``live=true`` the agent is callable; when ``live=false`` the
|
||||
deploy POST succeeded but the pod never came up with the new
|
||||
version — caller should treat this as a failure, not success.
|
||||
|
||||
Compare ``live_skills[].input_schema`` against the code you wrote
|
||||
to confirm the advertised schema matches the params the function
|
||||
actually accepts. Mismatches cause downstream ``agent 400`` from
|
||||
``call_agent``.
|
||||
|
||||
Requires the orchestrator to have forwarded the user's CP JWT
|
||||
(the platform does this automatically when this agent's Card
|
||||
@@ -222,14 +272,89 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
||||
if r.status_code >= 400:
|
||||
return json.dumps({"error": f"cp {r.status_code}", "detail": r.text[:1000]})
|
||||
body = r.json()
|
||||
name_ = body.get("name")
|
||||
version_ = body.get("version")
|
||||
url_ = body.get("url")
|
||||
|
||||
# CP returns immediately with status="building". The agent isn't
|
||||
# actually callable until the pod rolls + serves the new card.
|
||||
# Poll /.well-known/agent-card until the live card reports the
|
||||
# version we just deployed (or timeout). Surface the live skills
|
||||
# so the LLM can verify the input_schema actually matches the
|
||||
# code it wrote — catches the schema/code skew that causes
|
||||
# downstream 400s on call_agent.
|
||||
live, live_card = await _wait_for_live_card(url_, version_)
|
||||
out: dict[str, Any] = {
|
||||
"ok": live,
|
||||
"name": name_,
|
||||
"version": version_,
|
||||
"status": body.get("status"),
|
||||
"url": url_,
|
||||
"live": live,
|
||||
}
|
||||
if live:
|
||||
out["live_version"] = live_card.get("version")
|
||||
out["live_skills"] = [
|
||||
{
|
||||
"name": s.get("name"),
|
||||
"input_schema": s.get("input_schema") or {},
|
||||
}
|
||||
for s in (live_card.get("skills") or [])
|
||||
]
|
||||
else:
|
||||
out["error"] = (
|
||||
"deploy did not become live within timeout — pod may still"
|
||||
" be rolling or build failed. Call cp_deploy_tarball again"
|
||||
" or inspect via discover_agent before declaring done."
|
||||
)
|
||||
if live_card:
|
||||
out["last_seen_version"] = live_card.get("version")
|
||||
return json.dumps(out)
|
||||
|
||||
@tool
|
||||
async def cp_refresh_agent(name: str) -> str:
|
||||
"""Force the control plane to re-fetch the agent's live
|
||||
``/.well-known/agent-card`` and update its stored copy.
|
||||
|
||||
Use this when the pod was redeployed out-of-band (CI bump, manual
|
||||
rollout) and the CP's cached card is stale — symptoms are stale
|
||||
``input_schema`` in ``list_my_agents`` / ``discover_agent`` and
|
||||
4xx from ``call_agent`` on what should be a valid skill call.
|
||||
|
||||
``cp_deploy_tarball`` already does this automatically by polling
|
||||
the live card, so you only need ``cp_refresh_agent`` when you
|
||||
did NOT deploy through this tool.
|
||||
|
||||
Returns JSON: the fresh ``AgentDetailOut`` (with ``card`` +
|
||||
``skills`` + ``input_schema``), or ``{error, ...}``.
|
||||
"""
|
||||
if not ctx.cp_jwt:
|
||||
return json.dumps({
|
||||
"ok": True, "name": body.get("name"), "version": body.get("version"),
|
||||
"status": body.get("status"), "url": body.get("url"),
|
||||
"error": "no CP JWT forwarded — agent declaration missing wants_cp_jwt=True",
|
||||
})
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as c:
|
||||
r = await c.get(
|
||||
f"{settings.cp_url}/v1/agents/{name}",
|
||||
headers={"authorization": f"bearer {ctx.cp_jwt}"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return json.dumps({"error": f"cp unreachable: {exc}"})
|
||||
if r.status_code >= 400:
|
||||
return json.dumps({"error": f"cp {r.status_code}", "detail": r.text[:1000]})
|
||||
body = r.json() or {}
|
||||
return json.dumps({
|
||||
"ok": True,
|
||||
"name": body.get("name"),
|
||||
"version": body.get("version"),
|
||||
"status": body.get("status"),
|
||||
"url": body.get("url"),
|
||||
"card": body.get("card") or {},
|
||||
})
|
||||
|
||||
return [
|
||||
list_agent_files, write_agent_file, read_agent_file,
|
||||
test_agent_in_sandbox, cp_deploy_tarball,
|
||||
test_agent_in_sandbox, cp_deploy_tarball, cp_refresh_agent,
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user