commit 4f4da2a7f376c0d9e7cd5ba0728cf5df7e00d349 Author: a2a-platform Date: Mon Jun 1 00:28:39 2026 +0000 deploy diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ee3359 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# chart-agent + +A new A2A agent + +This project was scaffolded with `a2a init`. It starts as a DeepAgents-backed +A2A agent that uses a scoped platform LLM grant from `ctx.llm`. + +The same `ctx.llm` path is used if you intentionally switch the agent to +`LLMProvisioning.CALLER_PROVIDED` for BYOK: the platform forwards the caller's +selected credentials as `ctx.llm`. Trusted platform agents may use +`LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED` to prefer caller creds and fall +back to a platform grant. Do not read provider keys, LiteLLM master keys, +`OPENAI_API_KEY`, or `A2A_LITELLM_KEY` directly in agent code, and do not +substitute fake fallback API keys when `ctx.llm.api_key` is empty. + +## Durable Files + +A2A Cloud workspaces are grant-scoped and backed by MinIO. Files become durable +when your agent uses one of the platform-owned file paths: + +- `ctx.workspace` for direct workspace reads and writes +- `ctx.write_artifact(...)` for explicit output artifacts +- `ctx.sandbox` for commands that read or write files in a sandbox +- `ctx.workspace_backend()` for framework file tools such as DeepAgents + +DeepAgents has its own built-in file tools (`write_file`, `read_file`, +`edit_file`). Without an A2A backend, those tools write into LangGraph state +only, so files can appear to the agent but never reach MinIO or `/workspace`. + +Keep this line when building DeepAgents graphs: + +```python +backend = ctx.workspace_backend() +return create_deep_agent(model=model, backend=backend, tools=[...]) +``` + +Invoke DeepAgents graphs with the starter recursion budget: + +```python +state = await graph.ainvoke( + {"messages": [{"role": "user", "content": prompt}]}, + config={"recursion_limit": 500}, +) +``` + +The backend respects the caller's grant. In handoffs, generated files should go +through `ctx.workspace_backend()`, `ctx.write_artifact(...)`, or a sandbox helper +so they are mirrored to the caller workspace instead of becoming private virtual +files. + +For stable, human-readable paths, write intentional outputs to +`/workspace/outputs/...` or `ctx.write_artifact(...)`. If sandboxed code writes +to process-local paths such as `/tmp/result.csv`, `/root`, or `/app`, the +platform captures changed rootfs files under `outputs/rootfs-captures/...` so +the caller can still download and inspect them. + +When a skill needs to run real code, render media, convert files, or call a +CLI that writes outputs, use the workspace-mounted sandbox helpers: + +```python +result = await ctx.workspace_shell( + "python script.py --out /tmp/result.txt", + image="python:3.11-slim", + timeout_seconds=120, +) +``` + +Do not rely on `asyncio.create_subprocess_exec(...)` for durable outputs. A +plain subprocess runs in the agent container, which is not mounted to the +caller workspace; files it creates in `/tmp` or the image filesystem can vanish +after the request. Use the sandbox helpers for any file-producing toolchain. + +## DeepAgents Skills + +If this project grows reusable workflow knowledge, add source-controlled skill +folders under `skills//SKILL.md`. The starter's `_seed_runtime_skills` +helper copies those packaged skills into the invocation workspace and passes the +resulting source path to `create_deep_agent(..., skills=[...])`, which is how +DeepAgents discovers skills with progressive disclosure. + +## Run Locally + +```bash +python -m pip install -r requirements.txt +a2a dev +a2a test +a2a test --invoke --skill summarize --args-json '{"text":"hello"}' +a2a card +``` + +`a2a dev` loads `.env.local`, creates `.a2a/workspace/{inputs,outputs}`, serves +the same HTTP invoke/card/MCP endpoints as production, and hot reloads local +code. Files written through `ctx.workspace_backend()` land under +`.a2a/workspace/outputs` before you deploy. + +## Auth + +The template is public by default (`auth_model = NoAuth`). To require the +caller's app login, declare a typed auth model and resolver in `agent.py`. +Resolvers receive the inbound bearer token and return the principal exposed as +`ctx.auth`. + +```python +from a2a_pack import JWTAuth, OIDCUserInfoAuthResolver + +auth_model = JWTAuth +auth_resolver = OIDCUserInfoAuthResolver( + "https://auth.example.com/oauth2/userinfo", + auth_model=JWTAuth, +) +``` + +For homegrown auth or SAML-backed apps, expose a bearer-token `/me` or +`/introspect` endpoint and use the same resolver contract. + +## Deploy + +```bash +a2a deploy +``` diff --git a/a2a.yaml b/a2a.yaml new file mode 100644 index 0000000..7cdc575 --- /dev/null +++ b/a2a.yaml @@ -0,0 +1,16 @@ +# Project identity for `a2a deploy`. Most metadata (resources, scopes, +# secrets, workspace, etc.) lives on the Python class — this file only +# tells the CLI how to find it. +name: chart-agent +version: 0.1.0 +entrypoint: agent:ChartAgent +expose: + public: true + +frontend: + path: frontend + build: npm run build + dist: dist + mount: /app + auth: inherit + diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..3927efb --- /dev/null +++ b/agent.py @@ -0,0 +1,204 @@ +"""chart-agent agent. + +Starter stack: + - DeepAgents for tool-calling orchestration + - A2A platform LLM grants via ctx.llm + - A tiny model-call middleware hook you can replace with tracing, + routing, rate limits, or policy checks +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from pydantic import BaseModel + +from a2a_pack import ( + A2AAgent, + LLMProvisioning, + NoAuth, + Pricing, + RunContext, + WorkspaceAccess, + WorkspaceMode, + skill, +) +from a2a_pack.context import LLMCreds + + +class ChartAgentConfig(BaseModel): + pass + + +SYSTEM_PROMPT = """\ +You are a compact tool-calling agent. + +Use the text_stats tool when the user asks about text, counts, summaries, +or anything where exact length/word numbers would help. Mention tool results +briefly instead of dumping raw JSON. +""" + +RUNTIME_SKILLS_DIR = "chart-agent/.deepagents/skills/" +DEEPAGENTS_RECURSION_LIMIT = 500 + + +class ChartAgent(A2AAgent[ChartAgentConfig, NoAuth]): + name = "chart-agent" + description = "A new A2A agent" + version = "0.1.0" + + config_model = ChartAgentConfig + auth_model = NoAuth + + # Default hosted generated agents to platform LLM grants. The runtime + # exposes the scoped LiteLLM token through ctx.llm, so agent code never + # reads provider keys, LiteLLM master keys, or OPENAI_API_KEY directly. + # Use CALLER_PROVIDED only for explicit BYOK agents; reserve + # PLATFORM_OR_CALLER_PROVIDED for trusted platform/meta agents. + llm_provisioning = LLMProvisioning.PLATFORM + pricing = Pricing( + price_per_call_usd=0.0, + caller_pays_llm=False, + notes="Starter agent uses a scoped platform LLM grant via ctx.llm.", + ) + workspace_access = WorkspaceAccess.dynamic( + max_files=64, + allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY), + require_reason=False, + ) + tools_used = ("deepagents", "langchain") + + @skill(description="Ask the starter DeepAgent to answer with tool calls when useful") + async def ask(self, ctx: RunContext[NoAuth], prompt: str) -> str: + creds = ctx.llm + await ctx.emit_progress(f"llm: {creds.model} via {creds.source}") + if not creds.api_key: + return ( + "LLM credentials were not available for this run. Hosted " + "generated agents should receive a platform LLM grant; for " + "local --invoke runs set A2A_LITELLM_KEY or AGENT_LLM_KEY." + ) + graph = self._build_deep_agent(ctx=ctx, creds=creds) + state = await graph.ainvoke( + {"messages": [{"role": "user", "content": prompt}]}, + config={"recursion_limit": DEEPAGENTS_RECURSION_LIMIT}, + ) + await ctx.emit_progress("deepagent finished") + return _last_message_text(state) + + def _build_deep_agent( + self, + *, + ctx: RunContext[NoAuth], + creds: LLMCreds, + ) -> Any: + # Lazy imports keep `a2a card` usable before local dependencies are + # installed. `a2a deploy` installs requirements.txt during the build. + from deepagents import create_deep_agent + from langchain.agents.middleware import wrap_model_call + from langchain_core.tools import tool + from langchain_openai import ChatOpenAI + + @tool + def text_stats(text: str) -> str: + """Return exact word, character, and line counts for text.""" + words = [part for part in text.split() if part.strip()] + return json.dumps( + { + "characters": len(text), + "words": len(words), + "lines": len(text.splitlines()) or 1, + } + ) + + @wrap_model_call + async def log_model_call(request: Any, handler: Any) -> Any: + messages = request.state.get("messages", []) + print( + "[middleware] model_call " + f"model={creds.model} source={creds.source} messages={len(messages)}" + ) + return await handler(request) + + model_kwargs: dict[str, Any] = { + "model": creds.model, + "base_url": creds.base_url, + # For CALLER_PROVIDED this is the caller's forwarded key. For + # PLATFORM this is the short-lived A2A LiteLLM grant token. Do not + # substitute provider keys, LiteLLM master keys, or fake fallback + # values here. + "api_key": creds.api_key, + } + if creds.temperature_mode != "omit": + model_kwargs["temperature"] = ( + creds.temperature if creds.temperature is not None else 0.0 + ) + if creds.extra_body: + model_kwargs["extra_body"] = dict(creds.extra_body) + + model = ChatOpenAI(**model_kwargs) + backend = ctx.workspace_backend() + skill_sources = _seed_runtime_skills(backend, ctx) + return create_deep_agent( + model=model, + backend=backend, + skills=skill_sources or None, + tools=[text_stats], + middleware=[log_model_call], + system_prompt=SYSTEM_PROMPT, + ) + + +def _runtime_skills_root(ctx: RunContext[Any]) -> str: + workspace = getattr(ctx, "_workspace", None) + prefixes = tuple(getattr(workspace, "write_prefixes", ()) or ()) + if not prefixes: + outputs_prefix = getattr(workspace, "outputs_prefix", None) + prefixes = (outputs_prefix or "outputs/",) + prefix = str(prefixes[0]).strip("/") + return f"/{prefix}/{RUNTIME_SKILLS_DIR}" if prefix else f"/{RUNTIME_SKILLS_DIR}" + + +def _seed_runtime_skills(backend: Any, ctx: RunContext[Any]) -> list[str]: + """Copy packaged DeepAgents skills into the invocation workspace. + + DeepAgents loads skills from its backend, while source-controlled + ``skills/`` folders live in the image. This bridge lets generated agents + ship reusable SKILL.md bundles without giving up durable A2A workspace + files. + """ + root = Path(__file__).parent / "skills" + if not root.exists(): + return [] + runtime_skills_root = _runtime_skills_root(ctx) + uploads: list[tuple[str, bytes]] = [] + for path in root.rglob("*"): + if path.is_file(): + rel = path.relative_to(root).as_posix() + uploads.append((runtime_skills_root + rel, path.read_bytes())) + if uploads: + backend.upload_files(uploads) + return [runtime_skills_root] + return [] + + +def _last_message_text(state: dict[str, Any]) -> str: + messages = state.get("messages") or [] + if not messages: + return json.dumps(state, default=str) + + content = getattr(messages[-1], "content", None) + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict): + text = item.get("text") or item.get("content") + if text: + parts.append(str(text)) + elif item: + parts.append(str(item)) + return "\n".join(parts) if parts else json.dumps(content, default=str) + return str(content or messages[-1]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3865541 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + chart-agent app + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e91bc47 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "chart-agent-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.0" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..f154176 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,143 @@ +import { useEffect, useMemo, useState } from "react"; +import { + callSkill, + loadAgentConfig, + loadSession, + sampleArgs, +} from "./a2a.js"; + +export function App() { + const [config, setConfig] = useState(null); + const [session, setSession] = useState(null); + const [selectedSkillName, setSelectedSkillName] = useState(""); + const [argsText, setArgsText] = useState("{}"); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [running, setRunning] = useState(false); + + useEffect(() => { + loadAgentConfig() + .then((data) => { + setConfig(data); + const firstSkill = data.skills?.[0]; + if (firstSkill) { + setSelectedSkillName(firstSkill.name); + setArgsText(JSON.stringify(sampleArgs(firstSkill), null, 2)); + } + return loadSession(data).catch(() => null); + }) + .then((sessionData) => setSession(sessionData)) + .catch((err) => setError(err.message || String(err))); + }, []); + + const selectedSkill = useMemo( + () => config?.skills?.find((skill) => skill.name === selectedSkillName), + [config, selectedSkillName], + ); + + function selectSkill(skill) { + setSelectedSkillName(skill.name); + setArgsText(JSON.stringify(sampleArgs(skill), null, 2)); + setResult(null); + setError(null); + } + + async function runSkill(event) { + event.preventDefault(); + if (!config || !selectedSkill) return; + setRunning(true); + setError(null); + setResult(null); + try { + const args = JSON.parse(argsText || "{}"); + const data = await callSkill(config, selectedSkill.name, args); + setResult(data); + } catch (err) { + setError(err.message || String(err)); + } finally { + setRunning(false); + } + } + + if (!config) { + return ( +
+

{error || "Loading A2A agent contract..."}

+
+ ); + } + + return ( +
+ + +
+
+
+

Skill runner

+

{selectedSkill?.name || "No skills found"}

+
+ + Docs + +
+ + {selectedSkill ? ( +
+