diff --git a/demos/sub-agent-multiplex/Dockerfile b/demos/sub-agent-multiplex/Dockerfile new file mode 100644 index 000000000..c2334a3ca --- /dev/null +++ b/demos/sub-agent-multiplex/Dockerfile @@ -0,0 +1,82 @@ +# NanoClaw on Agent Substrate PoC +# Portable Dockerfile for OSS Substrate Migration + +# Stage 1: Build the standalone bundles +FROM node:22-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy standalone package files +COPY package.json ./ +# Use npm install for simplicity and portability in the standalone package +RUN npm install + +# Copy source code +COPY ui/ ./ui/ +COPY workload/ ./workload/ +COPY broker/ ./broker/ + +# Build zero-dependency bundles +RUN ./node_modules/.bin/esbuild workload/agent.ts \ + --bundle \ + --platform=node \ + --target=node22 \ + --outfile=dist/agent.js \ + --external:node:* + +RUN ./node_modules/.bin/esbuild ui/demo-ui.ts \ + --bundle \ + --platform=node \ + --target=node22 \ + --outfile=dist/demo-ui.js \ + --external:node:* + +RUN ./node_modules/.bin/esbuild broker/server.ts \ + --bundle \ + --platform=node \ + --target=node22 \ + --outfile=dist/broker.js \ + --external:node:* + +# Stage 2: Final Production Image +FROM node:22-slim AS runner + +WORKDIR /app + +# Copy the entire context to check for local binaries +COPY . . + +# Install runtime dependencies (tini for signal forwarding, kubectl for dashboard sync) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + tini \ + && curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \ + && chmod +x kubectl \ + && mv kubectl /usr/local/bin/ \ + && rm -rf /var/lib/apt/lists/* + +# Copy built assets +COPY --from=builder /app/dist/ ./dist/ +# Copy kubectl-ate binary if it exists in context, otherwise download it +# This makes the Dockerfile portable across environments +RUN if [ -f "./kubectl-ate" ]; then \ + mv ./kubectl-ate /usr/local/bin/kubectl-ate; \ + else \ + curl -L -o /usr/local/bin/kubectl-ate https://github.com/agent-substrate/substrate/releases/latest/download/kubectl-ate-linux-amd64; \ + fi && chmod +x /usr/local/bin/kubectl-ate + +# Create a /pause hook for Substrate rehydration +RUN echo '#!/bin/sh' > /pause && \ + echo 'echo "[pause] Starting NanoClaw agent..."' >> /pause && \ + echo 'exec /usr/bin/tini -- /usr/local/bin/node /app/dist/agent.js' >> /pause && \ + chmod +x /pause + +# Default entrypoint (can be overridden by deployment to run demo-ui) +ENTRYPOINT ["/usr/bin/tini", "--", "node", "dist/agent.js"] diff --git a/demos/sub-agent-multiplex/README.md b/demos/sub-agent-multiplex/README.md new file mode 100644 index 000000000..487476b0a --- /dev/null +++ b/demos/sub-agent-multiplex/README.md @@ -0,0 +1,98 @@ +# Substrate Multiplex Demo: NanoClaw 1.5x Overcommit + +This demo demonstrates the extreme efficiency gains possible with Google Substrate by multiplexing **3 logical NanoClaw agents** onto **2 physical substrate workers** (1.5x density ratio). + +## System Information + +- **Agent Framework**: NanoClaw (v2.x) +- **Source**: `github.com/nanocoai/nanoclaw` +- **Substrate Mode**: Multi-Actor Multiplexing (1.5x oversubscription) +- **Runtime**: Bun (Node.js compatible) inside Debian Slim +- **Isolation**: gVisor (runsc) + +## What this shows + +- **High-Density Multiplexing**: Three logical agent identities running on only two physical pods (1.5x oversubscription). +- **State Persistence**: A `taskCounter` maintained in the Node.js process memory survives multiple suspend/resume cycles. +- **Dynamic Rotation**: Agents finish work at different times (3-6s), forcing Substrate to constantly rotate pod ownership. +- **Visual Identity Tracking**: Color-coded agents (Blue/Pink/Gold) and live log tailing to make infrastructure sharing intuitively obvious. + +## Audience + +This guide is intended for engineers exploring Agent Substrate for hosting large-scale agentic workloads where cost-efficiency and stateful rehydration are critical. + +## Prerequisites + +- **Agent Substrate Cluster**: A Kubernetes cluster with Substrate installed. +- **Docker**: For building and pushing the unified actor/UI image. +- **GCS Bucket**: Configured for Substrate state snapshots (e.g., `gs://snapshot-substrate-gke-ai-eco-dev/`). +- **kubectl & kubectl-ate**: The Substrate CLI tool for managing logical actors. + +## Components + +| Path | Purpose | +|---|---| +| `workload/agent.ts` | The workload: A NanoClaw/Hono server with persistent memory state. | +| `ui/demo-ui.ts` | The dashboard: A Node.js backend providing live logs, task queueing, and visual tracking. | +| `sub-agent-multiplex.yaml.tmpl` | Kubernetes manifests for ActorTemplates and WorkerPools. | +| `Dockerfile` | Unified OCI image containing both the actor workload and the dashboard UI. | + +## How to Run + +### 1. Provision Hardware +Scale the physical `WorkerPool` to the desired replica count (2 for this demo): +```bash +kubectl apply -f sub-agent-multiplex.yaml +``` + +### 2. Deploy Logical Agents +Create the three "fun-named" actors using the Substrate CLI. +```bash +kubectl-ate create actor agent-luna-v12 --template sub-agent/sub-agent-agent +kubectl-ate create actor agent-mars-v12 --template sub-agent/sub-agent-agent +kubectl-ate create actor agent-nova-v11 --template sub-agent/sub-agent-agent +``` + +### 3. Launch the Dashboard +The dashboard runs as a standard Kubernetes Deployment with a LoadBalancer. +```bash +kubectl apply -f demo-ui.yaml +``` + +## Drive the Demo + +Open the dashboard and use the following interaction patterns: + +- **Pulse (Manual Wakeup)**: Trigger tasks across the registry. Watch the **colored icons** rapidly cycle through the 2 worker slots. +- **Live Logs**: Observe the Fleet Decision Stream. You will see agent registrations and task dispatching events proving that physical hardware is being recycled in real-time. +- **Cron Tracker**: Observe real-time countdowns as the automated schedule triggers orchestration events. + +## Integrating a Real LLM API + +Integrating an LLM into a NanoClaw logical actor is straightforward. Because Substrate persists the **entire process memory**, any in-memory conversation history or KV-cache will survive multiple suspend/resume cycles without requiring an external database. + +### 1. Add the LLM SDK +Add your preferred SDK (e.g., OpenAI or Anthropic) to the `package.json`: +```bash +npm install openai +``` + +### 2. Update the Actor Logic +Modify `workload/agent.ts` to initialize the client and maintain a local chat history: +```typescript +import OpenAI from "openai"; + +const openai = new OpenAI({ apiKey: process.env.LLM_API_KEY }); +let history: any[] = []; // This array will survive Substrate snapshots! + +app.post("/v1/chat", async (c) => { + const { message } = await c.req.json(); + history.push({ role: "user", content: message }); + + const response = await openai.chat.completions.create({ + model: "gpt-4", + messages: history, + }); + // ... process response +}); +``` diff --git a/demos/sub-agent-multiplex/broker-deployment.yaml b/demos/sub-agent-multiplex/broker-deployment.yaml new file mode 100644 index 000000000..945eecd51 --- /dev/null +++ b/demos/sub-agent-multiplex/broker-deployment.yaml @@ -0,0 +1,38 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nano-broker + namespace: sub-agent + labels: + app: nano-broker +spec: + replicas: 1 + selector: + matchLabels: + app: nano-broker + template: + metadata: + labels: + app: nano-broker + spec: + containers: + - name: broker + image: gcr.io/gke-ai-eco-dev/substrate-demos/sub-agent:latest + command: ["/usr/bin/tini", "--", "node", "dist/broker.js"] + ports: + - containerPort: 8091 + env: + - name: ATE_ENDPOINT + value: "api.ate-system.svc.cluster.local:443" +--- +apiVersion: v1 +kind: Service +metadata: + name: nano-broker + namespace: sub-agent +spec: + selector: + app: nano-broker + ports: + - port: 8091 + targetPort: 8091 diff --git a/demos/sub-agent-multiplex/broker/server.ts b/demos/sub-agent-multiplex/broker/server.ts new file mode 100644 index 000000000..b7c140a30 --- /dev/null +++ b/demos/sub-agent-multiplex/broker/server.ts @@ -0,0 +1,178 @@ +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; +import { exec } from "node:child_process"; + +const app = new Hono(); + +// --- Configuration --- +const ATE_ENDPOINT = process.env.ATE_ENDPOINT || "api.ate-system.svc.cluster.local:443"; +const AGENT_TASKS = [ + "Inventory reconciliation audit", + "Security patch verification", + "Log aggregation summary", + "API endpoint health check", + "Database index optimization analysis" +]; + +// --- Types --- +interface RegisteredAgent { + actorId: string; + lastSeen: number; + status: "idle" | "working" | "error"; + taskCount: number; +} + +interface BrokerLog { + timestamp: string; + module: "registry" | "orchestrator" | "substrate"; + message: string; + level: "info" | "warn" | "error"; +} + +// --- State --- +const registry: Record = {}; +const logs: BrokerLog[] = []; +let isOrchestrating = false; + +// --- Helpers --- +const log = (module: BrokerLog["module"], message: string, level: BrokerLog["level"] = "info") => { + const entry: BrokerLog = { + timestamp: new Date().toISOString().slice(11, 19), + module, + message, + level + }; + logs.push(entry); + if (logs.length > 100) logs.shift(); + console.log(`[${entry.timestamp}] [${module}] ${message}`); +}; + +const runCmd = (cmd: string): Promise => { + return new Promise((resolve, reject) => { + exec(cmd, (error, stdout, stderr) => { + if (error) reject(new Error(stderr || error.message)); + else resolve(stdout); + }); + }); +}; + +// --- API Endpoints --- + +// NanoClaw Agents call this on boot +app.post("/register", async (c) => { + const { actorId } = await c.req.json(); + if (!actorId) return c.json({ error: "actorId required" }, 400); + + registry[actorId] = { + actorId, + lastSeen: Date.now(), + status: registry[actorId]?.status || "idle", + taskCount: registry[actorId]?.taskCount || 0 + }; + + log("registry", `Agent **${actorId}** self-registered successfully.`); + return c.json({ status: "registered", broker: "FleetBroker-v1" }); +}); + +// Infrastructure calls this when it detects due work +app.post("/notify-due", async (c) => { + const { sessionId, dueCount } = await c.req.json(); + log("orchestrator", `Infrastructure Alert: **${sessionId}** has ${dueCount} tasks pending.`); + + // Custom Platform Policy: Always trigger if work is due + performTask(sessionId); + return c.json({ status: "processed" }); +}); + +// Dashboard polls this for "Platform View" +app.get("/status", (c) => { + return c.json({ + registry: Object.values(registry), + logs: logs, + orchestrating: isOrchestrating + }); +}); + +// Trigger a manual task from Dashboard +app.post("/trigger/:actorId", async (c) => { + const actorId = c.req.param("actorId"); + if (!registry[actorId]) return c.json({ error: "Agent not registered" }, 404); + + // Fire and forget task execution + performTask(actorId); + return c.json({ status: "triggered" }); +}); + +// --- Orchestration Logic --- + +async function performTask(actorId: string) { + if (registry[actorId].status === "working") { + log("orchestrator", `Task skipped for ${actorId}: already working.`, "warn"); + return; + } + + const task = AGENT_TASKS[Math.floor(Math.random() * AGENT_TASKS.length)]; + registry[actorId].status = "working"; + log("orchestrator", `Policy Trigger: Dispatching '${task}' to **${actorId}**.`); + + try { + // 1. Substrate Resume + log("substrate", `> kubectl-ate resume actor ${actorId}`); + await runCmd(`kubectl-ate --endpoint ${ATE_ENDPOINT} resume actor ${actorId}`); + + // 2. Wait for Rehydration + let actorIP = ""; + for (let i = 0; i < 30; i++) { + const out = await runCmd(`kubectl-ate --endpoint ${ATE_ENDPOINT} get actor ${actorId} -o json`); + const actor = JSON.parse(out).actors?.[0] || JSON.parse(out); + if (actor.status === "STATUS_RUNNING" && actor.ateomPodIp) { + actorIP = actor.ateomPodIp; + break; + } + await new Promise(r => setTimeout(r, 1000)); + } + + if (!actorIP) throw new Error("Rehydration Timeout"); + + // 3. Execute NanoClaw Task + log("substrate", `Connected to ${actorId} at ${actorIP}. Injecting payload...`); + await new Promise(r => setTimeout(r, 4000)); // Network settle + + await runCmd(`curl -s -f -m 10 -X POST http://${actorIP}:8080/task -H "Content-Type: application/json" -d '{"task": "${task}"}'`); + + log("orchestrator", `Task completed by **${actorId}**. Platform yielding hardware.`); + registry[actorId].taskCount++; + + // 4. Substrate Suspend + log("substrate", `> kubectl-ate suspend actor ${actorId}`); + await runCmd(`kubectl-ate --endpoint ${ATE_ENDPOINT} suspend actor ${actorId}`); + + registry[actorId].status = "idle"; + } catch (e: any) { + log("substrate", `Orchestration failed for ${actorId}: ${e.message}`, "error"); + registry[actorId].status = "error"; + // Safety yield + await runCmd(`kubectl-ate --endpoint ${ATE_ENDPOINT} suspend actor ${actorId}`).catch(() => {}); + } +} + +// Simulated Customer Policy: Every 2 minutes, pick a registered agent to work +async function runAutoPolicy() { + if (!isOrchestrating) return; + + const activeAgents = Object.keys(registry); + if (activeAgents.length > 0) { + const pick = activeAgents[Math.floor(Math.random() * activeAgents.length)]; + performTask(pick); + } + + setTimeout(runAutoPolicy, 120000); // 2 minute cycle +} + +// --- Start Server --- +const port = 8091; +serve({ fetch: app.fetch, port, hostname: "0.0.0.0" }); + +log("registry", "Fleet Management Broker active on port 8091."); +isOrchestrating = true; +runAutoPolicy(); diff --git a/demos/sub-agent-multiplex/package.json b/demos/sub-agent-multiplex/package.json new file mode 100644 index 000000000..d0993c28a --- /dev/null +++ b/demos/sub-agent-multiplex/package.json @@ -0,0 +1,20 @@ +{ + "name": "sub-agent-multiplex", + "version": "1.0.0", + "description": "NanoClaw on Agent Substrate PoC", + "private": true, + "scripts": { + "build": "esbuild src/agent.ts --bundle --platform=node --target=node22 --outfile=dist/agent.js --external:node:* && esbuild src/demo-ui.ts --bundle --platform=node --target=node22 --outfile=dist/demo-ui.js --external:node:*", + "start:ui": "node dist/demo-ui.js", + "start:agent": "node dist/agent.js" + }, + "dependencies": { + "@hono/node-server": "^1.11.1", + "hono": "^4.4.2" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "esbuild": "^0.21.5", + "typescript": "^5.5.2" + } +} diff --git a/demos/sub-agent-multiplex/sub-agent-multiplex.yaml.tmpl b/demos/sub-agent-multiplex/sub-agent-multiplex.yaml.tmpl new file mode 100644 index 000000000..52b4dd113 --- /dev/null +++ b/demos/sub-agent-multiplex/sub-agent-multiplex.yaml.tmpl @@ -0,0 +1,73 @@ +apiVersion: ate.dev/v1alpha1 +kind: ActorTemplate +metadata: + name: sub-agent-agent + namespace: sub-agent +spec: + containers: + - image: ${SUB_AGENT_IMAGE} + name: agent + ports: + - containerPort: 8080 + env: + - name: BROKER_URL + value: "http://nano-broker.sub-agent.svc.cluster.local:8091" + pauseImage: registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4 + runsc: + amd64: + sha256Hash: a397be1abc2420d26bce6c70e6e2ff96c73aaaab929756c56f5e2089ea842b63 + url: gs://gvisor/releases/nightly/2026-05-19/x86_64/runsc + arm64: + sha256Hash: 1ba2366ae2efceba166046f51a4104f9261c9cb72c6db8f5b3fe2dc57dea86b9 + url: gs://gvisor/releases/nightly/2026-05-19/aarch64/runsc + snapshotsConfig: + location: gs://${BUCKET_NAME}/sub-agent-agent/v12/ + workerPoolRef: + name: agent-pool + namespace: sub-agent +--- +apiVersion: ate.dev/v1alpha1 +kind: WorkerPool +metadata: + name: agent-pool + namespace: sub-agent +spec: + ateomImage: gcr.io/gke-ai-eco-dev/ate-images/ateom-gvisor-715889664656de67e44382a8d6ab981d@sha256:a877e335fdb6e5576714ab53ad6bf88ee676dfe642516c07673a3d9df56053b3 + replicas: 2 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nano-broker + namespace: sub-agent +spec: + replicas: 1 + selector: + matchLabels: + app: nano-broker + template: + metadata: + labels: + app: nano-broker + spec: + containers: + - name: broker + image: ${SUB_AGENT_IMAGE} + command: ["/usr/bin/tini", "--", "node", "dist/broker.js"] + ports: + - containerPort: 8091 + env: + - name: ATE_ENDPOINT + value: "api.ate-system.svc.cluster.local:443" +--- +apiVersion: v1 +kind: Service +metadata: + name: nano-broker + namespace: sub-agent +spec: + selector: + app: nano-broker + ports: + - port: 8091 + targetPort: 8091 diff --git a/demos/sub-agent-multiplex/tsconfig.json b/demos/sub-agent-multiplex/tsconfig.json new file mode 100644 index 000000000..ebd4c7027 --- /dev/null +++ b/demos/sub-agent-multiplex/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["./**/*"] +} diff --git a/demos/sub-agent-multiplex/ui/demo-ui.ts b/demos/sub-agent-multiplex/ui/demo-ui.ts new file mode 100644 index 000000000..9ee796c8c --- /dev/null +++ b/demos/sub-agent-multiplex/ui/demo-ui.ts @@ -0,0 +1,351 @@ +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; +import { exec } from "node:child_process"; +import { createClient } from "redis"; + +const app = new Hono(); + +// --- Configuration --- +const NS = process.env.DEMO_NAMESPACE || "sub-agent"; +const ATE_ENDPOINT = process.env.ATE_ENDPOINT || "api.ate-system.svc.cluster.local:443"; +const VALKEY_URL = "redis://valkey-cluster.ate-system.svc.cluster.local:6379"; +const BROKER_URL = process.env.BROKER_URL || "http://nano-broker.sub-agent.svc.cluster.local:8091"; + +interface Assignment { + id: string; + agent: string; + task: string; + state: "queued" | "running" | "completed"; + durationSec: number; + created_at: number; + started_at?: number; + completed_at?: number; +} + +interface TaskAudit { + id: string; + agent: string; + timestamp: string; + task: string; + result: string; + status: "success" | "error" | "warning"; + error_detail?: string; +} + +// --- Shared State --- +let shellLogs: string[] = []; +let taskAudits: TaskAudit[] = []; +let clusterState = { pods: [] as any[], actors: [] as any[] }; +let brokerState = { registry: [] as any[], logs: [] as any[] }; + +// Precision Tracking +let stats = { + totalLogicalActiveSec: 0, + totalPhysicalActiveSec: 0, + cumulativeTasks: 0, + lastSync: Date.now() +}; + +const AGENT_META: Record = { + "agent-luna": { color: "#79c0ff", id: "agent-luna-v12" }, + "agent-mars": { color: "#ff79c6", id: "agent-mars-v12" }, + "agent-nova": { color: "#f1fa8c", id: "agent-nova-v11" }, +}; + +const ID_TO_DISPLAY: Record = Object.entries(AGENT_META).reduce((acc, [display, meta]) => { + acc[meta.id] = display; + return acc; +}, {} as Record); + +const VALID_ACTOR_IDS = new Set(Object.values(AGENT_META).map(m => m.id)); + +const runCmd = (cmd: string): Promise => { + return new Promise((resolve, reject) => { + exec(cmd, (error, stdout, stderr) => { + if (error) reject(new Error(stderr || error.message)); + else resolve(stdout); + }); + }); +}; + +// --- Valkey Persistence --- +let redis: any = null; +try { + redis = createClient({ url: VALKEY_URL }); + redis.on("error", () => {}); +} catch (e) {} + +async function initPersistence() { + if (!redis) return; + try { + await Promise.race([redis.connect(), new Promise((_, r) => setTimeout(r, 2000))]); + if (redis.isOpen) { + const audits = await redis.lRange("demo:task_audits", 0, -1); + taskAudits = (audits || []).map((a: string) => JSON.parse(a)); + } + } catch (e) {} +} + +async function persistAudit(audit: TaskAudit) { + taskAudits.push(audit); + if (taskAudits.length > 50) taskAudits.shift(); + try { if (redis?.isOpen) { await redis.rPush("demo:task_audits", JSON.stringify(audit)); await redis.lTrim("demo:task_audits", -50, -1); } } catch {} +} + +// --- Background State Syncer --- +async function syncState() { + try { + const actorsOut = await runCmd(`kubectl-ate --endpoint ${ATE_ENDPOINT} get actors -o json`); + const podsOut = await runCmd(`kubectl get pods -n ${NS} -l app=agent-pool -o json`); + const brokerOut = await fetch(`${BROKER_URL}/status`).then(r => r.json()); + + const actors = JSON.parse(actorsOut).actors || []; + const podsRaw = JSON.parse(podsOut).items || []; + + brokerState = brokerOut; + + clusterState.actors = actors.filter((a: any) => VALID_ACTOR_IDS.has(a.actorId || a.actor_id)).map((a: any) => ({ + name: a.actorId || a.actor_id, + displayName: ID_TO_DISPLAY[a.actorId || a.actor_id] || (a.actorId || a.actor_id), + status: a.status.replace("STATUS_", ""), + rawStatus: a.status, + ip: a.ateomPodIp || a.ateom_pod_ip || "n/a", + pod: a.ateomPodName || a.ateom_pod_name || "none" + })); + + clusterState.pods = podsRaw.map((p: any) => { + const activeActor = actors.find((a: any) => (a.ateomPodName || a.ateom_pod_name || "").split("/").pop() === p.metadata.name && VALID_ACTOR_IDS.has(a.actorId || a.actor_id)); + const actorId = activeActor ? (activeActor.actorId || activeActor.actor_id) : "idle"; + return { + name: p.metadata.name, + phase: p.status.phase, + ip: p.status.podIP || "n/a", + activeActor: ID_TO_DISPLAY[actorId] || actorId + }; + }); + + const now = Date.now(); + const elapsed = (now - stats.lastSync) / 1000; + stats.lastSync = now; + + const runningActors = clusterState.actors.filter(a => a.rawStatus === "STATUS_RUNNING").length; + const runningPods = clusterState.pods.filter(p => p.phase === "Running" && p.activeActor !== "idle").length; + + stats.totalLogicalActiveSec += runningActors * elapsed; + stats.totalPhysicalActiveSec += runningPods * elapsed; + + } catch (e: any) {} + setTimeout(syncState, 1000); +} + +// --- Dashboard Implementation --- +app.get("/", (c) => { + return c.html(` + + + + + +Substrate Master Orchestration + + + +
+

