From 9a90add73879b73a0c779a7f632f9345759fc11d Mon Sep 17 00:00:00 2001 From: robert Date: Tue, 2 Jun 2026 14:19:13 -0300 Subject: [PATCH] Sanitize generated pricing fields --- agent_builder/builder.py | 4 + .../skills/a2apack-agent-authoring/SKILL.md | 5 + .../skills/agent-quality-review/SKILL.md | 3 + agent_builder/tools.py | 132 +++++++++++++++++- tests/test_builder_prompt.py | 44 +++++- 5 files changed, 183 insertions(+), 5 deletions(-) diff --git a/agent_builder/builder.py b/agent_builder/builder.py index 0eb8f67..22fa4df 100644 --- a/agent_builder/builder.py +++ b/agent_builder/builder.py @@ -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 missing ``ctx.llm.api_key`` a clear setup/config result before constructing ``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 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 diff --git a/agent_builder/skills/a2apack-agent-authoring/SKILL.md b/agent_builder/skills/a2apack-agent-authoring/SKILL.md index 4f281e4..631d75f 100644 --- a/agent_builder/skills/a2apack-agent-authoring/SKILL.md +++ b/agent_builder/skills/a2apack-agent-authoring/SKILL.md @@ -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 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 `self`, and annotate every public argument. Do not use `*args` or `**kwargs`; they are rejected and would not publish a useful schema. diff --git a/agent_builder/skills/agent-quality-review/SKILL.md b/agent_builder/skills/agent-quality-review/SKILL.md index c4150d4..e9ea43d 100644 --- a/agent_builder/skills/agent-quality-review/SKILL.md +++ b/agent_builder/skills/agent-quality-review/SKILL.md @@ -39,6 +39,9 @@ Check these items: `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 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 returns a clear setup/config result before the model constructor runs. - Any use of DeepAgents file tools passes `backend=ctx.workspace_backend()`. diff --git a/agent_builder/tools.py b/agent_builder/tools.py index 3980f4f..ee264d4 100644 --- a/agent_builder/tools.py +++ b/agent_builder/tools.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import ast import base64 import io 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])?$") _BUILDER_STATE_FILE = ".a2a-builder-state.json" _BUILDER_INTERNAL_PREFIX = ".agent-builder/" +_DERIVED_PRICING_FIELDS = frozenset({"compute", "total_usd"}) def _validate_name(name: str) -> None: @@ -307,9 +309,21 @@ def build_tools(ctx: ToolContext) -> list[Any]: rel = path.lstrip("/") if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX): 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 - store.put(key, content.encode("utf-8"), "text/plain; charset=utf-8") - return json.dumps({"ok": True, "path": key, "size": len(content)}) + encoded = content.encode("utf-8") + 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 def read_agent_file(name: str, path: str) -> str: @@ -388,6 +402,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: prefix = _agent_prefix(name) except ValueError as 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 # 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: return json.dumps({"error": f"sandbox {r.status_code}", "detail": r.text[:1000]}) out = r.json() - return json.dumps({ + result: dict[str, Any] = { "exit_code": out.get("exit_code"), "stdout": (out.get("stdout") or "")[:4000], "stderr": (out.get("stderr") or "")[:4000], - }) + } + if sanitized_fields: + result["sanitized_agent_card_fields"] = list(sanitized_fields) + return json.dumps(result) @tool async def cp_deploy_tarball( @@ -492,6 +510,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: if force: base_head = None + sanitized_fields = _sanitize_agent_workspace_pricing(store, prefix) bundle_bytes = _tarball_workspace_dir(store, prefix) if not bundle_bytes: 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, "live": live, } + if sanitized_fields: + out["sanitized_agent_card_fields"] = list(sanitized_fields) if live: out["live_version"] = live_card.get("version") out["live_skills"] = [ @@ -897,6 +918,109 @@ def _tarball_workspace_dir( 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]: try: data = json.loads(store.get(_builder_state_key(name)).decode("utf-8")) diff --git a/tests/test_builder_prompt.py b/tests/test_builder_prompt.py index 893d26b..8bbee11 100644 --- a/tests/test_builder_prompt.py +++ b/tests/test_builder_prompt.py @@ -12,7 +12,7 @@ from agent_builder.builder import ( _with_builder_skills, ) 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 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("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: self.assertIn("declare resources in BOTH places", SYSTEM_PROMPT) self.assertIn("resources = Resources", SYSTEM_PROMPT)