Apply the bracketed Convex API module references required by the kebab-case module rename
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"run:zopu": "bun --env-file=../../.env flue run zopu"
|
||||
},
|
||||
"dependencies": {
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/env": "workspace:*",
|
||||
"@flue/runtime": "latest",
|
||||
"convex": "catalog:",
|
||||
|
||||
30
packages/agents/src/agents/project-manager.ts
Normal file
30
packages/agents/src/agents/project-manager.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
import type { AgentRouteHandler } from "@flue/runtime";
|
||||
|
||||
import { agentOs } from "../sandboxes/agent-os";
|
||||
import { createProjectTools } from "../tools/project";
|
||||
|
||||
export const description =
|
||||
"Works one repository issue inside an isolated AgentOS workspace.";
|
||||
|
||||
export const route: AgentRouteHandler = (_context, next) => next();
|
||||
|
||||
export default defineAgent(({ env, id }) => {
|
||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||
|
||||
return {
|
||||
cwd: "/workspace",
|
||||
description,
|
||||
instructions: `You are the issue-scoped project manager and coding agent.
|
||||
|
||||
Start every run by calling lookup_issue_context, reading issue.md, project.md, business.md, design.md, agent.md, work.md, steps.md, artifacts.md, signals.md, agent-manager.md, context.md, and card.md from the AgentOS workspace. Call report_work_status with working before editing.
|
||||
|
||||
Work only on the bound issue. Inspect existing files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant command or scenario in AgentOS, and preserve command evidence.
|
||||
|
||||
Before finishing, update the local work.md, steps.md, artifacts.md, and context.md files and publish each changed canonical artifact with publish_project_artifact. Report completed only after verification. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
|
||||
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
||||
sandbox: agentOs(env),
|
||||
tools: createProjectTools(id, env),
|
||||
};
|
||||
});
|
||||
234
packages/agents/src/sandboxes/agent-os.ts
Normal file
234
packages/agents/src/sandboxes/agent-os.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { createSandboxSessionEnv } from "@flue/runtime";
|
||||
import type { FileStat, SandboxApi, SandboxFactory } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import * as v from "valibot";
|
||||
|
||||
const execResultSchema = v.object({
|
||||
exitCode: v.number(),
|
||||
stderr: v.string(),
|
||||
stdout: v.string(),
|
||||
});
|
||||
|
||||
const statResultSchema = v.object({
|
||||
isDirectory: v.boolean(),
|
||||
isSymbolicLink: v.boolean(),
|
||||
mtimeMs: v.number(),
|
||||
size: v.number(),
|
||||
});
|
||||
|
||||
interface ExecuteOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
class AgentOsSandboxApi implements SandboxApi {
|
||||
readonly #actorKey: string[];
|
||||
readonly #client: ConvexHttpClient;
|
||||
readonly #daemonId: string;
|
||||
|
||||
constructor(
|
||||
client: ConvexHttpClient,
|
||||
daemonId: string,
|
||||
actorKey: readonly string[]
|
||||
) {
|
||||
this.#client = client;
|
||||
this.#daemonId = daemonId;
|
||||
this.#actorKey = [...actorKey];
|
||||
}
|
||||
|
||||
async #execute(
|
||||
method: string,
|
||||
args: unknown[],
|
||||
options: ExecuteOptions = {}
|
||||
): Promise<unknown> {
|
||||
const commandId = await this.#client.mutation(api.daemonCommands.enqueue, {
|
||||
actorKey: this.#actorKey,
|
||||
args,
|
||||
daemonId: this.#daemonId,
|
||||
method,
|
||||
});
|
||||
const timeoutMs = options.timeoutMs ?? 120_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (options.signal?.aborted) {
|
||||
// oxlint-disable-next-line no-await-in-loop -- cancellation must settle before the poll exits.
|
||||
await this.#client.mutation(api.daemonCommands.cancel, { commandId });
|
||||
throw options.signal.reason ?? new Error("AgentOS command aborted");
|
||||
}
|
||||
// oxlint-disable-next-line no-await-in-loop -- each query observes the next durable command state.
|
||||
const command = await this.#client.query(api.daemonCommands.get, {
|
||||
commandId,
|
||||
});
|
||||
if (!command) {
|
||||
throw new Error(`AgentOS command ${commandId} disappeared`);
|
||||
}
|
||||
if (command.status === "succeeded") {
|
||||
return command.result;
|
||||
}
|
||||
if (command.status === "failed" || command.status === "cancelled") {
|
||||
throw new Error(
|
||||
command.error ?? `AgentOS command ${method} ${command.status}`
|
||||
);
|
||||
}
|
||||
const { promise, resolve } = Promise.withResolvers<null>();
|
||||
setTimeout(() => resolve(null), 200);
|
||||
// oxlint-disable-next-line no-await-in-loop -- polling intentionally waits between sequential reads.
|
||||
await promise;
|
||||
}
|
||||
|
||||
await this.#client.mutation(api.daemonCommands.cancel, { commandId });
|
||||
throw new Error(`AgentOS command ${method} timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
return new TextDecoder().decode(await this.readFileBuffer(path));
|
||||
}
|
||||
|
||||
async readFileBuffer(path: string): Promise<Uint8Array> {
|
||||
const result = await this.#execute("readFile", [path]);
|
||||
if (result instanceof ArrayBuffer) {
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
if (ArrayBuffer.isView(result)) {
|
||||
return new Uint8Array(
|
||||
result.buffer.slice(
|
||||
result.byteOffset,
|
||||
result.byteOffset + result.byteLength
|
||||
)
|
||||
);
|
||||
}
|
||||
throw new TypeError(
|
||||
`AgentOS readFile returned non-binary data for ${path}`
|
||||
);
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string | Uint8Array): Promise<void> {
|
||||
const serialized =
|
||||
typeof content === "string" ? content : Uint8Array.from(content).buffer;
|
||||
await this.#execute("writeFile", [path, serialized]);
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<FileStat> {
|
||||
const result = v.parse(
|
||||
statResultSchema,
|
||||
await this.#execute("stat", [path])
|
||||
);
|
||||
return {
|
||||
isDirectory: result.isDirectory,
|
||||
isFile: !result.isDirectory && !result.isSymbolicLink,
|
||||
isSymbolicLink: result.isSymbolicLink,
|
||||
mtime: new Date(result.mtimeMs),
|
||||
size: result.size,
|
||||
};
|
||||
}
|
||||
|
||||
async readdir(path: string): Promise<string[]> {
|
||||
return v.parse(v.array(v.string()), await this.#execute("readdir", [path]));
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return v.parse(v.boolean(), await this.#execute("exists", [path]));
|
||||
}
|
||||
|
||||
async mkdir(
|
||||
path: string,
|
||||
options?: { readonly recursive?: boolean }
|
||||
): Promise<void> {
|
||||
if (!options?.recursive) {
|
||||
await this.#execute("mkdir", [path]);
|
||||
return;
|
||||
}
|
||||
const quotedPath = `'${path.replaceAll("'", `'"'"'`)}'`;
|
||||
const result = v.parse(
|
||||
execResultSchema,
|
||||
await this.#execute("exec", [`mkdir -p ${quotedPath}`])
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr || `Could not create ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
async rm(
|
||||
path: string,
|
||||
options?: { readonly force?: boolean; readonly recursive?: boolean }
|
||||
): Promise<void> {
|
||||
if (options?.force && !(await this.exists(path))) {
|
||||
return;
|
||||
}
|
||||
await this.#execute("deleteFile", [
|
||||
path,
|
||||
{ recursive: options?.recursive },
|
||||
]);
|
||||
}
|
||||
|
||||
async exec(
|
||||
command: string,
|
||||
options?: {
|
||||
readonly cwd?: string;
|
||||
readonly env?: Record<string, string>;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
): Promise<{ exitCode: number; stderr: string; stdout: string }> {
|
||||
return v.parse(
|
||||
execResultSchema,
|
||||
await this.#execute(
|
||||
"exec",
|
||||
[
|
||||
command,
|
||||
{
|
||||
...(options?.cwd === undefined ? {} : { cwd: options.cwd }),
|
||||
...(options?.env === undefined ? {} : { env: options.env }),
|
||||
},
|
||||
],
|
||||
{ signal: options?.signal, timeoutMs: options?.timeoutMs }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const agentOs = (
|
||||
runtimeEnv: Record<string, string | undefined>
|
||||
): SandboxFactory => {
|
||||
const env = parseAgentEnv(runtimeEnv);
|
||||
const client = new ConvexHttpClient(env.CONVEX_URL);
|
||||
|
||||
return {
|
||||
async createSessionEnv({ id }) {
|
||||
// Flue controls this id from the issue document selected by the web app.
|
||||
const issueId = id as Id<"projectIssues">;
|
||||
const run = await client.mutation(api.agentWorkspace.ensureRun, {
|
||||
daemonId: env.DAEMON_ID,
|
||||
issueId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
if (!run) {
|
||||
throw new Error(`Could not initialize AgentOS work run for ${id}`);
|
||||
}
|
||||
const sandbox = new AgentOsSandboxApi(
|
||||
client,
|
||||
env.DAEMON_ID,
|
||||
run.actorKey
|
||||
);
|
||||
const context = await client.query(api.agentWorkspace.get, {
|
||||
issueId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
await sandbox.mkdir("/workspace", { recursive: true });
|
||||
await Promise.all(
|
||||
context.artifacts.map((artifact) =>
|
||||
sandbox.writeFile(`/workspace/${artifact.path}`, artifact.content)
|
||||
)
|
||||
);
|
||||
await sandbox.writeFile(
|
||||
"/workspace/issue.md",
|
||||
`# Issue ${context.issue.number}: ${context.issue.title}\n\n${context.issue.body}\n`
|
||||
);
|
||||
return createSandboxSessionEnv(sandbox, "/workspace");
|
||||
},
|
||||
};
|
||||
};
|
||||
70
packages/agents/src/tools/project.ts
Normal file
70
packages/agents/src/tools/project.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { ARTIFACT_PATHS } from "@code/backend/convex/artifactModel";
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineTool } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import * as v from "valibot";
|
||||
|
||||
export const createProjectTools = (
|
||||
issueAgentId: string,
|
||||
runtimeEnv: Record<string, string | undefined>
|
||||
) => {
|
||||
const env = parseAgentEnv(runtimeEnv);
|
||||
const client = new ConvexHttpClient(env.CONVEX_URL);
|
||||
// Flue receives this controlled id from the issue card that starts the run.
|
||||
const issueId = issueAgentId as Id<"projectIssues">;
|
||||
|
||||
return [
|
||||
defineTool({
|
||||
description:
|
||||
"Read the repository, issue, and canonical project artifacts bound to this agent.",
|
||||
name: "lookup_issue_context",
|
||||
async run() {
|
||||
return await client.query(api.agentWorkspace.get, {
|
||||
issueId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
},
|
||||
}),
|
||||
defineTool({
|
||||
description:
|
||||
"Publish the complete current content of one canonical project markdown artifact after changing it in AgentOS.",
|
||||
input: v.object({
|
||||
content: v.pipe(v.string(), v.maxLength(200_000)),
|
||||
path: v.picklist(ARTIFACT_PATHS),
|
||||
}),
|
||||
name: "publish_project_artifact",
|
||||
async run({ input }) {
|
||||
const revision = await client.mutation(
|
||||
api.agentWorkspace.updateArtifact,
|
||||
{
|
||||
content: input.content,
|
||||
issueId,
|
||||
path: input.path,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
}
|
||||
);
|
||||
return { path: input.path, revision };
|
||||
},
|
||||
}),
|
||||
defineTool({
|
||||
description:
|
||||
"Report the durable issue state. Use needs-input before asking a blocking question and completed only after verification.",
|
||||
input: v.object({
|
||||
status: v.picklist(["working", "needs-input", "completed", "failed"]),
|
||||
summary: v.pipe(v.string(), v.maxLength(4000)),
|
||||
}),
|
||||
name: "report_work_status",
|
||||
async run({ input }) {
|
||||
await client.mutation(api.agentWorkspace.setStatus, {
|
||||
issueId,
|
||||
status: input.status,
|
||||
summary: input.summary,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
return { recorded: true, status: input.status };
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
Reference in New Issue
Block a user