Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions __tests__/dockerfile-cache-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { AGENT_BROWSER_IDLE_TIMEOUT_MS } from "@/lib/ai/tools/utils/agent-browser-runtime";

const dockerfilePath = resolve(process.cwd(), "docker/Dockerfile");
const dockerfile = readFileSync(dockerfilePath, "utf8");
Expand Down Expand Up @@ -81,6 +82,12 @@ describe("sandbox Dockerfile cache cleanup", () => {
expect(doctorIndex).toBeGreaterThan(cleanupIndex);
});

test("keeps the browser daemon timeout aligned with the Agent runtime", () => {
expect(dockerfile).toContain(
`ENV AGENT_BROWSER_IDLE_TIMEOUT_MS=${AGENT_BROWSER_IDLE_TIMEOUT_MS}`,
);
});

test("removes Go module and build caches after installing binaries", () => {
const goRun = findRun(
"github.com/projectdiscovery/interactsh/cmd/interactsh-client",
Expand Down
3 changes: 3 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,9 @@ ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
ENV AGENT_BROWSER_ARGS="--disable-blink-features=AutomationControlled,--no-first-run,--no-default-browser-check,--disable-dev-shm-usage,--no-sandbox,--lang=en-US"
ENV AGENT_BROWSER_SCREENSHOT_DIR=/home/user/agent-browser-screenshots
# Keep the browser daemon available across one maximum-length terminal command
# plus a five-minute buffer, then reclaim abandoned Chromium processes.
ENV AGENT_BROWSER_IDLE_TIMEOUT_MS=900000

RUN npm install -g --cache /tmp/npm-cache agent-browser@0.26.0 && \
rm -rf /tmp/npm-cache && \
Expand Down
18 changes: 18 additions & 0 deletions lib/__tests__/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,10 +442,28 @@ Commands run directly on the host OS "workstation" without Docker isolation. Be
expect(cloudPrompt).toContain(
"Invoke `agent-browser` directly through the terminal command tool",
);
expect(cloudPrompt).toContain(
"shuts down after 15 minutes without an agent-browser command",
);
expect(cloudPrompt).toContain(
"assume open tabs, in-memory browser state, and element refs are lost",
);
expect(cloudPrompt).toContain(
"reopen the URL and take a fresh snapshot instead of reusing old tabs or refs",
);
expect(cloudPrompt).toContain(
"authenticate again through the user-approved flow",
);
expect(cloudPrompt).toContain(
"Do not save cookies, local storage, or other authentication state to sandbox files",
);
expect(cloudPrompt).not.toContain("agent-browser state save");
expect(cloudPrompt).not.toContain("agent-browser --state");

for (const prompt of [localPrompt, askPrompt]) {
expect(prompt).not.toContain("<agent_browser>");
expect(prompt).not.toContain("agent-browser doctor --fix");
expect(prompt).not.toContain("authentication state to sandbox files");
expect(prompt).not.toContain(
"Invoke `agent-browser` directly through the terminal command tool",
);
Expand Down
81 changes: 81 additions & 0 deletions lib/ai/tools/__tests__/run-terminal-cmd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,86 @@ describe("run_terminal_cmd — PTY action dispatch", () => {
expect(detectAgentBrowserUsage("agent-browser-next open")).toBeNull();
});

test("injects the idle timeout only for cloud agent-browser commands", async () => {
const e2b = {
jupyterUrl: "http://fake",
sandboxId: "sandbox-browser-env",
setTimeout: jest.fn(async () => undefined),
isRunning: jest.fn(async () => true),
commands: {
run: jest.fn(async (command: string) => {
if (command === "echo ready") {
return { stdout: "ready\n", stderr: "", exitCode: 0 };
}

return {
pid: 4321,
stdout: "",
stderr: "",
wait: jest.fn(async () => ({
stdout: "done\n",
stderr: "",
exitCode: 0,
})),
kill: jest.fn(async () => true),
};
}),
},
};
const { context } = makeContext({ sandbox: e2b });
const tool = createRunTerminalCmd(context);

await runTool(tool, {
command: "agent-browser open https://example.com",
brief: "open a browser page",
is_background: false,
timeout: 5,
interactive: false,
});
await runTool(tool, {
command: "echo ok",
brief: "print a status",
is_background: false,
timeout: 5,
interactive: false,
});

const browserCall = e2b.commands.run.mock.calls.find(
([command]) => command === "agent-browser open https://example.com",
);
const unrelatedCall = e2b.commands.run.mock.calls.find(
([command]) => command === "echo ok",
);

expect(browserCall?.[1]).toMatchObject({
envs: { AGENT_BROWSER_IDLE_TIMEOUT_MS: "900000" },
});
expect(unrelatedCall?.[1]).not.toHaveProperty("envs");
});

test("injects the idle timeout into cloud interactive browser shells", async () => {
const fakeHandle = makeFakeHandle();
const e2b = makeFakeE2BSandbox();
mockCreateE2BPtyHandle.mockResolvedValue(fakeHandle);
const { context } = makeContext({ sandbox: e2b });

setTimeout(() => fakeHandle.resolveExit(0), 10);
await runTool(createRunTerminalCmd(context), {
command: "agent-browser snapshot -i",
brief: "inspect the browser page",
is_background: false,
timeout: 5,
interactive: true,
});

expect(mockCreateE2BPtyHandle).toHaveBeenCalledWith(
e2b,
expect.objectContaining({
envs: { AGENT_BROWSER_IDLE_TIMEOUT_MS: "900000" },
}),
);
});

test("regression: legacy schema {command, brief, is_background, timeout} still works", async () => {
// Use a non-E2B sandbox (sandboxKind !== "centrifugo" is NOT enough after
// the isE2BSandbox hardening — a sandbox with sandboxKind: "centrifugo" is
Expand Down Expand Up @@ -1413,6 +1493,7 @@ describe("run_terminal_cmd — PTY action dispatch", () => {
expect(JSON.stringify(mockPhEvent.mock.calls)).not.toContain(
"secret.example",
);
expect(nonE2B.commands.run.mock.calls[0]?.[1]).not.toHaveProperty("envs");
});

test("schema defaults action=exec and interactive=false when omitted", async () => {
Expand Down
54 changes: 54 additions & 0 deletions lib/ai/tools/__tests__/todo-write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,60 @@ describe("todo_write", () => {
});
});

it("skips exact normalized duplicate assistant todos and reports their ids", async () => {
const context = makeContext();
const result = await runTool(createTodoWrite(context), {
merge: false,
todos: [
{ id: "first", content: "Review auth flow", status: "in_progress" },
{
id: "duplicate",
content: " review AUTH flow ",
status: "pending",
},
{
id: "distinct",
content: "Review auth flow on mobile",
status: "pending",
},
],
});

expect(result).toMatchObject({
result: expect.stringContaining(
"Skipped new to-do IDs with exact duplicate normalized content matching an earlier item in this write or a preserved manual to-do: duplicate.",
),
skippedTodoIds: ["duplicate"],
counts: { completed: 0, total: 2 },
currentTodos: [
{ id: "first", content: "Review auth flow" },
{ id: "distinct", content: "Review auth flow on mobile" },
],
});
});

it("keeps manual todos and skips new assistant duplicates of them", async () => {
const context = makeContext([
{ id: "manual", content: "Review auth flow", status: "pending" },
]);
const result = await runTool(createTodoWrite(context), {
merge: true,
todos: [
{ id: "duplicate", content: "review auth flow", status: "pending" },
{ id: "new", content: "Verify remediation", status: "pending" },
],
});

expect(result).toMatchObject({
skippedTodoIds: ["duplicate"],
counts: { completed: 0, total: 2 },
currentTodos: [
{ id: "manual", content: "Review auth flow" },
{ id: "new", content: "Verify remediation" },
],
});
});

it("tracks unique todo changes without treating inherited todos as current-run work", async () => {
const context = makeContext([
{ id: "stale", content: "Old work", status: "pending" },
Expand Down
18 changes: 16 additions & 2 deletions lib/ai/tools/run-terminal-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ import {
stripAnsi,
peekExited,
} from "./utils/pty-wait-utils";
import { captureAgentBrowserUsage } from "./utils/agent-browser-usage";
import {
captureAgentBrowserUsage,
getAgentBrowserRuntimeEnv,
} from "./utils/agent-browser-usage";
import {
RUN_TERMINAL_DEFAULT_STREAM_TIMEOUT_SECONDS,
RUN_TERMINAL_MAX_TIMEOUT_SECONDS,
Expand Down Expand Up @@ -392,6 +395,9 @@ export const createRunTerminalCmd = (context: ToolContext) => {
interactive: true,
isBackground: false,
});
const agentBrowserEnv = isE2B
? getAgentBrowserRuntimeEnv(command)
: undefined;

// Factory is invoked BY `ptySessionManager.create` — this ensures
// that if the concurrency cap is hit, the factory is never called
Expand Down Expand Up @@ -420,6 +426,7 @@ export const createRunTerminalCmd = (context: ToolContext) => {
return createE2BPtyHandle(sandbox, {
cols,
rows,
envs: agentBrowserEnv,
});
},
});
Expand Down Expand Up @@ -1041,6 +1048,9 @@ export const createRunTerminalCmd = (context: ToolContext) => {
onStderr: forwardCommandOutput,
},
);
const agentBrowserEnv = isE2BSandbox(sandboxInstance)
? getAgentBrowserRuntimeEnv(command)
: undefined;
const runOptions = isCentrifugoSandbox(sandboxInstance)
? {
...commonOptions,
Expand All @@ -1052,7 +1062,11 @@ export const createRunTerminalCmd = (context: ToolContext) => {
},
}
: isE2BSandbox(sandboxInstance)
? { ...commonOptions, signal: abortSignal }
? {
...commonOptions,
...(agentBrowserEnv && { envs: agentBrowserEnv }),
signal: abortSignal,
}
: commonOptions;

// Determine if an error is a permanent command failure (don't retry)
Expand Down
2 changes: 1 addition & 1 deletion lib/ai/tools/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ export const todoWriteToolInputSchema = z.object({
)
.min(1)
.describe(
"Array of todo items to write to the workspace. For merge=false, new items should include content and status and replace the assistant-generated plan while preserving manually created todos. Partial items are treated as merge-style updates. For merge=true, existing items may be patched with partial updates, but new items should include content and status.",
"Array of todo items to write to the workspace. For merge=false, new items should include content and status and replace the assistant-generated plan while preserving manually created todos. Partial items are treated as merge-style updates. For merge=true, existing items may be patched with partial updates, but new items should include content and status. A new item whose exact normalized content matches an earlier new item in the same write or a preserved manual todo is skipped and reported by ID.",
),
});

Expand Down
26 changes: 20 additions & 6 deletions lib/ai/tools/todo-write.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { tool } from "ai";
import type { ToolContext, Todo } from "@/types";
import { todoWriteTool } from "./schemas";
import {
dedupeNewAssistantTodosByContent,
dedupeTodosById,
} from "@/lib/utils/todo-utils";

export const createTodoWrite = (context: ToolContext) => {
const { todoManager, assistantMessageId } = context;
Expand Down Expand Up @@ -30,12 +34,17 @@ export const createTodoWrite = (context: ToolContext) => {
t.status === null,
);

const existingTodoIds = new Set(
todoManager.getAllTodos().map((todo) => todo.id),
);
const existingTodos = todoManager.getAllTodos();
const existingTodoIds = new Set(existingTodos.map((todo) => todo.id));
const uniqueTodos = dedupeTodosById(todos);
const { todos: contentDedupedTodos, skippedTodoIds } =
dedupeNewAssistantTodosByContent(uniqueTodos, {
existingTodoIds: shouldMerge ? existingTodoIds : new Set<string>(),
manualTodos: existingTodos.filter((todo) => !todo.sourceMessageId),
});
const todosWithSourceMessageId: Array<Partial<Todo> & { id: string }> =
assistantMessageId
? todos.map((todo) => {
? contentDedupedTodos.map((todo) => {
const isNewCompleteMergeTodo =
shouldMerge &&
!existingTodoIds.has(todo.id) &&
Expand All @@ -48,7 +57,7 @@ export const createTodoWrite = (context: ToolContext) => {
? { ...todo, sourceMessageId: assistantMessageId }
: todo;
})
: todos;
: contentDedupedTodos;

// Update backend state first (TodoManager handles deduplication)
const updatedTodos = todoManager.setTodos(
Expand All @@ -74,13 +83,18 @@ export const createTodoWrite = (context: ToolContext) => {
}));

return {
result: `Successfully ${action} to-dos. Make sure to follow and update your to-do list as you make progress. Cancel and add new to-do tasks as needed when the user makes a correction or follow-up request.${
result: `Successfully ${action} to-dos.${
skippedTodoIds.length > 0
? ` Skipped new to-do IDs with exact duplicate normalized content matching an earlier item in this write or a preserved manual to-do: ${skippedTodoIds.join(", ")}.`
: ""
} Make sure to follow and update your to-do list as you make progress. Cancel and add new to-do tasks as needed when the user makes a correction or follow-up request.${
stats.inProgress === 0
? " No to-dos are marked in-progress, make sure to mark them before starting the next."
: ""
}`,
counts,
currentTodos,
skippedTodoIds,
};
} catch (error) {
return {
Expand Down
8 changes: 8 additions & 0 deletions lib/ai/tools/utils/agent-browser-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { MAX_COMMAND_EXECUTION_TIME } from "./sandbox-command-options";

const AGENT_BROWSER_IDLE_GRACE_MS = 5 * 60 * 1000;

// Allow one maximum-length foreground command plus a short handoff window
// before reclaiming a browser daemon that receives no further commands.
export const AGENT_BROWSER_IDLE_TIMEOUT_MS =
MAX_COMMAND_EXECUTION_TIME + AGENT_BROWSER_IDLE_GRACE_MS;
11 changes: 11 additions & 0 deletions lib/ai/tools/utils/agent-browser-usage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AnySandbox, SandboxType, ToolContext } from "@/types";
import { phLogger } from "@/lib/posthog/server";
import { isCentrifugoSandbox, isE2BSandbox } from "./sandbox-types";
import { AGENT_BROWSER_IDLE_TIMEOUT_MS } from "./agent-browser-runtime";

const AGENT_BROWSER_INVOCATION_RE =
/(?:^|[;&|()]\s*)(?:(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;&|()]+)\s+)*)?(?:npx\s+(?:--yes\s+|-y\s+)?)?agent-browser(?:@[^\s;&|()]+)?(?=$|\s|[;&|()])(?:\s+([^\s;&|()]+))?/g;
Expand Down Expand Up @@ -99,6 +100,16 @@ export function detectAgentBrowserUsage(
};
}

export function getAgentBrowserRuntimeEnv(
command: string,
): Record<string, string> | undefined {
if (!detectAgentBrowserUsage(command)) return undefined;

return {
AGENT_BROWSER_IDLE_TIMEOUT_MS: String(AGENT_BROWSER_IDLE_TIMEOUT_MS),
};
}

function getAgentBrowserSandboxType(
context: ToolContext,
sandbox: AnySandbox,
Expand Down
4 changes: 4 additions & 0 deletions lib/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ Useful reading commands:
- \`agent-browser get text @e1\`, \`agent-browser get attr @e1 href\`, \`agent-browser get url\`, and \`agent-browser get title\` for targeted extraction.
- Use semantic locators such as \`agent-browser find role button click --name "Submit"\` when a snapshot ref is unavailable.

Session lifetime:
- The cloud browser shuts down after 15 minutes without an agent-browser command. The next command starts a new browser, so assume open tabs, in-memory browser state, and element refs are lost; reopen the URL and take a fresh snapshot instead of reusing old tabs or refs.
- If login state is lost after relaunch, authenticate again through the user-approved flow. Do not save cookies, local storage, or other authentication state to sandbox files for idle recovery because a user's cloud sandbox can be reused across Agent runs.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Recovery:
- For daemon, socket, connection, or browser-not-running failures, run \`agent-browser doctor\`; use \`agent-browser doctor --fix\` only when the diagnosis identifies a repairable problem, then reopen the page and retry.
- For malformed command syntax, correct the command. For stale or invalid element refs, run a fresh \`agent-browser snapshot -i\`; do not blindly retry the same failing action.
Expand Down
Loading