Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
17 changes: 17 additions & 0 deletions lib/__tests__/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,10 +442,27 @@ 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(
"reopen the URL and take a fresh snapshot instead of reusing old tabs or refs",
);
expect(cloudPrompt).toContain("replace TASK_ID with a task-unique slug");
expect(cloudPrompt).toContain(
"agent-browser state save /home/user/agent-browser-state-TASK_ID.json",
);
expect(cloudPrompt).toContain(
"agent-browser --state /home/user/agent-browser-state-TASK_ID.json open <url>",
);
expect(cloudPrompt).toContain(
"Treat the state file as sensitive sandbox data and delete it when it is no longer needed",
);

for (const prompt of [localPrompt, askPrompt]) {
expect(prompt).not.toContain("<agent_browser>");
expect(prompt).not.toContain("agent-browser doctor --fix");
expect(prompt).not.toContain("agent-browser-state-TASK_ID.json");
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 exact duplicate to-do IDs: 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. New items with exact duplicate normalized content in one write are skipped and reported by ID.",
Comment thread
ross0x01 marked this conversation as resolved.
Outdated
),
});

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 exact duplicate to-do IDs: ${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 reopen the URL and take a fresh snapshot instead of reusing old tabs or refs.
- Before leaving a login-heavy flow idle, replace TASK_ID with a task-unique slug and save its cookies and local storage with \`agent-browser state save /home/user/agent-browser-state-TASK_ID.json\`. After a restart, reopen it with \`agent-browser --state /home/user/agent-browser-state-TASK_ID.json open <url>\`, then take a fresh snapshot. Treat the state file as sensitive sandbox data and delete it when it is no longer needed.

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