Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
# Preserve authenticated browser work 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
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
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
4 changes: 2 additions & 2 deletions lib/ai/tools/utils/__tests__/sandbox-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const listSandbox = (
{
sandboxId: "sandbox-1",
state: "running",
metadata: { sandboxVersion: "v12" },
metadata: { sandboxVersion: "v13" },
...overrides,
},
]),
Expand Down Expand Up @@ -257,7 +257,7 @@ describe("E2B sandbox lease lifecycle", () => {
const createdSandbox = { sandboxId: "sandbox-2" } as unknown as Sandbox;
listSandbox({
state: "paused",
metadata: { sandboxVersion: "v10" },
metadata: { sandboxVersion: "v12" },
});
sandboxApi.kill.mockResolvedValue(true);
sandboxApi.create.mockResolvedValue(createdSandbox);
Expand Down
3 changes: 2 additions & 1 deletion lib/ai/tools/utils/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ const logSandboxKillFailure = (
// v10: added whois, Chromium, and agent-browser browser automation
// v11: removed preinstalled interception CLI from the sandbox image
// v12: increased sandbox memory from 2GB to 4GB
const SANDBOX_VERSION = "v12";
// v13: reclaims abandoned agent-browser daemons after prolonged inactivity
const SANDBOX_VERSION = "v13";

/**
* Ensures a sandbox connection is established and maintained
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
52 changes: 52 additions & 0 deletions lib/utils/__tests__/todo-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect } from "@jest/globals";
import {
mergeTodos,
applyTodoWriteUpdate,
dedupeNewAssistantTodosByContent,
TodoUpdateError,
hasPartialTodos,
shouldTreatAsMerge,
Expand All @@ -18,6 +19,57 @@ import {
import type { Todo } from "@/types";

describe("todo-utils", () => {
describe("dedupeNewAssistantTodosByContent", () => {
it("skips only exact normalized new duplicates and preserves manual content", () => {
const result = dedupeNewAssistantTodosByContent(
[
{ id: "manual-copy", content: "Manual task", status: "pending" },
{ id: "first", content: "Review auth flow", status: "pending" },
{
id: "duplicate",
content: " review AUTH flow ",
status: "in_progress",
},
{
id: "distinct",
content: "Review auth flow on mobile",
status: "pending",
},
],
{
manualTodos: [
{ id: "manual", content: "manual task", status: "pending" },
],
},
);

expect(result).toEqual({
todos: [
{ id: "first", content: "Review auth flow", status: "pending" },
{
id: "distinct",
content: "Review auth flow on mobile",
status: "pending",
},
],
skippedTodoIds: ["manual-copy", "duplicate"],
});
});

it("does not suppress updates to existing todo ids", () => {
const result = dedupeNewAssistantTodosByContent(
[
{ id: "existing-a", content: "Same task", status: "completed" },
{ id: "existing-b", content: "same task", status: "cancelled" },
],
{ existingTodoIds: new Set(["existing-a", "existing-b"]) },
);

expect(result.todos).toHaveLength(2);
expect(result.skippedTodoIds).toEqual([]);
});
});

describe("mergeTodos", () => {
it("should merge new todos with existing ones", () => {
const currentTodos: Todo[] = [
Expand Down
44 changes: 44 additions & 0 deletions lib/utils/todo-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,50 @@ export const dedupeTodosById = <T extends { id: string }>(
return deduped.reverse();
};

const normalizeTodoContentForDeduplication = (content: string): string =>
content.normalize("NFKC").trim().replace(/\s+/g, " ").toLowerCase();

export const dedupeNewAssistantTodosByContent = <T extends TodoLike>(
todos: ReadonlyArray<T>,
options: {
existingTodoIds?: ReadonlySet<string>;
manualTodos?: ReadonlyArray<Todo>;
} = {},
): { todos: T[]; skippedTodoIds: string[] } => {
const existingTodoIds = options.existingTodoIds ?? new Set<string>();
const seenContent = new Set(
(options.manualTodos ?? []).map((todo) =>
normalizeTodoContentForDeduplication(todo.content),
),
);
const deduped: T[] = [];
const skippedTodoIds: string[] = [];

for (const todo of todos) {
if (
existingTodoIds.has(todo.id) ||
typeof todo.content !== "string" ||
todo.content.trim() === ""
) {
deduped.push(todo);
continue;
}

const normalizedContent = normalizeTodoContentForDeduplication(
todo.content,
);
if (seenContent.has(normalizedContent)) {
skippedTodoIds.push(todo.id);
continue;
}

seenContent.add(normalizedContent);
deduped.push(todo);
}

return { todos: deduped, skippedTodoIds };
};

export interface ApplyTodoWriteUpdateOptions {
currentTodos: Todo[];
incomingTodos: ReadonlyArray<TodoLike>;
Expand Down