Files
Agentswarm/scripts/run_swarm_poster_demo.py
2026-06-08 17:32:34 +08:00

661 lines
21 KiB
Python

#!/usr/bin/env python3
"""Create a real swarm task, wait for start and half-progress, and render a poster page."""
from __future__ import annotations
import argparse
import base64
import json
import os
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parents[1]
OUTPUT_DIR = ROOT / "artifacts" / "swarm-posters"
@dataclass
class ApiConfig:
base_url: str
token: str
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Create a swarm task, wait for real progress milestones, and build a poster.",
)
parser.add_argument(
"--base-url",
default=os.getenv("SWARM_BASE_URL", "http://52.139.240.116:8000"),
help="Base URL of the running swarm runtime.",
)
parser.add_argument(
"--runtime-token",
default=os.getenv("AGNET_RUNTIME_SERVICE_TOKEN"),
help="Runtime bearer token. If omitted, read from the Kubernetes secret.",
)
parser.add_argument(
"--budget-seconds",
type=int,
default=40,
help="Task budget duration used to compute the half-progress milestone.",
)
parser.add_argument(
"--poll-interval",
type=float,
default=3.0,
help="Polling interval in seconds.",
)
parser.add_argument(
"--timeout-seconds",
type=int,
default=240,
help="Maximum time to wait for the half-progress milestone.",
)
parser.add_argument(
"--objective",
default=(
"Build a blog system MVP in the configured workspace repository. "
"Deliver a runnable implementation with a simple backend and frontend for posts: "
"list posts, view a post, create, edit, and delete. Keep setup minimal and include short run instructions."
),
help="High-level objective for the swarm.",
)
parser.add_argument(
"--task-description",
default=(
"Create a runnable blog system MVP in the target repository with post list, "
"post detail, and create/edit/delete flows. Keep the implementation pragmatic and "
"coherent with the existing repo. Commit and push the result branch when changes are ready."
),
help="Concrete task description sent to the agent.",
)
parser.add_argument(
"--poster-title",
default="Swarm Blog MVP Live Demo",
help="Poster title rendered into the HTML.",
)
return parser.parse_args()
def iso_now() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def to_human_time(timestamp: Optional[float]) -> str:
if not timestamp:
return "-"
return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
def read_runtime_token(explicit: Optional[str]) -> str:
if explicit:
return explicit
cmd = [
"kubectl",
"get",
"secret",
"-n",
"swarm-system",
"agnet-runtime-secrets",
"-o",
"jsonpath={.data.runtime-service-token}",
]
encoded = subprocess.check_output(cmd, text=True).strip()
if not encoded:
raise RuntimeError("Runtime token was not provided and could not be read from Kubernetes.")
return base64.b64decode(encoded).decode("utf-8")
def api_request(
config: ApiConfig,
path: str,
method: str = "GET",
body: Optional[dict[str, Any]] = None,
headers: Optional[dict[str, str]] = None,
query: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
query_string = f"?{urlencode(query)}" if query else ""
url = f"{config.base_url.rstrip('/')}{path}{query_string}"
request_headers = {
"Authorization": f"Bearer {config.token}",
"Content-Type": "application/json",
}
if headers:
request_headers.update(headers)
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
req = Request(url, data=payload, headers=request_headers, method=method)
try:
with urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"API request failed with {exc.code}: {raw}") from exc
except URLError as exc:
raise RuntimeError(f"API request failed: {exc}") from exc
def create_swarm(config: ApiConfig, args: argparse.Namespace) -> dict[str, Any]:
request_id = iso_now()
correlation_id = f"corr_poster_demo_{request_id}"
idempotency_key = f"idem_poster_demo_{request_id}"
manager_deployment_id = f"dep_poster_demo_{request_id}"
agents = [
{
"task_id": f"poster-blog-{request_id.lower()}",
"role": "fullstack",
"title": "Implement blog system MVP",
"description": args.task_description,
"depends_on": [],
}
]
body = {
"orchestration_plan": {
"objective": args.objective,
"sub_mode": "code",
"risk_level": "low",
"budget": {
"duration_seconds": args.budget_seconds,
"token_limit": 120000,
},
"agents": agents,
},
"agents": agents,
"callback": {
"url": f"{config.base_url.rstrip('/')}/health",
"subscribed_events": [
"deployment.status_changed",
"task.created",
"task.running",
"task.completed",
"task.failed",
"timeline.updated",
"artifact.created",
],
},
"metadata": {
"manager_deployment_id": manager_deployment_id,
"correlation_id": correlation_id,
},
}
response = api_request(
config,
"/api/swarms",
method="POST",
body=body,
headers={
"X-Correlation-Id": correlation_id,
"X-Idempotency-Key": idempotency_key,
},
)
if not response.get("success"):
raise RuntimeError(f"Swarm creation failed: {json.dumps(response, ensure_ascii=False)}")
return response["data"]
def fetch_tasks(config: ApiConfig, swarm_id: str) -> list[dict[str, Any]]:
response = api_request(config, f"/api/swarms/{swarm_id}/tasks")
return response["data"]["tasks"]
def fetch_logs(config: ApiConfig, swarm_id: str, limit: int = 100) -> list[dict[str, Any]]:
response = api_request(
config,
f"/api/swarms/{swarm_id}/logs",
query={"limit": limit},
)
return response["data"]["events"]
def fetch_metrics(config: ApiConfig, swarm_id: str) -> dict[str, Any]:
response = api_request(config, f"/api/swarms/{swarm_id}/metrics")
return response["data"]
def fetch_agents(base_url: str) -> list[dict[str, Any]]:
req = Request(f"{base_url.rstrip('/')}/agents")
with urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode("utf-8"))["agents"]
def manual_assign(base_url: str, task_id: str, agent_id: str) -> bool:
payload = json.dumps({"task_id": task_id, "agent_id": agent_id}).encode("utf-8")
req = Request(
f"{base_url.rstrip('/')}/tasks/assign",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
return bool(data.get("task_id") or data.get("status"))
except Exception:
return False
def find_event(events: list[dict[str, Any]], event_type: str) -> Optional[dict[str, Any]]:
for event in events:
if event.get("event_type") == event_type:
return event
return None
def last_event(events: list[dict[str, Any]], event_type: str) -> Optional[dict[str, Any]]:
for event in reversed(events):
if event.get("event_type") == event_type:
return event
return None
def render_html(
poster_path: Path,
args: argparse.Namespace,
swarm_data: dict[str, Any],
task: dict[str, Any],
metrics: dict[str, Any],
started_at: float,
halfway_at: float,
halfway_observed_at: float,
latest_events: list[dict[str, Any]],
) -> None:
latest_status = task.get("status", "unknown")
latest_heartbeat = last_event(latest_events, "task.heartbeat")
heartbeat_time = latest_heartbeat.get("occurred_at") if latest_heartbeat else "-"
runtime_ratio = metrics.get("budget", {}).get("duration_ratio")
ratio_display = f"{runtime_ratio * 100:.0f}%" if isinstance(runtime_ratio, (int, float)) else "50%+"
html = f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{args.poster_title}</title>
<style>
:root {{
--bg: #07111f;
--panel: rgba(10, 25, 47, 0.84);
--panel-strong: rgba(12, 32, 60, 0.95);
--line: rgba(125, 211, 252, 0.25);
--cyan: #7dd3fc;
--teal: #5eead4;
--lime: #bef264;
--text: #e6f0ff;
--muted: #9bb1c8;
--warn: #fbbf24;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: "Avenir Next", "PingFang SC", "Helvetica Neue", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(45, 212, 191, 0.18), transparent 32%),
radial-gradient(circle at top right, rgba(125, 211, 252, 0.24), transparent 28%),
linear-gradient(135deg, #050c16 0%, #07111f 42%, #0d1d35 100%);
min-height: 100vh;
}}
.canvas {{
width: 1600px;
min-height: 900px;
margin: 0 auto;
padding: 56px;
position: relative;
overflow: hidden;
}}
.grid {{
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(125, 211, 252, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(125, 211, 252, 0.05) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: linear-gradient(to bottom, rgba(0,0,0,.75), transparent);
pointer-events: none;
}}
.hero {{
display: flex;
justify-content: space-between;
gap: 28px;
align-items: flex-start;
margin-bottom: 28px;
}}
.hero h1 {{
margin: 0 0 12px 0;
font-size: 68px;
line-height: 0.95;
letter-spacing: -2px;
}}
.hero p {{
margin: 0;
max-width: 800px;
color: var(--muted);
font-size: 24px;
line-height: 1.5;
}}
.badge {{
display: inline-flex;
align-items: center;
gap: 10px;
border: 1px solid var(--line);
background: rgba(6, 19, 36, 0.72);
border-radius: 999px;
padding: 12px 18px;
color: var(--cyan);
font-size: 16px;
letter-spacing: 0.08em;
text-transform: uppercase;
}}
.layout {{
display: grid;
grid-template-columns: 1.25fr 0.75fr;
gap: 28px;
}}
.panel {{
background: var(--panel);
border: 1px solid var(--line);
border-radius: 28px;
padding: 28px;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.32);
backdrop-filter: blur(16px);
}}
.panel strong {{
display: block;
font-size: 16px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--cyan);
margin-bottom: 14px;
}}
.cards {{
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
margin-bottom: 28px;
}}
.metric {{
background: var(--panel-strong);
border: 1px solid rgba(190, 242, 100, 0.16);
border-radius: 22px;
padding: 20px;
}}
.metric .label {{
color: var(--muted);
font-size: 15px;
margin-bottom: 8px;
}}
.metric .value {{
font-size: 34px;
font-weight: 700;
letter-spacing: -1px;
}}
.timeline {{
display: grid;
gap: 16px;
margin-top: 8px;
}}
.step {{
position: relative;
padding: 18px 18px 18px 68px;
border-radius: 22px;
background: rgba(7, 18, 33, 0.9);
border: 1px solid rgba(125, 211, 252, 0.12);
}}
.step::before {{
content: "";
position: absolute;
left: 28px;
top: 26px;
width: 16px;
height: 16px;
border-radius: 50%;
background: linear-gradient(135deg, var(--teal), var(--cyan));
box-shadow: 0 0 0 8px rgba(94, 234, 212, 0.12);
}}
.step h3 {{
margin: 0 0 8px 0;
font-size: 24px;
}}
.step p {{
margin: 0;
color: var(--muted);
font-size: 16px;
line-height: 1.5;
}}
.code {{
font-family: "SF Mono", "JetBrains Mono", monospace;
color: var(--lime);
word-break: break-all;
}}
.sidebar {{
display: grid;
gap: 18px;
}}
.status {{
border-radius: 24px;
padding: 22px;
background: linear-gradient(160deg, rgba(94, 234, 212, 0.14), rgba(125, 211, 252, 0.08));
border: 1px solid rgba(94, 234, 212, 0.25);
}}
.status .headline {{
font-size: 18px;
color: var(--muted);
margin-bottom: 8px;
}}
.status .value {{
font-size: 44px;
font-weight: 700;
letter-spacing: -1px;
margin-bottom: 10px;
}}
.status .sub {{
color: var(--muted);
font-size: 16px;
line-height: 1.5;
}}
.meta-row {{
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid rgba(125, 211, 252, 0.08);
font-size: 15px;
}}
.meta-row:last-child {{ border-bottom: none; }}
.meta-label {{ color: var(--muted); }}
.meta-value {{ text-align: right; max-width: 56%; }}
.footer {{
margin-top: 22px;
color: var(--warn);
font-size: 15px;
line-height: 1.5;
}}
</style>
</head>
<body>
<div class="canvas">
<div class="grid"></div>
<div class="hero">
<div>
<div class="badge">Live Swarm Poster</div>
<h1>{args.poster_title}</h1>
<p>真实蜂群任务已成功创建,并已记录到“任务开始”和“进行过半”两段里程碑。下面的内容全部来自当前运行中的 swarm 状态,而不是手工拼接。</p>
</div>
<div class="panel" style="min-width: 360px;">
<strong>Demo Goal</strong>
<p style="font-size: 18px; color: var(--text); line-height: 1.55;">在目标仓库里开发一个博客系统 MVP,包括文章列表、详情和基础增删改能力。</p>
</div>
</div>
<div class="layout">
<div class="panel">
<div class="cards">
<div class="metric">
<div class="label">Swarm</div>
<div class="value">{swarm_data["swarm_id"]}</div>
</div>
<div class="metric">
<div class="label">Task Status</div>
<div class="value">{latest_status}</div>
</div>
<div class="metric">
<div class="label">Budget Progress</div>
<div class="value">{ratio_display}</div>
</div>
</div>
<strong>Timeline</strong>
<div class="timeline">
<div class="step">
<h3>任务开始</h3>
<p>任务已被 agent 领取并进入运行态。开始时间:<span class="code">{to_human_time(started_at)}</span></p>
</div>
<div class="step">
<h3>进行到一半</h3>
<p>脚本按 budget 的 50% 自动确认里程碑。预算过半时间:<span class="code">{to_human_time(halfway_at)}</span>,实际记录时间:<span class="code">{to_human_time(halfway_observed_at)}</span></p>
</div>
<div class="step">
<h3>当前执行中</h3>
<p>任务仍处于 <span class="code">{latest_status}</span>,最近心跳:<span class="code">{heartbeat_time}</span></p>
</div>
</div>
</div>
<div class="sidebar">
<div class="status">
<div class="headline">Poster Snapshot</div>
<div class="value">Mid-Run</div>
<div class="sub">这张海报展示的是蜂群任务已经真正启动,并且已经跑到预算半程时的现场状态。</div>
</div>
<div class="panel">
<strong>Swarm Meta</strong>
<div class="meta-row"><span class="meta-label">Deployment</span><span class="meta-value code">{swarm_data["deployment_id"]}</span></div>
<div class="meta-row"><span class="meta-label">Task</span><span class="meta-value code">{task["task_id"]}</span></div>
<div class="meta-row"><span class="meta-label">Agent</span><span class="meta-value code">{task.get("assigned_agent_id") or "-"}</span></div>
<div class="meta-row"><span class="meta-label">Attempts</span><span class="meta-value">{task.get("attempt", 0)}</span></div>
<div class="meta-row"><span class="meta-label">Budget</span><span class="meta-value">{args.budget_seconds}s</span></div>
<div class="meta-row"><span class="meta-label">Output File</span><span class="meta-value code">{poster_path.name}</span></div>
</div>
<div class="panel">
<strong>Latest Events</strong>
<div class="meta-row"><span class="meta-label">1</span><span class="meta-value code">{latest_events[-3]["event_type"] if len(latest_events) >= 3 else "-"}</span></div>
<div class="meta-row"><span class="meta-label">2</span><span class="meta-value code">{latest_events[-2]["event_type"] if len(latest_events) >= 2 else "-"}</span></div>
<div class="meta-row"><span class="meta-label">3</span><span class="meta-value code">{latest_events[-1]["event_type"] if latest_events else "-"}</span></div>
<div class="footer">注:如果后续继续执行,日志与状态还会变化;这张图锁定的是“开始后已过半”的那一刻。</div>
</div>
</div>
</div>
</div>
</body>
</html>
"""
poster_path.write_text(html, encoding="utf-8")
def main() -> int:
args = parse_args()
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
token = read_runtime_token(args.runtime_token)
config = ApiConfig(base_url=args.base_url, token=token)
swarm_data = create_swarm(config, args)
swarm_id = swarm_data["swarm_id"]
deadline = time.time() + args.timeout_seconds
started_at = None
task_snapshot = None
latest_events: list[dict[str, Any]] = []
while time.time() < deadline:
tasks = fetch_tasks(config, swarm_id)
latest_events = fetch_logs(config, swarm_id, limit=100)
if tasks:
task_snapshot = tasks[0]
if task_snapshot.get("status") == "pending" and not task_snapshot.get("assigned_agent_id"):
for agent in fetch_agents(config.base_url):
if agent.get("status") == "idle":
if manual_assign(config.base_url, task_snapshot["task_id"], agent["agent_id"]):
break
if task_snapshot.get("started_at"):
started_at = float(task_snapshot["started_at"])
break
running_event = find_event(latest_events, "task.running")
if running_event:
started_at = datetime.fromisoformat(
running_event["occurred_at"].replace("Z", "+00:00")
).timestamp()
break
time.sleep(args.poll_interval)
if started_at is None or task_snapshot is None:
raise RuntimeError("Task did not reach a running state before timeout.")
halfway_at = started_at + args.budget_seconds / 2
while time.time() < deadline:
tasks = fetch_tasks(config, swarm_id)
latest_events = fetch_logs(config, swarm_id, limit=100)
task_snapshot = tasks[0]
if time.time() >= halfway_at:
break
if task_snapshot.get("status") in {"completed", "failed"}:
break
time.sleep(args.poll_interval)
metrics = fetch_metrics(config, swarm_id)
halfway_observed_at = time.time()
run_id = swarm_id.replace("swarm-", "")
poster_path = OUTPUT_DIR / f"swarm-poster-{run_id}.html"
summary_path = OUTPUT_DIR / f"swarm-poster-{run_id}.json"
render_html(
poster_path=poster_path,
args=args,
swarm_data=swarm_data,
task=task_snapshot,
metrics=metrics,
started_at=started_at,
halfway_at=halfway_at,
halfway_observed_at=halfway_observed_at,
latest_events=latest_events,
)
summary = {
"swarm": swarm_data,
"task": task_snapshot,
"metrics": metrics,
"started_at": started_at,
"halfway_at": halfway_at,
"halfway_observed_at": halfway_observed_at,
"poster_html": str(poster_path),
}
summary_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("Interrupted.", file=sys.stderr)
raise SystemExit(130)