This commit is contained in:
a2a-platform
2026-06-01 13:37:14 +00:00
commit 4ad5c42397
4 changed files with 343 additions and 0 deletions

124
README.md Normal file
View File

@@ -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-name>/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
```

9
a2a.yaml Normal file
View File

@@ -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

204
agent.py Normal file
View File

@@ -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])

6
requirements.txt Normal file
View File

@@ -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