Skip to content

Commit 402f552

Browse files
lalaluneclaude
andcommitted
feat(local-agent-on-android): full @elizaos/agent runs on the phone
Five-phase landing of the local-agent-on-Android architecture proven by the spike in scripts/spike-android-agent/ and documented in docs/agent-on-mobile.md. Phase A — APK asset pipeline (run-mobile-build.mjs + scripts/lib/stage-android-agent.mjs): pinned bun-1.3.13 + Alpine v3.21 musl loader + libstdc++ + libgcc fetched per ABI, cached under ~/.cache/milady-android-agent/, staged into apps/app/android/app/src/ main/assets/agent/{x86_64,arm64-v8a}/ with idempotent re-runs. ~190 MB across both ABIs. Phase B — MiladyAgentService.java foreground service: extracts assets to /data/data/<pkg>/files/agent/ at first launch, chmods the bun binary + musl loader + launch.sh, ProcessBuilder-spawns bun with the spike's proven invocation pattern, pumps stdout/stderr to logcat + agent.log, runs a watchdog that polls process.isAlive() + http://127.0.0.1:31337/ api/health every 10 s with exponential-backoff restart up to 5 attempts, SIGTERM/SIGKILL on shutdown. FOREGROUND_SERVICE_SPECIAL_USE gated to API 34+ (minSdk=26). Wired into MiladyBootReceiver (BOOT_COMPLETED) and MainActivity.onCreate(). Includes integration step: Service calls SELinux.restoreconRecursive(filesDir + "/agent") via reflection after extraction so Phase C's domain transition fires. Phase D — agent payload (eliza/packages/agent/scripts/build-mobile- bundle.mjs + mobile-stubs/): bun-build of @elizaos/agent with native deps (node-llama-cpp, sharp, onnxruntime-node, @huggingface/ transformers, puppeteer-core, pty-manager, canvas) replaced by CJS stubs that re-export the names the runtime statically imports. Mobile core-plugin allowlist of @elizaos/plugin-sql + AI providers; plugin- collector restricts the load set so dynamic imports of unbundled packages are impossible. isMobilePlatform() helper in @elizaos/shared gates ~20 child_process.spawn sites (n8n sidecar, telegram polling, embedding warmup, sandbox, signal-pairing, app-route registration, trigger bridges, training crons). MILADY_DISABLE_DIRECT_RUN flattens the runtime/eliza.ts self-invocation so the bundle's bin.ts→cli stays the entry point. Bundle boots on the cuttlefish, completes PGlite migrations, answers /api/health with {"ready":true,"runtime":"ok", "database":"ok",...}. Phase E — UI mode wiring (mobile-runtime-mode.ts + RuntimeGate.tsx + probe-local-agent.ts + platform/init.ts): "local" added to MobileRuntimeMode union, async probe-driven local-tile gating with 30 s memo + inflight dedupe + spinner placeholder, mobile-friendly copy ("Local Agent (Beta)" / "ON DEVICE") gated on isAndroid, finishAsLocal() pins apiBase=http://127.0.0.1:31337 and persists mobile-runtime-mode + active-server side-channel for auto-resume on next launch. canHostLocalAgent() helper. 4 new i18n keys × 7 locales. 20 onboarding tests pass. client-base.setBaseUrl() now mirrors apiBase to window.__ELIZA_API_BASE__ via setElizaApiBase()/ clearElizaApiBase() so the Capacitor agent plugin web fallback resolves the right base on Android (was the Phase E caveat). Stage-android-agent.mjs prefers eliza/packages/agent/dist-mobile/ agent-bundle.js when present (Phase D's real bundle) and falls back to the spike's stub server.js — so a bare Milady checkout still builds, with `bun run --cwd eliza/packages/agent build:mobile` producing the real payload. PGlite vector.tar.gz and fuzzystrmatch.tar.gz are staged into the assets root and MiladyAgentService extracts them ONE DIR ABOVE the bundle so PGlite's `new URL("../X.tar.gz", import.meta.url)` resolves on- device. dist-mobile/ is gitignored — build artifact only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 200c809 commit 402f552

38 files changed

