diff --git a/agent.py b/agent.py index 8ddc51a..ad816ff 100644 --- a/agent.py +++ b/agent.py @@ -96,8 +96,10 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]): # it and surface a clean "I need to run through the orchestrator" # error rather than letting it bubble up as a 500. try: - bucket = getattr(ctx.workspace, "bucket", None) + workspace = ctx.workspace + bucket = getattr(workspace, "bucket", None) except PermissionError: + workspace = None bucket = None if not bucket: return { @@ -126,6 +128,8 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]): graph = build_agent_builder(BuilderContext( bucket=bucket, cp_jwt=cp_jwt, project_prefix=f"agents/{name}", + workspace=workspace, + grant_token=getattr(workspace, "_grant_token", None), llm_base_url=creds.base_url, llm_api_key=creds.api_key, llm_model=creds.model, diff --git a/agent_builder/builder.py b/agent_builder/builder.py index c2be7dd..6f2dc18 100644 --- a/agent_builder/builder.py +++ b/agent_builder/builder.py @@ -25,6 +25,8 @@ class BuilderContext: cp_jwt: str | None = None settings: Settings | None = None project_prefix: str | None = None + workspace: Any | None = None + grant_token: str | None = None # Optional caller-provided LLM creds (when the outer platform Card # declares llm_provisioning=caller_provided). Falls back to settings. llm_base_url: str | None = None @@ -49,6 +51,12 @@ small tool-calling DeepAgent with ``create_deep_agent`` plus ``wrap_model_call`` middleware. Do not recreate this from memory: call ``init_agent_template`` first and modify the generated files. +Default generated/user agents to ``LLMProvisioning.CALLER_PROVIDED`` and +``caller_pays_llm=True`` unless the user explicitly wants platform-paid LLM +usage. ``LLMProvisioning.PLATFORM`` is allowed only through the A2A LiteLLM +grant path: the generated agent must still read ``ctx.llm`` and must never read +``A2A_LITELLM_KEY`` or any provider key directly. + You have packaged DeepAgents skills loaded from ``/.agent-builder/skills/``. Use ``deepagent-agent-design`` before designing generated agent internals, ``a2apack-agent-authoring`` when shaping the public A2A class and Card, @@ -255,9 +263,15 @@ Discipline: def build_agent_builder(ctx: BuilderContext) -> Any: settings = ctx.settings or load_settings() - tools = build_tools(ToolContext( - bucket=ctx.bucket, settings=settings, cp_jwt=ctx.cp_jwt, - )) + tools = build_tools( + ToolContext( + bucket=ctx.bucket, + settings=settings, + cp_jwt=ctx.cp_jwt, + workspace=ctx.workspace, + grant_token=ctx.grant_token, + ) + ) model_kwargs: dict[str, Any] = { "model": ctx.llm_model or settings.litellm_model, "base_url": ctx.llm_base_url or (settings.litellm_url + "/v1"), @@ -295,6 +309,12 @@ def _build_project_backend(ctx: BuilderContext, *, settings: Settings) -> Any | prefix = ctx.project_prefix.strip("/") if not prefix: return _with_builder_skills(StateBackend()) + if ctx.workspace is not None: + try: + from a2a_pack.deepagents import WorkspaceBackend + except Exception: # noqa: BLE001 + return _with_builder_skills(StateBackend()) + return _with_builder_skills(WorkspaceBackend(ctx.workspace)) try: from a2a_pack import Grant, WorkspaceAccess, WorkspaceMode from a2a_pack.deepagents import WorkspaceBackend diff --git a/agent_builder/skills/a2apack-agent-authoring/SKILL.md b/agent_builder/skills/a2apack-agent-authoring/SKILL.md index e0c8544..1e6356e 100644 --- a/agent_builder/skills/a2apack-agent-authoring/SKILL.md +++ b/agent_builder/skills/a2apack-agent-authoring/SKILL.md @@ -21,6 +21,12 @@ Use `A2AAgent` class attributes for runtime and marketplace behavior: - `wants_cp_jwt=True` is only for trusted platform agents. It forwards the caller's control-plane token. +For generated/user-owned agents that call an LLM, default to +`LLMProvisioning.CALLER_PROVIDED` with `Pricing(..., caller_pays_llm=True, ...)`. +Use `LLMProvisioning.PLATFORM` only when the user explicitly wants +platform-paid LLM usage. In either mode, read `ctx.llm`; never read +`A2A_LITELLM_KEY`, provider keys, or platform secrets directly. + 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 6bc2251..c4ab0e2 100644 --- a/agent_builder/skills/agent-quality-review/SKILL.md +++ b/agent_builder/skills/agent-quality-review/SKILL.md @@ -30,8 +30,13 @@ Check these items: arguments. - The public schema is small and stable. No unbounded command strings, hidden prompt fragments, or provider credentials as public inputs. -- The implementation reads `ctx.llm` when `llm_provisioning` is - `CALLER_PROVIDED`. +- The implementation reads `ctx.llm` for both `CALLER_PROVIDED` and + `PLATFORM` LLM provisioning. +- Generated/user agents should default to + `llm_provisioning = LLMProvisioning.CALLER_PROVIDED` and + `Pricing(..., caller_pays_llm=True, ...)`. If the user explicitly wants + platform-paid LLM usage, `LLMProvisioning.PLATFORM` is acceptable only if + the code still uses `ctx.llm` and never reads LiteLLM/provider keys directly. - Any use of DeepAgents file tools passes `backend=ctx.workspace_backend()`. - Any project skills are seeded into the backend and passed with `skills=skill_sources or None`. @@ -81,8 +86,8 @@ frontend: For React/Vite frontends, expect `frontend/package.json`, `frontend/vite.config.js`, `frontend/src/App.jsx`, and `frontend/src/a2a.js`. For static frontends, expect `frontend/dist/index.html`. The browser code must -not contain platform secrets, provider keys, hard-coded deployment URLs, or -private stack details. It should load `/app/config.json`, use +not contain platform secrets, provider keys, LiteLLM keys, hard-coded deployment +URLs, or private stack details. It should load `/app/config.json`, use `/app/a2a-client.js` or generated config endpoints, and call only the public `@skill` schemas. diff --git a/agent_builder/tools.py b/agent_builder/tools.py index 7b1e9f6..4592b0a 100644 --- a/agent_builder/tools.py +++ b/agent_builder/tools.py @@ -1,6 +1,6 @@ """LangChain tools the inner deepagents loop uses. - - workspace file CRUD (boto3 → MinIO, scoped to the user's bucket) + - workspace file CRUD through the caller's scoped workspace grant - sandbox python (for `a2a card` / `a2a validate` round-trips) - cp_deploy_tarball (call /v1/agents/from-tarball on the user's behalf) """ @@ -28,7 +28,7 @@ if TYPE_CHECKING: from .config import Settings -A2A_PACK_MIN_VERSION = "0.1.25" +A2A_PACK_MIN_VERSION = "0.1.26" @dataclass(frozen=True) @@ -36,6 +36,8 @@ class ToolContext: bucket: str settings: "Settings" cp_jwt: str | None = None + workspace: Any | None = None + grant_token: str | None = None async def _wait_for_live_card( @@ -95,6 +97,99 @@ def _ensure_bucket(s3: Any, bucket: str) -> None: raise +class _ObjectStore: + def iter_keys(self, prefix: str) -> list[str]: + raise NotImplementedError + + def get(self, key: str) -> bytes: + raise NotImplementedError + + def put(self, key: str, body: bytes, content_type: str) -> None: + raise NotImplementedError + + def delete(self, key: str) -> None: + raise NotImplementedError + + +class _S3ObjectStore(_ObjectStore): + def __init__(self, s3: Any, bucket: str) -> None: + self._s3 = s3 + self._bucket = bucket + + def iter_keys(self, prefix: str) -> list[str]: + keys: list[str] = [] + paginator = self._s3.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix): + for obj in page.get("Contents") or (): + key = obj.get("Key") + if isinstance(key, str): + keys.append(key) + return sorted(keys) + + def get(self, key: str) -> bytes: + obj = self._s3.get_object(Bucket=self._bucket, Key=key) + return obj["Body"].read() + + def put(self, key: str, body: bytes, content_type: str) -> None: + self._s3.put_object( + Bucket=self._bucket, + Key=key, + Body=body, + ContentType=content_type, + ) + + def delete(self, key: str) -> None: + self._s3.delete_object(Bucket=self._bucket, Key=key) + + +class _WorkspaceObjectStore(_ObjectStore): + def __init__(self, workspace: Any) -> None: + self._workspace = workspace + + def iter_keys(self, prefix: str) -> list[str]: + return sorted( + key + for key in self._workspace.iter_paths() + if isinstance(key, str) and key.startswith(prefix) + ) + + def get(self, key: str) -> bytes: + return self._workspace.read_bytes(key) + + def put(self, key: str, body: bytes, content_type: str) -> None: + del content_type + self._workspace.write_bytes(key, body) + + def delete(self, key: str) -> None: + self._workspace.delete_path(key) + + +def _store_from_args( + store_or_s3: Any, + bucket_or_prefix: str, + prefix: str | None, +) -> tuple[_ObjectStore, str]: + """Support new helper calls with ObjectStore and old tests with fake S3.""" + if isinstance(store_or_s3, _ObjectStore): + return store_or_s3, bucket_or_prefix + if hasattr(store_or_s3, "iter_keys") and hasattr(store_or_s3, "get"): + return store_or_s3, bucket_or_prefix + if prefix is None: + raise TypeError("prefix is required when passing an S3 client") + return _S3ObjectStore(store_or_s3, bucket_or_prefix), prefix + + +def _sandbox_headers( + bearer_token: str | None, + grant_token: str | None, +) -> dict[str, str] | None: + if bearer_token: + return {"authorization": f"Bearer {bearer_token}"} + if grant_token: + return {"X-A2A-Grant": grant_token} + return None + + _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" @@ -128,8 +223,12 @@ def _builder_state_key(name: str) -> str: def build_tools(ctx: ToolContext) -> list[Any]: bucket = ctx.bucket settings = ctx.settings - s3 = _s3(ctx) - _ensure_bucket(s3, bucket) + if ctx.workspace is not None: + store: _ObjectStore = _WorkspaceObjectStore(ctx.workspace) + else: + s3 = _s3(ctx) + _ensure_bucket(s3, bucket) + store = _S3ObjectStore(s3, bucket) @tool def init_agent_template( @@ -190,16 +289,11 @@ def build_tools(ctx: ToolContext) -> list[Any]: except ValueError as exc: return json.dumps({"error": str(exc)}) out: list[dict[str, Any]] = [] - paginator = s3.get_paginator("list_objects_v2") - for page in paginator.paginate(Bucket=bucket, Prefix=prefix): - for obj in page.get("Contents") or (): - rel = obj["Key"][len(prefix):] - if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX): - continue - out.append({ - "path": rel, - "size": int(obj.get("Size") or 0), - }) + for key in store.iter_keys(prefix): + rel = key[len(prefix):] + if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX): + continue + out.append({"path": rel, "size": len(store.get(key))}) return json.dumps({"agent": name, "files": out}) @tool @@ -217,10 +311,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX): return json.dumps({"error": f"{rel} is managed by agent-builder"}) key = prefix + rel - s3.put_object( - Bucket=bucket, Key=key, Body=content.encode("utf-8"), - ContentType="text/plain; charset=utf-8", - ) + store.put(key, content.encode("utf-8"), "text/plain; charset=utf-8") return json.dumps({"ok": True, "path": key, "size": len(content)}) @tool @@ -235,10 +326,10 @@ def build_tools(ctx: ToolContext) -> list[Any]: return json.dumps({"error": f"{rel} is managed by agent-builder"}) key = prefix + rel try: - obj = s3.get_object(Bucket=bucket, Key=key) - except ClientError as exc: + data = store.get(key) + except Exception as exc: # noqa: BLE001 return json.dumps({"error": f"read failed: {exc}"}) - body = obj["Body"].read().decode("utf-8", errors="replace") + body = data.decode("utf-8", errors="replace") return json.dumps({"path": key, "content": body[:200_000]}) @tool @@ -275,12 +366,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: for rel, content in payloads.items(): key = base + rel encoded = content.encode("utf-8") - s3.put_object( - Bucket=bucket, - Key=key, - Body=encoded, - ContentType="text/plain; charset=utf-8", - ) + store.put(key, encoded, "text/plain; charset=utf-8") written.append({"path": key, "size": len(encoded)}) return json.dumps({ "ok": True, @@ -308,7 +394,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: # Bundle the workspace files into the sandbox via base64 tar so # the sandbox doesn't need MinIO read access for this skill. - bundle_bytes = _tarball_workspace_dir(s3, bucket, prefix) + bundle_bytes = _tarball_workspace_dir(store, prefix) if not bundle_bytes: return json.dumps({ "error": "no files in agents/" + name + "/ — write some first", @@ -331,10 +417,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: " a2a frontend info --project . || true\n" "fi\n" ) - headers = ( - {"authorization": f"Bearer {settings.sandbox_token}"} - if settings.sandbox_token else None - ) + headers = _sandbox_headers(settings.sandbox_token, ctx.grant_token) async with httpx.AsyncClient(timeout=settings.sandbox_timeout_s + 30) as c: r = await c.post( f"{settings.sandbox_url}/v1/run_shell", @@ -399,7 +482,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: latest_deploy = await _latest_cp_deployment(settings.cp_url, ctx.cp_jwt, name) except RuntimeError as exc: return json.dumps({"error": str(exc)}) - builder_state = _read_builder_state(s3, bucket, name) + builder_state = _read_builder_state(store, name) drift = _deployment_drift_error( name, latest_deploy, @@ -412,7 +495,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: if force: base_head = None - bundle_bytes = _tarball_workspace_dir(s3, bucket, prefix) + bundle_bytes = _tarball_workspace_dir(store, prefix) if not bundle_bytes: return json.dumps({"error": f"no files at agents/{name}/"}) # Read the entrypoint from a2a.yaml so we can pass it verbatim. @@ -446,8 +529,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: head_sha = body.get("head_sha") if isinstance(head_sha, str) and head_sha: _write_builder_state( - s3, - bucket, + store, name, { "agent": name, @@ -579,10 +661,9 @@ def build_tools(ctx: ToolContext) -> list[Any]: return json.dumps({"error": f"invalid source tarball: {exc}"}) if not files: return json.dumps({"error": "managed repo source export was empty"}) - written = _replace_workspace_files(s3, bucket, prefix, files) + written = _replace_workspace_files(store, prefix, files) _write_builder_state( - s3, - bucket, + store, name, { "agent": name, @@ -691,58 +772,46 @@ def build_tools(ctx: ToolContext) -> list[Any]: # --- helpers (not tools) --- -def _tarball_workspace_dir(s3: Any, bucket: str, prefix: str) -> bytes: - """Pull every object under ``prefix`` from MinIO and produce a - gzipped tarball whose entries are relative to ``prefix``.""" - paginator = s3.get_paginator("list_objects_v2") +def _tarball_workspace_dir( + store_or_s3: Any, + bucket_or_prefix: str, + prefix: str | None = None, +) -> bytes: + """Pull every object under ``prefix`` and produce a relative tarball.""" + store, prefix = _store_from_args(store_or_s3, bucket_or_prefix, prefix) buf = io.BytesIO() added = False with tarfile.open(fileobj=buf, mode="w:gz") as tf: - for page in paginator.paginate(Bucket=bucket, Prefix=prefix): - for obj in page.get("Contents") or (): - key = obj["Key"] - rel = key[len(prefix):] - if not rel: - continue - if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX): - continue - body = s3.get_object(Bucket=bucket, Key=key)["Body"].read() - info = tarfile.TarInfo(name=rel) - info.size = len(body) - info.mode = 0o644 - tf.addfile(info, io.BytesIO(body)) - added = True + for key in store.iter_keys(prefix): + rel = key[len(prefix):] + if not rel: + continue + if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX): + continue + body = store.get(key) + info = tarfile.TarInfo(name=rel) + info.size = len(body) + info.mode = 0o644 + tf.addfile(info, io.BytesIO(body)) + added = True return buf.getvalue() if added else b"" -def _read_builder_state(s3: Any, bucket: str, name: str) -> dict[str, Any]: +def _read_builder_state(store: _ObjectStore, name: str) -> dict[str, Any]: try: - obj = s3.get_object(Bucket=bucket, Key=_builder_state_key(name)) - except ClientError as exc: - code = exc.response.get("Error", {}).get("Code") - if code in {"404", "NoSuchKey", "NotFound"}: - return {} - return {} - try: - data = json.loads(obj["Body"].read().decode("utf-8")) - except (TypeError, ValueError, UnicodeDecodeError): + data = json.loads(store.get(_builder_state_key(name)).decode("utf-8")) + except Exception: # noqa: BLE001 return {} return data if isinstance(data, dict) else {} def _write_builder_state( - s3: Any, - bucket: str, + store: _ObjectStore, name: str, state: dict[str, Any], ) -> None: body = json.dumps(state, sort_keys=True, indent=2).encode("utf-8") - s3.put_object( - Bucket=bucket, - Key=_builder_state_key(name), - Body=body, - ContentType="application/json", - ) + store.put(_builder_state_key(name), body, "application/json") def _render_skill_md(skill_name: str, description: str, instructions: str) -> str: @@ -798,16 +867,9 @@ def _safe_supporting_skill_path(path: str) -> str: return rel -def _delete_workspace_objects(s3: Any, bucket: str, prefix: str) -> None: - # MinIO rejects boto3's batch DeleteObjects request in this environment - # when Content-MD5 is not present. Per-object deletes avoid that fragile - # API path and are fine for agent source workspaces. - paginator = s3.get_paginator("list_objects_v2") - for page in paginator.paginate(Bucket=bucket, Prefix=prefix): - for obj in page.get("Contents") or (): - key = obj.get("Key") - if key: - s3.delete_object(Bucket=bucket, Key=key) +def _delete_workspace_objects(store: _ObjectStore, prefix: str) -> None: + for key in store.iter_keys(prefix): + store.delete(key) def _files_from_tarball(bundle: bytes) -> dict[str, bytes]: @@ -834,21 +896,25 @@ def _files_from_tarball(bundle: bytes) -> dict[str, bytes]: def _replace_workspace_files( - s3: Any, - bucket: str, - prefix: str, - files: dict[str, bytes], + store_or_s3: Any, + bucket_or_prefix: str, + prefix_or_files: str | dict[str, bytes], + files: dict[str, bytes] | None = None, ) -> list[dict[str, Any]]: - _delete_workspace_objects(s3, bucket, prefix) + if files is None: + if not isinstance(prefix_or_files, dict): + raise TypeError("files are required") + store, prefix = _store_from_args(store_or_s3, bucket_or_prefix, None) + files = prefix_or_files + else: + if not isinstance(prefix_or_files, str): + raise TypeError("prefix must be a string") + store, prefix = _store_from_args(store_or_s3, bucket_or_prefix, prefix_or_files) written: list[dict[str, Any]] = [] + _delete_workspace_objects(store, prefix) for rel, body in sorted(files.items()): key = prefix + rel - s3.put_object( - Bucket=bucket, - Key=key, - Body=body, - ContentType="application/octet-stream", - ) + store.put(key, body, "application/octet-stream") written.append({"path": rel, "size": len(body)}) return written diff --git a/deploy/20-deployment.yaml b/deploy/20-deployment.yaml index e9de072..dcfda9e 100644 --- a/deploy/20-deployment.yaml +++ b/deploy/20-deployment.yaml @@ -40,29 +40,11 @@ spec: value: "1200" - name: DEPLOY_WAIT_TIMEOUT_S value: "900" - - name: A2A_PLATFORM_SECRET + - name: A2A_GRANT_VERIFYING_KEY valueFrom: - secretKeyRef: {name: platform-secrets, key: platform_secret} - - name: A2A_LITELLM_URL - value: http://litellm.llm.svc.cluster.local:4000 - - name: A2A_LITELLM_KEY - valueFrom: - secretKeyRef: {name: litellm-credentials, key: api_key} - - name: A2A_LITELLM_MODEL - value: gpt-5.5 + secretKeyRef: {name: platform-secrets, key: grant_verifying_key} - name: A2A_CP_URL value: http://control-plane.control-plane.svc.cluster.local - - name: A2A_MINIO_ENDPOINT - value: http://microcash-infra-minio.microcash-infra.svc.cluster.local:9000 - - name: A2A_MINIO_ACCESS_KEY - valueFrom: - secretKeyRef: {name: minio-credentials, key: access_key} - - name: A2A_MINIO_SECRET_KEY - valueFrom: - secretKeyRef: {name: minio-credentials, key: secret_key} - - name: A2A_SANDBOX_TOKEN - valueFrom: - secretKeyRef: {name: sandbox-auth, key: token} readinessProbe: httpGet: {path: /healthz, port: 8000} initialDelaySeconds: 8 diff --git a/requirements.txt b/requirements.txt index 3a8586e..519f6bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -a2a-pack>=0.1.25 +a2a-pack>=0.1.26 httpx>=0.27 boto3>=1.34 deepagents>=0.5.0