This commit is contained in:
@@ -62,6 +62,10 @@ keys directly. If the user explicitly asks for BYOK or caller-paid inference,
|
|||||||
use ``LLMProvisioning.CALLER_PROVIDED`` with ``caller_pays_llm=True`` and make
|
use ``LLMProvisioning.CALLER_PROVIDED`` with ``caller_pays_llm=True`` and make
|
||||||
missing ``ctx.llm.api_key`` a clear setup/config result before constructing
|
missing ``ctx.llm.api_key`` a clear setup/config result before constructing
|
||||||
``ChatOpenAI``.
|
``ChatOpenAI``.
|
||||||
|
``Pricing`` accepts only ``price_per_call_usd``, ``caller_pays_llm``, and
|
||||||
|
``notes``. Never set ``runtime.pricing.compute`` or
|
||||||
|
``runtime.pricing.total_usd`` in generated source; those are derived later by
|
||||||
|
the control plane/dashboard from declared ``Resources`` and billing policy.
|
||||||
``LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED`` is reserved for trusted
|
``LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED`` is reserved for trusted
|
||||||
platform/meta agents that should prefer caller-selected credentials and fall
|
platform/meta agents that should prefer caller-selected credentials and fall
|
||||||
back to a scoped platform grant; do not use it for ordinary generated agents
|
back to a scoped platform grant; do not use it for ordinary generated agents
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ never read `A2A_LITELLM_KEY`, `OPENAI_API_KEY`, provider keys, or platform
|
|||||||
secrets directly. If `ctx.llm.api_key` is empty, return a clear setup/config
|
secrets directly. If `ctx.llm.api_key` is empty, return a clear setup/config
|
||||||
result before constructing `ChatOpenAI`.
|
result before constructing `ChatOpenAI`.
|
||||||
|
|
||||||
|
`Pricing` only accepts `price_per_call_usd`, `caller_pays_llm`, and `notes`.
|
||||||
|
Do not put `runtime.pricing.compute`, `runtime.pricing.total_usd`, `compute=`,
|
||||||
|
or `total_usd=` in generated `agent.py`; the platform derives those values from
|
||||||
|
declared `Resources` and billing policy.
|
||||||
|
|
||||||
Keep each `@skill` method `async`, put `RunContext[...]` immediately after
|
Keep each `@skill` method `async`, put `RunContext[...]` immediately after
|
||||||
`self`, and annotate every public argument. Do not use `*args` or `**kwargs`;
|
`self`, and annotate every public argument. Do not use `*args` or `**kwargs`;
|
||||||
they are rejected and would not publish a useful schema.
|
they are rejected and would not publish a useful schema.
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ Check these items:
|
|||||||
`LLMProvisioning.CALLER_PROVIDED` only when the user explicitly wants BYOK or
|
`LLMProvisioning.CALLER_PROVIDED` only when the user explicitly wants BYOK or
|
||||||
caller-paid inference. In both modes, the code must use `ctx.llm` and never
|
caller-paid inference. In both modes, the code must use `ctx.llm` and never
|
||||||
read LiteLLM/provider keys directly.
|
read LiteLLM/provider keys directly.
|
||||||
|
- `Pricing` contains only `price_per_call_usd`, `caller_pays_llm`, and `notes`.
|
||||||
|
Reject `runtime.pricing.compute`, `runtime.pricing.total_usd`, `compute=`, or
|
||||||
|
`total_usd=` in `agent.py`; those are derived platform fields.
|
||||||
- Any code path that builds `ChatOpenAI` checks `ctx.llm.api_key` first or
|
- Any code path that builds `ChatOpenAI` checks `ctx.llm.api_key` first or
|
||||||
returns a clear setup/config result before the model constructor runs.
|
returns a clear setup/config result before the model constructor runs.
|
||||||
- Any use of DeepAgents file tools passes `backend=ctx.workspace_backend()`.
|
- Any use of DeepAgents file tools passes `backend=ctx.workspace_backend()`.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import ast
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
@@ -195,6 +196,7 @@ _AGENT_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
|||||||
_SKILL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
|
_SKILL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
|
||||||
_BUILDER_STATE_FILE = ".a2a-builder-state.json"
|
_BUILDER_STATE_FILE = ".a2a-builder-state.json"
|
||||||
_BUILDER_INTERNAL_PREFIX = ".agent-builder/"
|
_BUILDER_INTERNAL_PREFIX = ".agent-builder/"
|
||||||
|
_DERIVED_PRICING_FIELDS = frozenset({"compute", "total_usd"})
|
||||||
|
|
||||||
|
|
||||||
def _validate_name(name: str) -> None:
|
def _validate_name(name: str) -> None:
|
||||||
@@ -307,9 +309,21 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
rel = path.lstrip("/")
|
rel = path.lstrip("/")
|
||||||
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
|
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
|
||||||
return json.dumps({"error": f"{rel} is managed by agent-builder"})
|
return json.dumps({"error": f"{rel} is managed by agent-builder"})
|
||||||
|
sanitized_fields: tuple[str, ...] = ()
|
||||||
|
if rel == "agent.py":
|
||||||
|
content, sanitized_fields = _strip_unsupported_pricing_fields(content)
|
||||||
key = prefix + rel
|
key = prefix + rel
|
||||||
store.put(key, content.encode("utf-8"), "text/plain; charset=utf-8")
|
encoded = content.encode("utf-8")
|
||||||
return json.dumps({"ok": True, "path": key, "size": len(content)})
|
out: dict[str, Any] = {"ok": True, "path": key, "size": len(encoded)}
|
||||||
|
if sanitized_fields:
|
||||||
|
out["sanitized_agent_card_fields"] = list(sanitized_fields)
|
||||||
|
out["warning"] = (
|
||||||
|
"Removed derived Pricing fields that the Agent Card schema "
|
||||||
|
"does not accept. Declare Resources separately; compute and "
|
||||||
|
"total_usd are filled by the platform."
|
||||||
|
)
|
||||||
|
store.put(key, encoded, "text/plain; charset=utf-8")
|
||||||
|
return json.dumps(out)
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
def read_agent_file(name: str, path: str) -> str:
|
def read_agent_file(name: str, path: str) -> str:
|
||||||
@@ -388,6 +402,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
prefix = _agent_prefix(name)
|
prefix = _agent_prefix(name)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return json.dumps({"error": str(exc)})
|
return json.dumps({"error": str(exc)})
|
||||||
|
sanitized_fields = _sanitize_agent_workspace_pricing(store, prefix)
|
||||||
|
|
||||||
# Bundle the workspace files into the sandbox via base64 tar so
|
# Bundle the workspace files into the sandbox via base64 tar so
|
||||||
# the sandbox doesn't need MinIO read access for this skill.
|
# the sandbox doesn't need MinIO read access for this skill.
|
||||||
@@ -428,11 +443,14 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
return json.dumps({"error": f"sandbox {r.status_code}", "detail": r.text[:1000]})
|
return json.dumps({"error": f"sandbox {r.status_code}", "detail": r.text[:1000]})
|
||||||
out = r.json()
|
out = r.json()
|
||||||
return json.dumps({
|
result: dict[str, Any] = {
|
||||||
"exit_code": out.get("exit_code"),
|
"exit_code": out.get("exit_code"),
|
||||||
"stdout": (out.get("stdout") or "")[:4000],
|
"stdout": (out.get("stdout") or "")[:4000],
|
||||||
"stderr": (out.get("stderr") or "")[:4000],
|
"stderr": (out.get("stderr") or "")[:4000],
|
||||||
})
|
}
|
||||||
|
if sanitized_fields:
|
||||||
|
result["sanitized_agent_card_fields"] = list(sanitized_fields)
|
||||||
|
return json.dumps(result)
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
async def cp_deploy_tarball(
|
async def cp_deploy_tarball(
|
||||||
@@ -492,6 +510,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
if force:
|
if force:
|
||||||
base_head = None
|
base_head = None
|
||||||
|
|
||||||
|
sanitized_fields = _sanitize_agent_workspace_pricing(store, prefix)
|
||||||
bundle_bytes = _tarball_workspace_dir(store, prefix)
|
bundle_bytes = _tarball_workspace_dir(store, prefix)
|
||||||
if not bundle_bytes:
|
if not bundle_bytes:
|
||||||
return json.dumps({"error": f"no files at agents/{name}/"})
|
return json.dumps({"error": f"no files at agents/{name}/"})
|
||||||
@@ -561,6 +580,8 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
"workspace_base_head_sha": base_head,
|
"workspace_base_head_sha": base_head,
|
||||||
"live": live,
|
"live": live,
|
||||||
}
|
}
|
||||||
|
if sanitized_fields:
|
||||||
|
out["sanitized_agent_card_fields"] = list(sanitized_fields)
|
||||||
if live:
|
if live:
|
||||||
out["live_version"] = live_card.get("version")
|
out["live_version"] = live_card.get("version")
|
||||||
out["live_skills"] = [
|
out["live_skills"] = [
|
||||||
@@ -897,6 +918,109 @@ def _tarball_workspace_dir(
|
|||||||
return buf.getvalue() if added else b""
|
return buf.getvalue() if added else b""
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_agent_workspace_pricing(
|
||||||
|
store: _ObjectStore,
|
||||||
|
prefix: str,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
key = prefix + "agent.py"
|
||||||
|
try:
|
||||||
|
source = store.get(key).decode("utf-8", errors="replace")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ()
|
||||||
|
sanitized, fields = _strip_unsupported_pricing_fields(source)
|
||||||
|
if fields and sanitized != source:
|
||||||
|
store.put(key, sanitized.encode("utf-8"), "text/plain; charset=utf-8")
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_unsupported_pricing_fields(source: str) -> tuple[str, tuple[str, ...]]:
|
||||||
|
"""Remove platform-derived pricing kwargs from generated Agent source."""
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source)
|
||||||
|
except SyntaxError:
|
||||||
|
return source, ()
|
||||||
|
|
||||||
|
offsets = _line_offsets(source)
|
||||||
|
ranges: list[tuple[int, int, str]] = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call) or not _is_pricing_call(node.func):
|
||||||
|
continue
|
||||||
|
for keyword in node.keywords:
|
||||||
|
if keyword.arg not in _DERIVED_PRICING_FIELDS:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
keyword.lineno is None
|
||||||
|
or keyword.end_lineno is None
|
||||||
|
or keyword.col_offset is None
|
||||||
|
or keyword.end_col_offset is None
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
start = offsets[keyword.lineno - 1] + keyword.col_offset
|
||||||
|
end = offsets[keyword.end_lineno - 1] + keyword.end_col_offset
|
||||||
|
ranges.append((start, end, keyword.arg))
|
||||||
|
|
||||||
|
if not ranges:
|
||||||
|
return source, ()
|
||||||
|
|
||||||
|
sanitized = source
|
||||||
|
removed: set[str] = set()
|
||||||
|
for start, end, field in sorted(ranges, key=lambda item: item[0], reverse=True):
|
||||||
|
sanitized = _remove_keyword_source_range(sanitized, start, end)
|
||||||
|
removed.add(field)
|
||||||
|
return sanitized, tuple(sorted(removed))
|
||||||
|
|
||||||
|
|
||||||
|
def _line_offsets(source: str) -> list[int]:
|
||||||
|
offsets: list[int] = []
|
||||||
|
offset = 0
|
||||||
|
for line in source.splitlines(keepends=True):
|
||||||
|
offsets.append(offset)
|
||||||
|
offset += len(line)
|
||||||
|
return offsets or [0]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pricing_call(func: ast.expr) -> bool:
|
||||||
|
if isinstance(func, ast.Name):
|
||||||
|
return func.id == "Pricing"
|
||||||
|
if isinstance(func, ast.Attribute):
|
||||||
|
return func.attr == "Pricing"
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_keyword_source_range(source: str, start: int, end: int) -> str:
|
||||||
|
line_start = source.rfind("\n", 0, start) + 1
|
||||||
|
if source[line_start:start].strip() == "":
|
||||||
|
trailing = _skip_horizontal_whitespace(source, end)
|
||||||
|
if trailing < len(source) and source[trailing] == ",":
|
||||||
|
trailing = _skip_horizontal_whitespace(source, trailing + 1)
|
||||||
|
if trailing >= len(source) or source[trailing] in "\r\n":
|
||||||
|
if source[trailing: trailing + 2] == "\r\n":
|
||||||
|
trailing += 2
|
||||||
|
elif trailing < len(source):
|
||||||
|
trailing += 1
|
||||||
|
return source[:line_start] + source[trailing:]
|
||||||
|
|
||||||
|
trailing = _skip_horizontal_whitespace(source, end)
|
||||||
|
if trailing < len(source) and source[trailing] == ",":
|
||||||
|
trailing = _skip_horizontal_whitespace(source, trailing + 1)
|
||||||
|
if trailing < len(source) and source[trailing] == " ":
|
||||||
|
trailing += 1
|
||||||
|
return source[:start] + source[trailing:]
|
||||||
|
|
||||||
|
leading = start
|
||||||
|
while leading > 0 and source[leading - 1] in " \t":
|
||||||
|
leading -= 1
|
||||||
|
if leading > 0 and source[leading - 1] == ",":
|
||||||
|
return source[: leading - 1] + source[end:]
|
||||||
|
return source[:start] + source[end:]
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_horizontal_whitespace(source: str, offset: int) -> int:
|
||||||
|
while offset < len(source) and source[offset] in " \t":
|
||||||
|
offset += 1
|
||||||
|
return offset
|
||||||
|
|
||||||
|
|
||||||
def _read_builder_state(store: _ObjectStore, name: str) -> dict[str, Any]:
|
def _read_builder_state(store: _ObjectStore, name: str) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
data = json.loads(store.get(_builder_state_key(name)).decode("utf-8"))
|
data = json.loads(store.get(_builder_state_key(name)).decode("utf-8"))
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from agent_builder.builder import (
|
|||||||
_with_builder_skills,
|
_with_builder_skills,
|
||||||
)
|
)
|
||||||
from agent_builder.config import Settings
|
from agent_builder.config import Settings
|
||||||
from agent_builder.tools import A2A_PACK_MIN_VERSION
|
from agent_builder.tools import A2A_PACK_MIN_VERSION, _strip_unsupported_pricing_fields
|
||||||
from deepagents.backends import StateBackend
|
from deepagents.backends import StateBackend
|
||||||
from agent import _build_failure_error
|
from agent import _build_failure_error
|
||||||
|
|
||||||
@@ -68,6 +68,48 @@ class BuilderPromptTests(unittest.TestCase):
|
|||||||
self.assertIn("caller_pays_llm=False", files["a2apack-agent-authoring/SKILL.md"])
|
self.assertIn("caller_pays_llm=False", files["a2apack-agent-authoring/SKILL.md"])
|
||||||
self.assertIn("ctx.llm.api_key", files["agent-quality-review/SKILL.md"])
|
self.assertIn("ctx.llm.api_key", files["agent-quality-review/SKILL.md"])
|
||||||
|
|
||||||
|
def test_prompt_rejects_platform_derived_pricing_fields(self) -> None:
|
||||||
|
self.assertIn("runtime.pricing.compute", SYSTEM_PROMPT)
|
||||||
|
self.assertIn("runtime.pricing.total_usd", SYSTEM_PROMPT)
|
||||||
|
self.assertIn("price_per_call_usd", SYSTEM_PROMPT)
|
||||||
|
|
||||||
|
files = _builder_skill_files()
|
||||||
|
for path in (
|
||||||
|
"a2apack-agent-authoring/SKILL.md",
|
||||||
|
"agent-quality-review/SKILL.md",
|
||||||
|
):
|
||||||
|
self.assertIn("runtime.pricing.compute", files[path])
|
||||||
|
self.assertIn("runtime.pricing.total_usd", files[path])
|
||||||
|
self.assertIn("compute=", files[path])
|
||||||
|
self.assertIn("total_usd=", files[path])
|
||||||
|
|
||||||
|
def test_pricing_sanitizer_removes_derived_agent_card_fields(self) -> None:
|
||||||
|
source = '''
|
||||||
|
from a2a_pack import Pricing
|
||||||
|
|
||||||
|
pricing = Pricing(
|
||||||
|
price_per_call_usd=0.05,
|
||||||
|
compute={
|
||||||
|
"cpu_usd": 0.000833,
|
||||||
|
"memory_usd": 0.00018,
|
||||||
|
},
|
||||||
|
total_usd=0.10125,
|
||||||
|
caller_pays_llm=False,
|
||||||
|
notes="caller gets the real billing projection from the platform",
|
||||||
|
)
|
||||||
|
|
||||||
|
inline = Pricing(price_per_call_usd=0.01, compute={"runtime_seconds": 600}, total_usd=0.02, caller_pays_llm=False)
|
||||||
|
'''
|
||||||
|
|
||||||
|
sanitized, removed = _strip_unsupported_pricing_fields(source)
|
||||||
|
|
||||||
|
self.assertEqual(set(removed), {"compute", "total_usd"})
|
||||||
|
self.assertIn("price_per_call_usd=0.05", sanitized)
|
||||||
|
self.assertIn("caller_pays_llm=False", sanitized)
|
||||||
|
self.assertNotIn("compute=", sanitized)
|
||||||
|
self.assertNotIn("total_usd=", sanitized)
|
||||||
|
compile(sanitized, "agent.py", "exec")
|
||||||
|
|
||||||
def test_prompt_requires_resource_mirror_for_heavy_agents(self) -> None:
|
def test_prompt_requires_resource_mirror_for_heavy_agents(self) -> None:
|
||||||
self.assertIn("declare resources in BOTH places", SYSTEM_PROMPT)
|
self.assertIn("declare resources in BOTH places", SYSTEM_PROMPT)
|
||||||
self.assertIn("resources = Resources", SYSTEM_PROMPT)
|
self.assertIn("resources = Resources", SYSTEM_PROMPT)
|
||||||
|
|||||||
Reference in New Issue
Block a user