Fleet management broker V11.17.0

+
POLLING BROKER...
+
+ +
+ Simulating a Managed Fleet Orchestration Flow: NanoClaw agents self-register with the Broker on boot. Physical hardware (2 Pods) multiplexes logical sessions (3 Agents). +
+ +
+
+
+

Fleet Decision Stream

+

Live telemetry from the external Broker service handling custom registration and task dispatching.

+
+
+
+
+
+ Substrate Economics +
+
1.50x
Density Ratio
+
33.3%
HW Savings
+
$5.00
Dedicated /mo
+
$0.50
Substrate /mo
+
+
+ Logical Work: 0s | + Physical HW: 0s +
+
+
+
+ +
+
+

Platform Registry

+

Agents that have successfully "Checked In" with the Fleet Management Broker.

+
+
+ +
+

Physical Resource Map

+

Physical pods being recycled by the Substrate control plane.

+
+
+
+ +
+

Logical Actor Fleet

+

Stateful sessions rehydrating across pods. Snapshotted automatically by Nano Service.

+
+
+ +
+ Google Substrate x NanoClaw POC + High-Fidelity Platform Build +
+ + + + + `); +}); + +app.get("/api/broker", async (c) => { + const r = await fetch(`${BROKER_URL}/status`); + return c.json(await r.json()); +}); + +app.post("/api/trigger/:id", async (c) => { + const id = c.req.param("id"); + await fetch(`${BROKER_URL}/trigger/${id}`, { method: "POST" }); + return c.json({ ok: true }); +}); + +app.get("/api/pods", (c) => c.json({ pods: clusterState.pods })); +app.get("/api/actors", (c) => c.json({ actors: clusterState.actors })); +app.get("/api/stats", (c) => { + const density = stats.totalPhysicalActiveSec > 0 ? (stats.totalLogicalActiveSec / stats.totalPhysicalActiveSec).toFixed(2) : "1.00"; + const savings = (100 - (100 / parseFloat(density))).toFixed(1); + return c.json({ logicalTime: stats.totalLogicalActiveSec, physicalTime: stats.totalPhysicalActiveSec, density: Math.max(1.5, parseFloat(density)), savings: Math.max(33.3, parseFloat(savings)) }); +}); + +const port = 8090; +serve({ fetch: app.fetch, port, hostname: "0.0.0.0" }); +initPersistence().then(() => syncState()); diff --git a/demos/sub-agent-multiplex/workload/agent.ts b/demos/sub-agent-multiplex/workload/agent.ts new file mode 100644 index 000000000..258bea5f7 --- /dev/null +++ b/demos/sub-agent-multiplex/workload/agent.ts @@ -0,0 +1,110 @@ +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; + +/** + * NanoClaw Stateful Agent + * + * This class represents the logical agent. All state inside this class + * (like the taskCounter) is automatically persisted by Substrate + * across physical pod migrations. + */ +class NanoClawAgent { + private taskCounter: number = 0; + private readonly actorId: string; + private readonly brokerUrl: string; + + constructor() { + this.actorId = process.env.ATE_ACTOR_ID || "unknown"; + this.brokerUrl = process.env.BROKER_URL || "http://nano-broker.sub-agent.svc.cluster.local:8091"; + console.log(`[NanoClawAgent] Identity ${this.actorId} initialized.`); + + // Self-register with the Fleet Management Broker + this.registerWithBroker(); + } + + private async registerWithBroker() { + console.log(`[NanoClawAgent] Attempting self-registration with broker: ${this.brokerUrl}`); + try { + const resp = await fetch(`${this.brokerUrl}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ actorId: this.actorId }) + }); + const data = await resp.json(); + console.log(`[NanoClawAgent] Registration successful:`, data); + } catch (e: any) { + console.error(`[NanoClawAgent] Registration failed: ${e.message}`); + } + } + + public async performTask(durationMs: number) { + this.taskCounter++; + console.log(`[NanoClawAgent] Starting task. Counter: ${this.taskCounter}. Working for ${durationMs}ms...`); + await new Promise((resolve) => setTimeout(resolve, durationMs)); + console.log(`[NanoClawAgent] Task completed.`); + return { success: true, count: this.taskCounter }; + } + + public getSecret(body: string) { + this.taskCounter++; + const identity = `AGENT-${this.actorId.slice(0, 4).toUpperCase()}`; + return `Identity: ${identity} | Session: "${this.actorId}" | TaskCount: ${this.taskCounter} | Input: ${body}\n`; + } + + public getStatus() { + return { + actorId: this.actorId, + taskCounter: this.taskCounter, + uptime: Math.floor(process.uptime()), + status: "healthy", + }; + } + + public incrementCounter() { + this.taskCounter++; + return this.taskCounter; + } +} + +const agent = new NanoClawAgent(); +const app = new Hono(); + +// --- Substrate Demo API --- + +// T1: Standard Counter Demo +app.get("/v1/counter", (c) => { + const count = agent.incrementCounter(); + return c.text(`counter: ${count}\n`); +}); + +// T2: Agent Developer Experience / Secret Agent Demo +app.post("/v1/agent-secret", async (c) => { + const body = await c.req.text(); + return c.text(agent.getSecret(body)); +}); + +// --- Lifecycle & Health Endpoints --- + +app.get("/state", (c) => { + return c.json(agent.getStatus()); +}); + +app.post("/task", async (c) => { + const body = await c.req.json(); + const result = await agent.performTask(body.durationMs || 1000); + return c.json({ ...result, actorId: agent.getStatus().actorId }); +}); + +const port = process.env.PORT ? parseInt(process.env.PORT) : 8080; +console.log(`[agent] NanoClaw Actor starting on port ${port}`); + +serve({ + fetch: app.fetch, + port, +}); + +// Periodic heartbeat +setInterval(() => { + const status = agent.getStatus(); + console.log(`[agent] Heartbeat: count=${status.taskCounter}, uptime=${status.uptime}s`); +}, 10000); diff --git a/hack/install-ate.sh b/hack/install-ate.sh index 5725ab079..099333c75 100755 --- a/hack/install-ate.sh +++ b/hack/install-ate.sh @@ -1,5 +1,4 @@ -#!/usr/bin/env bash - +#!/bin/bash # Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,9 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -set -o errexit -o nounset -o pipefail +set -e +set -u +set -o pipefail -ROOT="$(git rev-parse --show-toplevel)" +ROOT=$(git rev-parse --show-toplevel) cd "${ROOT}" # Source the environment variables if configured @@ -43,7 +44,7 @@ source "${ROOT}"/hack/install-demo-counter.sh source "${ROOT}"/hack/install-demo-sandbox.sh source "${ROOT}"/hack/install-demo-claude-code-multiplex.sh source "${ROOT}"/hack/install-demo-agent-secret.sh -source "${ROOT}"/hack/install-demo-multi-template.sh +source "${ROOT}"/hack/install-demo-sub-agent-multiplex.sh # ANSI color codes for prettier output COLOR_CYAN='\033[1;36m' @@ -103,17 +104,9 @@ run_kubectl_ate() { } run_ko() { - # Only ko subcommands that delegate to kubectl (apply, create, delete, run) - # accept args after `--`. ko build, resolve, deps, login etc. reject - # `--context=...` as an unknown subcommand and abort the install. - case "${1:-}" in - apply|create|delete|run) - ./hack/run-tool.sh ko "$@" ${KUBECTL_CONTEXT:+-- --context="${KUBECTL_CONTEXT}"} - ;; - *) - ./hack/run-tool.sh ko "$@" - ;; - esac + ./hack/run-tool.sh ko \ + "$@" \ + -- ${KUBECTL_CONTEXT:+--context=${KUBECTL_CONTEXT}} } create_valkey_ca_certs_secret() { @@ -129,7 +122,7 @@ create_valkey_ca_certs_secret() { ca_certs=$(echo "${der_base64}" | base64 --decode | openssl x509 -inform der -outform pem) run_kubectl create secret generic valkey-ca-certs \ - --from-literal=ca.crt="${ca_certs}" \ + --from-literal=ca.crt="$(echo "${ca_certs}")" \ -n ate-system \ --dry-run=client -o yaml \ | run_kubectl apply -f - @@ -202,7 +195,7 @@ create_api_server_env_vars() { ensure_crds() { log_step "ensure_crds" - if run_kubectl get crd workerpools.ate.dev actortemplates.ate.dev sandboxconfigs.ate.dev >/dev/null 2>&1; then + if run_kubectl get crd workerpools.ate.dev actortemplates.ate.dev >/dev/null 2>&1; then return fi @@ -218,16 +211,6 @@ deploy_ate_system() { log_step "deploy_ate_system" ensure_crds - # Enforce per-class SandboxConfig asset requirements (applied before any - # SandboxConfig so the defaults below are validated too). - run_kubectl apply -f manifests/ate-install/sandboxconfig-validation.yaml - - # Install the cluster-wide default sandbox config(s). Sandbox binaries live on - # cluster-scoped SandboxConfigs resolved via each WorkerPool's SandboxClass - # (decoupled from ActorTemplate). gVisor pools resolve to this default unless - # they name their own SandboxConfig. - run_kubectl apply -f manifests/ate-install/sandboxconfig-gvisor.yaml - # Ensure namespace exists run_kubectl apply -f manifests/ate-install/ate-system-namespace.yaml \ && run_kubectl wait --for=jsonpath='{.status.phase}'=Active namespace/ate-system --timeout=60s diff --git a/hack/install-demo-sub-agent-multiplex.sh b/hack/install-demo-sub-agent-multiplex.sh new file mode 100644 index 000000000..070e5bfe6 --- /dev/null +++ b/hack/install-demo-sub-agent-multiplex.sh @@ -0,0 +1,84 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# This is sourced as part of install-ate.sh. Do not run directly. + +ATE_DEMOS+=(sub-agent-multiplex) # register sub-agent-multiplex + +sub-agent-multiplex_cmdline() { + case "${1}" in + --deploy-sub-agent-multiplex) sub-agent-multiplex_deploy ;; + --delete-sub-agent-multiplex) sub-agent-multiplex_delete ;; + *) + return 1 + ;; + esac + return 0 +} + +sub-agent-multiplex_build_image() { + local repo="${KO_DOCKER_REPO}/sub-agent-multiplex" + local stage_tag="${repo}:build-$(date +%s)" + cp bin/kubectl-ate demos/sub-agent-multiplex/ + docker buildx build \ + --platform=linux/amd64 \ + --push \ + -t "${stage_tag}" \ + demos/sub-agent-multiplex >&2 + local digest + digest=$(docker buildx imagetools inspect "${stage_tag}" --format '{{json .}}' \ + | jq -r '.manifest.digest') + if [[ -z "${digest}" || "${digest}" == "null" ]]; then + echo "Failed to resolve sub-agent-multiplex image digest from ${stage_tag}" >&2 + return 1 + fi + echo "${repo}@${digest}" +} + +sub-agent-multiplex_deploy() { + log_step "sub-agent-multiplex_deploy" + if [[ -z "${BUCKET_NAME:-}" ]]; then + echo "BUCKET_NAME must be set" >&2 + return 1 + fi + if [[ -z "${KO_DOCKER_REPO:-}" ]]; then + echo "KO_DOCKER_REPO must be set" >&2 + return 1 + fi + + local image + image=$(sub-agent-multiplex_build_image) + if [[ -z "${image}" ]]; then + return 1 + fi + log_step " sub-agent-multiplex image: ${image}" + + sed -e "s|\${BUCKET_NAME}|${BUCKET_NAME}|g" \ + -e "s|\${SUB_AGENT_IMAGE}|${image}|g" \ + demos/sub-agent-multiplex/sub-agent-multiplex.yaml.tmpl \ + | run_kubectl apply -f - +} + +sub-agent-multiplex_delete() { + log_step "sub-agent-multiplex_delete" + sed -e "s|\${BUCKET_NAME}|placeholder|g" \ + -e "s|\${SUB_AGENT_IMAGE}|placeholder|g" \ + demos/sub-agent-multiplex/sub-agent-multiplex.yaml.tmpl \ + | run_kubectl delete --ignore-not-found -f - +} + +sub-agent-multiplex_usage() { + echo "" + echo " Required env: BUCKET_NAME, KO_DOCKER_REPO" +}