mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 18:01:30 +00:00
# Conflicts: # factory/packages/backend/src/driver.ts # factory/packages/backend/src/integrations/sandbox-agent/client.ts # factory/packages/backend/test/helpers/test-driver.ts # factory/packages/frontend/src/components/mock-layout.tsx # pnpm-lock.yaml # sdks/react/src/ProcessTerminal.tsx
421 lines
12 KiB
TypeScript
421 lines
12 KiB
TypeScript
import type { AgentType } from "@openhandoff/shared";
|
|
import type {
|
|
ListEventsRequest,
|
|
ListPage,
|
|
ListPageRequest,
|
|
ProcessCreateRequest,
|
|
ProcessInfo,
|
|
ProcessLogFollowQuery,
|
|
ProcessLogsResponse,
|
|
ProcessSignalQuery,
|
|
SessionEvent,
|
|
SessionPersistDriver,
|
|
SessionRecord
|
|
} from "sandbox-agent";
|
|
import { SandboxAgent } from "sandbox-agent";
|
|
|
|
export type AgentId = AgentType | "opencode";
|
|
|
|
export interface SandboxSession {
|
|
id: string;
|
|
status: "running" | "idle" | "error";
|
|
}
|
|
|
|
export interface SandboxSessionCreateRequest {
|
|
prompt?: string;
|
|
cwd?: string;
|
|
agent?: AgentId;
|
|
}
|
|
|
|
export interface SandboxSessionPromptRequest {
|
|
sessionId: string;
|
|
prompt: string;
|
|
notification?: boolean;
|
|
}
|
|
|
|
export interface SandboxAgentClientOptions {
|
|
endpoint: string;
|
|
token?: string;
|
|
agent?: AgentId;
|
|
persist?: SessionPersistDriver;
|
|
}
|
|
|
|
const DEFAULT_AGENT: AgentId = "codex";
|
|
|
|
function modeIdForAgent(agent: AgentId): string | null {
|
|
switch (agent) {
|
|
case "codex":
|
|
return "full-access";
|
|
case "claude":
|
|
return "acceptEdits";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeStatusFromMessage(payload: unknown): SandboxSession["status"] | null {
|
|
if (payload && typeof payload === "object") {
|
|
const envelope = payload as {
|
|
error?: unknown;
|
|
method?: unknown;
|
|
result?: unknown;
|
|
};
|
|
|
|
const maybeError = envelope.error;
|
|
if (maybeError) {
|
|
return "error";
|
|
}
|
|
|
|
if (envelope.result && typeof envelope.result === "object") {
|
|
const stopReason = (envelope.result as { stopReason?: unknown }).stopReason;
|
|
if (typeof stopReason === "string" && stopReason.length > 0) {
|
|
return "idle";
|
|
}
|
|
}
|
|
|
|
const method = envelope.method;
|
|
if (typeof method === "string") {
|
|
const lowered = method.toLowerCase();
|
|
if (lowered.includes("error") || lowered.includes("failed")) {
|
|
return "error";
|
|
}
|
|
if (lowered.includes("ended") || lowered.includes("complete") || lowered.includes("stopped")) {
|
|
return "idle";
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export class SandboxAgentClient {
|
|
readonly endpoint: string;
|
|
readonly token?: string;
|
|
readonly agent: AgentId;
|
|
readonly persist?: SessionPersistDriver;
|
|
private sdkPromise?: Promise<SandboxAgent>;
|
|
private readonly statusBySessionId = new Map<string, SandboxSession["status"]>();
|
|
|
|
constructor(options: SandboxAgentClientOptions) {
|
|
this.endpoint = options.endpoint.replace(/\/$/, "");
|
|
this.token = options.token;
|
|
this.agent = options.agent ?? DEFAULT_AGENT;
|
|
this.persist = options.persist;
|
|
}
|
|
|
|
private async sdk(): Promise<SandboxAgent> {
|
|
if (!this.sdkPromise) {
|
|
this.sdkPromise = SandboxAgent.connect({
|
|
baseUrl: this.endpoint,
|
|
token: this.token,
|
|
persist: this.persist,
|
|
});
|
|
}
|
|
|
|
return this.sdkPromise;
|
|
}
|
|
|
|
private setStatus(sessionId: string, status: SandboxSession["status"]): void {
|
|
this.statusBySessionId.set(sessionId, status);
|
|
}
|
|
|
|
private isLikelyPromptTimeout(err: unknown): boolean {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const lowered = message.toLowerCase();
|
|
// sandbox-agent server times out long-running ACP prompts and returns a 504-like error.
|
|
return lowered.includes("timeout waiting for agent response") || lowered.includes("timed out waiting for agent response") || lowered.includes("504");
|
|
}
|
|
|
|
async createSession(request: string | SandboxSessionCreateRequest): Promise<SandboxSession> {
|
|
const normalized: SandboxSessionCreateRequest = typeof request === "string" ? { prompt: request } : request;
|
|
const sdk = await this.sdk();
|
|
// Do not wrap createSession in a local Promise.race timeout. The underlying SDK
|
|
// call is not abortable, so local timeout races create overlapping ACP requests and
|
|
// can produce duplicate/orphaned sessions while the original request is still running.
|
|
const session = await sdk.createSession({
|
|
agent: normalized.agent ?? this.agent,
|
|
sessionInit: {
|
|
cwd: normalized.cwd ?? "/",
|
|
mcpServers: [],
|
|
},
|
|
});
|
|
const modeId = modeIdForAgent(normalized.agent ?? this.agent);
|
|
|
|
// Codex defaults to a restrictive "read-only" preset in some environments.
|
|
// For OpenHandoff automation we need to allow edits + command execution + network
|
|
// access (git push / PR creation). Use full-access where supported.
|
|
//
|
|
// If the agent doesn't support session modes, ignore.
|
|
//
|
|
// Do this in the background: ACP mode updates can occasionally time out (504),
|
|
// and waiting here can stall session creation long enough to trip handoff init
|
|
// step timeouts even though the session itself was created.
|
|
if (modeId) {
|
|
void session.rawSend("session/set_mode", { modeId }).catch(() => {
|
|
// ignore
|
|
});
|
|
}
|
|
|
|
const prompt = normalized.prompt?.trim();
|
|
if (!prompt) {
|
|
this.setStatus(session.id, "idle");
|
|
return {
|
|
id: session.id,
|
|
status: "idle",
|
|
};
|
|
}
|
|
|
|
// Fire the first turn in the background. We intentionally do not await this:
|
|
// session creation must remain fast, and we observe completion via events/stopReason.
|
|
//
|
|
// Note: sandbox-agent's ACP adapter for Codex may take >2 minutes to respond.
|
|
// sandbox-agent can return a timeout error (504) even though the agent continues
|
|
// running. Treat that timeout as non-fatal and keep polling events.
|
|
void session
|
|
.prompt([{ type: "text", text: prompt }])
|
|
.then(() => {
|
|
this.setStatus(session.id, "idle");
|
|
})
|
|
.catch((err) => {
|
|
if (this.isLikelyPromptTimeout(err)) {
|
|
this.setStatus(session.id, "running");
|
|
return;
|
|
}
|
|
this.setStatus(session.id, "error");
|
|
});
|
|
|
|
this.setStatus(session.id, "running");
|
|
return {
|
|
id: session.id,
|
|
status: "running",
|
|
};
|
|
}
|
|
|
|
async createSessionNoTask(dir: string): Promise<SandboxSession> {
|
|
return this.createSession({
|
|
cwd: dir,
|
|
});
|
|
}
|
|
|
|
async listSessions(request: ListPageRequest = {}): Promise<ListPage<SessionRecord>> {
|
|
const sdk = await this.sdk();
|
|
const page = await sdk.listSessions(request);
|
|
return {
|
|
items: page.items.map((session) => session.toRecord()),
|
|
nextCursor: page.nextCursor,
|
|
};
|
|
}
|
|
|
|
async listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>> {
|
|
const sdk = await this.sdk();
|
|
return sdk.getEvents(request);
|
|
}
|
|
|
|
async createProcess(request: ProcessCreateRequest): Promise<ProcessInfo> {
|
|
const sdk = await this.sdk();
|
|
return await sdk.createProcess(request);
|
|
}
|
|
|
|
async listProcesses(): Promise<{ processes: ProcessInfo[] }> {
|
|
const sdk = await this.sdk();
|
|
return await sdk.listProcesses();
|
|
}
|
|
|
|
async getProcessLogs(
|
|
processId: string,
|
|
query: ProcessLogFollowQuery = {}
|
|
): Promise<ProcessLogsResponse> {
|
|
const sdk = await this.sdk();
|
|
return await sdk.getProcessLogs(processId, query);
|
|
}
|
|
|
|
async stopProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo> {
|
|
const sdk = await this.sdk();
|
|
return await sdk.stopProcess(processId, query);
|
|
}
|
|
|
|
async killProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo> {
|
|
const sdk = await this.sdk();
|
|
return await sdk.killProcess(processId, query);
|
|
}
|
|
|
|
async deleteProcess(processId: string): Promise<void> {
|
|
const sdk = await this.sdk();
|
|
await sdk.deleteProcess(processId);
|
|
}
|
|
|
|
async sendPrompt(request: SandboxSessionPromptRequest): Promise<void> {
|
|
const sdk = await this.sdk();
|
|
const existing = await sdk.getSession(request.sessionId);
|
|
if (!existing) {
|
|
throw new Error(`session '${request.sessionId}' not found`);
|
|
}
|
|
|
|
const session = await sdk.resumeSession(request.sessionId);
|
|
const modeId = modeIdForAgent(this.agent);
|
|
// Keep mode update best-effort and non-blocking for the same reason as createSession.
|
|
if (modeId) {
|
|
void session.rawSend("session/set_mode", { modeId }).catch(() => {
|
|
// ignore
|
|
});
|
|
}
|
|
const text = request.prompt.trim();
|
|
if (!text) return;
|
|
|
|
// sandbox-agent's Session.send(notification=true) forwards an extNotification with
|
|
// method "session/prompt", which some agents (e.g. codex-acp) do not implement.
|
|
// Use Session.prompt and treat notification=true as "fire-and-forget".
|
|
const fireAndForget = request.notification ?? true;
|
|
if (fireAndForget) {
|
|
void session
|
|
.prompt([{ type: "text", text }])
|
|
.then(() => {
|
|
this.setStatus(request.sessionId, "idle");
|
|
})
|
|
.catch((err) => {
|
|
if (this.isLikelyPromptTimeout(err)) {
|
|
this.setStatus(request.sessionId, "running");
|
|
return;
|
|
}
|
|
this.setStatus(request.sessionId, "error");
|
|
});
|
|
} else {
|
|
try {
|
|
await session.prompt([{ type: "text", text }]);
|
|
this.setStatus(request.sessionId, "idle");
|
|
} catch (err) {
|
|
if (this.isLikelyPromptTimeout(err)) {
|
|
this.setStatus(request.sessionId, "running");
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
this.setStatus(request.sessionId, "running");
|
|
}
|
|
|
|
async cancelSession(sessionId: string): Promise<void> {
|
|
const sdk = await this.sdk();
|
|
const existing = await sdk.getSession(sessionId);
|
|
if (!existing) {
|
|
throw new Error(`session '${sessionId}' not found`);
|
|
}
|
|
|
|
const session = await sdk.resumeSession(sessionId);
|
|
await session.rawSend("session/cancel", {});
|
|
this.setStatus(sessionId, "idle");
|
|
}
|
|
|
|
async destroySession(sessionId: string): Promise<void> {
|
|
const sdk = await this.sdk();
|
|
await sdk.destroySession(sessionId);
|
|
this.setStatus(sessionId, "idle");
|
|
}
|
|
|
|
async sessionStatus(sessionId: string): Promise<SandboxSession> {
|
|
const cached = this.statusBySessionId.get(sessionId);
|
|
if (cached && cached !== "running") {
|
|
return { id: sessionId, status: cached };
|
|
}
|
|
|
|
const sdk = await this.sdk();
|
|
const session = await sdk.getSession(sessionId);
|
|
|
|
if (!session) {
|
|
this.setStatus(sessionId, "error");
|
|
return { id: sessionId, status: "error" };
|
|
}
|
|
|
|
const record = session.toRecord();
|
|
if (record.destroyedAt) {
|
|
this.setStatus(sessionId, "idle");
|
|
return { id: sessionId, status: "idle" };
|
|
}
|
|
|
|
const events = await sdk.getEvents({
|
|
sessionId,
|
|
limit: 25,
|
|
});
|
|
|
|
for (let i = events.items.length - 1; i >= 0; i--) {
|
|
const item = events.items[i];
|
|
if (!item) continue;
|
|
const status = normalizeStatusFromMessage(item.payload);
|
|
if (status) {
|
|
this.setStatus(sessionId, status);
|
|
return { id: sessionId, status };
|
|
}
|
|
}
|
|
|
|
this.setStatus(sessionId, "running");
|
|
return { id: sessionId, status: "running" };
|
|
}
|
|
|
|
async killSessionsInDirectory(dir: string): Promise<void> {
|
|
const sdk = await this.sdk();
|
|
let cursor: string | undefined;
|
|
|
|
do {
|
|
const page = await sdk.listSessions({
|
|
cursor,
|
|
limit: 100,
|
|
});
|
|
|
|
for (const session of page.items) {
|
|
const initCwd = session.toRecord().sessionInit?.cwd;
|
|
if (initCwd !== dir) {
|
|
continue;
|
|
}
|
|
await sdk.destroySession(session.id);
|
|
this.statusBySessionId.delete(session.id);
|
|
}
|
|
|
|
cursor = page.nextCursor;
|
|
} while (cursor);
|
|
}
|
|
|
|
async generateCommitMessage(dir: string, spec: string, task: string): Promise<string> {
|
|
const prompt = [
|
|
"Generate a conventional commit message for the following changes.",
|
|
"Return ONLY the commit message, no explanation or markdown formatting.",
|
|
"",
|
|
`Task: ${task}`,
|
|
"",
|
|
`Spec/diff:\n${spec}`,
|
|
].join("\n");
|
|
|
|
const sdk = await this.sdk();
|
|
const session = await sdk.createSession({
|
|
agent: this.agent,
|
|
sessionInit: {
|
|
cwd: dir,
|
|
mcpServers: [],
|
|
},
|
|
});
|
|
|
|
await session.prompt([{ type: "text", text: prompt }]);
|
|
this.setStatus(session.id, "idle");
|
|
|
|
const events = await sdk.getEvents({
|
|
sessionId: session.id,
|
|
limit: 100,
|
|
});
|
|
|
|
for (let i = events.items.length - 1; i >= 0; i--) {
|
|
const event = events.items[i];
|
|
if (!event) continue;
|
|
if (event.sender !== "agent") continue;
|
|
|
|
const payload = event.payload as Record<string, unknown>;
|
|
const params = payload.params;
|
|
if (!params || typeof params !== "object") continue;
|
|
|
|
const text = (params as { text?: unknown }).text;
|
|
if (typeof text === "string" && text.trim().length > 0) {
|
|
return text.trim();
|
|
}
|
|
}
|
|
|
|
throw new Error("sandbox-agent commit message response was empty");
|
|
}
|
|
}
|