deploy
This commit is contained in:
120
README.md
Normal file
120
README.md
Normal file
@@ -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-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`.
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
16
a2a.yaml
Normal file
16
a2a.yaml
Normal file
@@ -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
|
||||||
|
|
||||||
204
agent.py
Normal file
204
agent.py
Normal file
@@ -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])
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>chart-agent app</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
143
frontend/src/App.jsx
Normal file
143
frontend/src/App.jsx
Normal file
@@ -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 (
|
||||||
|
<main className="loading">
|
||||||
|
<p>{error || "Loading A2A agent contract..."}</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="app-shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">A2A packed app</p>
|
||||||
|
<h1>chart-agent</h1>
|
||||||
|
<p className="agent-meta">
|
||||||
|
{config.agent.name} v{config.agent.version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="skill-list" aria-label="Agent skills">
|
||||||
|
{(config.skills || []).map((skill) => (
|
||||||
|
<button
|
||||||
|
className={skill.name === selectedSkillName ? "skill active" : "skill"}
|
||||||
|
key={skill.name}
|
||||||
|
onClick={() => selectSkill(skill)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{skill.name}</span>
|
||||||
|
<small>{skill.stream ? "streaming" : "request"}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="session">
|
||||||
|
<span>{session?.authenticated ? "Signed in" : "Local session"}</span>
|
||||||
|
<strong>{session?.user?.email || session?.org?.slug || "dev@example.local"}</strong>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="workbench">
|
||||||
|
<header className="toolbar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Skill runner</p>
|
||||||
|
<h2>{selectedSkill?.name || "No skills found"}</h2>
|
||||||
|
</div>
|
||||||
|
<a href={config.docs.base} rel="noreferrer" target="_blank">
|
||||||
|
Docs
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{selectedSkill ? (
|
||||||
|
<form className="runner" onSubmit={runSkill}>
|
||||||
|
<label>
|
||||||
|
Arguments JSON
|
||||||
|
<textarea
|
||||||
|
spellCheck="false"
|
||||||
|
value={argsText}
|
||||||
|
onChange={(event) => setArgsText(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button disabled={running} type="submit">
|
||||||
|
{running ? "Running..." : `Run ${selectedSkill.name}`}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p className="empty">Add a `@skill` to your agent to make it callable here.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="panels">
|
||||||
|
<div className="panel">
|
||||||
|
<h3>Input schema</h3>
|
||||||
|
<pre>{JSON.stringify(selectedSkill?.input_schema || {}, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>Result</h3>
|
||||||
|
<pre>{error || JSON.stringify(result, null, 2) || "No run yet."}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
frontend/src/a2a.js
Normal file
65
frontend/src/a2a.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export const CONFIG_URL = import.meta.env.DEV ? "/app/config.json" : "./config.json";
|
||||||
|
|
||||||
|
export async function requestJson(url, init = {}) {
|
||||||
|
const response = await fetch(url, { credentials: "same-origin", ...init });
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? JSON.parse(text) : null;
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = data && (data.detail || data.message);
|
||||||
|
throw new Error(detail || `request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAgentConfig() {
|
||||||
|
return requestJson(CONFIG_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSession(config) {
|
||||||
|
return requestJson(config.endpoints.session);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callSkill(config, skillName, args) {
|
||||||
|
if (config.auth?.invokeRequiresSession) {
|
||||||
|
const session = await loadSession(config);
|
||||||
|
if (!session?.authenticated) {
|
||||||
|
throw new Error("sign in required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return requestJson(
|
||||||
|
`${config.endpoints.invoke}/${encodeURIComponent(skillName)}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ arguments: args }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sampleValue(schema) {
|
||||||
|
if (!schema || typeof schema !== "object") return null;
|
||||||
|
if ("default" in schema) return schema.default;
|
||||||
|
if ("const" in schema) return schema.const;
|
||||||
|
if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0];
|
||||||
|
const type = Array.isArray(schema.type)
|
||||||
|
? schema.type.find((item) => item !== "null") || schema.type[0]
|
||||||
|
: schema.type;
|
||||||
|
if (type === "array") return [];
|
||||||
|
if (type === "boolean") return false;
|
||||||
|
if (type === "integer" || type === "number") return 0;
|
||||||
|
if (type === "string") return "";
|
||||||
|
const props = schema.properties || {};
|
||||||
|
if (type === "object" || Object.keys(props).length) {
|
||||||
|
const required = Array.isArray(schema.required) ? schema.required : Object.keys(props);
|
||||||
|
return Object.fromEntries(
|
||||||
|
required.map((key) => [key, sampleValue(props[key])]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sampleArgs(skill) {
|
||||||
|
const schema = skill?.input_schema || skill?.inputSchema || {};
|
||||||
|
const value = sampleValue(schema);
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||||
|
}
|
||||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App.jsx";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
244
frontend/src/style.css
Normal file
244
frontend/src/style.css
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #090909;
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #090909;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #b8b8b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 340px) 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
min-height: 100vh;
|
||||||
|
border-right: 1px solid #222;
|
||||||
|
background: #0d0d0d;
|
||||||
|
padding: 28px;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #8bd8c6;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: clamp(32px, 5vw, 56px);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #d7d7d7;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-meta {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: #a6a6a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 58px;
|
||||||
|
border: 1px solid #252525;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #121212;
|
||||||
|
color: #f3f3f3;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill:hover,
|
||||||
|
.skill.active {
|
||||||
|
border-color: #5bb7a7;
|
||||||
|
background: #15201d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill small {
|
||||||
|
color: #9d9d9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session {
|
||||||
|
border: 1px solid #252525;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
color: #a6a6a6;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session strong {
|
||||||
|
color: #f3f3f3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench {
|
||||||
|
padding: 28px;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar a,
|
||||||
|
.runner button {
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #f4f4f4;
|
||||||
|
color: #111;
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner label {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
color: #d7d7d7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 170px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid #252525;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #070707;
|
||||||
|
color: #eeeeee;
|
||||||
|
padding: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner button {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #252525;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
min-height: 260px;
|
||||||
|
max-height: 420px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
color: #dcdcdc;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
min-height: auto;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panels {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/vite.config.js
Normal file
18
frontend/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
const agent = process.env.A2A_DEV_AGENT_URL || "http://127.0.0.1:8000";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: "./",
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/app": agent,
|
||||||
|
"/invoke": agent,
|
||||||
|
"/auth": agent,
|
||||||
|
"/mcp": agent,
|
||||||
|
"/.well-known": agent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal 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
|
||||||
Reference in New Issue
Block a user