Restore local agent work after sai merge
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/env": "workspace:*",
|
||||
"@flue/runtime": "latest",
|
||||
"@rivet-dev/agentos-core": "^0.2.10",
|
||||
"convex": "catalog:",
|
||||
"hono": "^4.12.30",
|
||||
"valibot": "^1.4.2"
|
||||
|
||||
91
packages/agents/scripts/agentos-adapter-test.ts
Normal file
91
packages/agents/scripts/agentos-adapter-test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Smoke test: exercise the agentos sandbox adapter through Flue's
|
||||
* createSandboxSessionEnv wrapper — the same path the zopu agent uses.
|
||||
*
|
||||
* Run: bun --env-file=../../.env packages/agents/scripts/agentos-adapter-test.ts
|
||||
*/
|
||||
import { agentos } from "../src/sandboxes/agentos";
|
||||
|
||||
const main = async () => {
|
||||
const factory = agentos();
|
||||
|
||||
// Flue calls this once per harness initialization.
|
||||
const env = await factory.createSessionEnv({ id: "smoke-test" });
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const assert = (label: string, condition: boolean) => {
|
||||
if (condition) {
|
||||
pass += 1;
|
||||
console.log(` ✓ ${label}`);
|
||||
} else {
|
||||
fail += 1;
|
||||
console.log(` ✗ ${label}`);
|
||||
}
|
||||
};
|
||||
|
||||
console.log("\n=== File operations ===");
|
||||
|
||||
await env.writeFile("test.txt", "hello from Flue adapter");
|
||||
assert("writeFile", true);
|
||||
|
||||
const content = await env.readFile("test.txt");
|
||||
assert(
|
||||
"readFile returns written content",
|
||||
content === "hello from Flue adapter"
|
||||
);
|
||||
|
||||
const stat = await env.stat("test.txt");
|
||||
assert("stat isFile", stat.isFile === true);
|
||||
assert("stat size", stat.size === "hello from Flue adapter".length);
|
||||
|
||||
assert("exists true", (await env.exists("test.txt")) === true);
|
||||
assert("exists false for missing", (await env.exists("nope.txt")) === false);
|
||||
|
||||
console.log("\n=== Directory operations ===");
|
||||
|
||||
await env.mkdir("subdir", { recursive: true });
|
||||
await env.writeFile("subdir/a.ts", "export const a = 1;");
|
||||
await env.writeFile("subdir/b.ts", "export const b = 2;");
|
||||
const entries = await env.readdir("subdir");
|
||||
assert(
|
||||
"readdir returns names",
|
||||
entries.length === 2 && entries.includes("a.ts")
|
||||
);
|
||||
|
||||
await env.rm("subdir/b.ts");
|
||||
const after = await env.readdir("subdir");
|
||||
assert("rm removes file", after.length === 1 && after[0] === "a.ts");
|
||||
|
||||
await env.rm("subdir", { recursive: true });
|
||||
assert("rm recursive removes dir", (await env.exists("subdir")) === false);
|
||||
|
||||
console.log("\n=== Shell exec ===");
|
||||
|
||||
const echoRes = await env.exec("echo 'adapter shell works'");
|
||||
assert("exec echo exit code", echoRes.exitCode === 0);
|
||||
assert("exec echo stdout", echoRes.stdout.trim() === "adapter shell works");
|
||||
|
||||
const pipeRes = await env.exec("echo 'hello' | tr a-z A-Z");
|
||||
assert("exec pipe", pipeRes.stdout.trim() === "HELLO");
|
||||
|
||||
const lsRes = await env.exec("ls", { cwd: "/workspace" });
|
||||
assert("exec with cwd", lsRes.stdout.includes("test.txt"));
|
||||
|
||||
const envRes = await env.exec("echo $TEST_VAR", {
|
||||
env: { TEST_VAR: "injected" },
|
||||
});
|
||||
assert("exec with env", envRes.stdout.trim() === "injected");
|
||||
|
||||
console.log(`\n=== ${pass} passed, ${fail} failed ===`);
|
||||
if (fail > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
142
packages/agents/scripts/agentos-sandbox-ref.ts
Normal file
142
packages/agents/scripts/agentos-sandbox-ref.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Reference: agentOS VM as a sandbox backend for Flue.
|
||||
*
|
||||
* This script demonstrates the core idea behind a Flue sandbox adapter that
|
||||
* delegates file/shell operations to an agentOS VM. It does NOT wire into the
|
||||
* Flue agent yet — it proves the primitives work end-to-end so you can see
|
||||
* exactly how the adapter will map calls.
|
||||
*
|
||||
* Run: bun --env-file=../../.env packages/agents/scripts/agentos-sandbox-ref.ts
|
||||
*
|
||||
* Architecture:
|
||||
*
|
||||
* Flue agent (host) agentOS VM (isolated Wasm+V8)
|
||||
* ┌─────────────────────┐ ┌──────────────────────┐
|
||||
* │ defineAgent(...) │ │ Linux-like sandbox │
|
||||
* │ sandbox: agentOs()│── readFile ──► │ persistent FS │
|
||||
* │ │── writeFile ─► │ shell (sh, coreutils)│
|
||||
* │ │── exec ──────► │ ~6ms cold start │
|
||||
* └─────────────────────┘ └──────────────────────┘
|
||||
*
|
||||
* The Flue SandboxApi maps 1:1 to AgentOs methods:
|
||||
*
|
||||
* Flue SandboxApi AgentOs (core SDK)
|
||||
* ───────────────────────── ──────────────────────────
|
||||
* readFile(path) vm.readFile(path)
|
||||
* readFileBuffer(path) vm.readFile(path)
|
||||
* writeFile(path, content) vm.writeFile(path, content)
|
||||
* stat(path) vm.stat(path)
|
||||
* readdir(path) vm.readdir(path)
|
||||
* exists(path) vm.exists(path)
|
||||
* mkdir(path) vm.mkdir(path)
|
||||
* rm(path) vm.remove(path)
|
||||
* exec(command, opts) vm.exec(command, opts)
|
||||
*/
|
||||
|
||||
import { AgentOs } from "@rivet-dev/agentos-core";
|
||||
import type { VirtualStat } from "@rivet-dev/agentos-core";
|
||||
|
||||
const formatStat = (stat: VirtualStat): string =>
|
||||
JSON.stringify({
|
||||
isDirectory: stat.isDirectory,
|
||||
isSymbolicLink: stat.isSymbolicLink,
|
||||
mtime: new Date(stat.mtimeMs).toISOString(),
|
||||
size: stat.size,
|
||||
});
|
||||
|
||||
const main = async () => {
|
||||
console.log("=== Booting agentOS VM ===\n");
|
||||
|
||||
// AgentOs.create() boots a local VM with the default software bundle
|
||||
// (sh + coreutils). The VM sleeps when idle and wakes on the next action.
|
||||
const vm = await AgentOs.create();
|
||||
console.log("VM booted.\n");
|
||||
|
||||
// ── File operations ──────────────────────────────────────────────
|
||||
|
||||
console.log("=== writeFile ===");
|
||||
await vm.writeFile("/workspace/hello.txt", "Hello from agentOS VM!");
|
||||
console.log("wrote /workspace/hello.txt\n");
|
||||
|
||||
console.log("=== readFile ===");
|
||||
const content = await vm.readFile("/workspace/hello.txt");
|
||||
console.log("content:", new TextDecoder().decode(content), "\n");
|
||||
|
||||
console.log("=== stat ===");
|
||||
const stat = await vm.stat("/workspace/hello.txt");
|
||||
console.log("stat:", formatStat(stat), "\n");
|
||||
|
||||
console.log("=== exists ===");
|
||||
console.log(
|
||||
"exists /workspace/hello.txt:",
|
||||
await vm.exists("/workspace/hello.txt")
|
||||
);
|
||||
console.log(
|
||||
"exists /workspace/nope.txt:",
|
||||
await vm.exists("/workspace/nope.txt"),
|
||||
"\n"
|
||||
);
|
||||
|
||||
console.log("=== mkdir + readdir ===");
|
||||
await vm.mkdir("/workspace/src", { recursive: true });
|
||||
await vm.writeFile("/workspace/src/a.ts", "export const a = 1;");
|
||||
await vm.writeFile("/workspace/src/b.ts", "export const b = 2;");
|
||||
console.log(
|
||||
"readdir /workspace/src:",
|
||||
await vm.readdir("/workspace/src"),
|
||||
"\n"
|
||||
);
|
||||
|
||||
// ── Shell exec ───────────────────────────────────────────────────
|
||||
|
||||
console.log("=== exec: echo ===");
|
||||
const echoResult = await vm.exec("echo 'shell works!'");
|
||||
console.log(
|
||||
"stdout:",
|
||||
echoResult.stdout.trim(),
|
||||
`(exit ${echoResult.exitCode})\n`
|
||||
);
|
||||
|
||||
console.log("=== exec: pipe + coreutils ===");
|
||||
const pipeResult = await vm.exec("echo 'hello world' | tr a-z A-Z | wc -w");
|
||||
console.log(
|
||||
"stdout:",
|
||||
pipeResult.stdout.trim(),
|
||||
`(exit ${pipeResult.exitCode})\n`
|
||||
);
|
||||
|
||||
console.log("=== exec: with cwd + env ===");
|
||||
await vm.mkdir("/workspace/project", { recursive: true });
|
||||
await vm.writeFile("/workspace/project/file.txt", "data");
|
||||
const cwdResult = await vm.exec("pwd && ls", {
|
||||
cwd: "/workspace/project",
|
||||
env: { CUSTOM_VAR: "from-env" },
|
||||
});
|
||||
console.log(
|
||||
"stdout:",
|
||||
cwdResult.stdout.trim(),
|
||||
`(exit ${cwdResult.exitCode})\n`
|
||||
);
|
||||
|
||||
// ── Remove ───────────────────────────────────────────────────────
|
||||
|
||||
console.log("=== remove ===");
|
||||
await vm.remove("/workspace/src/b.ts");
|
||||
console.log(
|
||||
"after remove, readdir:",
|
||||
await vm.readdir("/workspace/src"),
|
||||
"\n"
|
||||
);
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────────
|
||||
|
||||
await vm.dispose();
|
||||
console.log("=== VM disposed. All primitives work. ===");
|
||||
};
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,22 +1,30 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
import type { AgentRouteHandler } from "@flue/runtime";
|
||||
// import { agentos } from "../sandboxes/agentos";
|
||||
import { local } from "@flue/runtime/node";
|
||||
|
||||
import paseo from "../skills/paseo/SKILL.md" with { type: "skill" };
|
||||
import { paseoCli } from "../tools/paseo";
|
||||
|
||||
const repositoryRoot = path.resolve(process.cwd(), "../..");
|
||||
|
||||
export const route: AgentRouteHandler = (_context, next) => next();
|
||||
|
||||
export default defineAgent(({ env }) => {
|
||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||
|
||||
return {
|
||||
cwd: repositoryRoot,
|
||||
description: "A simple conversational agent with persistent history.",
|
||||
instructions:
|
||||
"You are Zopu, a concise and helpful assistant. Answer the user's request directly. Ask a clarifying question only when the request cannot be completed without one.",
|
||||
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
||||
sandbox: local({ cwd: "/workspace/code" }),
|
||||
// sandbox: agentos(),
|
||||
sandbox: local({ cwd: repositoryRoot }),
|
||||
|
||||
skills: [paseo],
|
||||
tools: [paseoCli],
|
||||
};
|
||||
|
||||
121
packages/agents/src/sandboxes/agentos.ts
Normal file
121
packages/agents/src/sandboxes/agentos.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
createSandboxSessionEnv,
|
||||
SandboxOperationUnsupportedError,
|
||||
} from "@flue/runtime";
|
||||
import type {
|
||||
FileStat,
|
||||
SandboxApi,
|
||||
SandboxFactory,
|
||||
SessionEnv,
|
||||
} from "@flue/runtime";
|
||||
import { AgentOs } from "@rivet-dev/agentos-core";
|
||||
import type { VirtualStat } from "@rivet-dev/agentos-core";
|
||||
|
||||
class AgentOsSandboxApi implements SandboxApi {
|
||||
private readonly vm: AgentOs;
|
||||
|
||||
constructor(vm: AgentOs) {
|
||||
this.vm = vm;
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
const bytes = await this.vm.readFile(path);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
readFileBuffer(path: string): Promise<Uint8Array> {
|
||||
return this.vm.readFile(path);
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string | Uint8Array): Promise<void> {
|
||||
await this.vm.writeFile(path, content);
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<FileStat> {
|
||||
const s: VirtualStat = await this.vm.stat(path);
|
||||
return {
|
||||
isDirectory: s.isDirectory,
|
||||
isFile: !s.isDirectory && !s.isSymbolicLink,
|
||||
isSymbolicLink: s.isSymbolicLink,
|
||||
mtime: new Date(s.mtimeMs),
|
||||
size: s.size,
|
||||
};
|
||||
}
|
||||
|
||||
readdir(path: string): Promise<string[]> {
|
||||
return this.vm.readdir(path);
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.vm.exists(path);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
||||
await this.vm.mkdir(path, options);
|
||||
}
|
||||
|
||||
async rm(
|
||||
path: string,
|
||||
options?: { recursive?: boolean; force?: boolean }
|
||||
): Promise<void> {
|
||||
// agentOS remove has no `force` flag. Reject it per the adapter contract:
|
||||
// never ignore an option or leave its behavior provider-defined.
|
||||
if (options?.force) {
|
||||
throw new SandboxOperationUnsupportedError({
|
||||
operation: "rm",
|
||||
options: ["force"],
|
||||
provider: "agentos",
|
||||
});
|
||||
}
|
||||
await this.vm.remove(path, { recursive: options?.recursive });
|
||||
}
|
||||
|
||||
async exec(
|
||||
command: string,
|
||||
options?: {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
// agentOS exec takes timeout in ms, same unit as Flue's timeoutMs.
|
||||
// Forward directly — no rounding needed.
|
||||
const result = await this.vm.exec(command, {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
timeout: options?.timeoutMs,
|
||||
});
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr,
|
||||
stdout: result.stdout,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts an agentOS VM into Flue's sandbox contract.
|
||||
*
|
||||
* The VM boots lazily on the first operation and sleeps when idle. Files
|
||||
* persist for the VM's lifetime. Shell commands run in an isolated Wasm+V8
|
||||
* Linux environment with sh and coreutils.
|
||||
*/
|
||||
export const agentos = (): SandboxFactory => {
|
||||
let vm: AgentOs | null = null;
|
||||
|
||||
return {
|
||||
async createSessionEnv(): Promise<SessionEnv> {
|
||||
// Boot once per harness. Repeated calls reuse the same VM.
|
||||
if (!vm) {
|
||||
vm = await AgentOs.create();
|
||||
}
|
||||
const api = new AgentOsSandboxApi(vm);
|
||||
return createSandboxSessionEnv(api, "/workspace");
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import path from "node:path";
|
||||
|
||||
import { defineTool } from "@flue/runtime";
|
||||
import * as v from "valibot";
|
||||
|
||||
const repositoryRoot = path.resolve(process.cwd(), "../..");
|
||||
|
||||
export const paseoCli = defineTool({
|
||||
description:
|
||||
"Run the locally installed Paseo CLI. Pass every argument exactly as it should appear after the paseo executable.",
|
||||
@@ -14,18 +20,24 @@ export const paseoCli = defineTool({
|
||||
stdout: v.string(),
|
||||
}),
|
||||
async run({ input, signal }) {
|
||||
const process = Bun.spawn(["paseo", ...input.args], {
|
||||
cwd: "/workspace/code",
|
||||
const process = spawn("paseo", input.args, {
|
||||
cwd: repositoryRoot,
|
||||
signal,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const [exitCode, stderr, stdout] = await Promise.all([
|
||||
process.exited,
|
||||
new Response(process.stderr).text(),
|
||||
new Response(process.stdout).text(),
|
||||
]);
|
||||
const stderrChunks: Buffer[] = [];
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
|
||||
return { exitCode, stderr, stdout };
|
||||
process.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
|
||||
process.stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
|
||||
const [code] = await once(process, "close");
|
||||
const exitCode = typeof code === "number" ? code : 1;
|
||||
|
||||
return {
|
||||
exitCode,
|
||||
stderr: Buffer.concat(stderrChunks).toString(),
|
||||
stdout: Buffer.concat(stdoutChunks).toString(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user