commit 4ad5c423975a3a293697fd907bd8f21e2f8b071a Author: a2a-platform Date: Mon Jun 1 13:37:14 2026 +0000 deploy diff --git a/README.md b/README.md new file mode 100644 index 0000000..b21f563 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# openpannel + +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`. + +Hosted browser/direct invokes that use `LLMProvisioning.PLATFORM` still need an +A2A Cloud session so the runtime can mint a short-lived LLM/workspace grant for +that user. Agent-to-agent handoffs pass that grant explicitly. + +```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..67e16bd --- /dev/null +++ b/a2a.yaml @@ -0,0 +1,9 @@ +# 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: openpannel +version: 0.1.0 +entrypoint: agent:Openpannel +expose: + public: true + diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..a0b09fb --- /dev/null +++ b/agent.py @@ -0,0 +1,204 @@ +"""openpannel 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 OpenpannelConfig(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 = "openpannel/.deepagents/skills/" +DEEPAGENTS_RECURSION_LIMIT = 500 + + +class Openpannel(A2AAgent[OpenpannelConfig, NoAuth]): + name = "openpannel" + description = "A new A2A agent" + version = "0.1.0" + + config_model = OpenpannelConfig + 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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b9227d0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# a2a-pack is auto-installed by the deploy build. +# These starter deps power the DeepAgents tool-calling example in agent.py. +deepagents>=0.5.0 +langchain>=0.3 +langchain-openai>=0.2 +langgraph>=0.6