Lines changed: 4144 additions & 1491 deletions

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,3 +418,9 @@ packages/app-core/platforms/electrobun/artifacts/
418418
# Compiled native dylibs (rebuild from source)
419419
packages/app-core/platforms/electrobun/src/*.dylib
420420
benchmark_results/
421+
422+
# Phase D mobile bundle outputs (regenerated by build-mobile-bundle.mjs).
423+
# The Android APK staging step copies these into apps/app/android/app/
424+
# src/main/assets/agent/ at build time; committing the 33 MB bundle
425+
# blob and 5 MB pglite.data into the repo is wrong.
426+
packages/agent/dist-mobile/

packages/agent/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"start": "bun run src/bin.ts",
2727
"dev": "bun --hot src/bin.ts",
2828
"build": "bun run build:dist",
29+
"build:mobile": "bun run scripts/build-mobile-bundle.mjs",
2930
"build:docker-dist": "rm -rf dist && tsc --noCheck -p tsconfig.build.json && node ../../scripts/prepare-package-dist.mjs packages/agent --compiled-prefix=packages/agent/src",
3031
"typecheck": "tsc --noEmit -p tsconfig.json",
3132
"build:dist": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/prepare-package-dist.mjs packages/agent --compiled-prefix=packages/agent/src",
Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
#!/usr/bin/env bun
2+
// build-mobile-bundle.mjs — produce the on-device agent payload.
3+
//
4+
// Output layout (consumed by the Phase A asset pipeline):
5+
//
6+
// eliza/packages/agent/dist-mobile/
7+
// agent-bundle.js the actual bun-runnable payload
8+
// pglite.wasm PGlite WebAssembly module
9+
// pglite.data PGlite filesystem image
10+
// vector.tar.gz pgvector contrib (referenced via ../)
11+
// fuzzystrmatch.tar.gz fuzzystrmatch contrib (referenced via ../)
12+
// plugins-manifest.json list of plugins statically baked into the bundle
13+
//
14+
// What this build does NOT do:
15+
// - Stage `node_modules`. All `MOBILE_CORE_PLUGINS` resolve through
16+
// `STATIC_ELIZA_PLUGINS` in the agent runtime (via static `import * as
17+
// pluginX from "@elizaos/plugin-X"`), so they are inlined by `Bun.build`.
18+
// - Bundle a model. Inference goes through `ANTHROPIC_API_KEY` /
19+
// `ELIZAOS_CLOUD_API_KEY` from the user's onboarding for first-light.
20+
//
21+
// PGlite extension paths:
22+
// `@electric-sql/pglite` resolves four assets via `new URL(..., import.meta.url)`:
23+
// - "./pglite.wasm" => same dir as the bundle
24+
// - "./pglite.data" => same dir as the bundle
25+
// - "../vector.tar.gz" => one dir above the bundle
26+
// - "../fuzzystrmatch.tar.gz" => one dir above the bundle
27+
// After `Bun.build`, `import.meta.url` becomes the bundle's path, so we
28+
// ship the four files alongside it and the asset pipeline mounts them so
29+
// the relative paths land. Phase A is responsible for placing the .tar.gz
30+
// files at parent-of-bundle on the device.
31+
32+
import {
33+
copyFile,
34+
mkdir,
35+
readdir,
36+
rm,
37+
stat,
38+
writeFile,
39+
} from "node:fs/promises";
40+
import { existsSync, readdirSync } from "node:fs";
41+
import path from "node:path";
42+
import { fileURLToPath } from "node:url";
43+
44+
const here = path.dirname(fileURLToPath(import.meta.url));
45+
const agentRoot = path.resolve(here, "..");
46+
const repoRoot = path.resolve(agentRoot, "..", "..", "..");
47+
const outDir = path.join(agentRoot, "dist-mobile");
48+
const stubsDir = path.join(here, "mobile-stubs");
49+
const entry = path.join(agentRoot, "src", "bin.ts");
50+
51+
console.log("[build-mobile] agent root:", agentRoot);
52+
console.log("[build-mobile] output dir:", outDir);
53+
54+
await rm(outDir, { recursive: true, force: true });
55+
await mkdir(outDir, { recursive: true });
56+
57+
function findPgliteDist() {
58+
const candidates = [
59+
path.join(repoRoot, "node_modules", "@electric-sql", "pglite", "dist"),
60+
];
61+
const bunDir = path.join(repoRoot, "node_modules", ".bun");
62+
if (existsSync(bunDir)) {
63+
for (const entry of readdirSyncSafe(bunDir)) {
64+
if (entry.startsWith("@electric-sql+pglite@")) {
65+
candidates.push(
66+
path.join(
67+
bunDir,
68+
entry,
69+
"node_modules",
70+
"@electric-sql",
71+
"pglite",
72+
"dist",
73+
),
74+
);
75+
}
76+
}
77+
}
78+
for (const c of candidates) {
79+
if (existsSync(path.join(c, "pglite.wasm"))) return c;
80+
}
81+
return null;
82+
}
83+
84+
function readdirSyncSafe(p) {
85+
try {
86+
return readdirSync(p);
87+
} catch {
88+
return [];
89+
}
90+
}
91+
92+
const pgliteDist = findPgliteDist();
93+
if (!pgliteDist) {
94+
console.error(
95+
"[build-mobile] FATAL: could not locate @electric-sql/pglite/dist. " +
96+
"Run `bun install` first.",
97+
);
98+
process.exit(1);
99+
}
100+
console.log("[build-mobile] pglite dist:", pgliteDist);
101+
102+
// Native deps without an Android prebuild — replace at bundle time with
103+
// throw-on-call shims. Bun.build's `--external` would leave bare-name imports
104+
// in the output; `MILADY_PLATFORM=android` would then fail at runtime when
105+
// the mobile bun process can't resolve the missing package. A plugin onResolve
106+
// that maps the bare specifier to the stub path keeps the resolution pure.
107+
// Native deps without an Android prebuild — replaced with throw-on-call shims.
108+
const nativeStubs = {
109+
"node-llama-cpp": path.join(stubsDir, "node-llama-cpp.cjs"),
110+
"@node-llama-cpp/linux-x64": path.join(stubsDir, "node-llama-cpp.cjs"),
111+
"@node-llama-cpp/linux-arm64": path.join(stubsDir, "node-llama-cpp.cjs"),
112+
"@node-llama-cpp/mac-arm64": path.join(stubsDir, "node-llama-cpp.cjs"),
113+
"@node-llama-cpp/mac-x64": path.join(stubsDir, "node-llama-cpp.cjs"),
114+
"@node-llama-cpp/win-x64": path.join(stubsDir, "node-llama-cpp.cjs"),
115+
"onnxruntime-node": path.join(stubsDir, "onnxruntime-node.cjs"),
116+
"@huggingface/transformers": path.join(stubsDir, "huggingface-transformers.cjs"),
117+
"puppeteer-core": path.join(stubsDir, "puppeteer-core.cjs"),
118+
"pty-manager": path.join(stubsDir, "pty-manager.cjs"),
119+
sharp: path.join(stubsDir, "sharp.cjs"),
120+
canvas: path.join(stubsDir, "canvas.cjs"),
121+
};
122+
123+
// Optional @elizaos plugins that the agent runtime statically references but
124+
// transitively pull in old/incompatible `@elizaos/core` versions. Stubbing
125+
// them keeps the bundle from carrying multiple AgentRuntime classes (the
126+
// failure mode is: plugin-sql's adapter exposes methods one runtime expects
127+
// but the OTHER runtime doesn't, then `getAgentsByIds is not a function` at
128+
// boot). The narrow list below is exactly the packages whose dependency
129+
// closure pulls in `@elizaos/core@2.0.0-alpha.3` or `2.0.0-alpha.223`.
130+
//
131+
// Other packages — including `@elizaos/app-task-coordinator`,
132+
// `@elizaos/app-companion`, `@elizaos/app-lifeops`, `@elizaos/app-training`
133+
// — are imported by `api/server.ts` as named functions (e.g.
134+
// `wireCoordinatorBridgesWhenReady`). Stubbing them with a Proxy doesn't
135+
// satisfy Bun's `__toESM` namespace builder (it iterates `ownKeys`), so we
136+
// let them bundle. The mobile plugin filter still strips them out of the
137+
// runtime load set, so they don't try to register at boot.
138+
const optionalPluginStubs = {
139+
"@elizaos/plugin-cron": path.join(stubsDir, "null-plugin.cjs"),
140+
"@elizaos/plugin-cli": path.join(stubsDir, "null-plugin.cjs"),
141+
};
142+
143+
const stubAliases = { ...nativeStubs, ...optionalPluginStubs };
144+
145+
const stubResolverPlugin = {
146+
name: "milady-mobile-stubs",
147+
setup(build) {
148+
const aliasNames = Object.keys(stubAliases);
149+
const filter = new RegExp(
150+
"^(?:" +
151+
aliasNames
152+
.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
153+
.join("|") +
154+
")(?:/.*)?$",
155+
);
156+
build.onResolve({ filter }, (args) => {
157+
// Match the longest alias that's a prefix of the importer.
158+
let best = null;
159+
for (const name of aliasNames) {
160+
if (
161+
(args.path === name || args.path.startsWith(`${name}/`)) &&
162+
(best === null || name.length > best.length)
163+
) {
164+
best = name;
165+
}
166+
}
167+
if (best === null) return undefined;
168+
return { path: stubAliases[best], namespace: "file" };
169+
});
170+
},
171+
};
172+
173+
// Force a single resolution for `@elizaos/core` and `@elizaos/shared`.
174+
//
175+
// `eliza/packages/agent/tsconfig.json` maps `@elizaos/core` to the source
176+
// at `../typescript/src/index.node.ts`, but `@elizaos/plugin-sql` (and other
177+
// plugin packages) compile against the prebuilt `dist/index.node.js`. Bun
178+
// then bundles BOTH copies, ending up with two distinct AgentRuntime classes
179+
// — the runtime instance receives an adapter from one copy and tries to
180+
// call methods that only exist on the other (`getAgentsByIds is not a
181+
// function`). Pin every `@elizaos/core` (and `@elizaos/shared`) import to
182+
// the same workspace `src/` entry so the bundle ships exactly one identity.
183+
const corePackages = [
184+
"@elizaos/core",
185+
"@elizaos/shared",
186+
"@elizaos/plugin-sql",
187+
];
188+
189+
const dedupeTargets = {
190+
"@elizaos/core": path.resolve(
191+
repoRoot,
192+
"eliza",
193+
"packages",
194+
"typescript",
195+
"src",
196+
"index.node.ts",
197+
),
198+
"@elizaos/shared": path.resolve(
199+
repoRoot,
200+
"eliza",
201+
"packages",
202+
"shared",
203+
"src",
204+
"index.ts",
205+
),
206+
// Pin plugin-sql to its src as well. The published `dist/node/index.node.js`
207+
// was compiled against an older `@elizaos/core` API (pre-`getAgentsByIds`),
208+
// so the bundled `BaseDrizzleAdapter` is missing methods the current runtime
209+
// depends on. Building from src against the same `@elizaos/core` source the
210+
// runtime uses keeps the adapter and the runtime in lockstep.
211+
"@elizaos/plugin-sql": path.resolve(
212+
repoRoot,
213+
"eliza",
214+
"plugins",
215+
"plugin-sql",
216+
"typescript",
217+
"index.node.ts",
218+
),
219+
};
220+
221+
for (const [pkg, target] of Object.entries(dedupeTargets)) {
222+
if (!existsSync(target)) {
223+
console.error(
224+
`[build-mobile] FATAL: dedupe target for ${pkg} not found: ${target}`,
225+
);
226+
process.exit(1);
227+
}
228+
}
229+
230+
const dedupePlugin = {
231+
name: "milady-mobile-core-dedupe",
232+
setup(build) {
233+
const filter = new RegExp(
234+
"^(?:" +
235+
corePackages
236+
.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
237+
.join("|") +
238+
")$",
239+
);
240+
build.onResolve({ filter }, (args) => {
241+
const target = dedupeTargets[args.path];
242+
if (!target) return undefined;
243+
return { path: target, namespace: "file" };
244+
});
245+
},
246+
};
247+
248+
console.log("[build-mobile] starting Bun.build...");
249+
const buildResult = await Bun.build({
250+
entrypoints: [entry],
251+
outdir: outDir,
252+
naming: "agent-bundle.js",
253+
target: "bun",
254+
format: "esm",
255+
// Don't minify. Bundling is already significant — this is a debugging step
256+
// to keep stack traces readable. Re-enable selectively if APK size matters.
257+
minify: false,
258+
define: {
259+
"process.env.MILADY_PLATFORM": JSON.stringify("android"),
260+
// Disable the `isDirectRun` self-invocation guard in the agent's
261+
// `runtime/eliza.ts`. After bundling, `import.meta.url` and
262+
// `process.argv[1]` both resolve to the same bundle path, so the guard
263+
// (intended to let `bun runtime/eliza.ts` run standalone) fires when the
264+
// CLI ALSO drives `startEliza`. Two concurrent boots fight over the API
265+
// port and the second one's stdin-driven chat REPL exits on EOF, taking
266+
// the whole process down. Defining the marker as `false` flattens the
267+
// branch at build time.
268+
"process.env.MILADY_DISABLE_DIRECT_RUN": JSON.stringify("1"),
269+
},
270+
plugins: [dedupePlugin, stubResolverPlugin],
271+
});
272+
273+
if (!buildResult.success) {
274+
console.error("[build-mobile] Bun.build failed:");
275+
for (const log of buildResult.logs) {
276+
console.error(" ", log.level, log.message, log.position);
277+
}
278+
process.exit(1);
279+
}
280+
281+
const bundlePath = path.join(outDir, "agent-bundle.js");
282+
if (!existsSync(bundlePath)) {
283+
console.error(
284+
"[build-mobile] FATAL: agent-bundle.js not produced at",
285+
bundlePath,
286+
);
287+
console.error(
288+
"[build-mobile] outputs reported:",
289+
buildResult.outputs.map((o) => o.path),
290+
);
291+
process.exit(1);
292+
}
293+
const bundleSize = (await stat(bundlePath)).size;
294+
console.log(
295+
`[build-mobile] bundle size: ${(bundleSize / 1024 / 1024).toFixed(2)} MB`,
296+
);
297+
298+
// Copy PGlite assets next to the bundle. The bundle's `import.meta.url` will
299+
// resolve to its location at runtime, and `new URL("./pglite.wasm", ...)`
300+
// lands here.
301+
for (const asset of ["pglite.wasm", "pglite.data"]) {
302+
const src = path.join(pgliteDist, asset);
303+
if (!existsSync(src)) {
304+
console.error(`[build-mobile] FATAL: missing ${asset} in ${pgliteDist}`);
305+
process.exit(1);
306+
}
307+
await copyFile(src, path.join(outDir, asset));
308+
const sz = (await stat(src)).size;
309+
console.log(
310+
`[build-mobile] copied ${asset} (${(sz / 1024 / 1024).toFixed(2)} MB)`,
311+
);
312+
}
313+
314+
// Copy contrib extension tarballs. They live one dir above the bundle on
315+
// device (Phase A handles placement); we surface them in dist-mobile/ so the
316+
// asset pipeline can pick them up.
317+
for (const asset of ["vector.tar.gz", "fuzzystrmatch.tar.gz"]) {
318+
const src = path.join(pgliteDist, asset);
319+
if (!existsSync(src)) {
320+
console.error(`[build-mobile] FATAL: missing ${asset} in ${pgliteDist}`);
321+
process.exit(1);
322+
}
323+
await copyFile(src, path.join(outDir, asset));
324+
const sz = (await stat(src)).size;
325+
console.log(`[build-mobile] copied ${asset} (${(sz / 1024).toFixed(1)} KB)`);
326+
}
327+
328+
const manifest = {
329+
generatedAt: new Date().toISOString(),
330+
bundle: "agent-bundle.js",
331+
bunTarget: "bun",
332+
platform: "android",
333+
pglite: {
334+
wasm: "pglite.wasm",
335+
data: "pglite.data",
336+
extensions: {
337+
vector: { file: "vector.tar.gz", expectedAt: "../vector.tar.gz" },
338+
fuzzystrmatch: {
339+
file: "fuzzystrmatch.tar.gz",
340+
expectedAt: "../fuzzystrmatch.tar.gz",
341+
},
342+
},
343+
},
344+
plugins: {
345+
core: ["@elizaos/plugin-sql"],
346+
optional: [
347+
"@elizaos/plugin-anthropic",
348+
"@elizaos/plugin-openai",
349+
"@elizaos/plugin-ollama",
350+
"@elizaos/plugin-elizacloud",
351+
],
352+
},
353+
externalsAsStubs: Object.keys(stubAliases),
354+
notes: [
355+
"All listed plugins are bundled via static imports in",
356+
" eliza/packages/agent/src/runtime/eliza.ts (STATIC_ELIZA_PLUGINS).",
357+
"The mobile runtime substitutes MOBILE_CORE_PLUGINS for CORE_PLUGINS",
358+
"when MILADY_PLATFORM=android.",
359+
],
360+
};
361+
await writeFile(
362+
path.join(outDir, "plugins-manifest.json"),
363+
JSON.stringify(manifest, null, 2),
364+
);
365+
console.log("[build-mobile] wrote plugins-manifest.json");
366+
367+
console.log("[build-mobile] done.");
368+
console.log("[build-mobile] outputs:");
369+
for (const file of (await readdir(outDir)).sort()) {
370+
const s = await stat(path.join(outDir, file));
371+
console.log(` ${file.padEnd(28)} ${(s.size / 1024).toFixed(1)} KB`);
372+
}

0 commit comments

Comments
 (0)