settings: a Dot has no goal and no browser settings; it is asked for what it does, and it manages its identities itself

The goal is gone from the schema, the engine's protocol and prompt, the header, the cards, the search, the
forms and the CLI sample; the first messages offered no longer draw on it. Migration 0010 takes it out of
stored configs and moves their version.

The Browser panel is gone with what it set: managed_by_dot, max_identities and max_open. The engine keeps
its own limits (3 open, 20 in all, DEFAULT_MAX_OPEN and DEFAULT_MAX_IDENTITIES in main.py), fixed when the
manager is made, and offers every identity tool its permission allows. Migration 0011 takes the section out
of stored configs. The limit message says "the most it may keep" instead of "the most its configuration
allows". The real e2e's eviction step launches a fourth identity instead of lowering max_open to 2.

Test floors are lowered by what went with the features: pytest 2231 to 2217 (the set_limits, managed_by_dot
and goal tests), vitest 2125 to 2124 on Windows and 2122 to 2121 on Linux (three first-message tests about the goal
became one and the max_open rule's test went; the two migration tests came).
This commit is contained in:
feder-cr committed 2026-10-07 03:21:44 +02:00
1 parent 23c130b10a
commit bb37a3a105
90 files changed
+334 -987

No files matched your search

+3 -3
View File
@@ -1,8 +1,8 @@
{
"//": "What each suite must reach on each host, read by .github/scripts/test-guard.mjs in CI (tests.yml) and in the pre-push hook. Raise a floor when tests are added; lowering one is a decision to write down in the commit that does it. Linux skips only the tests that drive a real Windows PowerShell; a Windows host compiles dot-agentd's unix-only test files out and skips the two Go tests that need a Linux guest. The playwright floor is the web client's browser tests (the web job of tests.yml; the same eighty-six run on a Windows host, which is where they are developed). The pytest floor is the Python engine's (invisible_engine_dots, the engine job of tests.yml, Linux only, nothing may skip). The whatsapp floor is the suite that needs the real WhatsApp client, an opt-in install (npm run test:whatsapp, the whatsapp-client job of tests.yml, which installs the client and runs it with PostgreSQL, so the pg runs of the auth state count); a host without PostgreSQL reaches the win32 number.",
"vitest": {
"linux": { "min_passed": 2122, "allow_skipped": "^generated PowerShell on a real Windows PowerShell " },
"win32": { "min_passed": 2125, "allow_skipped": "" }
"linux": { "min_passed": 2121, "allow_skipped": "^generated PowerShell on a real Windows PowerShell " },
"win32": { "min_passed": 2124, "allow_skipped": "" }
},
"postgres": {
"linux": { "min_passed": 699, "allow_skipped": "", "require": { "\\bpg\\b": 272 } }
@@ -16,7 +16,7 @@
"win32": { "min_passed": 70, "allow_skipped": "^agentd (TestSystemRoute|TestPowerOffStartsTheCommandAndAnswers202)$" }
},
"pytest": {
"linux": { "min_passed": 2231, "allow_skipped": "" }
"linux": { "min_passed": 2217, "allow_skipped": "" }
},
"playwright": {
"linux": { "min_passed": 86, "allow_skipped": "" },
+9 -9
View File
@@ -28,7 +28,7 @@ files, memory and skills that outlast every task, and permissions you set.</p>
</p>
A **Dot** is a persistent AI agent with a computer of its own. You give it a
goal, a model and a set of permissions; invisible_dots gives it a QEMU virtual
name, a model and a set of permissions; invisible_dots gives it a QEMU virtual
machine on your own PC, with a disk that persists, a Linux desktop, a shell, its
own files, memory and skills, and browser identities that keep their cookies and
logins from one task to the next. You talk to it from a web UI, the command
@@ -197,7 +197,7 @@ node apps/cli/dist/invisible-dots.mjs logs my-first-dot
`secret openrouter` asks for the key (it is never a command-line argument, and
what you paste at the prompt is not shown on the screen). `init` writes
`dot.yaml`, the Dot's name, goal, model, resources and permissions, to edit
`dot.yaml`, the Dot's name, instructions, model, resources and permissions, to edit
before `create`. The first Dot takes a while to boot; the message waits in the
queue until it is ready. `logs` prints the Dot's events and keeps following new
ones until Ctrl+C (`--no-follow` prints and exits). It worked when `logs` shows
@@ -406,10 +406,10 @@ proxy cannot be reached, nothing leaves the VM. When a VM proxy and an identity
proxy are both set, the identity's proxy is reached through the VM's tunnel.
Launching is explicit: a page action on an identity that is not open fails, so
denying `browser.identity.launch` cannot be undone by navigating. At most
`max_open` identities are open at once (default 3, roughly 0.8 GB each), the
least recently used one closing first, and at most `max_identities` exist
(default 20). Screenshots go to the model and are never stored.
denying `browser.identity.launch` cannot be undone by navigating. At most 3
identities are open at once (roughly 0.8 GB each), the least recently used one
closing first, and at most 20 exist. Screenshots go to the model and are never
stored.
How well the browser holds up against bot checks is measured in
[invisible_playwright's README](https://github.com/feder-cr/invisible_playwright),
@@ -645,9 +645,9 @@ inside their VMs.
## Configuration
A Dot is one YAML file, checked by one schema: name, goal, instructions,
model, computer (cpu, memory, disk, idle timeout), browser identities,
permissions and limits. Every field and its range is in
A Dot is one YAML file, checked by one schema: name, instructions,
model, computer (cpu, memory, disk, idle timeout), permissions and
limits. Every field and its range is in
[architecture: Dot configuration](docs/architecture.md#7-dot-configuration);
`invisible-dots init` writes a sample.
+2 -2
View File
@@ -20,7 +20,7 @@ const REPORT: DoctorCheck[] = [
];
const yaml = (name: string, idle = "15m") =>
`name: ${name}\ngoal: watch fares\nmodel:\n provider: openrouter\n id: test/model\ncomputer:\n idle_timeout: ${idle}\n`;
`name: ${name}\nmodel:\n provider: openrouter\n id: test/model\ncomputer:\n idle_timeout: ${idle}\n`;
describe.each(testAdapters())("control-plane API (%s)", (kind) => {
let t: TestDatabase;
@@ -136,7 +136,7 @@ describe.each(testAdapters())("control-plane API (%s)", (kind) => {
});
it("create validates the config: 400 with the issues, 409 for a taken name", async () => {
const invalid = await api.createDot({ name: "Bad Name", goal: "x", model: { provider: "openrouter", id: "m" } }).catch((e) => e);
const invalid = await api.createDot({ name: "Bad Name", model: { provider: "openrouter", id: "m" } }).catch((e) => e);
expect(invalid).toBeInstanceOf(ApiError);
expect(invalid).toMatchObject({ status: 400, code: "invalid_config" });
expect((invalid as ApiError).details).toEqual([expect.objectContaining({ path: "name" })]);
+1 -1
View File
@@ -14,7 +14,7 @@ const API_TOKEN = "test-token-0123456789abcdef";
const BOT_TOKEN = "123456:SECRET-TOKEN-VALUE";
const OTHER_BOT_TOKEN = "654321:OTHER-SECRET-TOKEN-VALUE";
const yaml = (name: string) => `name: ${name}\ngoal: watch fares\nmodel:\n provider: openrouter\n id: test/model\n`;
const yaml = (name: string) => `name: ${name}\nmodel:\n provider: openrouter\n id: test/model\n`;
describe.each(testAdapters())("channel routes of the control-plane API (%s)", { timeout: 60_000 }, (kind) => {
let t: TestDatabase;
+1 -1
View File
@@ -199,7 +199,7 @@ describe("startServer on an empty INVISIBLE_DOTS_HOME", { timeout: 120_000 }, ()
const dotId = newId("dot");
await first.db.dots.insert({
id: dotId,
config: parseDotConfig("name: channeled\ngoal: test goal\nmodel:\n provider: openrouter\n id: test/model\n"),
config: parseDotConfig("name: channeled\nmodel:\n provider: openrouter\n id: test/model\n"),
status: "DISABLED",
});
await first.channels.add(dotId, "telegram");
+7 -6
View File
@@ -11,7 +11,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestDatabase, type TestDatabase } from "@invisible-dots/database/testing";
import { prefixedStderrLogger, Scheduler } from "@invisible-dots/scheduler";
import { FakeGuest, waitFor } from "@invisible-dots/scheduler/testing";
import { FAKE_MAX_IDENTITIES, FakeGuest, waitFor } from "@invisible-dots/scheduler/testing";
import { computerResources, hostPaths, parseDotConfig, type HostPaths } from "@invisible-dots/shared";
import { silentLogger, VmManager } from "@invisible-dots/vm-manager";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -87,7 +87,7 @@ function manager(): VmManager {
}
function input(dotId = DOT, disk = "40gb") {
const config = parseDotConfig({ name: "drv", goal: "g", model: { provider: "openrouter", id: "m" }, computer: { disk } });
const config = parseDotConfig({ name: "drv", model: { provider: "openrouter", id: "m" }, computer: { disk } });
return { dotId, token: TOKEN, resources: computerResources(config) };
}
@@ -174,9 +174,10 @@ describe("VmManagerDriver over VmManager", () => {
expect((await client.listBrowserIdentities()).identities.map((i) => i.id)).toEqual([identity.id]);
expect((await client.getBrowserIdentity(identity.id)).name).toBe("Shop");
await expect(client.createBrowserIdentity({ name: "proxied", proxy: 8080 as never })).rejects.toMatchObject({ status: 400, code: "invalid" });
await client.putConfig({ ...guest!.config!, browser: { identities: { managed_by_dot: true, max_identities: 1, max_open: 1 } } } as never);
await expect(client.createBrowserIdentity({ name: "second" })).rejects.toMatchObject({ status: 409, code: "limit" });
await client.deleteBrowserIdentity(identity.id);
const more = [];
for (let n = 1; n < FAKE_MAX_IDENTITIES; n++) more.push(await client.createBrowserIdentity({ name: `extra-${n}` }));
await expect(client.createBrowserIdentity({ name: "one-too-many" })).rejects.toMatchObject({ status: 409, code: "limit" });
for (const made of [identity, ...more]) await client.deleteBrowserIdentity(made.id);
expect(await driver.stop(DOT, TOKEN)).toEqual({ forced: false });
expect(host.requests).toContain("POST /v1/system/poweroff");
@@ -234,7 +235,7 @@ describe("Scheduler over VmManagerDriver", () => {
it("create -> READY -> task over HTTP -> sleep -> restart of the control plane -> wake", { timeout: 30_000 }, async () => {
let s = scheduler();
const yaml = "name: real-path\ngoal: g\nmodel:\n provider: openrouter\n id: m\n";
const yaml = "name: real-path\nmodel:\n provider: openrouter\n id: m\n";
const dot = await s.createDot(yaml);
await waitFor(async () => (await t.db.dots.get(dot.id))?.status === "READY", "READY", 10_000);
const computer = await t.db.computers.get(dot.id);
+1 -1
View File
@@ -17,7 +17,7 @@ import { hostFacts } from "./host-facts.js";
const API_TOKEN = "test-token-0123456789abcdef";
const NUMBER = "15550001111";
const yaml = (name: string) => `name: ${name}\ngoal: watch fares\nmodel:\n provider: openrouter\n id: test/model\n`;
const yaml = (name: string) => `name: ${name}\nmodel:\n provider: openrouter\n id: test/model\n`;
describe.each(testAdapters())("WhatsApp routes of the control-plane API (%s)", { timeout: 60_000 }, (kind) => {
let t: TestDatabase;
-8
View File
@@ -126,8 +126,6 @@ Exit codes: 0 ok; 1 the server reported an error, a doctor check is not ok or a
export const SAMPLE_DOT = `# A Dot configuration (docs/architecture.md, section 7).
name: my-first-dot # lowercase letters, digits and '-', up to 40
goal: >
Keep a short daily summary of the front page of a news site in ~/workspace/news.md.
instructions: >
Be concise. Write findings to files in ~/workspace.
model:
@@ -138,11 +136,6 @@ computer:
memory: 4gb
disk: 40gb
idle_timeout: 15m # sleep after 15 minutes with nothing to do; 0 = never
browser:
identities:
managed_by_dot: true
max_identities: 20
max_open: 3
permissions:
computer.exec: allow
browser.identity.delete: ask
@@ -370,7 +363,6 @@ export async function run(argv: string[], io: CliIo): Promise<number> {
["id", dot.id],
["status", dot.status + (dot.error ? ` (${dot.error})` : "")],
["model", dot.config.model.id],
["goal", oneLine(dot.config.goal, 100)],
["computer", `${computer.state}${computer.ready ? ", ready" : ""}${computer.last_error ? ` (last error: ${computer.last_error})` : ""}`],
["resources", `${dot.config.computer.cpu} cpu, ${dot.config.computer.memory} memory, ${dot.config.computer.disk} disk, idle_timeout ${dot.config.computer.idle_timeout}`],
["last active", computer.last_active_at ?? "never"],
-1
View File
@@ -22,7 +22,6 @@ const dot = {
name: "fare-watch",
config: {
name: "fare-watch",
goal: "Check fares",
model: { provider: "openrouter", id: "test/model" },
computer: { cpu: 2, memory: "4gb", disk: "40gb", idle_timeout: "15m" },
},
+3 -3
View File
@@ -79,10 +79,10 @@ describe("serve", () => {
const running = h.run({ web: true });
await tick();
expect(h.log).toEqual(["server start env=given", "web start"]);
expect(h.webOptions[0]).toMatchObject({ listen: { host: "127.0.0.1", port: 3000 }, apiUrl: "http://127.0.0.1:8787" });
expect(h.webOptions[0]).toMatchObject({ listen: { host: "127.0.0.2", port: 3000 }, apiUrl: "http://127.0.0.1:8787" });
expect(h.webOptions[0]!.build.missing).toBeUndefined();
expect(h.lines).toContain("info API token in /home/x/.invisible-dots/config/api.token; stop with Ctrl+C (Dots keep running)");
expect(h.lines).toContain("info web client at http://127.0.0.1:3000; sign in with the API token");
expect(h.lines).toContain("info web client at http://127.0.0.2:3000");
h.signals.emit("SIGINT");
await running;
expect(h.log).toEqual(["server start env=given", "web start", "web stop", "server close"]);
@@ -107,7 +107,7 @@ describe("serve", () => {
it("names the web client's own default, not the API's, when INVISIBLE_DOTS_WEB_LISTEN is malformed", async () => {
const h = harness();
await expect(h.run({ web: true, env: { INVISIBLE_DOTS_WEB_LISTEN: "three-thousand" } })).rejects.toThrow(
'INVISIBLE_DOTS_WEB_LISTEN must look like "127.0.0.1:3000", got "three-thousand"',
'INVISIBLE_DOTS_WEB_LISTEN must look like "127.0.0.2:3000", got "three-thousand"',
);
});
+2 -2
View File
@@ -40,9 +40,9 @@ describe("setup --all", () => {
});
it("names the address the web client is configured to listen on", async () => {
expect(webAddress({})).toBe("http://127.0.0.1:3000");
expect(webAddress({})).toBe("http://127.0.0.2:3000");
expect(webAddress({ [ENV.WEB_LISTEN]: " 127.0.0.1:4100 " })).toBe("http://127.0.0.1:4100");
expect(webAddress({ [ENV.WEB_LISTEN]: "" })).toBe("http://127.0.0.1:3000");
expect(webAddress({ [ENV.WEB_LISTEN]: "" })).toBe("http://127.0.0.2:3000");
const result = await all({ env: { [ENV.WEB_LISTEN]: "127.0.0.1:4100" } });
expect(result.text).toContain("open http://127.0.0.1:4100 in your browser");
});
+7 -8
View File
@@ -102,6 +102,9 @@ function desktopPng(): Uint8Array {
}
/** The error shape of vm-manager's GuestRequestError: a status (0 = unreachable) and the guest's code. */
/** The most browser identities the engine keeps (`DEFAULT_MAX_IDENTITIES` in nanobot/dots/main.py). */
export const FAKE_MAX_IDENTITIES = 20;
export class FakeGuestError extends Error {
constructor(
readonly status: number,
@@ -167,8 +170,7 @@ export class FakeGuest implements GuestApi {
readonly automations = new Map<string, FakeAutomation>();
/**
* The tools the fake's engine has: the real table's shape, a few rows; `offered` follows the permissions of the
* config, and for a tool that creates an identity also `browser.identities.managed_by_dot`, as the engine's
* `offered_tools` does.
* config, as the engine's `offered_tools` does.
*/
readonly tools: Omit<ToolInfo, "offered">[] = [
{ name: "exec", permission: "computer.exec", description: "Run a shell command on the computer." },
@@ -424,8 +426,8 @@ export class FakeGuest implements GuestApi {
this.#reachable("createBrowserIdentity");
let checked: { name: string; proxy?: string };
try {
// The agent's own rules: before any config the schema default of 20 applies.
checked = checkIdentityRequest(body, this.identities.size, this.config?.browser.identities.max_identities ?? 20);
// The agent's own rules, with the engine's own limit (DEFAULT_MAX_IDENTITIES in nanobot/dots/main.py).
checked = checkIdentityRequest(body, this.identities.size, FAKE_MAX_IDENTITIES);
} catch (error) {
if (error instanceof IdentityRequestError) throw new FakeGuestError(error.code === "limit" ? 409 : 400, error.message, error.code);
throw error;
@@ -516,11 +518,8 @@ export class FakeGuest implements GuestApi {
async listTools(): Promise<ToolListAnswer> {
this.#reachable("listTools");
const permissions: Record<string, string | undefined> = this.config?.permissions ?? {};
const managed = this.config?.browser.identities.managed_by_dot ?? true;
const offered = (tool: Omit<ToolInfo, "offered">): boolean =>
this.config !== null &&
(permissions[tool.permission] === "allow" || permissions[tool.permission] === "ask") &&
(managed || tool.name !== "browser_identity_create");
this.config !== null && (permissions[tool.permission] === "allow" || permissions[tool.permission] === "ask");
return { tools: this.tools.map((tool) => ({ ...tool, offered: offered(tool) })) };
}
+1 -1
View File
@@ -13,7 +13,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { Scheduler } from "../src/index.js";
import { FakeDriver, ManualClock, waitFor, waitUntilSettledReady } from "../src/testing.js";
const yaml = (name: string) => `name: ${name}\ngoal: keep watch\nmodel:\n provider: openrouter\n id: test/model\n`;
const yaml = (name: string) => `name: ${name}\nmodel:\n provider: openrouter\n id: test/model\n`;
describe("the commit points of the host", () => {
let t: TestDatabase;
+5 -5
View File
@@ -12,8 +12,8 @@ import { Scheduler, type Logger, type SchedulerOptions } from "../src/index.js";
import type { GuestApi, GuestEndpoint } from "../src/index.js";
import { FakeDriver, FakeGuest, FakeGuestError, ManualClock, waitFor, waitUntilSettledReady } from "../src/testing.js";
const yaml = (name: string, goal = "keep watch") =>
`name: ${name}\ngoal: ${goal}\nmodel:\n provider: openrouter\n id: test/model\ncomputer:\n idle_timeout: 10m\n`;
const yaml = (name: string, instructions = "keep watch") =>
`name: ${name}\ninstructions: ${instructions}\nmodel:\n provider: openrouter\n id: test/model\ncomputer:\n idle_timeout: 10m\n`;
const KEY = "sk-or-delivery-test-key-0123456789";
@@ -291,11 +291,11 @@ describe.each(testAdapters())("delivery to guests (%s)", (kind) => {
scheduler.lifecycle.markSuspect(dot.id);
const ready = scheduler.lifecycle.ensureReady(dot.id);
await pushing.reached;
const update = await scheduler.updateDot(dot.id, yaml("late-config", "a brand new goal"));
expect(update.config.goal).toBe("a brand new goal");
const update = await scheduler.updateDot(dot.id, yaml("late-config", "a brand new instruction"));
expect(update.config.instructions).toBe("a brand new instruction");
pushing.open();
await ready;
expect(guest.config?.goal).toBe("a brand new goal");
expect(guest.config?.instructions).toBe("a brand new instruction");
});
it("a guest that echoes the key in a failed secret push never puts it in the log, the rows or the events", async () => {
+2 -6
View File
@@ -24,7 +24,6 @@ import { FakeGuest, type FakeAutomation } from "../src/testing.js";
interface OfferingCase {
permissions: Record<string, string>;
managed_identities: boolean;
offered: string[];
tools: unknown[];
}
@@ -33,13 +32,12 @@ const shapes = JSON.parse(
readFileSync(fileURLToPath(new URL("../../../invisible_engine_dots/tests/dots/wire_shapes.json", import.meta.url)), "utf8"),
) as { limits: { max_run_at_ms: number }; outbound_events: { type: string; data: Record<string, unknown> }[]; tool_offering: OfferingCase[]; skills: unknown[] };
const baseConfig = toRuntimeConfig(parseDotConfig("name: shapes\ngoal: check\nmodel:\n provider: openrouter\n id: test/model\n"));
const baseConfig = toRuntimeConfig(parseDotConfig("name: shapes\nmodel:\n provider: openrouter\n id: test/model\n"));
function configFor(offering: OfferingCase): DotRuntimeConfig {
return {
...baseConfig,
permissions: offering.permissions as DotRuntimeConfig["permissions"],
browser: { identities: { ...baseConfig.browser.identities, managed_by_dot: offering.managed_identities } },
};
}
@@ -131,10 +129,8 @@ describe("what the engine answers, as the host describes it", () => {
expect(tools.length).toBeGreaterThan(0);
for (const tool of tools) {
expect(engineRows.get(tool.name), `the engine has no tool ${tool.name}`).toBe(tool.permission);
expect(tool.offered, `${tool.name} with ${JSON.stringify(offering.permissions)}, managed identities ${offering.managed_identities}`).toBe(offering.offered.includes(tool.name));
expect(tool.offered, `${tool.name} with ${JSON.stringify(offering.permissions)}`).toBe(offering.offered.includes(tool.name));
}
}
// The identity switch is among what is compared: some case grants the tool that creates an identity and is not offered it.
expect(shapes.tool_offering.some((o) => !o.managed_identities && o.permissions["browser.identity.create"] === "allow" && !o.offered.includes("browser_identity_create"))).toBe(true);
});
});
+3 -9
View File
@@ -19,9 +19,8 @@ import { FakeDriver, ManualClock, waitFor, waitUntilSettledReady } from "../src/
const FIXTURE = fileURLToPath(new URL("../../../invisible_engine_dots/tests/dots/host_wire_shapes.json", import.meta.url));
const MINIMAL = "name: shapes-min\ngoal: keep watch\nmodel:\n provider: openrouter\n id: test/model\n";
const MINIMAL = "name: shapes-min\nmodel:\n provider: openrouter\n id: test/model\n";
const FULL = `name: shapes-full
goal: keep watch
instructions: Answer in one line.
model:
provider: openrouter
@@ -33,11 +32,6 @@ computer:
memory: 6G
disk: 30G
idle_timeout: 5m
browser:
identities:
managed_by_dot: false
max_identities: 5
max_open: 2
permissions:
computer.exec: deny
files.write: ask
@@ -84,7 +78,7 @@ describe("what the host sends the engine", () => {
configs.push({ name, config: structuredClone(driver.guestOf(dot.id).config) });
}
const dot = await readyDot("name: shapes-talk\ngoal: keep watch\nmodel:\n provider: openrouter\n id: test/model\n", "shapes-talk");
const dot = await readyDot("name: shapes-talk\nmodel:\n provider: openrouter\n id: test/model\n", "shapes-talk");
const guest = driver.guestOf(dot.id);
// A quiet guest: it takes a task up and says nothing else, so nothing but what the host sends is in `inbound`.
guest.onInbound = (event, g) => {
@@ -126,7 +120,7 @@ describe("what the host sends the engine", () => {
const priorities = written.inbound_events.filter((event) => event.type === "task.created").map((event) => event.data.priority);
expect(priorities).toEqual([5, 0]);
const [minimal, full] = written.runtime_configs.map((entry) => entry.config as Record<string, unknown>);
expect(Object.keys(full!).sort()).toEqual(["browser", "goal", "instructions", "limits", "model", "models", "name", "permissions"]);
expect(Object.keys(full!).sort()).toEqual(["instructions", "limits", "model", "models", "name", "permissions"]);
expect(Object.keys(minimal!)).not.toContain("instructions");
expect(minimal).not.toHaveProperty("computer");
});
+1 -1
View File
@@ -7,7 +7,7 @@ import { FakeDriver, FakeGuestError, ManualClock, waitFor, waitUntilSettledReady
const yaml = (name: string, idle = "15m") =>
`name: ${name}\ngoal: keep watch\nmodel:\n provider: openrouter\n id: test/model\ncomputer:\n idle_timeout: ${idle}\n`;
`name: ${name}\nmodel:\n provider: openrouter\n id: test/model\ncomputer:\n idle_timeout: ${idle}\n`;
const automation = (over: Partial<FakeAutomation> = {}): FakeAutomation => ({
id: "job_1",
+7 -7
View File
@@ -19,18 +19,18 @@ function startTurn(guest: FakeGuest): void {
}
test("the person talks to the Dot: the working row follows the turn, the steps sit between the messages, the answer is markdown", async ({ page, harness }) => {
const dot = await harness.createDot("chat-turn", "Watch the fares to Lisbon");
const dot = await harness.createDot("chat-turn");
const guest = harness.driver.guestOf(dot.id);
startTurn(guest);
await page.goto(`${harness.webUrl}/dots/${dot.id}/chat`);
// A Dot nobody has talked to: its goal, and ways to begin that fill the box and do not send.
// A Dot nobody has talked to: ways to begin that fill the box and do not send.
await expect(page.getByRole("heading", { name: "Say hello to chat-turn" })).toBeVisible();
await expect(page.getByText("Its goal: Watch the fares to Lisbon")).toBeVisible();
await expect(page.getByText("Tell it what you need.")).toBeVisible();
const box = page.getByRole("textbox", { name: "Message" });
await expect(box).toBeFocused();
await page.getByRole("button", { name: "Tell me what you will do first." }).click();
await expect(box).toHaveValue("Tell me what you will do first.");
await page.getByRole("button", { name: "What can you do on your computer?" }).click();
await expect(box).toHaveValue("What can you do on your computer?");
await box.fill("");
// Nothing below reloads the page: this marker would not survive it.
@@ -219,7 +219,7 @@ test("the chat works from the keyboard and keeps a draft across a reload", async
});
test("the chat fits a phone: long lines wrap, code scrolls inside its block, and the computer is a strip above the thread", async ({ page, harness }) => {
const dot = await harness.createDot("chat-phone", "A goal long enough to run past the edge of a narrow screen if nothing held it back, word after word");
const dot = await harness.createDot("chat-phone");
const guest = harness.driver.guestOf(dot.id);
await page.setViewportSize({ width: 390, height: 844 });
await harness.api.sendMessage(dot.id, "An unbroken line of text that is long enough to wrap on a narrow screen several times over before it ends");
@@ -285,7 +285,7 @@ test("a Dot opened by its name moves to its id, and the chat hears the Dot live"
});
test("a message that came through a chat says which one, and one typed here says nothing", async ({ page, harness }) => {
const dot = await harness.createDot("chat-origin", "Watch the fares to Lisbon");
const dot = await harness.createDot("chat-origin");
const origin = { channel: "telegram", binding_id: "bind_e2e", chat_id: "4242", external_id: "99:1" } as const;
await harness.control.scheduler.sendMessage(dot.id, "Is the 9 am flight still the cheapest?", origin);
await harness.control.scheduler.sendMessage(dot.id, "And the one on Friday?");
+6 -10
View File
@@ -10,7 +10,6 @@ test("a Dot is created by the form: its config is what the form showed, and the
await expect(page.getByRole("region", { name: "Before you create" }).getByText("OpenRouter key: Ready")).toBeVisible();
await page.getByLabel("Name", { exact: true }).fill("form-made");
await page.getByLabel("Goal", { exact: true }).fill("Watch the fares from Milan to Lisbon");
await page.getByLabel("Instructions").fill("Write findings to fares.csv.");
await page.getByLabel("Processors", { exact: true }).fill("4");
await page.getByLabel("Memory, exact value").fill("8");
@@ -25,7 +24,6 @@ test("a Dot is created by the form: its config is what the form showed, and the
const stored = await harness.api.getDot("form-made");
expect(stored.config).toMatchObject({
name: "form-made",
goal: "Watch the fares from Milan to Lisbon",
instructions: "Write findings to fares.csv.",
model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" },
computer: { cpu: 4, memory: "8gb", disk: "40gb", idle_timeout: "15m" },
@@ -40,12 +38,11 @@ test("a Dot is created by the form: its config is what the form showed, and the
test("a Dot is created from YAML, edited in place", async ({ page, harness }) => {
await page.goto(`${harness.webUrl}/new`);
await page.getByLabel("Name", { exact: true }).fill("yaml-made");
await page.getByLabel("Goal", { exact: true }).fill("Sort the mail");
await page.getByRole("button", { name: "Advanced YAML" }).click();
const editor = page.getByRole("textbox", { name: "Configuration (YAML)" });
await expect(editor).toHaveValue(/name: yaml-made/);
// Something only the YAML can say.
await editor.fill(`${await editor.inputValue()}browser:\n identities:\n max_open: 2\n`);
await editor.fill((await editor.inputValue()).replace("limits:\n", "limits:\n max_steps_per_task: 12\n"));
await expect(page.getByText("The configuration is valid.")).toBeVisible();
// The form cannot show it, so going back is refused and the text stays.
await page.getByRole("button", { name: "Form" }).click();
@@ -54,7 +51,7 @@ test("a Dot is created from YAML, edited in place", async ({ page, harness }) =>
await expect(page).toHaveURL(/\/dots\/[^/]+\/chat$/);
const stored = await harness.api.getDot("yaml-made");
expect(stored.config.browser.identities.max_open).toBe(2);
expect(stored.config.limits.max_steps_per_task).toBe(12);
});
test("a name that is not valid, or is taken, is said so at once, and nothing is created", async ({ page, harness }) => {
@@ -66,7 +63,6 @@ test("a name that is not valid, or is taken, is said so at once, and nothing is
await expect(page.getByText(/lowercase letters, digits and '-'/).first()).toBeVisible();
await name.fill("name-taken");
await expect(page.getByText('A Dot named "name-taken" already exists')).toBeVisible();
await page.getByLabel("Goal", { exact: true }).fill("anything");
await page.getByRole("button", { name: "Create Dot" }).click();
await expect(page.getByRole("alert").filter({ hasText: "to fix" })).toBeVisible();
await expect(page).toHaveURL(/\/new$/);
@@ -94,10 +90,10 @@ test("the checks beside the form say what the host lacks and the command that fi
});
test("the Home page shows a card per Dot and the computer's power on it works", async ({ page, harness }) => {
const dot = await harness.createDot("home-card", "Watch the fares from Milan to Lisbon");
const dot = await harness.createDot("home-card");
await page.goto(`${harness.webUrl}/`);
const card = page.getByRole("article", { name: "home-card" });
await expect(card.getByText("Watch the fares from Milan to Lisbon")).toBeVisible();
await expect(card.getByText(/goal/i)).toHaveCount(0);
await expect(card.getByText("test/model")).toBeVisible();
await expect(card.getByText("$0.00")).toBeVisible();
await expect(card.getByRole("img", { name: "Ready" })).toBeVisible();
@@ -115,12 +111,12 @@ test("the Home page shows a card per Dot and the computer's power on it works",
test("a Dot made elsewhere appears on the Home page while it is open", async ({ page, harness }) => {
await page.goto(`${harness.webUrl}/`);
await expect(page.getByRole("heading", { level: 1, name: "Dots" })).toBeVisible();
await harness.createDot("home-live", "arrives while you look");
await harness.createDot("home-live");
await expect(page.getByRole("article", { name: "home-live" })).toBeVisible();
});
test("Home and the create page fit a phone: no sideways scroll at 390 px", async ({ page, harness }) => {
await harness.createDot("phone-card", "a goal long enough that it has to wrap onto a second line of the card on a narrow screen");
await harness.createDot("phone-card");
await page.setViewportSize({ width: 390, height: 844 });
const loaded: Record<string, () => Promise<void>> = {
"/": () => expect(page.getByRole("article", { name: "phone-card" })).toBeVisible(),
+9 -11
View File
@@ -76,23 +76,21 @@ test("a preset sets the rows together, and the review lists every row that moved
await expect(page.getByText("No changes.")).toBeVisible();
});
test("the other settings are saved with the permissions: the summary model, the spending cap and the browser limits", async ({ page, harness }) => {
test("the other settings are saved with the permissions: the summary model, the spending cap and the step limit", async ({ page, harness }) => {
const dot = await harness.createDot("config-rest");
const guest = harness.driver.guestOf(dot.id);
await page.goto(settings(harness.webUrl, dot.id));
await page.getByLabel(/^Summary model/).fill("openai/gpt-5-mini");
await page.getByLabel("Spending cap per task").fill("0.5");
await page.getByLabel("Most identities").fill("10");
await page.getByLabel("Most open at once").fill("2");
await page.getByLabel("Steps per task").fill("25");
await choose(page, "Delete browser identities", "Deny");
await expect(page.getByText("6 unsaved changes.")).toBeVisible();
await expect(page.getByText("4 unsaved changes.")).toBeVisible();
await saveReviewed(page);
const config = (await harness.api.getDot(dot.id)).config;
expect(config.models).toEqual({ summary: "openai/gpt-5-mini" });
expect(config.limits).toMatchObject({ max_cost_per_task_usd: 0.5, max_steps_per_task: 25 });
expect(config.browser.identities).toMatchObject({ max_identities: 10, max_open: 2 });
expect("browser" in config).toBe(false);
// There is no memory to set: the Dot keeps its notes itself.
expect("memory" in config).toBe(false);
await expect(page.getByRole("switch", { name: /^Memory/ })).toHaveCount(0);
@@ -105,13 +103,13 @@ test("the other settings are saved with the permissions: the summary model, the
expect((await harness.api.getDot(dot.id)).config.models).toEqual({});
});
test("the rule between the browser limits is said where it is broken, and the disk cannot shrink", async ({ page, harness }) => {
test("a setting that is wrong is said where it is, and the disk cannot shrink", async ({ page, harness }) => {
const dot = await harness.createDot("config-rules");
await page.goto(settings(harness.webUrl, dot.id));
await page.getByLabel("Most identities").fill("2");
await expect(page.getByText("max_open (3) cannot exceed max_identities (2)")).toBeVisible();
await page.getByRole("combobox", { name: "Model", exact: true }).fill("two words");
await expect(page.getByText("model id must not contain whitespace")).toBeVisible();
await expect(page.getByRole("button", { name: "Review and save" })).toBeDisabled();
await page.getByLabel("Most open at once").fill("2");
await page.getByRole("combobox", { name: "Model", exact: true }).fill("openai/gpt-5");
await expect(page.getByRole("button", { name: "Review and save" })).toBeEnabled();
await page.getByRole("button", { name: "Discard changes" }).click();
@@ -228,8 +226,8 @@ for (const scheme of ["light", "dark"] as const) {
await page.goto(settings(harness.webUrl, dot.id));
await choose(page, "Run commands", "Deny");
await expect(page.getByRole("list", { name: "Tools of Run commands" })).toBeVisible();
await page.getByLabel("Most identities").fill("2");
await expect(page.getByText("max_open (3) cannot exceed max_identities (2)")).toBeVisible();
await page.getByRole("combobox", { name: "Model", exact: true }).fill("two words");
await expect(page.getByText("model id must not contain whitespace")).toBeVisible();
const spots: Record<string, string> = {
"a permission's description": "li:has(fieldset legend:text-is('Run commands')) > div p.text-sm",
+3 -3
View File
@@ -47,7 +47,7 @@ export interface Harness {
*/
host: { images: DoctorCheck[] };
/** A Dot whose computer is up and READY. */
createDot(name: string, goal?: string): Promise<DotRecord>;
createDot(name: string): Promise<DotRecord>;
/** The control plane stops answering (its server closes) while the web client goes on: what a person sees when the API dies. `close` still cleans up. */
stopApi(): Promise<void>;
close(): Promise<void>;
@@ -144,8 +144,8 @@ export async function startHarness({ withKey = true }: HarnessOptions = {}): Pro
host,
api,
token: control.token,
async createDot(name, goal = "watch the fares") {
const dot = await api.createDot(`name: ${name}\ngoal: ${goal}\nmodel:\n provider: openrouter\n id: test/model\ncomputer:\n idle_timeout: 15m\n`);
async createDot(name) {
const dot = await api.createDot(`name: ${name}\nmodel:\n provider: openrouter\n id: test/model\ncomputer:\n idle_timeout: 15m\n`);
await waitUntilSettledReady(control.scheduler, driver, dot.id, name);
return dot;
},
+3 -3
View File
@@ -1,14 +1,14 @@
import { expect, test } from "./fixtures.js";
test("the rail lists the Dots and a Dot's page shows its header and its tabs", async ({ page, harness }) => {
const dot = await harness.createDot("shell-header", "Watch the fares from Milan to Lisbon");
const dot = await harness.createDot("shell-header");
await page.goto(`${harness.webUrl}/`);
const dots = page.getByRole("navigation", { name: "Dots" });
await dots.getByRole("link", { name: /shell-header/ }).click();
await expect(page).toHaveURL(new RegExp(`/dots/${dot.id}/chat$`));
await expect(page.getByRole("heading", { level: 1, name: "shell-header" })).toBeVisible();
await expect(page.getByRole("button", { name: /^Watch the fares/ })).toBeVisible();
await expect(page.getByRole("button", { name: /Show the whole goal/ })).toHaveCount(0);
await expect(dots.getByRole("link", { name: /shell-header/ })).toHaveAttribute("aria-current", "page");
await expect(page.getByRole("img", { name: "Ready" }).first()).toBeVisible();
await expect(page.getByText("$0.00")).toBeVisible();
@@ -80,7 +80,7 @@ test("the theme follows the system, can be chosen, and is applied before the pag
});
test("on a phone the rail is a sheet behind the menu button and nothing scrolls sideways", async ({ page, harness }) => {
const dot = await harness.createDot("shell-phone", "A goal long enough to run past the edge of a narrow screen if nothing held it back, word after word");
const dot = await harness.createDot("shell-phone");
await page.setViewportSize({ width: 390, height: 800 });
await page.goto(`${harness.webUrl}/dots/${dot.id}/chat`);
+3 -3
View File
@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { isWorking } from "../../lib/agent";
import { lastStepSinceUser } from "../../lib/chat-thread";
import { viaChannel } from "../../lib/channels";
import { composerState, messageNote, suggestions } from "../../lib/chat-view";
import { composerState, messageNote, SUGGESTIONS } from "../../lib/chat-view";
import { readDraft, writeDraft } from "../../lib/draft";
import { ComputerPanel } from "../computer/ComputerPanel";
import { useDot } from "../DotShell";
@@ -96,10 +96,10 @@ function ChatInner({ dotId }: { dotId: string }) {
<ConversationEmptyState
icon={<MessageSquareIcon className="size-6" />}
title={`Say hello to ${who.name}`}
description={record?.config?.goal ? `Its goal: ${record.config.goal}` : "Tell it what you need."}
description="Tell it what you need."
>
<ul aria-label="Ways to begin" className="flex flex-wrap justify-center gap-2">
{suggestions(record?.config?.goal).map((text) => (
{SUGGESTIONS.map((text) => (
<li key={text}>
<Button type="button" variant="outline" size="sm" className="h-auto max-w-72 whitespace-normal py-1.5 text-left" onClick={() => suggest(text)}>
{text}
@@ -16,7 +16,7 @@ import { Button } from "../ui/button";
import { Skeleton } from "../ui/skeleton";
import { ConfigYamlEditor } from "../yaml-editor";
import { DeleteDot } from "./delete-dot";
import { BrowserPanel, ComputerPanel, GeneralPanel, LimitsPanel, ModelPanel } from "./panels";
import { ComputerPanel, GeneralPanel, LimitsPanel, ModelPanel } from "./panels";
import type { PanelProps } from "./panel";
import { PermissionsPanel } from "./permissions-panel";
import { ReviewDialog } from "./review-dialog";
@@ -161,7 +161,6 @@ function Editor({
<ModelPanel {...panel} />
<PermissionsPanel {...panel} dotId={dotId} computerState={computerState} />
<ComputerPanel {...panel} />
<BrowserPanel {...panel} />
<LimitsPanel {...panel} />
</>
) : (
@@ -7,7 +7,7 @@ export interface PanelProps {
/** The config as the host has it: what the person is editing from. */
saved: DotConfig;
change: (next: DotConfig) => void;
/** The schema's complaint about a config path (`browser.identities.max_open`), or null. */
/** The schema's complaint about a config path (`limits.context_tokens`), or null. */
errorOf: (path: string) => string | null;
}
@@ -1,12 +1,9 @@
"use client";
import { CONFIG_BOUNDS } from "@invisible-dots/shared/browser";
import type { ReactNode } from "react";
import { setField } from "../../lib/config-fields";
import { FORM_BOUNDS, gibOf, gibSize, IDLE_CHOICES, MODEL_SUGGESTIONS } from "../../lib/dot-form";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { Textarea } from "../ui/textarea";
import { Field, NumberField, SliderField } from "../new-dot/Field";
import { Panel, type PanelProps } from "./panel";
@@ -22,9 +19,6 @@ export function GeneralPanel({ draft, change, errorOf }: PanelProps) {
<p className="text-xs text-muted-foreground">A Dot keeps its name: it names the Dot in commands, logs and every page.</p>
{errorOf("name") ? <p className="text-xs text-danger">{errorOf("name")}</p> : null}
</div>
<Field id="set-goal" label="Goal" hint="What the Dot should achieve, in a sentence or two." error={errorOf("goal")}>
{(control) => <Textarea {...control} rows={3} value={draft.goal} onChange={(event) => change(setField(draft, "goal", event.target.value))} />}
</Field>
<Field id="set-instructions" label="Instructions" optional hint="How it should work: where to write, what to avoid, what a good result looks like." error={errorOf("instructions")}>
{(control) => <Textarea {...control} rows={4} value={draft.instructions ?? ""} onChange={(event) => change(setField(draft, "instructions", event.target.value))} />}
</Field>
@@ -109,63 +103,6 @@ export function ComputerPanel({ draft, saved, change, errorOf }: PanelProps) {
);
}
/** A switch with what it means in words: its label says the state, the text under it says what that does. */
function SwitchRow({ id, label, meaning, checked, onChange, children }: { id: string; label: string; meaning: string; checked: boolean; onChange: (next: boolean) => void; children?: ReactNode }) {
return (
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1 space-y-1">
<Label htmlFor={id} className="text-sm font-medium">
{label}
</Label>
<p className="text-sm text-muted-foreground">{meaning}</p>
{children}
</div>
<Switch id={id} checked={checked} onCheckedChange={onChange} />
</div>
);
}
export function BrowserPanel({ draft, change, errorOf }: PanelProps) {
const identities = draft.browser.identities;
return (
<Panel id="browser" title="Browser" description="The browser identities the Dot works with: each its own profile, with its own logins.">
<SwitchRow
id="set-managed"
label={identities.managed_by_dot ? "The Dot manages its identities" : "You manage its identities"}
meaning={
identities.managed_by_dot
? "The Dot may create and delete browser identities itself, as far as its permissions allow."
: "The Dot is not offered the tools that create and delete identities. You make them on the Computer page."
}
checked={identities.managed_by_dot}
onChange={(next) => change(setField(draft, "browser.identities.managed_by_dot", next))}
/>
<NumberField
id="set-max-identities"
label="Most identities"
min={CONFIG_BOUNDS.maxIdentities.min}
max={CONFIG_BOUNDS.maxIdentities.max}
step={1}
value={identities.max_identities}
onChange={(value) => change(setField(draft, "browser.identities.max_identities", value))}
hint="No identity can be made past this, by the Dot or by you."
error={errorOf("browser.identities.max_identities")}
/>
<NumberField
id="set-max-open"
label="Most open at once"
min={CONFIG_BOUNDS.maxOpen.min}
max={CONFIG_BOUNDS.maxOpen.max}
step={1}
value={identities.max_open}
onChange={(value) => change(setField(draft, "browser.identities.max_open", value))}
hint="Each open browser uses the computer's memory. It cannot be more than the most identities."
error={errorOf("browser.identities.max_open")}
/>
</Panel>
);
}
export function LimitsPanel({ draft, change, errorOf }: PanelProps) {
const { limits } = draft;
return (
+1 -15
View File
@@ -2,7 +2,6 @@
import { AlertCircleIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { statePill } from "../../lib/agent";
import { api } from "../../lib/api";
import { allowedActions, taskRunning } from "../../lib/computer";
@@ -22,14 +21,13 @@ import { TONE_DOT } from "./tone";
import { useDotRing } from "./use-ring";
/**
* The Dot header (S4): its avatar, name and goal; the state it is in, what it cost today, and its computer with
* The Dot header (S4): its avatar and name; the state it is in, what it cost today, and its computer with
* the power menu; and under it the banners that say why a Dot is in ERROR or that its agent was restarted.
*/
export function DotHeader({ dotId, dot }: { dotId: string; dot: Resource<Dot> }) {
const live = useDotLive(dotId);
const ring = useDotRing(dotId, dot.data);
const { dismissRestart } = useShell();
const [goalOpen, setGoalOpen] = useState(false);
const record = dot.data;
const hasError = record !== undefined && (record.status === "ERROR" || record.computer_state === "ERROR");
@@ -51,7 +49,6 @@ export function DotHeader({ dotId, dot }: { dotId: string; dot: Resource<Dot> })
}
const pill = statePill(record.status, live.agent);
const goal = record.config?.goal ?? "";
// What the error banner offers besides the settings: a reboot, which the host only does to a computer that is up;
// with the computer itself in ERROR the way back is to start it again.
const allowed = allowedActions(record.computer_state ?? "");
@@ -65,17 +62,6 @@ export function DotHeader({ dotId, dot }: { dotId: string; dot: Resource<Dot> })
<DotAvatar id={record.id} name={record.name} ring={ring} size="lg" />
<div className="min-w-0 flex-1 basis-56">
<h1 className="truncate text-xl font-semibold tracking-tight">{record.name}</h1>
{goal ? (
<button
type="button"
onClick={() => setGoalOpen((open) => !open)}
aria-expanded={goalOpen}
title={goalOpen ? "Show less" : "Show the whole goal"}
className={cn("block max-w-full text-left text-sm text-muted-foreground", goalOpen ? "whitespace-pre-wrap" : "truncate")}
>
{goal}
</button>
) : null}
{live.progress ? (
<Link
href={`/dots/${encodeURIComponent(dotId)}/tasks/${encodeURIComponent(live.progress.taskId)}`}
-1
View File
@@ -54,7 +54,6 @@ export function DotCard({ dot }: { dot: Dot }) {
<div className="min-w-0 space-y-1">
{status.reason ? <p className="text-sm text-danger">{status.reason}</p> : null}
<p className="line-clamp-2 text-sm text-muted-foreground">{dot.config?.goal ?? ""}</p>
<dl className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{model ? (
+1 -1
View File
@@ -56,7 +56,7 @@ export function HomePage() {
{showSearch(list.length) ? (
<div className="relative max-w-sm">
<SearchIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input type="search" aria-label="Search Dots" placeholder="Search by name, goal or model" value={query} onChange={(event) => setQuery(event.target.value)} className="pl-9" />
<Input type="search" aria-label="Search Dots" placeholder="Search by name or model" value={query} onChange={(event) => setQuery(event.target.value)} className="pl-9" />
</div>
) : null}
{shown.length > 0 ? (
@@ -62,18 +62,6 @@ export function IdentitySection({ form, change, errorOf, touch }: SectionProps)
/>
)}
</Field>
<Field id="dot-goal" label="Goal" hint="What the Dot should achieve, in a sentence or two." error={errorOf("goal")}>
{(control) => (
<Textarea
{...control}
rows={3}
value={form.goal}
placeholder="Check one-way fares from Milan to Lisbon every morning and report the cheapest day."
onChange={(event) => change({ goal: event.target.value })}
onBlur={() => touch("goal")}
/>
)}
</Field>
<Field id="dot-instructions" label="Instructions" optional hint="How it should work: where to write, what to avoid, what a good result looks like." error={errorOf("instructions")}>
{(control) => <Textarea {...control} rows={4} value={form.instructions} onChange={(event) => change({ instructions: event.target.value })} />}
</Field>
+2 -8
View File
@@ -30,14 +30,8 @@ export function composerState(dot: Pick<Dot, "status" | "computer_state"> | unde
}
}
const GOAL_EXCERPT = 90;
/** Three first messages to offer in an empty chat, the last taken from the Dot's goal. */
export function suggestions(goal: string | undefined): string[] {
const text = (goal ?? "").trim().replace(/\s+/g, " ");
const excerpt = text.length > GOAL_EXCERPT ? `${text.slice(0, GOAL_EXCERPT - 3).trimEnd()}...` : text;
return ["Tell me what you will do first.", "What can you do on your computer?", excerpt ? `Start on your goal: ${excerpt}` : "What do you need from me to get started?"];
}
/** Three first messages to offer in an empty chat. */
export const SUGGESTIONS: readonly string[] = ["What can you do on your computer?", "Open a browser and tell me what is on example.com.", "What do you need from me to get started?"];
/** The note under a message the computer had to wake up for: the Dot answers once it is up. */
export const QUEUED_NOTE = "Queued: the computer is waking up, and the Dot answers once it is.";
-23
View File
@@ -32,7 +32,6 @@ function shownText(value: ConfigValue): string {
return text.length > TEXT_SHOWN ? `${text.slice(0, TEXT_SHOWN)}...` : text;
}
const shownSwitch = (value: ConfigValue) => (value ? "on" : "off");
const shownPlain = (value: ConfigValue) => String(value);
/** The default decision of a permission: what it resolves to when the config says nothing about it. */
@@ -48,7 +47,6 @@ export function setPermission(config: DotConfig, permission: Permission, decisio
const SCALARS = {
"name": { label: "Name", applies: "turn", get: (c) => c.name, set: (c, v) => ({ ...c, name: String(v) }), show: shownPlain },
"goal": { label: "Goal", applies: "turn", get: (c) => c.goal, set: (c, v) => ({ ...c, goal: String(v) }), show: shownText },
"instructions": {
label: "Instructions",
applies: "turn",
@@ -80,27 +78,6 @@ const SCALARS = {
set: (c, v) => ({ ...c, computer: { ...c.computer, idle_timeout: String(v) } }),
show: (v) => (v === "0" ? "never" : String(v)),
},
"browser.identities.managed_by_dot": {
label: "The Dot manages its browser identities",
applies: "turn",
get: (c) => c.browser.identities.managed_by_dot,
set: (c, v) => ({ ...c, browser: { identities: { ...c.browser.identities, managed_by_dot: Boolean(v) } } }),
show: shownSwitch,
},
"browser.identities.max_identities": {
label: "Most browser identities",
applies: "turn",
get: (c) => c.browser.identities.max_identities,
set: (c, v) => ({ ...c, browser: { identities: { ...c.browser.identities, max_identities: Number(v) } } }),
show: shownPlain,
},
"browser.identities.max_open": {
label: "Most browsers open at once",
applies: "turn",
get: (c) => c.browser.identities.max_open,
set: (c, v) => ({ ...c, browser: { identities: { ...c.browser.identities, max_open: Number(v) } } }),
show: shownPlain,
},
"limits.max_steps_per_task": {
label: "Steps per task",
applies: "turn",
+2 -2
View File
@@ -10,12 +10,12 @@ export function showSearch(dotCount: number): boolean {
return dotCount > SEARCH_THRESHOLD;
}
/** The Dots whose name, goal or model contains every word of `query`, in the order given. */
/** The Dots whose name or model contains every word of `query`, in the order given. */
export function filterDots(dots: readonly Dot[], query: string): Dot[] {
const words = query.toLocaleLowerCase().split(/\s+/).filter(Boolean);
if (words.length === 0) return [...dots];
return dots.filter((dot) => {
const text = `${dot.name}\n${dot.config?.goal ?? ""}\n${dot.config?.model?.id ?? ""}`.toLocaleLowerCase();
const text = `${dot.name}\n${dot.config?.model?.id ?? ""}`.toLocaleLowerCase();
return words.every((word) => text.includes(word));
});
}
+4 -9
View File
@@ -60,7 +60,6 @@ export const FORM_BOUNDS = {
export interface DotForm {
name: string;
goal: string;
instructions: string;
modelId: string;
/** The model that writes summaries; empty means the Dot's own model does. */
@@ -77,7 +76,6 @@ export interface DotForm {
export function emptyForm(): DotForm {
return {
name: "",
goal: "",
instructions: "",
modelId: DEFAULT_MODEL_ID,
summaryModelId: "",
@@ -94,7 +92,6 @@ export function emptyForm(): DotForm {
export function formToConfig(form: DotForm): Record<string, unknown> {
const config: Record<string, unknown> = {
name: form.name,
goal: form.goal,
model: { provider: "openrouter", id: form.modelId },
computer: { cpu: form.cpu, memory: gibSize(form.memoryGib), disk: gibSize(form.diskGib), idle_timeout: form.idleTimeout },
permissions: { ...form.permissions },
@@ -108,7 +105,6 @@ export function formToConfig(form: DotForm): Record<string, unknown> {
export function configToForm(config: DotConfig): DotForm {
return {
name: config.name,
goal: config.goal,
instructions: config.instructions ?? "",
modelId: config.model.id,
summaryModelId: config.models.summary ?? "",
@@ -121,7 +117,7 @@ export function configToForm(config: DotConfig): DotForm {
};
}
export type FieldId = "name" | "goal" | "instructions" | "modelId" | "summaryModelId" | "cpu" | "memoryGib" | "diskGib" | "idleTimeout" | "maxCostUsd";
export type FieldId = "name" | "instructions" | "modelId" | "summaryModelId" | "cpu" | "memoryGib" | "diskGib" | "idleTimeout" | "maxCostUsd";
export interface FormIssue {
/** The control the problem belongs to; null when it belongs to none (the YAML as a whole, a section the form has no control for). */
@@ -139,7 +135,6 @@ export function issueText(issue: FormIssue): string {
/** Where each config path shows up in the form. A path not listed has no control. */
const FIELD_OF_PATH: Readonly<Record<string, FieldId>> = {
name: "name",
goal: "goal",
instructions: "instructions",
"model.id": "modelId",
"models.summary": "summaryModelId",
@@ -195,8 +190,8 @@ export type YamlRead = { ok: true; form: DotForm } | { ok: false; issues: FormIs
/**
* Read YAML text back into the form. It fails with the schema's issues when the text is not a valid config, and
* with one issue of its own when it is valid but sets something the form has no control for (the browser's limits,
* the step and token limits): the form would drop it, so the person stays in the YAML.
* with one issue of its own when it is valid but sets something the form has no control for (the step and token
* limits): the form would drop it, so the person stays in the YAML.
*/
export function yamlToForm(text: string): YamlRead {
const parsed = safeParseDotConfig(text);
@@ -206,7 +201,7 @@ export function yamlToForm(text: string): YamlRead {
if (!again.ok || canonical(withoutEmptyInstructions(again.config)) !== canonical(withoutEmptyInstructions(parsed.config))) {
return {
ok: false,
issues: [{ field: null, path: "", message: "This YAML sets options the form has no controls for (such as the browser limits or the step limit). Keep editing it as YAML." }],
issues: [{ field: null, path: "", message: "This YAML sets options the form has no controls for (such as the step limit). Keep editing it as YAML." }],
};
}
return { ok: true, form };
+5 -17
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { composerState, messageNote, QUEUED_NOTE, suggestions } from "../src/lib/chat-view";
import { composerState, messageNote, QUEUED_NOTE, SUGGESTIONS } from "../src/lib/chat-view";
describe("what the composer says", () => {
it("lets the person write to a Dot whose computer is up, and says nothing", () => {
@@ -25,22 +25,10 @@ describe("what the composer says", () => {
});
describe("the first messages offered", () => {
it("are three, the last drawn from the goal", () => {
const offered = suggestions("Watch the airline fares to Lisbon and tell me when one drops");
expect(offered).toHaveLength(3);
expect(offered[2]).toBe("Start on your goal: Watch the airline fares to Lisbon and tell me when one drops");
});
it("cut a long goal to one line, whatever its whitespace", () => {
const offered = suggestions(`${"word ".repeat(60)}\n\nend`);
expect(offered[2]!.length).toBeLessThanOrEqual("Start on your goal: ".length + 90);
expect(offered[2]).toMatch(/\.\.\.$/);
expect(offered[2]).not.toMatch(/\n/);
});
it("do not pretend to have a goal when there is none", () => {
expect(suggestions(undefined)[2]).toBe("What do you need from me to get started?");
expect(suggestions(" ")[2]).toBe("What do you need from me to get started?");
it("are three, each a question the Dot can answer without a goal: a Dot has none", () => {
expect(SUGGESTIONS).toHaveLength(3);
expect(new Set(SUGGESTIONS).size).toBe(3);
expect(SUGGESTIONS.join(" ")).not.toMatch(/goal/i);
});
});
+3 -3
View File
@@ -36,7 +36,7 @@ beforeEach(() => {
navigate = () => {};
window.localStorage.clear();
plane = new FakeControlPlane();
plane.dots = [dotRecord("d1", { name: "fares", config: { goal: "Watch the fares to Lisbon" } as never })];
plane.dots = [dotRecord("d1", { name: "fares" })];
plane.install();
stubMatchMedia();
stubResizeObserver();
@@ -126,10 +126,10 @@ describe("the conversation", () => {
expect(within(screen.getByText("from this page").closest("article")!).queryByText(/^via /)).toBeNull();
});
it("invites the first message with the goal and three ways to begin, which fill the box and do not send", async () => {
it("invites the first message with three ways to begin, which fill the box and do not send", async () => {
await renderChat();
expect(await screen.findByRole("heading", { name: "Say hello to fares" })).toBeTruthy();
expect(screen.getByText("Its goal: Watch the fares to Lisbon")).toBeTruthy();
expect(screen.getByText("Tell it what you need.")).toBeTruthy();
const ways = within(screen.getByRole("list", { name: "Ways to begin" })).getAllByRole("button");
expect(ways).toHaveLength(3);
await userEvent.click(within(screen.getByRole("list", { name: "Ways to begin" })).getByRole("button", { name: "What can you do on your computer?" }));
+1 -1
View File
@@ -272,7 +272,7 @@ describe("the usage", () => {
it("compares what the computer was given with what it uses, and names the images it started from", async () => {
plane.system = system;
plane.dots = [dotRecord("d1", { name: "fares", config: { goal: "g", computer: { cpu: 2, memory: "4gb", disk: "40gb", idle_timeout: "15m" } } as never })];
plane.dots = [dotRecord("d1", { name: "fares", config: { computer: { cpu: 2, memory: "4gb", disk: "40gb", idle_timeout: "15m" } } as never })];
await renderComputer({ view: "usage" });
const given = await screen.findByRole("region", { name: "Given to the computer" });
expect(within(given).getByText("4gb")).toBeTruthy();
+8 -8
View File
@@ -13,32 +13,32 @@ describe("the draft of a config", () => {
});
it("edits the draft and goes back to the base on discard, keeping the version", () => {
const edited = edit(startDraft(config, 3), setField(config, "goal", "mine"));
expect(edited.draft.goal).toBe("mine");
const edited = edit(startDraft(config, 3), setField(config, "instructions", "mine"));
expect(edited.draft.instructions).toBe("mine");
expect(edited.base).toBe(config);
expect(discard(edited)).toEqual(startDraft(config, 3));
});
it("takes the host's newer config as it is when nothing was edited, without saying anything", () => {
const newer = setField(config, "goal", "elsewhere");
const newer = setField(config, "instructions", "elsewhere");
expect(follow(startDraft(config, 3), newer, 4)).toEqual(startDraft(newer, 4));
});
it("keeps edits that are under way on top of the newer config, and says so", () => {
const mine = edit(startDraft(config, 3), setPermission(config, "computer.exec", "ask"));
const newer = setField(config, "goal", "elsewhere");
const newer = setField(config, "instructions", "elsewhere");
const followed = follow(mine, newer, 4);
expect(followed.rebased).toBe(true);
expect(followed.version).toBe(4);
expect(followed.base).toBe(newer);
expect(followed.draft.goal).toBe("elsewhere");
expect(followed.draft.instructions).toBe("elsewhere");
expect(followed.draft.permissions).toEqual({ "computer.exec": "ask" });
});
it("changes nothing for the version it has, or an older one that was slow to arrive", () => {
const mine = edit(startDraft(config, 3), setField(config, "goal", "mine"));
expect(follow(mine, setField(config, "goal", "x"), 3)).toBe(mine);
expect(follow(mine, setField(config, "goal", "x"), 2)).toBe(mine);
const mine = edit(startDraft(config, 3), setField(config, "instructions", "mine"));
expect(follow(mine, setField(config, "instructions", "x"), 3)).toBe(mine);
expect(follow(mine, setField(config, "instructions", "x"), 2)).toBe(mine);
});
});
+8 -16
View File
@@ -35,7 +35,6 @@ describe("setPermission", () => {
describe("setField", () => {
it("sets each setting where the config keeps it, and the result is a config the schema accepts", () => {
let config = fullConfig();
config = setField(config, "goal", "A new goal");
config = setField(config, "instructions", "Write to the workspace");
config = setField(config, "model.id", "openai/gpt-5");
config = setField(config, "models.summary", "openai/gpt-5-mini");
@@ -43,20 +42,15 @@ describe("setField", () => {
config = setField(config, "computer.memory", "8gb");
config = setField(config, "computer.disk", "80gb");
config = setField(config, "computer.idle_timeout", "1h");
config = setField(config, "browser.identities.managed_by_dot", false);
config = setField(config, "browser.identities.max_identities", 30);
config = setField(config, "browser.identities.max_open", 5);
config = setField(config, "limits.max_steps_per_task", 90);
config = setField(config, "limits.context_tokens", 64_000);
config = setField(config, "limits.max_cost_per_task_usd", 2.5);
expect(parseDotConfig(config)).toEqual({
name: "fare-watch",
goal: "A new goal",
instructions: "Write to the workspace",
model: { provider: "openrouter", id: "openai/gpt-5" },
models: { summary: "openai/gpt-5-mini" },
computer: { cpu: 4, memory: "8gb", disk: "80gb", idle_timeout: "1h" },
browser: { identities: { managed_by_dot: false, max_identities: 30, max_open: 5 } },
permissions: {},
limits: { max_steps_per_task: 90, context_tokens: 64_000, max_cost_per_task_usd: 2.5 },
});
@@ -92,11 +86,11 @@ describe("configChanges", () => {
it("says that the size of the computer applies at its next start, and cuts a long text", () => {
const before = fullConfig();
const changes = configChanges(before, setField(setField(before, "computer.memory", "8gb"), "goal", "g".repeat(500)));
const changes = configChanges(before, setField(setField(before, "computer.memory", "8gb"), "instructions", "g".repeat(500)));
expect(changes.find((c) => c.key === "computer.memory")?.applies).toBe("start");
const goal = changes.find((c) => c.key === "goal")!;
expect(goal.after.length).toBe(303);
expect(goal.after.endsWith("...")).toBe(true);
const instructions = changes.find((c) => c.key === "instructions")!;
expect(instructions.after.length).toBe(303);
expect(instructions.after.endsWith("...")).toBe(true);
});
it("names an empty text as empty", () => {
@@ -109,10 +103,10 @@ describe("rebase", () => {
const base = fullConfig();
it("puts my edits on top of a config someone else changed in other fields", () => {
const mine = setPermission(setField(base, "goal", "My goal"), "files.write", "ask");
const mine = setPermission(setField(base, "instructions", "My instructions"), "files.write", "ask");
const latest = setPermission(setField(base, "limits.max_steps_per_task", 10), "automations", "allow");
const merged = rebase(base, mine, latest);
expect(merged.goal).toBe("My goal");
expect(merged.instructions).toBe("My instructions");
expect(merged.limits.max_steps_per_task).toBe(10);
expect(merged.permissions).toEqual({ "files.write": "ask", automations: "allow" });
});
@@ -127,7 +121,7 @@ describe("rebase", () => {
});
it("with no edits is the latest config", () => {
const latest = setField(base, "goal", "elsewhere");
const latest = setField(base, "instructions", "elsewhere");
expect(rebase(base, base, latest)).toEqual(latest);
});
@@ -158,17 +152,15 @@ describe("the fields", () => {
const a: DotConfig = fullConfig();
const b: DotConfig = parseDotConfig({
name: "other",
goal: "other goal",
instructions: "other",
model: { provider: "openrouter", id: "x/y" },
models: { summary: "x/z" },
computer: { cpu: 3, memory: "8gb", disk: "50gb", idle_timeout: "1h" },
browser: { identities: { managed_by_dot: false, max_identities: 2, max_open: 1 } },
permissions: Object.fromEntries(PERMISSIONS.map((p) => [p, "deny"])),
limits: { max_steps_per_task: 5, context_tokens: 5000, max_cost_per_task_usd: 7 },
});
// Every difference between two whole configs is a listed change, and putting all of them on a base gives the config back.
expect(configChanges(a, b)).toHaveLength(15 + PERMISSIONS.length);
expect(configChanges(a, b)).toHaveLength(11 + PERMISSIONS.length);
expect(configChanges(rebase(a, b, a), b)).toEqual([]);
});
});
+6 -6
View File
@@ -11,8 +11,8 @@ describe("showSearch", () => {
describe("filterDots", () => {
const dots = [
dotRecord("1", { name: "fare-watch", config: { goal: "Watch the fares from Milan to Lisbon", model: { provider: "openrouter", id: "z-ai/glm" } } as never }),
dotRecord("2", { name: "inbox", config: { goal: "Sort the mail", model: { provider: "openrouter", id: "a/claude" } } as never }),
dotRecord("1", { name: "fare-watch", config: { model: { provider: "openrouter", id: "z-ai/glm" } } as never }),
dotRecord("2", { name: "inbox", config: { model: { provider: "openrouter", id: "a/claude" } } as never }),
];
it("keeps every Dot for an empty or blank query", () => {
@@ -20,15 +20,15 @@ describe("filterDots", () => {
expect(filterDots(dots, " ")).toHaveLength(2);
});
it("matches the name, the goal and the model, ignoring case", () => {
it("matches the name and the model, ignoring case", () => {
expect(filterDots(dots, "FARE").map((d) => d.id)).toEqual(["1"]);
expect(filterDots(dots, "mail").map((d) => d.id)).toEqual(["2"]);
expect(filterDots(dots, "inbox").map((d) => d.id)).toEqual(["2"]);
expect(filterDots(dots, "claude").map((d) => d.id)).toEqual(["2"]);
});
it("needs every word, in any order", () => {
expect(filterDots(dots, "lisbon fares").map((d) => d.id)).toEqual(["1"]);
expect(filterDots(dots, "lisbon mail")).toEqual([]);
expect(filterDots(dots, "glm fare").map((d) => d.id)).toEqual(["1"]);
expect(filterDots(dots, "glm inbox")).toEqual([]);
});
it("copes with a Dot whose config is missing", () => {
+6 -9
View File
@@ -16,20 +16,20 @@ import {
import { presetPermissions } from "../src/lib/permission-presets";
function filled(change: Partial<DotForm> = {}): DotForm {
return { ...emptyForm(), name: "fare-watch", goal: "Watch the fares from Milan to Lisbon", ...change };
return { ...emptyForm(), name: "fare-watch", ...change };
}
describe("the empty form", () => {
it("starts from the schema's own defaults, so a new Dot gets what an omitted field would", () => {
const config = parseDotConfig(formToConfig(filled()));
const bare = parseDotConfig({ name: "fare-watch", goal: "g", model: { provider: "openrouter", id: DEFAULT_MODEL_ID } });
const bare = parseDotConfig({ name: "fare-watch", model: { provider: "openrouter", id: DEFAULT_MODEL_ID } });
expect(config.computer).toEqual(bare.computer);
expect(config.limits).toEqual(bare.limits);
for (const permission of PERMISSIONS) expect(resolvePermission(config, permission)).toBe(resolvePermission(bare, permission));
});
it("is not valid until it has a name and a goal, and says which controls are at fault", () => {
expect(formIssues(emptyForm()).map((issue) => issue.field).sort()).toEqual(["goal", "name"]);
it("is not valid until it has a name, and says which control is at fault", () => {
expect(formIssues(emptyForm()).map((issue) => issue.field)).toEqual(["name"]);
expect(formIssues(filled())).toEqual([]);
});
@@ -53,7 +53,6 @@ describe("formToConfig", () => {
it("writes what the controls show in the shape of the config, and nothing for what is empty", () => {
expect(formToConfig(filled())).toEqual({
name: "fare-watch",
goal: "Watch the fares from Milan to Lisbon",
model: { provider: "openrouter", id: DEFAULT_MODEL_ID },
computer: { cpu: 2, memory: "4gb", disk: "40gb", idle_timeout: "15m" },
permissions: {},
@@ -114,7 +113,7 @@ describe("the YAML editor", () => {
});
it("gives the schema's issues for text that is not a valid config, each on its control", () => {
const read = yamlToForm("name: Bad Name\ngoal: g\nmodel:\n provider: openrouter\n id: a/b\ncomputer:\n cpu: 99\n");
const read = yamlToForm("name: Bad Name\nmodel:\n provider: openrouter\n id: a/b\ncomputer:\n cpu: 99\n");
expect(read.ok).toBe(false);
if (!read.ok) expect(read.issues.map((issue) => issue.field).sort()).toEqual(["cpu", "name"]);
});
@@ -128,8 +127,6 @@ describe("the YAML editor", () => {
it("refuses to go back to the form when the YAML sets something the form would drop", () => {
const base = formToYaml(filled());
const withExtra: Record<string, string> = {
"the browser limits": `${base}browser:\n identities:\n max_open: 5\n`,
"memory off": `${base}memory:\n enabled: false\n`,
"the step limit": base.replace("limits:\n", "limits:\n max_steps_per_task: 10\n"),
};
for (const [what, yaml] of Object.entries(withExtra)) {
@@ -152,7 +149,7 @@ describe("the YAML editor", () => {
describe("configToForm", () => {
it("shows a config's sizes in GiB for the sliders", () => {
const config = parseDotConfig({ name: "a", goal: "g", model: { provider: "openrouter", id: "a/b" }, computer: { memory: "16gb", disk: "512gb" } });
const config = parseDotConfig({ name: "a", model: { provider: "openrouter", id: "a/b" }, computer: { memory: "16gb", disk: "512gb" } });
expect(configToForm(config)).toMatchObject({ memoryGib: 16, diskGib: 512 });
});
});
+21 -31
View File
@@ -80,7 +80,6 @@ describe("the settings of a Dot", () => {
instructions: "Write to the workspace.",
models: { summary: "openai/gpt-5-mini" },
computer: { cpu: 3, memory: "6gb", disk: "50gb", idle_timeout: "1h" },
browser: { identities: { managed_by_dot: false, max_identities: 8, max_open: 2 } },
limits: { max_steps_per_task: 30, context_tokens: 16_000, max_cost_per_task_usd: 0.5 },
}),
}),
@@ -88,7 +87,6 @@ describe("the settings of a Dot", () => {
await renderSettings();
expect(screen.getByText("fares", { selector: "code" })).toBeTruthy();
expect(screen.queryByLabelText("Name")).toBeNull();
expect((field("Goal") as HTMLTextAreaElement).value).toBe("Watch the fares from Milan to Lisbon");
expect((field(/^Instructions/) as HTMLTextAreaElement).value).toBe("Write to the workspace.");
expect((field("Model") as HTMLInputElement).value).toBe("z-ai/glm-5.3-flash");
expect((field(/^Summary model/) as HTMLInputElement).value).toBe("openai/gpt-5-mini");
@@ -96,9 +94,6 @@ describe("the settings of a Dot", () => {
expect((field("Memory") as HTMLInputElement).value).toBe("6");
expect((field("Disk") as HTMLInputElement).value).toBe("50");
expect((field("Sleep after") as HTMLSelectElement).value).toBe("1h");
expect(screen.getByRole("switch", { name: "You manage its identities" }).getAttribute("aria-checked")).toBe("false");
expect((field("Most identities") as HTMLInputElement).value).toBe("8");
expect((field("Most open at once") as HTMLInputElement).value).toBe("2");
expect((field("Spending cap per task") as HTMLInputElement).value).toBe("0.5");
expect((field("Steps per task") as HTMLInputElement).value).toBe("30");
expect((field("Context tokens") as HTMLInputElement).value).toBe("16000");
@@ -110,7 +105,7 @@ describe("the settings of a Dot", () => {
it("has a section for each part of the config, and a danger zone last", async () => {
await renderSettings();
const headings = screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent);
expect(headings).toEqual(["General", "Model", "Permissions and tools", "Computer", "Browser", "Limits", "VM proxy", "Danger zone"]);
expect(headings).toEqual(["General", "Model", "Permissions and tools", "Computer", "Limits", "VM proxy", "Danger zone"]);
});
});
@@ -278,10 +273,10 @@ describe("the review before a save", () => {
it("discards the edits and goes back to what the host has", async () => {
await renderSettings();
await choose("Run commands", "Deny");
fireEvent.change(field("Goal"), { target: { value: "Something else" } });
fireEvent.change(field(/^Instructions/), { target: { value: "Something else" } });
await userEvent.setup().click(screen.getByRole("button", { name: "Discard changes" }));
expect(chosen("Run commands")).toBe("allow");
expect((field("Goal") as HTMLTextAreaElement).value).toBe("Watch the fares from Milan to Lisbon");
expect((field(/^Instructions/) as HTMLTextAreaElement).value).toBe("");
expect(screen.getByText("No changes.")).toBeTruthy();
});
@@ -322,12 +317,8 @@ describe("the other settings", () => {
expect((plane.updates[1]!.config as DotConfig).models).toEqual({});
});
it("saves the browser limits and the spending cap with the rest, in one patch", async () => {
it("saves the limits and the spending cap with the rest, in one patch", async () => {
await renderSettings();
const user = userEvent.setup();
await user.click(screen.getByRole("switch", { name: "The Dot manages its identities" }));
fireEvent.change(field("Most identities"), { target: { value: "9" } });
fireEvent.change(field("Most open at once"), { target: { value: "4" } });
fireEvent.change(field("Spending cap per task"), { target: { value: "0.25" } });
fireEvent.change(field("Steps per task"), { target: { value: "12" } });
fireEvent.change(field("Context tokens"), { target: { value: "8000" } });
@@ -337,19 +328,19 @@ describe("the other settings", () => {
await waitFor(() => expect(plane.updates).toHaveLength(1));
const config = plane.updates[0]!.config as DotConfig;
expect("memory" in config).toBe(false);
expect(config.browser.identities).toEqual({ managed_by_dot: false, max_identities: 9, max_open: 4 });
expect("browser" in config).toBe(false);
expect(config.limits).toEqual({ max_steps_per_task: 12, context_tokens: 8000, max_cost_per_task_usd: 0.25 });
});
it("says the rule between the browser limits where it is broken, and does not allow the save", async () => {
it("says what is wrong with a setting where it is, and does not allow the save", async () => {
await renderSettings();
fireEvent.change(field("Most identities"), { target: { value: "2" } });
expect(await screen.findByText("max_open (3) cannot exceed max_identities (2)")).toBeTruthy();
expect(field("Most open at once").getAttribute("aria-invalid")).toBe("true");
fireEvent.change(field("Model"), { target: { value: "two words" } });
expect(await screen.findByText("model id must not contain whitespace")).toBeTruthy();
expect(field("Model").getAttribute("aria-invalid")).toBe("true");
expect(reviewButton().disabled).toBe(true);
expect(screen.getByText("Some settings need fixing before this can be saved.")).toBeTruthy();
fireEvent.change(field("Most open at once"), { target: { value: "2" } });
await waitFor(() => expect(screen.queryByText(/cannot exceed max_identities/)).toBeNull());
fireEvent.change(field("Model"), { target: { value: "openai/gpt-5" } });
await waitFor(() => expect(screen.queryByText("model id must not contain whitespace")).toBeNull());
expect(reviewButton().disabled).toBe(false);
});
@@ -364,11 +355,10 @@ describe("the other settings", () => {
expect((plane.updates[0]!.config as DotConfig).computer.disk).toBe("60gb");
});
it("refuses a goal that is empty before it can be saved", async () => {
it("has no goal to set: what a Dot is for is what its person asks of it", async () => {
await renderSettings();
fireEvent.change(field("Goal"), { target: { value: " " } });
expect(await screen.findByText("goal must not be empty")).toBeTruthy();
expect(reviewButton().disabled).toBe(true);
await screen.findByLabelText(/^Instructions/);
expect(screen.queryByLabelText("Goal")).toBeNull();
});
});
@@ -422,12 +412,12 @@ describe("the YAML view", () => {
expect(yamlBox().value).toBe("name: [unclosed");
});
it("carries every option between the text and the form, the identity switch included", async () => {
it("carries every option between the text and the form, the step limit included", async () => {
await renderSettings();
await userEvent.setup().click(screen.getByRole("button", { name: "Advanced YAML" }));
fireEvent.change(yamlBox(), { target: { value: `${yamlBox().value.replace("managed_by_dot: true", "managed_by_dot: false")}` } });
fireEvent.change(yamlBox(), { target: { value: yamlBox().value.replace(/max_steps_per_task: \d+/, "max_steps_per_task: 7") } });
await userEvent.setup().click(screen.getByRole("button", { name: "Form" }));
expect(screen.getByRole("switch", { name: "You manage its identities" })).toBeTruthy();
expect((field("Steps per task") as HTMLInputElement).value).toBe("7");
});
});
@@ -456,10 +446,10 @@ describe("a config that changed under the edit", () => {
it("follows the host's config silently while nothing was edited", async () => {
await renderSettings();
const record = plane.dots[0]!;
record.config = setField(record.config, "goal", "A goal set elsewhere");
record.config = setField(record.config, "instructions", "Instructions set elsewhere");
record.config_version = 2;
act(() => plane.push("d1", "dot.updated", { name: "fares" }));
await waitFor(() => expect((field("Goal") as HTMLTextAreaElement).value).toBe("A goal set elsewhere"));
await waitFor(() => expect((field(/^Instructions/) as HTMLTextAreaElement).value).toBe("Instructions set elsewhere"));
expect(screen.queryByText("The configuration changed while you were editing")).toBeNull();
});
@@ -468,7 +458,7 @@ describe("a config that changed under the edit", () => {
await choose("Run commands", "Deny");
// The config changes behind the page's back, with no event heard (the stream is not the only way a save can be stale).
const record = plane.dots[0]!;
record.config = setField(record.config, "goal", "Changed in another tab");
record.config = setField(record.config, "instructions", "Changed in another tab");
record.config_version = 2;
await saveReviewed();
await waitFor(() => expect(plane.updates).toHaveLength(1));
@@ -477,7 +467,7 @@ describe("a config that changed under the edit", () => {
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.queryByText(/^Saved\./)).toBeNull();
expect(await screen.findByText("The configuration changed while you were editing")).toBeTruthy();
expect((field("Goal") as HTMLTextAreaElement).value).toBe("Changed in another tab");
expect((field(/^Instructions/) as HTMLTextAreaElement).value).toBe("Changed in another tab");
expect(chosen("Run commands")).toBe("deny");
expect(saved().permissions).toEqual({});
});
+3 -6
View File
@@ -48,15 +48,12 @@ async function renderDot(id = "d1") {
}
describe("the Dot header", () => {
it("shows the Dot's name, its goal on one line that opens on a click, and the tab's body below", async () => {
plane.dots = [dotRecord("d1", { name: "fares", config: { goal: "Watch fares\nand report" } as never })];
it("shows the Dot's name and no goal under it, and the tab's body below", async () => {
plane.dots = [dotRecord("d1", { name: "fares" })];
await renderDot();
expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("fares");
expect(screen.getByText("the tab body")).toBeTruthy();
const goal = screen.getByRole("button", { name: /Watch fares/ });
expect(goal.getAttribute("aria-expanded")).toBe("false");
await userEvent.click(goal);
expect(goal.getAttribute("aria-expanded")).toBe("true");
expect(screen.queryByRole("button", { name: /Show the whole goal/ })).toBeNull();
});
it("says the Dot cannot be loaded when the control plane has no such Dot", async () => {
+4 -5
View File
@@ -43,7 +43,7 @@ function card(name: string): HTMLElement {
function manyDots(count: number) {
return Array.from({ length: count }, (_, i) =>
dotRecord(`d${i}`, { name: `dot-${i}`, config: { goal: i === 3 ? "Sort the mail" : `goal ${i}`, model: { provider: "openrouter", id: "a/b" } } as never }),
dotRecord(i === 3 ? "d3" : `d${i}`, { name: i === 3 ? "mail-sorter" : `dot-${i}`, config: { model: { provider: "openrouter", id: "a/b" } } as never }),
);
}
@@ -144,12 +144,11 @@ describe("Home", () => {
expect(screen.queryByRole("region", { name: "Get this computer ready" })).toBeNull();
});
it("shows a card per Dot with its name, goal, state, model and what it spent today", async () => {
it("shows a card per Dot with its name, state, model and what it spent today", async () => {
plane.spentUsd = 1.5;
plane.dots = [dotRecord("d1", { name: "fares", config: { goal: "Watch the fares from Milan to Lisbon", model: { provider: "openrouter", id: "z-ai/glm" } } as never })];
plane.dots = [dotRecord("d1", { name: "fares", config: { model: { provider: "openrouter", id: "z-ai/glm" } } as never })];
await renderHome();
const fares = await screen.findByRole("article", { name: "fares" });
expect(within(fares).getByText("Watch the fares from Milan to Lisbon")).toBeTruthy();
expect(within(fares).getByText("Idle")).toBeTruthy();
expect(within(fares).getByText("z-ai/glm")).toBeTruthy();
await waitFor(() => expect(within(fares).getByText("$1.50")).toBeTruthy());
@@ -200,7 +199,7 @@ describe("Home", () => {
expect(within(card("quiet")).queryByText(/waiting/)).toBeNull();
});
it("offers the search only above six Dots, and narrows the cards by name, goal or model", async () => {
it("offers the search only above six Dots, and narrows the cards by name or model", async () => {
plane.dots = manyDots(6);
await renderHome();
await screen.findAllByRole("article");
+9 -12
View File
@@ -38,9 +38,8 @@ async function renderPage() {
await waitFor(() => expect(plane.streamOpen).toBe(true));
}
async function fillIdentity(name = "fare-watch", goal = "Watch the fares from Milan to Lisbon") {
async function fillIdentity(name = "fare-watch") {
await userEvent.type(screen.getByLabelText("Name"), name);
await userEvent.type(screen.getByLabelText("Goal"), goal);
}
describe("Create a Dot: the form", () => {
@@ -80,7 +79,6 @@ describe("Create a Dot: the form", () => {
expect(plane.created).toEqual([
{
name: "fare-watch",
goal: "Watch the fares from Milan to Lisbon",
instructions: "Write to fares.csv.",
model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" },
computer: { cpu: 4, memory: "8gb", disk: "40gb", idle_timeout: "30m" },
@@ -134,15 +132,14 @@ describe("Create a Dot: the form", () => {
});
describe("Create a Dot: what is wrong", () => {
it("does not create a Dot with no name and no goal, and lists what to fix", async () => {
it("does not create a Dot with no name, asks for no goal, and lists what to fix", async () => {
await renderPage();
expect(screen.queryByLabelText("Goal")).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "Create Dot" }));
expect(plane.created).toEqual([]);
const summary = await screen.findByRole("alert");
expect(summary.textContent).toContain("2 things to fix");
expect(within(summary).getAllByRole("listitem")).toHaveLength(2);
expect(within(summary).getAllByRole("listitem")).toHaveLength(1);
expect(screen.getByLabelText("Name").getAttribute("aria-invalid")).toBe("true");
expect(screen.getByLabelText("Goal").getAttribute("aria-invalid")).toBe("true");
await waitFor(() => expect(document.activeElement).toBe(summary.parentElement));
});
@@ -280,9 +277,9 @@ describe("Create a Dot: the YAML editor", () => {
it("lists what is wrong with the YAML as it is typed, and refuses to create from it", async () => {
await renderPage();
await userEvent.click(screen.getByRole("button", { name: "Advanced YAML" }));
// An empty form is already invalid: no name, no goal.
// An empty form is already invalid: it has no name.
const problems = screen.getByRole("list", { name: "Problems in the YAML" });
expect(within(problems).getAllByRole("listitem").length).toBeGreaterThanOrEqual(2);
expect(within(problems).getAllByRole("listitem").length).toBeGreaterThanOrEqual(1);
await userEvent.click(screen.getByRole("button", { name: "Create Dot" }));
expect(plane.created).toEqual([]);
});
@@ -302,13 +299,13 @@ describe("Create a Dot: the YAML editor", () => {
await renderPage();
await fillIdentity();
await userEvent.click(screen.getByRole("button", { name: "Advanced YAML" }));
fireEvent.change(yamlBox(), { target: { value: `${yamlBox().value}memory:\n enabled: false\n` } });
fireEvent.change(yamlBox(), { target: { value: yamlBox().value.replace("limits:\n", "limits:\n max_steps_per_task: 10\n") } });
await userEvent.click(screen.getByRole("button", { name: "Form" }));
expect(screen.getByText("The form cannot show this")).toBeTruthy();
expect(screen.getByText(/no controls for/)).toBeTruthy();
expect(yamlBox().value).toContain("enabled: false");
expect(yamlBox().value).toContain("max_steps_per_task: 10");
// Editing the text again takes the message away.
fireEvent.change(yamlBox(), { target: { value: yamlBox().value.replace("enabled: false", "enabled: true") } });
fireEvent.change(yamlBox(), { target: { value: yamlBox().value.replace(" max_steps_per_task: 10\n", "") } });
expect(screen.queryByText("The form cannot show this")).toBeNull();
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { PERMISSIONS, parseDotConfig, resolvePermission, toRuntimeConfig } from
import { describe, expect, it } from "vitest";
import { PRESET_IDS, PRESETS, presetOf, presetPermissions } from "../src/lib/permission-presets";
const BASE = { name: "p", goal: "g", model: { provider: "openrouter", id: "a/b" } };
const BASE = { name: "p", model: { provider: "openrouter", id: "a/b" } };
function resolved(preset: (typeof PRESET_IDS)[number]) {
return toRuntimeConfig(parseDotConfig({ ...BASE, permissions: presetPermissions(preset) })).permissions;
-1
View File
@@ -4,7 +4,6 @@ import { parseDotConfig, type DotConfig } from "@invisible-dots/shared/browser";
export function fullConfig(change: Record<string, unknown> = {}): DotConfig {
return parseDotConfig({
name: "fare-watch",
goal: "Watch the fares from Milan to Lisbon",
model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" },
...change,
});
+1 -1
View File
@@ -12,7 +12,7 @@ export function dotRecord(id: string, change: Partial<DotSummary> = {}): DotSumm
id,
name: id,
// The API parses every config with the schema's defaults, so these two always exist on a real record.
config: { goal: `the goal of ${id}`, permissions: {} } as unknown as DotConfig,
config: { permissions: {} } as unknown as DotConfig,
status: "READY",
error: null,
created_at: "2026-01-01T00:00:00Z",
+2 -12
View File
@@ -4,8 +4,6 @@ import { toYaml } from "../src/lib/yaml";
/** The example of the architecture document: every section of a config, written the way a person would. */
const EXAMPLE_CONFIG = `name: fare-watch
goal: >
Check one-way fares from Milan to Lisbon every morning and report the cheapest day.
instructions: >
Write findings to ~/workspace/fares.csv.
model:
@@ -16,16 +14,9 @@ computer:
memory: 4gb
disk: 40gb
idle_timeout: 15m
browser:
identities:
managed_by_dot: true
max_identities: 20
max_open: 3
permissions:
computer.exec: allow
browser.identity.delete: ask
memory:
enabled: true
limits:
max_steps_per_task: 60
context_tokens: 32000
@@ -42,7 +33,6 @@ describe("toYaml", () => {
it("keeps strings that YAML would otherwise read as other types", () => {
const config = parseDotConfig({
name: "tricky",
goal: "yes",
instructions: "key: value # not a comment\n indented second line\n",
model: { provider: "openrouter", id: "vendor/model:free" },
models: { summary: "0123" },
@@ -51,13 +41,13 @@ describe("toYaml", () => {
});
const text = toYaml(config);
expect(parseDotConfig(text)).toEqual(config);
expect(text).toContain('goal: "yes"');
expect(toYaml({ answer: "yes" })).toBe('answer: "yes"\n');
});
it("writes multi-line text as a literal block and round-trips edge cases", () => {
const cases = ["one\ntwo", "one\ntwo\n", " leading\nspace", "a\n\n\nb", "trailing\n\n", "tab\there\nx", "#hash\n- dash"];
for (const instructions of cases) {
const config = parseDotConfig({ name: "m", goal: "g", instructions, model: { provider: "openrouter", id: "a/b" } });
const config = parseDotConfig({ name: "m", instructions, model: { provider: "openrouter", id: "a/b" } });
expect(parseDotConfig(toYaml(config)).instructions).toBe(instructions);
}
const block = toYaml({ text: "one\ntwo" });
+24 -31
View File
@@ -8,7 +8,7 @@ change the document in the same commit.
A Dot is a persistent agent that owns a computer. Every Dot has:
- a configuration (name, goal, instructions, model, permissions, resources),
- a configuration (name, instructions, model, permissions, resources),
- its own QEMU virtual machine (hardware accelerated) with a persistent qcow2 disk,
- inside that VM, its own agent runtime, which calls models through OpenRouter,
- its own memory, conversation and task state, stored inside the VM,
@@ -730,7 +730,7 @@ command detached into another session outlives it.
| `GET /events/stream` | `?after=<seq>` | `text/event-stream`, one SSE message per outbound event, `id: <seq>` |
| `GET /state` | | `{ state, current_task_id, pending_approval }`; `pending_approval` is the id of the oldest approval the engine waits on, or `null` |
| `GET /browser-identities` | | `{ identities: BrowserIdentity[] }`, oldest first; an identity says `hasProxy` and nothing of its proxy, which is a secret |
| `POST /browser-identities` | `{ name, proxy? }`; `proxy` is an explicit option: absent, `null` or blank means none, the normal case | `201 BrowserIdentity`; `400 invalid` (a name that is empty or too long, or a proxy that is not a string; a proxy that is a string is kept as written and judged by invisible-playwright-mcp when the identity launches), `409 limit` (`max_identities`) |
| `POST /browser-identities` | `{ name, proxy? }`; `proxy` is an explicit option: absent, `null` or blank means none, the normal case | `201 BrowserIdentity`; `400 invalid` (a name that is empty or too long, or a proxy that is not a string; a proxy that is a string is kept as written and judged by invisible-playwright-mcp when the identity launches), `409 limit` (the 20 identities the engine keeps at most) |
| `GET /browser-identities/:id` | | `BrowserIdentity`; `404 not_found` |
| `DELETE /browser-identities/:id` | | `204` after the identity's browser is closed and its directory removed; `404 not_found` |
| `GET /browser-identities/:id/frame` | | `200 image/jpeg` (`Cache-Control: no-store`), one frame of the identity's window, taken with the server's `browser_watch`; `404 not_found`; `409 not_open` when the identity is closed; `503 busy` when a call of the Dot held the identity for longer than 5 seconds; `502 frame_failed` (the server has no page to show, or sent a frame that is not a JPEG: the engine is the one owner of that rule, the host passes the bytes on as `image/jpeg`) or `crashed` |
@@ -1011,9 +1011,10 @@ ignores anything else.
identity's proxy never changes after it is created (there is no route that
edits it), so the two agree, but a proxy edited in the engine's database would
not reach a browser whose session file already exists.
- At most `browser.identities.max_open` identities are open at once (default
3, roughly 0.8 GB of memory each). Launching one more closes the least
recently used. At most `browser.identities.max_identities` exist.
- At most 3 identities are open at once (roughly 0.8 GB of memory each).
Launching one more closes the least recently used. At most 20 exist. Both are
the engine's own (`DEFAULT_MAX_OPEN` and `DEFAULT_MAX_IDENTITIES` in
`nanobot/dots/main.py`), not settings: the Dot manages its identities itself.
- Launching is explicit. A browser action on an identity that is not open does
not launch it: it fails with "identity <id> is not open; call
browser_identity_launch first". Each permission therefore decides only its own
@@ -1059,9 +1060,7 @@ YAML in, validated by one schema in `packages/shared` (zod):
```yaml
name: fare-watch # [a-z0-9-], 1..40
goal: >
Check one-way fares from Milan to Lisbon every morning and report the cheapest day.
instructions: > # optional
instructions: > # optional: how it should work, in its system prompt
Write findings to ~/workspace/fares.csv.
model:
provider: openrouter # the only accepted value
@@ -1073,11 +1072,6 @@ computer:
memory: 4gb # 2gb..64gb
disk: 40gb # 20gb..1024gb
idle_timeout: 15m # sleep after this long with nothing to do; 0 = never
browser:
identities:
managed_by_dot: true # the Dot may create and delete identities itself
max_identities: 20
max_open: 3
permissions: # allow | ask | deny, keyed by permission name
computer.exec: allow
browser.identity.delete: ask
@@ -1087,10 +1081,14 @@ limits:
max_cost_per_task_usd: 1.00 # USD of model spend of a task or a chat turn, 0.01..100; the last request may exceed it (section 8.2)
```
A Dot has no goal: what it is for is what its person asks of it, in the chat, in a task or in its
instructions. Nor does it have browser settings: it manages its identities itself, within the
engine's limits (section 6). A config saved with either before (migrations `0010_dot_has_no_goal.sql`
and `0011_dot_has_no_browser_settings.sql` take them out) is refused as an unknown key.
The ranges and defaults of the numbers in that file (`computer.cpu`, `memory`,
`disk`, the default `idle_timeout`, `limits.max_cost_per_task_usd`,
`limits.max_steps_per_task`, `limits.context_tokens` and
`browser.identities.max_identities` and `max_open`) are `CONFIG_BOUNDS` in
`limits.max_steps_per_task` and `limits.context_tokens`) are `CONFIG_BOUNDS` in
`packages/shared`. The schema takes its numbers from it and
so does the web client's form, so a slider can never offer what the API refuses.
@@ -1207,8 +1205,8 @@ does not know.
| `cron` | `automations` | adds, lists and removes the Dot's own scheduled automations |
| `computer_screenshot` | `computer.screenshot` | takes a screenshot of the Dot's whole desktop and shows it to the model (no arguments) |
| `browser_identity_list` | `browser.identity.list` | lists the browser identities with their status (open or available), whether each has a proxy of its own (and nothing of the proxy), and the two limits |
| `browser_identity_create` | `browser.identity.create` | makes an identity, closed: `name`, `proxy?` (an explicit option that the model leaves out unless the person gave one; offered only when `managed_by_dot`) |
| `browser_identity_delete` | `browser.identity.delete` | closes an identity and deletes it with its profile: `identity_id` (offered only when `managed_by_dot`) |
| `browser_identity_create` | `browser.identity.create` | makes an identity, closed: `name`, `proxy?` (an explicit option that the model leaves out unless the person gave one) |
| `browser_identity_delete` | `browser.identity.delete` | closes an identity and deletes it with its profile: `identity_id` |
| `browser_identity_launch` | `browser.identity.launch` | opens the browser of an identity, closing the least recently used one at `max_open`: `identity_id` |
| `browser_identity_close` | `browser.identity.close` | closes the browser of an identity, keeping its profile: `identity_id` |
| `browser_navigate` | `browser.navigate` | loads an `http://` or `https://` URL and no other (`file:`, `about:`, `view-source:`, `data:` and `javascript:` are refused, so the permission to navigate is not a permission to read files): `identity_id, url` |
@@ -1229,9 +1227,7 @@ The browser tools are served by the `BrowserManager` and so by
`invisible-playwright-mcp`, the only browser of a Dot; each takes an
`identity_id`, and the model never sees the MCP server's own tool names. A
browser action on an identity that is not open does not launch it, it says so
(section 6). When `managed_by_dot` is false, `browser_identity_create` and
`browser_identity_delete` are not offered at all. A screenshot is shown to the
model and not stored (section 6).
(section 6). A screenshot is shown to the model and not stored (section 6).
The tool calls of one response run one at a time, in the order the model
gave them. Only the response's `tool_calls` count: a call written in the
@@ -1471,7 +1467,7 @@ other engine owns the state.
table whose permission is `allow` or `ask`, minus the memory tools when
memory is off), `max_steps_per_task` as the step limit, `context_tokens` as
the context budget, the 12000-character result cap, and the Dot's section of
the system prompt (its name, goal and instructions, then nanobot's tool
the system prompt (its name and instructions, then nanobot's tool
contract, a short note on its computer, the memory notes and the time).
There is no config file, no installer and no sudo rule. The same config
again changes nothing, and the same key again builds no provider
@@ -1520,8 +1516,7 @@ other engine owns the state.
holds the offered ones, so a denied tool is not even seen, and the gate
decides every call by the same table. The table also says which arguments
of a call an `approval.requested` may carry (all of them, but for the proxy
of `browser_identity_create`) and which tools exist only while the
Dot manages its browser identities itself (`browser.identities.managed_by_dot`).
of `browser_identity_create`).
The engine has no MCP server of its own to configure: the one server it runs
is `invisible-playwright-mcp`, and only through the `BrowserManager`, on a
registry no turn sees.
@@ -1533,7 +1528,7 @@ other engine owns the state.
`dot-agentd relay` with the environment of section 6 and nanobot's MCP client
on a registry of its own. Launch, close and delete run one at a time; calls on
one identity run one at a time. At `max_open` a launch closes the least
recently used identity first; a lower `max_identities` deletes nothing. A
recently used identity first. A
browser action on an identity that is not open fails with `not_open` and
never launches it. A browser that the server reports gone while its process
lives (Firefox crashed, its window was closed) is not reopened and the call is
@@ -1556,10 +1551,8 @@ other engine owns the state.
`browser_close` first, so Firefox flushes its profile, then ends the process.
Every `browser.identity.*` event commits with the row change it describes.
The model's identity and page tools (`browser_tools.py`) and the routes of
section 5.3 are the callers; a config that arrives applies `max_open` and
`max_identities` to the manager at once (a lower `max_open` closes the excess
open browsers, least recently used first), and the manager starts with the
host schema's defaults (3 and 20) until it does. A page tool names the one
section 5.3 are the callers; `max_open` and `max_identities` (3 and 20) are
set when the manager is made and nothing changes them. A page tool names the one
tool of the MCP server it calls (`PAGE_TOOLS`) and the manager adds
`browser: "main"` to every call.
- Images. A tool's image is shown to the model and kept out of the stored
@@ -2036,7 +2029,7 @@ is no other stylesheet: every screen is utilities over the tokens. The browser t
real control plane in-process over the fake VM layer and the built web client
against it (`e2e/harness.ts`), so a test drives what a Dot's computer does.
Home (`/`) is a card per Dot: its avatar ring, name and goal, the state with the
Home (`/`) is a card per Dot: its avatar ring and name, the state with the
recorded reason beside an ERROR, the model, what it spent today, the approvals
that wait, Open chat and the computer's power menu. Each card's spend pill is
scoped to its own Dot, so a message of one Dot re-reads only that Dot's usage.
@@ -2077,7 +2070,7 @@ own key row is left out, so the key is said once. Submitting is `POST /api/dots`
A Dot's settings (`/dots/<id>/settings`) are the same config, edited in place:
one draft of the whole `DotConfig`, as a form (General, Model, Permissions and
tools, Computer, Browser, Limits) or as the same YAML, never a second
tools, Computer, Limits) or as the same YAML, never a second
model of it. `lib/config-fields.ts` is the one table of what can change (where
each field lives, its words, when a change reaches the Dot) and gives what the
page needs from it: the list of changes, the notice a save ends with, and the
@@ -2118,7 +2111,7 @@ opened under this Dot's address is shown as missing. The story and the progress
the page reads from its start, asking only for the event types of a task's
story (`lib/event-log.ts` is the one function that pages through the route's
`types` filter) only once a task is running
or open, and then keeps current from the live stream. The newest progress line of a running task is also under the goal in
or open, and then keeps current from the live stream. The newest progress line of a running task is also under the name in
the Dot header. A task that waits for an answer shows its approval as a card on its own card and in its
drawer, answerable there (see the Inbox below).
-2
View File
@@ -65,8 +65,6 @@ and the checks read that file. What they pin:
browser closed under it is not reopened and the call is not repeated: the call fails saying the browser
is gone and to launch the identity again, the identity is closed (one `closed` event, its server ended,
no slot held, so the next launch closes nothing);
- `managed_by_dot` false drops the tools that create and delete identities from the offered list, whatever
the permissions say;
- `browser.identity.delete: ask` parks the call and the approval survives `kill -9`, which also ends every
server; after the restart every identity is `available`; SIGTERM asks an open browser to close before
its server ends; a host `DELETE` of an open identity closes it first;
+1 -2
View File
@@ -137,14 +137,13 @@ wait_health() {
# and calls push.
reset_config() {
echo '{"computer.exec":"allow"}' > /tmp/perms.json; chmod 0644 /tmp/perms.json
echo '{"managed_by_dot":true,"max_identities":20,"max_open":3}' > /tmp/browser.json; chmod 0644 /tmp/browser.json # the config's browser.identities
echo '{}' > /tmp/models.json; chmod 0644 /tmp/models.json # the config's models (the summary role)
echo 32000 > /tmp/context.json; chmod 0644 /tmp/context.json # the config's limits.context_tokens
}
push() {
api -o /dev/null -w '%{http_code}' -X POST -H 'content-type: application/json' -d "{\"openrouter_api_key\":\"$KEY\"}" "$A/secrets"
echo -n " "
api -o /dev/null -w '%{http_code}' -X PUT -H 'content-type: application/json' -d '{"name":"smoke","goal":"Answer the smoke test.","model":{"provider":"openrouter","id":"openai/gpt-4o-mini"},"browser":{"identities":'"$(cat /tmp/browser.json)"'},"permissions":'"$(cat /tmp/perms.json)"',"models":'"$(cat /tmp/models.json)"',"limits":{"max_steps_per_task":60,"context_tokens":'"$(cat /tmp/context.json)"',"max_cost_per_task_usd":1}}' "$A/config"
api -o /dev/null -w '%{http_code}' -X PUT -H 'content-type: application/json' -d '{"name":"smoke","model":{"provider":"openrouter","id":"openai/gpt-4o-mini"},"permissions":'"$(cat /tmp/perms.json)"',"models":'"$(cat /tmp/models.json)"',"limits":{"max_steps_per_task":60,"context_tokens":'"$(cat /tmp/context.json)"',"max_cost_per_task_usd":1}}' "$A/config"
}
# What the fake host runs on every agent.started: the same push, as a script.
write_push_script() { { declare -f api push; echo "H=(-sS -H 'Authorization: Bearer $TOKEN'); A=$A; KEY=$KEY; push"; } > /tmp/push.sh; }
+15 -19
View File
@@ -386,42 +386,38 @@ check "/state is IDLE with nothing pending" "st | jq -e '.state==\"IDLE\" and .p
# lists the tools it was offered. The expected list is the permission table of
# nanobot/dots/permissions.py, written out here: a tool the engine offers that the
# table does not name (an MCP tool, a core tool left in) makes a list differ.
offered_with() { # n, permissions json, managed_by_dot: the tools the model is offered in a chat turn
offered_with() { # n, permissions json: the tools the model is offered in a chat turn
echo "$2" > /tmp/perms.json
echo "{\"managed_by_dot\":$3,\"max_identities\":20,\"max_open\":3}" > /tmp/browser.json
[ "$(push)" = '204 204' ] || return 1
[ "$(ev "msg-tools-$1" user.message '{"text":"which tools?"}')" = 202 ] || return 1
wait_event $STREAM ".type==\"message.assistant\" and .data.in_reply_to==\"msg-tools-$1\"" || return 1
tail -1 /tmp/fake-tools.jsonl | jq -c '.tools|sort'
}
check_offered() { # n, label, permissions json, managed_by_dot, expected tools (sorted JSON)
local got; got=$(offered_with "$1" "$3" "$4")
check_offered() { # n, label, permissions json, expected tools (sorted JSON)
local got; got=$(offered_with "$1" "$3")
echo "offered ($2): $got"
check "offered tools: $2" "[ '$got' = '$5' ]"
check "offered tools: $2" "[ '$got' = '$4' ]"
}
BROWSER_GRANTED='"computer.screenshot":"allow","browser.identity.list":"allow","browser.identity.create":"allow","browser.identity.delete":"ask","browser.identity.launch":"allow","browser.identity.close":"allow","browser.navigate":"allow","browser.read":"allow","browser.act":"allow"'
check_offered 1 "every permission granted (files.write and browser.identity.delete ask), the Dot manages its identities" \
'{"computer.exec":"allow","files.read":"allow","files.write":"ask","automations":"allow",'"$BROWSER_GRANTED"'}' true \
check_offered 1 "every permission granted (files.write and browser.identity.delete ask)" \
'{"computer.exec":"allow","files.read":"allow","files.write":"ask","automations":"allow",'"$BROWSER_GRANTED"'}' \
'["apply_patch","browser_back","browser_click","browser_click_at","browser_forward","browser_identity_close","browser_identity_create","browser_identity_delete","browser_identity_launch","browser_identity_list","browser_navigate","browser_press_key","browser_read_text","browser_reload","browser_screenshot","browser_scroll","browser_select_option","browser_snapshot","browser_type","computer_screenshot","cron","edit_file","exec","exec_session","find_files","grep","list_dir","list_exec_sessions","read_file","write_file"]'
check_offered 2 "exec denied, files.read allowed, the rest missing from the map (deny)" \
'{"computer.exec":"deny","files.read":"allow"}' true \
'{"computer.exec":"deny","files.read":"allow"}' \
'["find_files","grep","list_dir","read_file"]'
check_offered 3 "only exec allowed: the command tools and nothing else" \
'{"computer.exec":"allow"}' true \
'{"computer.exec":"allow"}' \
'["exec","exec_session","list_exec_sessions"]'
check_offered 4 "an empty permission map offers nothing" '{}' true '[]'
check_offered 5 "managed_by_dot false: every browser permission granted, yet no tool that creates or deletes an identity" \
'{'"$BROWSER_GRANTED"'}' false \
'["browser_back","browser_click","browser_click_at","browser_forward","browser_identity_close","browser_identity_launch","browser_identity_list","browser_navigate","browser_press_key","browser_read_text","browser_reload","browser_screenshot","browser_scroll","browser_select_option","browser_snapshot","browser_type","computer_screenshot"]'
check_offered 6 "managed_by_dot false: browser.identity.delete asks and browser.read is allowed; only the reading tools are offered" \
'{"browser.identity.delete":"ask","browser.read":"allow"}' false \
'["browser_read_text","browser_screenshot","browser_snapshot"]'
check_offered 7 "managed_by_dot true: delete asks, create is denied, nothing else" \
'{"browser.identity.delete":"ask","browser.identity.create":"deny"}' true \
check_offered 4 "an empty permission map offers nothing" '{}' '[]'
check_offered 5 "browser.identity.delete asks and browser.read is allowed: the delete and the reading tools" \
'{"browser.identity.delete":"ask","browser.read":"allow"}' \
'["browser_identity_delete","browser_read_text","browser_screenshot","browser_snapshot"]'
check_offered 6 "delete asks, create is denied, nothing else" \
'{"browser.identity.delete":"ask","browser.identity.create":"deny"}' \
'["browser_identity_delete"]'
# GET /tools is the same table seen from the host: the whole table, and `offered` says what the model got.
# The map of check 3 is pushed again: the model was offered exactly exec and its two sessions tools.
offered_with 3b '{"computer.exec":"allow"}' true >/dev/null
offered_with 3b '{"computer.exec":"allow"}' >/dev/null
check "GET /tools through dot-agentd lists the 30 tools of the table, each with a description" "api $A/tools | jq -e '(.tools|length)==30 and all(.tools[]; (.description|length)>0 and (.permission|length)>0)' >/dev/null"
check "GET /tools offers what the model was offered" "[ \"\$(api $A/tools | jq -c '[.tools[]|select(.offered)|.name]|sort')\" = '[\"exec\",\"exec_session\",\"list_exec_sessions\"]' ]"
check "GET /tools names the permission each tool exercises" "api $A/tools | jq -e '(.tools|map({(.name):.permission})|add) | .exec==\"computer.exec\" and .read_file==\"files.read\" and .write_file==\"files.write\" and .cron==\"automations\"' >/dev/null"
@@ -395,19 +395,6 @@ class BrowserManager:
if tasks:
await asyncio.wait(tasks)
def set_limits(self, max_open: int, max_identities: int) -> None:
"""Apply new limits from a config change, and return at once.
Identities beyond a lower `max_open` are closed, least recently used first, and the others stay
open. Those closes run in the background (each can take `close_timeout_s`), and from this call on
the identities no longer count as open. A lower `max_identities` deletes nothing: it refuses new
identities until enough are deleted.
"""
_check_limits(max_open, max_identities)
self._max_open = max_open
self._max_identities = max_identities
self._make_room(max_open)
async def call_tool(self, identity_id: str, tool: str, arguments: Mapping[str, Any] | None = None) -> Any:
"""Call a tool of the identity's MCP server, with `browser: "main"` added to its arguments.
@@ -228,19 +228,6 @@ class Engine:
"""The Dot's browser identities: the engine closes their browsers when it stops work."""
return self._browser
def apply_browser_limits(self) -> None:
"""Give the browser manager the limits of the config: a lower `max_open` closes the excess open browsers
in the background.
Called after a config arrives (`PUT /config`) and after the stored one was applied at start; the
same limits again change nothing.
"""
config = self._config
if config is None:
return
identities = config.browser.identities
self._browser.set_limits(identities.max_open, identities.max_identities)
def state_answer(self) -> StateAnswer:
def answer(conn: sqlite3.Connection) -> StateAnswer:
running = dots_store.get_running_task(conn)
@@ -87,8 +87,8 @@ def check_identity_request(
if existing_count >= max_identities:
raise IdentityRequestError(
"limit",
f"this Dot already has {existing_count} browser identities, the most its configuration "
f"allows (max_identities {max_identities}); delete one first",
f"this Dot already has {existing_count} browser identities, the most it may keep "
f"(max_identities {max_identities}); delete one first",
)
return IdentityRequest(name, proxy)
+3 -3
View File
@@ -46,8 +46,9 @@ DEFAULT_STATE_DIR = "/home/dotengine/state"
UPSTREAM_COMMIT = "f75470e7"
# How often work that waits is looked at again, in seconds.
RETRY_INTERVAL_S = 5.0
# The browser limits until the host pushes the config, which replaces them at once: the defaults of
# the host's schema (`browser.identities` in packages/shared config.ts).
# The browser limits: at most this many identities open at once (each holds about 0.8 GB of the computer's
# memory; opening one more closes the one used least recently), and at most this many in all. They are the
# engine's own, not a setting: the Dot manages its identities itself.
DEFAULT_MAX_OPEN = 3
DEFAULT_MAX_IDENTITIES = 20
@@ -173,7 +174,6 @@ async def serve(environment: Environment, stop: asyncio.Event) -> None:
retry: asyncio.Task[None] | None = None
try:
engine.start()
engine.apply_browser_limits()
await cron.start()
retry = asyncio.get_running_loop().create_task(_retry(engine))
await stop.wait()
@@ -50,8 +50,6 @@ class ToolEntry:
build: factory taking ToolDeps to instantiate the Tool.
target: from the call's arguments, the one line `tool.called` shows of it (None: nothing).
starts_terminal: from the call's arguments, whether it starts a terminal session.
needs_managed_identities: the tool exists only while the Dot may manage its browser identities itself
(`browser.identities.managed_by_dot`).
arguments: from the call's arguments, the ones an `approval.requested` shows the person.
"""
@@ -60,7 +58,6 @@ class ToolEntry:
target: Callable[[Mapping[str, Any]], str | None]
# Whether the call starts a terminal session, which `tool.called` marks (`tty`); no other tool does.
starts_terminal: Callable[[Mapping[str, Any]], bool] = targets.never_starts_terminal
needs_managed_identities: bool = False
arguments: Callable[[Mapping[str, Any]], dict[str, Any]] = targets.all_arguments
@@ -192,8 +189,8 @@ TOOL_PERMISSIONS: Mapping[str, ToolEntry] = MappingProxyType(
"cron": ToolEntry("automations", _build_cron, targets.cron_target),
"computer_screenshot": ToolEntry("computer.screenshot", _build_computer_screenshot, targets.no_target),
"browser_identity_list": ToolEntry("browser.identity.list", _build_browser_identity_list, targets.no_target),
"browser_identity_create": ToolEntry("browser.identity.create", _build_browser_identity_create, targets.identity_name_target, needs_managed_identities=True, arguments=targets.identity_create_arguments),
"browser_identity_delete": ToolEntry("browser.identity.delete", _build_browser_identity_delete, targets.identity_target, needs_managed_identities=True),
"browser_identity_create": ToolEntry("browser.identity.create", _build_browser_identity_create, targets.identity_name_target, arguments=targets.identity_create_arguments),
"browser_identity_delete": ToolEntry("browser.identity.delete", _build_browser_identity_delete, targets.identity_target),
"browser_identity_launch": ToolEntry("browser.identity.launch", _build_browser_identity_launch, targets.identity_target),
"browser_identity_close": ToolEntry("browser.identity.close", _build_browser_identity_close, targets.identity_target),
"browser_navigate": ToolEntry("browser.navigate", _build_page_tool("browser_navigate"), targets.browser_navigate_target, arguments=targets.navigate_arguments),
@@ -246,19 +243,10 @@ def tool_arguments(tool_name: str, params: Mapping[str, Any]) -> dict[str, Any]:
return entry.arguments(params) if entry else dict(params)
def offered_tools(permissions: Mapping[str, str], *, managed_identities: bool = True) -> list[str]:
"""The tools the model is offered, sorted.
A tool is offered when its permission is allow or ask (a permission missing
from the map is deny), and for a tool that creates or deletes browser
identities when the Dot manages them itself.
"""
return sorted(
name
for name, entry in TOOL_PERMISSIONS.items()
if permissions.get(entry.permission) in ("allow", "ask")
and (managed_identities or not entry.needs_managed_identities)
)
def offered_tools(permissions: Mapping[str, str]) -> list[str]:
"""The tools the model is offered, sorted: those whose permission is allow or ask (a permission missing
from the map is deny)."""
return sorted(name for name, entry in TOOL_PERMISSIONS.items() if permissions.get(entry.permission) in ("allow", "ask"))
def tool_table(registry: ToolRegistry, offered: Collection[str]) -> list[dict[str, Any]]:
@@ -43,7 +43,7 @@ class EngineSettings:
def project(config: DotRuntimeConfig, *, workspace: str, openrouter_base_url: str | None) -> EngineSettings:
offered = offered_tools(config.permissions, managed_identities=config.browser.identities.managed_by_dot)
offered = offered_tools(config.permissions)
named = config.models or {}
return EngineSettings(
model_id=config.model.id,
@@ -60,8 +60,8 @@ def project(config: DotRuntimeConfig, *, workspace: str, openrouter_base_url: st
def dot_prompt_section(config: DotRuntimeConfig) -> str:
"""The system prompt section that tells the model whose Dot it is and what it is for."""
lines = [f'You are the Dot "{config.name}". Your goal:', config.goal.strip()]
"""The system prompt section that tells the model whose Dot it is, and what its person asked of it."""
lines = [f'You are the Dot "{config.name}".']
instructions = (config.instructions or "").strip()
if instructions:
lines += ["", "Instructions from the person who owns you:", instructions]
@@ -144,7 +144,6 @@ T = TypeVar("T")
NonEmptyStr = Annotated[str, Field(min_length=1, strict=True)]
StrictInt = Annotated[int, Field(strict=True)]
PositiveInt = Annotated[int, Field(strict=True, gt=0)]
StrictBool = Annotated[bool, Field(strict=True)]
def _refuse_null(value: T, expected: str) -> T:
@@ -277,16 +276,6 @@ class ModelConfig(_Open):
id: NonEmptyStr
class BrowserIdentitiesConfig(_Open):
managed_by_dot: StrictBool
max_identities: PositiveInt
max_open: PositiveInt
class BrowserConfig(_Open):
identities: BrowserIdentitiesConfig
class LimitsConfig(_Open):
max_steps_per_task: PositiveInt
context_tokens: Annotated[int, Field(strict=True, ge=4000, le=1_000_000)]
@@ -301,11 +290,9 @@ class DotRuntimeConfig(_Open):
"""
name: Annotated[str, Field(strict=True, pattern=r"^[a-z0-9-]{1,40}$")]
goal: NonEmptyStr
instructions: Annotated[str, Field(strict=True)] | None = None
model: ModelConfig
models: dict[str, NonEmptyStr] | None = None
browser: BrowserConfig
permissions: dict[str, Literal["allow", "ask", "deny"]]
limits: LimitsConfig
@@ -286,7 +286,6 @@ class AgentServer:
engine.set_config(body)
except DotsConfigError as error:
raise HttpError(400, "invalid_config", str(error)) from None
engine.apply_browser_limits()
return web.Response(status=204)
if path == AGENT_ROUTES["events"]:
+1 -1
View File
@@ -42,9 +42,9 @@ from nanobot.dots.gate import close_open_calls
from nanobot.dots.images import TurnImages, bind_turn_images, reset_turn_images
from nanobot.dots.permissions import tool_starts_terminal, tool_target
from nanobot.dots.projection import EngineSettings
from nanobot.dots.skills import all_skills
from nanobot.dots.provider import OpenRouterProviders
from nanobot.dots.secrets import KeyHolder
from nanobot.dots.skills import all_skills
from nanobot.dots.spend import CostCapReached, TurnSpend
from nanobot.dots.store import CHAT_SESSION_KEY, DotStore, ToolIntent
from nanobot.providers.base import ToolCallRequest
+1
View File
@@ -87,3 +87,4 @@ ignore = ["E501"]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["tests"]
markers = ["limits(max_open, max_identities): the browser manager's limits for a test of the browser tools"]
@@ -8,7 +8,7 @@ from nanobot.agent.context import ContextBuilder, TranscriptInput
from nanobot.dots.skills import Skill
NOW = datetime(2026, 10, 5, 14, 30, tzinfo=timezone.utc)
DOT = 'You are the Dot "fare-watch". Your goal:\nWatch fares.'
DOT = 'You are the Dot "fare-watch".'
def builder(memory_notes: tuple[str, ...] = ()) -> ContextBuilder:
@@ -3,7 +3,6 @@
{
"name": "shapes-min",
"config": {
"goal": "keep watch",
"name": "shapes-min",
"model": {
"id": "test/model",
@@ -15,13 +14,6 @@
"max_cost_per_task_usd": 1
},
"models": {},
"browser": {
"identities": {
"max_open": 3,
"managed_by_dot": true,
"max_identities": 20
}
},
"permissions": {
"computer.exec": "allow",
"computer.screenshot": "allow",
@@ -42,7 +34,6 @@
{
"name": "shapes-full",
"config": {
"goal": "keep watch",
"name": "shapes-full",
"model": {
"id": "test/model",
@@ -56,13 +47,6 @@
"models": {
"summary": "test/small"
},
"browser": {
"identities": {
"max_open": 2,
"managed_by_dot": false,
"max_identities": 5
}
},
"permissions": {
"computer.exec": "deny",
"computer.screenshot": "allow",
@@ -651,37 +651,6 @@ async def test_a_lost_browser_frees_its_slot_of_max_open(env: Env) -> None:
assert env.event_types().count("browser.identity.closed") == 1
async def test_set_limits_closes_only_the_sessions_beyond_a_lower_max_open_and_caps_new_identities(env: Env) -> None:
manager = env.manager(max_open=3, max_identities=5)
a, b, c = [await manager.create(name) for name in "abc"]
for identity in (a, b, c):
await manager.launch(identity.id)
manager.set_limits(1, 3)
assert manager.limits == (1, 3)
assert [manager.is_open(i.id) for i in (a, b, c)] == [False, False, True]
with pytest.raises(BrowserIdentityError, match="max_identities 3"):
await manager.create("d")
manager.set_limits(2, 4)
await manager.launch(a.id)
assert manager.open_count == 2
assert (await manager.create("d")).name == "d"
with pytest.raises(ValueError, match="max_open"):
manager.set_limits(0, 4)
assert manager.limits == (2, 4)
async def test_a_lower_max_identities_deletes_nothing(env: Env) -> None:
manager = env.manager(max_identities=5)
for name in "abc":
await manager.create(name)
manager.set_limits(1, 1)
assert len(manager.list_identities()) == 3
# ---------------------------------------------------------------------------
# Close
# ---------------------------------------------------------------------------
@@ -818,7 +787,7 @@ async def stop_launch(launching: asyncio.Task[Any]) -> None:
await launching
async def test_a_launch_in_flight_holds_up_no_other_identity_and_no_config_change(env: Env) -> None:
async def test_a_launch_in_flight_holds_up_no_other_identity(env: Env) -> None:
manager = env.manager(max_open=3, open_retry_initial_s=0.05, open_retry_max_s=0.05)
other = await manager.create("other")
spare = await manager.create("spare")
@@ -826,7 +795,6 @@ async def test_a_launch_in_flight_holds_up_no_other_identity_and_no_config_chang
slow_id, launching = await start_slow_launch(env, manager, "slow")
async with asyncio.timeout(10):
manager.set_limits(3, 30)
await manager.close(other.id)
await manager.delete(spare.id)
fresh = await manager.create("fresh")
@@ -838,47 +806,6 @@ async def test_a_launch_in_flight_holds_up_no_other_identity_and_no_config_chang
assert manager.open_count == 0
async def test_a_lower_max_open_answers_at_once_and_closes_the_excess_in_the_background(env: Env) -> None:
manager = env.manager(max_open=3)
a, b, c = [await manager.create(name) for name in "abc"]
for identity in (a, b, c):
await manager.launch(identity.id)
manager.set_limits(1, 20)
# Nothing has been closed yet, but only c counts as open and the others cannot be used.
assert [manager.is_open(i.id) for i in (a, b, c)] == [False, False, True]
assert env.event_types().count("browser.identity.closed") == 0
with pytest.raises(BrowserIdentityError) as refused:
await manager.call_tool(a.id, "browser_status")
assert refused.value.code == "not_open"
await manager.close_all()
assert sorted(
event["data"]["identity_id"] for event in env.events() if event["type"] == "browser.identity.closed"
) == sorted([a.id, b.id, c.id])
for identity in (a, b):
assert env.record(identity.id)[-1]["kind"] == "exit"
async def test_a_lower_max_open_closes_a_browser_that_is_still_opening_once_it_has_opened(env: Env) -> None:
manager = env.manager(max_open=2, open_retry_initial_s=0.05, open_retry_max_s=0.05)
identity = await manager.create("late")
write_control(env.mcp_home(identity.id), download_answers=3)
launching = asyncio.create_task(manager.launch(identity.id))
await asyncio.sleep(0.1)
other = await manager.create("other")
await manager.launch(other.id)
manager.set_limits(1, 20)
await launching
await manager.close_all()
assert env.event_types().count("browser.identity.launched") == 2
assert sorted(
event["data"]["identity_id"] for event in env.events() if event["type"] == "browser.identity.closed"
) == sorted([identity.id, other.id])
async def test_two_launches_of_one_identity_start_one_browser_and_share_the_outcome(env: Env) -> None:
manager = env.manager(open_retry_initial_s=0.05, open_retry_max_s=0.05)
identity = await manager.create("shared")
@@ -57,11 +57,11 @@ NOT_OPEN = "identity {} is not open; call browser_identity_launch first"
class Env:
def __init__(self, tmp_path: Path, store: DotStore) -> None:
def __init__(self, tmp_path: Path, store: DotStore, **limits: int) -> None:
self.tmp_path = tmp_path
(tmp_path / "computer").mkdir()
self.computer = LocalComputer(tmp_path / "computer")
self.manager: BrowserManager = make_browser_manager(tmp_path, store, self.computer)
self.manager: BrowserManager = make_browser_manager(tmp_path, store, self.computer, **limits)
self.registry: ToolRegistry = build_registry(
ToolDeps(self.computer, ExecSessionManager(), CronService(tmp_path / "cron" / "jobs.json"), self.manager)
)
@@ -85,9 +85,10 @@ class Env:
@pytest.fixture
async def bare_env(tmp_path: Path, dot_store: DotStore) -> AsyncIterator[Env]:
"""Tools and a manager, run outside any turn."""
made = Env(tmp_path, dot_store)
async def bare_env(request: pytest.FixtureRequest, tmp_path: Path, dot_store: DotStore) -> AsyncIterator[Env]:
"""Tools and a manager, run outside any turn; `@pytest.mark.limits(max_open=..., max_identities=...)` sets the manager's."""
marker = request.node.get_closest_marker("limits")
made = Env(tmp_path, dot_store, **(marker.kwargs if marker else {}))
yield made
await made.manager.close_all()
@@ -251,8 +252,8 @@ async def test_launch_opens_the_browser_and_close_keeps_the_profile(env: Env) ->
assert said(after) == NOT_OPEN.format(identity_id)
@pytest.mark.limits(max_open=1)
async def test_launching_past_max_open_says_which_browser_it_closed(env: Env) -> None:
env.manager.set_limits(1, 20)
first = await env.open_identity("first")
second = (await env.manager.create("second")).id
@@ -326,8 +327,8 @@ async def test_a_refused_request_is_an_error_result_that_says_why(env: Env) -> N
assert said(gone) == 'no browser identity "nope-aaaaaa"' == said(launch_gone)
@pytest.mark.limits(max_open=1, max_identities=1)
async def test_the_limit_on_identities_is_reported_to_the_model(env: Env) -> None:
env.manager.set_limits(1, 1)
await env.run("browser_identity_create", name="one")
over = await env.run("browser_identity_create", name="two")
@@ -1514,15 +1514,6 @@ class TestStoppingWithBrowsersOpen:
margin_s = 5
assert steps + margin_s <= PREPARE_SLEEP_TIMEOUT_S
async def test_a_config_applies_the_browser_limits_to_the_manager(self, make_engine: MakeEngine) -> None:
h = started(make_engine([]))
assert h.browser.limits == (3, 20)
h.configure(cfg(browser={"identities": {"managed_by_dot": True, "max_identities": 5, "max_open": 2}}))
h.engine.apply_browser_limits()
assert h.browser.limits == (2, 5)
class TestAChatTurnThatFailsBeforeItsOpeningIsStored:
@staticmethod
@@ -330,25 +330,6 @@ def test_every_browser_tool_is_served_by_invisible_playwright_mcp_and_no_other_t
assert set(browser_tools.PAGE_TOOLS) == {name for name, e in TOOL_PERMISSIONS.items() if e.permission in PAGE_PERMISSIONS}
def test_only_the_tools_that_make_or_delete_identities_need_the_dot_to_manage_them() -> None:
assert {name for name, entry in TOOL_PERMISSIONS.items() if entry.needs_managed_identities} == {
"browser_identity_create",
"browser_identity_delete",
}
def test_the_identity_tools_that_create_and_delete_follow_managed_by_dot() -> None:
permissions = {permission: "allow" for permission in BROWSER_PERMISSIONS}
managed = offered_tools(permissions, managed_identities=True)
unmanaged = offered_tools(permissions, managed_identities=False)
assert {"browser_identity_create", "browser_identity_delete"} <= set(managed)
assert not {"browser_identity_create", "browser_identity_delete"} & set(unmanaged)
assert set(managed) - set(unmanaged) == {"browser_identity_create", "browser_identity_delete"}
# Listing, opening and closing are not creating: they stay.
assert {"browser_identity_list", "browser_identity_launch", "browser_identity_close", "browser_navigate"} <= set(unmanaged)
def test_each_browser_permission_offers_its_own_tools_only() -> None:
assert offered_tools({"browser.read": "allow"}) == ["browser_read_text", "browser_screenshot", "browser_snapshot"]
assert offered_tools({"browser.navigate": "ask"}) == ["browser_navigate"]
@@ -80,18 +80,6 @@ def test_offers_the_model_only_the_tools_whose_permission_is_not_denied(make_con
assert settings(make_config({})).offered_tools == ()
def test_the_tools_that_make_and_delete_identities_follow_managed_by_dot(
config_body: Callable[..., dict[str, Any]],
) -> None:
permissions = {"browser.identity.create": "allow", "browser.identity.delete": "ask", "browser.identity.launch": "allow"}
managed = settings(parse_runtime_config(config_body(permissions=permissions)))
assert managed.offered_tools == ("browser_identity_create", "browser_identity_delete", "browser_identity_launch")
identities = {"managed_by_dot": False, "max_identities": 20, "max_open": 3}
unmanaged = settings(parse_runtime_config(config_body(permissions=permissions, browser={"identities": identities})))
assert unmanaged.offered_tools == ("browser_identity_launch",)
def test_offers_nothing_outside_the_permission_table(make_config: Callable[..., DotRuntimeConfig]) -> None:
everything = {permission: "allow" for permission in {e.permission for e in TOOL_PERMISSIONS.values()}}
assert set(settings(make_config(everything)).offered_tools) == set(TOOL_PERMISSIONS)
@@ -116,17 +104,16 @@ def test_the_projection_is_pure_and_the_settings_are_frozen(make_config: Callabl
assert config.permissions == {"files.read": "allow"}
def test_the_prompt_section_names_the_dot_and_its_goal(make_config: Callable[..., DotRuntimeConfig]) -> None:
assert dot_prompt_section(make_config({})) == 'You are the Dot "fare-watch". Your goal:\nWatch fares.'
def test_the_prompt_section_names_the_dot(make_config: Callable[..., DotRuntimeConfig]) -> None:
assert dot_prompt_section(make_config({})) == 'You are the Dot "fare-watch".'
def test_the_prompt_section_carries_the_instructions_when_there_are_some(
config_body: Callable[..., dict[str, Any]],
) -> None:
config = parse_runtime_config(config_body(goal=" Watch fares.\n", instructions=" Be brief.\nNo emoji. "))
config = parse_runtime_config(config_body(instructions=" Be brief.\nNo emoji. "))
assert dot_prompt_section(config) == (
'You are the Dot "fare-watch". Your goal:\n'
"Watch fares.\n"
'You are the Dot "fare-watch".\n'
"\n"
"Instructions from the person who owns you:\n"
"Be brief.\nNo emoji."
@@ -212,14 +212,10 @@ class TestRuntimeConfig:
@pytest.mark.parametrize(
("mutate", "path"),
[
(lambda b: b.update(goal=""), "goal"),
(lambda b: b.update(instructions=3), "instructions"),
(lambda b: b["model"].update(provider="anthropic"), "model.provider"),
(lambda b: b["model"].update(id=""), "model.id"),
(lambda b: b.update(models={"summary": ""}), "models.summary"),
(lambda b: b["browser"]["identities"].update(managed_by_dot="yes"), "browser.identities.managed_by_dot"),
(lambda b: b["browser"]["identities"].update(max_identities=0), "browser.identities.max_identities"),
(lambda b: b["browser"]["identities"].update(max_open=-1), "browser.identities.max_open"),
(lambda b: b.update(permissions={"files.read": "maybe"}), "permissions.files.read"),
(lambda b: b["limits"].update(max_steps_per_task=0), "limits.max_steps_per_task"),
(lambda b: b["limits"].update(max_steps_per_task=1.5), "limits.max_steps_per_task"),
@@ -227,7 +223,6 @@ class TestRuntimeConfig:
(lambda b: b["limits"].update(context_tokens=1_000_001), "limits.context_tokens"),
(lambda b: b["limits"].update(max_cost_per_task_usd=0), "limits.max_cost_per_task_usd"),
(lambda b: b["limits"].update(max_cost_per_task_usd=True), "limits.max_cost_per_task_usd"),
(lambda b: b.pop("browser"), "browser"),
(lambda b: b.pop("limits"), "limits"),
],
)
@@ -252,11 +247,11 @@ class TestRuntimeConfig:
assert parse_runtime_config(body).limits.max_cost_per_task_usd == 0.01
def test_lists_every_problem(self, config_body: Callable[..., dict[str, Any]]) -> None:
body = config_body(name="X", goal="")
body = config_body(name="X", instructions=3)
body["limits"]["context_tokens"] = 1
with pytest.raises(DotsConfigError) as caught:
parse_runtime_config(body)
for path in ("name:", "goal:", "limits.context_tokens:"):
for path in ("name:", "instructions:", "limits.context_tokens:"):
assert path in str(caught.value)
@pytest.mark.parametrize("value", [None, [], "config", 4])
@@ -266,7 +261,7 @@ class TestRuntimeConfig:
def test_never_echoes_a_value(self, config_body: Callable[..., dict[str, Any]]) -> None:
secret = "sk-or-this-must-not-appear"
body = config_body(name=secret, goal=1, permissions={"files.read": secret})
body = config_body(name=secret, instructions=1, permissions={"files.read": secret})
body["model"]["provider"] = secret
body["limits"]["context_tokens"] = secret
with pytest.raises(DotsConfigError) as caught:
@@ -303,14 +303,14 @@ class TestConfig:
api: Api = await make_api()
assert (await api.call("PUT", "/config", runtime_config_body())).status == 204
bad = await api.call("PUT", "/config", runtime_config_body(goal=""))
bad = await api.call("PUT", "/config", runtime_config_body(name="Not A Name"))
post = await api.call("POST", "/config", runtime_config_body())
assert (bad.status, bad.json["error"]) == (400, "invalid_config")
assert "goal" in bad.json["message"]
assert "name" in bad.json["message"]
assert post.status == 405
assert post.json["message"] == "POST is not allowed here; use PUT"
assert api.h.engine.config is not None and api.h.engine.config.goal == "Watch fares."
assert api.h.engine.config is not None and api.h.engine.config.name == "fare-watch"
class TestEvents:
@@ -543,9 +543,7 @@ class TestBrowserIdentities:
async def test_refuses_what_the_rules_refuse_with_the_status_the_host_expects(
self, make_api: Callable[..., Any]
) -> None:
api: Api = await make_api()
api.h.configure(runtime_config_body(browser={"identities": {"managed_by_dot": True, "max_identities": 1, "max_open": 1}}))
api.h.engine.apply_browser_limits()
api: Api = await make_api(browser={"max_identities": 1, "max_open": 1})
for body, message in (
({"name": 5}, "name must be a string"),
@@ -593,32 +591,7 @@ class TestBrowserIdentities:
await api.h.browser.close_all()
assert (await api.call("GET", "/health")).json["browser"] == {"identities": 2, "open": 0}
async def test_a_config_with_a_lower_max_open_closes_the_excess_open_browsers(
self, make_api: Callable[..., Any]
) -> None:
api: Api = await make_api()
one = (await api.call("POST", "/browser-identities", {"name": "one"})).json
two = (await api.call("POST", "/browser-identities", {"name": "two"})).json
await api.h.browser.launch(one["id"])
await api.h.browser.launch(two["id"])
assert api.h.browser.open_count == 2
put = await api.call(
"PUT",
"/config",
runtime_config_body(browser={"identities": {"managed_by_dot": True, "max_identities": 20, "max_open": 1}}),
)
assert put.status == 204
assert api.h.browser.limits == (1, 20)
# The least recently used is the one closed; the other stays open. The close runs after the answer.
assert [i["status"] for i in (await api.call("GET", "/browser-identities")).json["identities"]] == ["available", "open"]
async with asyncio.timeout(10):
while api.h.types().count("browser.identity.closed") < 1:
await asyncio.sleep(0.02)
assert api.h.types().count("browser.identity.closed") == 1
async def test_a_config_a_close_a_delete_and_a_create_do_not_wait_for_a_launch_in_flight(
async def test_a_close_a_delete_and_a_create_do_not_wait_for_a_launch_in_flight(
self, make_api: Callable[..., Any]
) -> None:
api: Api = await make_api(browser={"open_retry_initial_s": 0.05, "open_retry_max_s": 0.05})
@@ -631,16 +604,11 @@ class TestBrowserIdentities:
# The host gives a guest 30 s; a launch can take 15 minutes. None of these may wait for it.
async with asyncio.timeout(10):
put = await api.call(
"PUT",
"/config",
runtime_config_body(browser={"identities": {"managed_by_dot": True, "max_identities": 20, "max_open": 1}}),
)
closed = await api.call("POST", f"/browser-identities/{other['id']}/close")
created = await api.call("POST", "/browser-identities", {"name": "fresh"})
deleted = await api.call("DELETE", f"/browser-identities/{created.json['id']}")
assert (put.status, closed.status, created.status, deleted.status) == (204, 204, 201, 204)
assert (closed.status, created.status, deleted.status) == (204, 201, 204)
assert not launching.done()
launching.cancel()
with pytest.raises(asyncio.CancelledError):
@@ -105,7 +105,7 @@ class TestAChatTurn:
system = h.provider.requests[1]["messages"][0]
assert system["role"] == "system"
assert 'You are the Dot "fare-watch"' in system["content"]
assert "Watch fares." in system["content"]
assert "goal" not in system["content"].lower()
assert "Tool Usage Notes" in system["content"]
assert "your own Linux computer" in system["content"]
assert [(m["role"], m["content"]) for m in h.provider.requests[1]["messages"][1:]] == [
@@ -51,8 +51,7 @@ _OFFERED_CASES = [
{"permissions": {"computer.exec": "allow", "files.read": "ask", "files.write": "deny", "automations": "deny"}},
{"permissions": {}},
{"permissions": {"computer.exec": "ask", "files.read": "ask", "files.write": "ask", "automations": "ask"}},
{"permissions": {"browser.identity.list": "allow", "browser.identity.create": "allow", "browser.identity.delete": "ask"}, "managed_identities": True},
{"permissions": {"browser.identity.list": "allow", "browser.identity.create": "allow", "browser.identity.delete": "ask"}, "managed_identities": False},
{"permissions": {"browser.identity.list": "allow", "browser.identity.create": "allow", "browser.identity.delete": "ask"}},
]
@@ -147,8 +146,7 @@ def _stable(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
def _shapes(outbound_events: list[dict[str, Any]]) -> dict[str, Any]:
cases = []
for case in _OFFERED_CASES:
case = {"managed_identities": True, **case}
offered = offered_tools(case["permissions"], managed_identities=case["managed_identities"])
offered = offered_tools(case["permissions"])
cases.append({**case, "offered": offered, "tools": tool_table(_Registry(), offered)})
return {
"limits": {"max_run_at_ms": MAX_RUN_AT_MS},
@@ -180,10 +178,6 @@ def test_the_cases_reach_every_kind_of_tool(outbound_events: list[dict[str, Any]
shapes = _shapes(outbound_events)
for case in shapes["tool_offering"]:
assert [row["name"] for row in case["tools"]] == list(TOOL_PERMISSIONS)
unmanaged = next(c for c in shapes["tool_offering"] if not c["managed_identities"])
assert unmanaged["permissions"]["browser.identity.create"] == "allow"
assert "browser_identity_create" not in unmanaged["offered"] and "browser_identity_delete" not in unmanaged["offered"]
assert "browser_identity_list" in unmanaged["offered"]
def test_the_events_pinned_are_one_of_every_type_the_engine_writes_and_every_set_of_keys_it_writes_them_with(
@@ -176,7 +176,6 @@
],
"tool_offering": [
{
"managed_identities": true,
"offered": [
"exec",
"exec_session",
@@ -376,7 +375,6 @@
]
},
{
"managed_identities": true,
"offered": [],
"permissions": {},
"tools": [
@@ -563,7 +561,6 @@
]
},
{
"managed_identities": true,
"offered": [
"apply_patch",
"cron",
@@ -767,7 +764,6 @@
]
},
{
"managed_identities": true,
"offered": [
"browser_identity_create",
"browser_identity_delete",
@@ -960,199 +956,6 @@
"permission": "browser.act"
}
]
},
{
"managed_identities": false,
"offered": [
"browser_identity_list"
],
"permissions": {
"browser.identity.create": "allow",
"browser.identity.delete": "ask",
"browser.identity.list": "allow"
},
"tools": [
{
"description": "description of exec",
"name": "exec",
"offered": false,
"permission": "computer.exec"
},
{
"description": "description of exec_session",
"name": "exec_session",
"offered": false,
"permission": "computer.exec"
},
{
"description": "description of list_exec_sessions",
"name": "list_exec_sessions",
"offered": false,
"permission": "computer.exec"
},
{
"description": "description of read_file",
"name": "read_file",
"offered": false,
"permission": "files.read"
},
{
"description": "description of list_dir",
"name": "list_dir",
"offered": false,
"permission": "files.read"
},
{
"description": "description of find_files",
"name": "find_files",
"offered": false,
"permission": "files.read"
},
{
"description": "description of grep",
"name": "grep",
"offered": false,
"permission": "files.read"
},
{
"description": "description of write_file",
"name": "write_file",
"offered": false,
"permission": "files.write"
},
{
"description": "description of edit_file",
"name": "edit_file",
"offered": false,
"permission": "files.write"
},
{
"description": "description of apply_patch",
"name": "apply_patch",
"offered": false,
"permission": "files.write"
},
{
"description": "description of cron",
"name": "cron",
"offered": false,
"permission": "automations"
},
{
"description": "description of computer_screenshot",
"name": "computer_screenshot",
"offered": false,
"permission": "computer.screenshot"
},
{
"description": "description of browser_identity_list",
"name": "browser_identity_list",
"offered": true,
"permission": "browser.identity.list"
},
{
"description": "description of browser_identity_create",
"name": "browser_identity_create",
"offered": false,
"permission": "browser.identity.create"
},
{
"description": "description of browser_identity_delete",
"name": "browser_identity_delete",
"offered": false,
"permission": "browser.identity.delete"
},
{
"description": "description of browser_identity_launch",
"name": "browser_identity_launch",
"offered": false,
"permission": "browser.identity.launch"
},
{
"description": "description of browser_identity_close",
"name": "browser_identity_close",
"offered": false,
"permission": "browser.identity.close"
},
{
"description": "description of browser_navigate",
"name": "browser_navigate",
"offered": false,
"permission": "browser.navigate"
},
{
"description": "description of browser_snapshot",
"name": "browser_snapshot",
"offered": false,
"permission": "browser.read"
},
{
"description": "description of browser_read_text",
"name": "browser_read_text",
"offered": false,
"permission": "browser.read"
},
{
"description": "description of browser_screenshot",
"name": "browser_screenshot",
"offered": false,
"permission": "browser.read"
},
{
"description": "description of browser_click",
"name": "browser_click",
"offered": false,
"permission": "browser.act"
},
{
"description": "description of browser_click_at",
"name": "browser_click_at",
"offered": false,
"permission": "browser.act"
},
{
"description": "description of browser_type",
"name": "browser_type",
"offered": false,
"permission": "browser.act"
},
{
"description": "description of browser_press_key",
"name": "browser_press_key",
"offered": false,
"permission": "browser.act"
},
{
"description": "description of browser_select_option",
"name": "browser_select_option",
"offered": false,
"permission": "browser.act"
},
{
"description": "description of browser_scroll",
"name": "browser_scroll",
"offered": false,
"permission": "browser.act"
},
{
"description": "description of browser_back",
"name": "browser_back",
"offered": false,
"permission": "browser.act"
},
{
"description": "description of browser_forward",
"name": "browser_forward",
"offered": false,
"permission": "browser.act"
},
{
"description": "description of browser_reload",
"name": "browser_reload",
"offered": false,
"permission": "browser.act"
}
]
}
]
}
@@ -16,9 +16,7 @@ def runtime_config_body(**overrides: Any) -> dict[str, Any]:
"""A valid `PUT /config` body, with top-level fields replaced by `overrides`."""
body: dict[str, Any] = {
"name": "fare-watch",
"goal": "Watch fares.",
"model": {"provider": "openrouter", "id": "z-ai/glm-5.3-flash"},
"browser": {"identities": {"managed_by_dot": True, "max_identities": 20, "max_open": 3}},
"permissions": {},
"limits": {"max_steps_per_task": 60, "context_tokens": 32000, "max_cost_per_task_usd": 1},
}
+1 -1
View File
@@ -10,7 +10,7 @@ import { expect } from "vitest";
import { ChannelHub, type ChannelHubOptions, type ChannelType } from "../src/index.js";
import { FakeChannelType } from "../src/testing.js";
const yaml = (name: string) => `name: ${name}\ngoal: keep watch\nmodel:\n provider: openrouter\n id: test/model\n`;
const yaml = (name: string) => `name: ${name}\nmodel:\n provider: openrouter\n id: test/model\n`;
export const FAST_BACKOFF = { initialMs: 1, maxMs: 5, jitter: 0 };
@@ -0,0 +1,7 @@
-- A Dot has no goal any more: what it is for is what its person asks of it, in the chat, a task or its instructions
-- (architecture section 7). A config saved with one loses it, and its version moves on, so a form opened on the old
-- config cannot save it back.
UPDATE dots
SET config = config - 'goal',
config_version = config_version + 1
WHERE config ? 'goal';
@@ -0,0 +1,7 @@
-- A Dot has no browser settings any more: it manages its identities itself, within the engine's own limits (3 open,
-- 20 in all; architecture section 6). A config saved with the section loses it, and its version moves on, so a form
-- opened on the old config cannot save it back.
UPDATE dots
SET config = config - 'browser',
config_version = config_version + 1
WHERE config ? 'browser';
+1 -1
View File
@@ -5,7 +5,7 @@ import { createTestDatabase, testAdapters, type TestDatabase } from "../src/test
const SETUP_TIMEOUT = 60_000;
const settings = { approvals: true, notify_tasks: true, show_arguments: true };
const yaml = (name: string) => `name: ${name}\ngoal: test goal\nmodel:\n provider: openrouter\n id: test/model\n`;
const yaml = (name: string) => `name: ${name}\nmodel:\n provider: openrouter\n id: test/model\n`;
describe.each(testAdapters())("channel repository on %s", { timeout: SETUP_TIMEOUT }, (kind) => {
let t: TestDatabase;
+36 -1
View File
@@ -15,7 +15,7 @@ async function createdInOrder(db: Database, table: "tasks" | "approvals", ids: r
);
}
const yaml = (name: string) => `name: ${name}\ngoal: test goal\nmodel:\n provider: openrouter\n id: test/model\n`;
const yaml = (name: string) => `name: ${name}\nmodel:\n provider: openrouter\n id: test/model\n`;
async function seedDot(r: Repositories, name: string) {
const id = newId("dot");
@@ -57,9 +57,44 @@ describe.each(testAdapters())("repositories on %s", { timeout: SETUP_TIMEOUT },
"0007_dot_config_version",
"0008_computer_next_automation",
"0009_dot_keeps_its_memory",
"0010_dot_has_no_goal",
"0011_dot_has_no_browser_settings",
]);
});
it("0011: a config saved with browser settings loses them and moves its version; one without is left as it was", async () => {
const old = await seedDot(db, "alpha-browser");
const untouched = await seedDot(db, "alpha-browser-none");
await db.query(
`UPDATE dots SET config = config || '{"browser":{"identities":{"managed_by_dot":false,"max_identities":5,"max_open":2}}}'::jsonb WHERE id = $1`,
[old.id],
);
const sql = (await loadMigrations()).find((migration) => migration.version === "0011_dot_has_no_browser_settings")!.sql;
for (const statement of sql.split(/;\s*\n/).filter((part) => part.trim() !== "")) await db.query(statement);
const after = await db.dots.get(old.id);
expect("browser" in after!.config).toBe(false);
expect(after?.config).toEqual(old.config);
expect(after?.config_version).toBe(old.config_version + 1);
expect((await db.dots.get(untouched.id))?.config_version).toBe(untouched.config_version);
});
it("0010: a config saved with a goal loses it and moves its version; one without is left as it was", async () => {
const old = await seedDot(db, "alpha-goal");
const untouched = await seedDot(db, "alpha-goal-none");
await db.query(`UPDATE dots SET config = config || '{"goal":"watch the fares"}'::jsonb WHERE id = $1`, [old.id]);
const sql = (await loadMigrations()).find((migration) => migration.version === "0010_dot_has_no_goal")!.sql;
for (const statement of sql.split(/;\s*\n/).filter((part) => part.trim() !== "")) await db.query(statement);
const after = await db.dots.get(old.id);
expect("goal" in after!.config).toBe(false);
expect(after?.config).toEqual(old.config);
expect(after?.config_version).toBe(old.config_version + 1);
expect((await db.dots.get(untouched.id))?.config_version).toBe(untouched.config_version);
});
it("0009: a config saved with the memory switch and permission loses both and moves its version, and memory.written rows go", async () => {
const old = await seedDot(db, "alpha-memory");
const untouched = await seedDot(db, "alpha-memory-none");
+1 -27
View File
@@ -110,8 +110,6 @@ export const CONFIG_BOUNDS = {
maxCostPerTaskUsd: { min: 0.01, max: 100, default: 1 },
maxStepsPerTask: { min: 1, max: 1000, default: 60 },
contextTokens: { min: 4000, max: 1_000_000, default: 32_000 },
maxIdentities: { min: 1, max: 1000, default: 20 },
maxOpen: { min: 1, max: 16, default: 3 },
} as const;
export const DOT_NAME_PATTERN = /^[a-z0-9-]{1,40}$/;
@@ -180,7 +178,6 @@ export const dotConfigSchema = z
name: z
.string()
.regex(DOT_NAME_PATTERN, "name must be 1 to 40 characters of lowercase letters, digits and '-'"),
goal: z.string().trim().min(1, "goal must not be empty"),
instructions: z.string().optional(),
model: z
.object({
@@ -221,19 +218,6 @@ export const dotConfigSchema = z
disk: CONFIG_BOUNDS.disk.default,
idle_timeout: CONFIG_BOUNDS.idleTimeout.default,
}),
browser: z
.object({
identities: z
.object({
managed_by_dot: z.boolean().default(true),
max_identities: z.number().int().min(CONFIG_BOUNDS.maxIdentities.min).max(CONFIG_BOUNDS.maxIdentities.max).default(CONFIG_BOUNDS.maxIdentities.default),
max_open: z.number().int().min(CONFIG_BOUNDS.maxOpen.min).max(CONFIG_BOUNDS.maxOpen.max).default(CONFIG_BOUNDS.maxOpen.default),
})
.strict()
.default({ managed_by_dot: true, max_identities: CONFIG_BOUNDS.maxIdentities.default, max_open: CONFIG_BOUNDS.maxOpen.default }),
})
.strict()
.default({ identities: { managed_by_dot: true, max_identities: CONFIG_BOUNDS.maxIdentities.default, max_open: CONFIG_BOUNDS.maxOpen.default } }),
permissions: z
.record(z.string(), permissionDecision)
.default({})
@@ -265,17 +249,7 @@ export const dotConfigSchema = z
max_cost_per_task_usd: CONFIG_BOUNDS.maxCostPerTaskUsd.default,
}),
})
.strict()
.superRefine((config, ctx) => {
const ids = config.browser.identities;
if (ids.max_open > ids.max_identities) {
ctx.addIssue({
code: "custom",
path: ["browser", "identities", "max_open"],
message: `max_open (${ids.max_open}) cannot exceed max_identities (${ids.max_identities})`,
});
}
});
.strict();
export type DotConfig = z.output<typeof dotConfigSchema>;
export type DotConfigInput = z.input<typeof dotConfigSchema>;
+1 -1
View File
@@ -44,7 +44,7 @@ export function checkIdentityRequest(
if (existingCount >= maxIdentities) {
throw new IdentityRequestError(
"limit",
`this Dot already has ${existingCount} browser identities, the most its configuration allows (max_identities ${maxIdentities}); delete one first`,
`this Dot already has ${existingCount} browser identities, the most it may keep (max_identities ${maxIdentities}); delete one first`,
);
}
return proxy ? { name, proxy } : { name };
+8 -38
View File
@@ -16,8 +16,6 @@ import {
const FULL_YAML = `
name: fare-watch
goal: >
Check one-way fares from Milan to Lisbon every morning and report the cheapest day.
instructions: >
Write findings to ~/workspace/fares.csv.
model:
@@ -30,11 +28,6 @@ computer:
memory: 4gb
disk: 40gb
idle_timeout: 15m
browser:
identities:
managed_by_dot: true
max_identities: 20
max_open: 3
permissions:
computer.exec: allow
browser.identity.delete: ask
@@ -44,7 +37,7 @@ limits:
max_cost_per_task_usd: 1.00
`;
const MINIMAL = { name: "a", goal: "do it", model: { provider: "openrouter", id: "openrouter/auto" } };
const MINIMAL = { name: "a", model: { provider: "openrouter", id: "openrouter/auto" } };
function issuesOf(input: unknown): string[] {
const result = safeParseDotConfig(input);
@@ -108,20 +101,17 @@ describe("parseDotConfig", () => {
const config = parseDotConfig(MINIMAL);
expect(config).toEqual({
name: "a",
goal: "do it",
model: { provider: "openrouter", id: "openrouter/auto" },
models: {},
computer: { cpu: 2, memory: "4gb", disk: "40gb", idle_timeout: "15m" },
browser: { identities: { managed_by_dot: true, max_identities: 20, max_open: 3 } },
permissions: {},
limits: { max_steps_per_task: 60, context_tokens: 32_000, max_cost_per_task_usd: 1 },
});
});
it("fills defaults inside a partially given section", () => {
const config = parseDotConfig({ ...MINIMAL, computer: { cpu: 4 }, browser: { identities: { max_open: 1 } } });
const config = parseDotConfig({ ...MINIMAL, computer: { cpu: 4 } });
expect(config.computer).toEqual({ cpu: 4, memory: "4gb", disk: "40gb", idle_timeout: "15m" });
expect(config.browser.identities).toEqual({ managed_by_dot: true, max_identities: 20, max_open: 1 });
});
it("is idempotent, so a stored config can be parsed again", () => {
@@ -149,10 +139,10 @@ describe("parseDotConfig", () => {
expect(issues).toEqual(['model.provider: model.provider must be "openrouter"']);
});
it("requires goal and model", () => {
it("requires a model, and has no goal: what a Dot is for is what its person asks of it", () => {
const issues = issuesOf({ name: "a" });
expect(issues.some((i) => i.startsWith("goal:"))).toBe(true);
expect(issues.some((i) => i.startsWith("model:"))).toBe(true);
expect(issuesOf({ ...MINIMAL, goal: "watch the fares" })[0]).toMatch(/goal/);
});
it("enforces the resource ranges", () => {
@@ -168,11 +158,6 @@ describe("parseDotConfig", () => {
expect(parseDotConfig({ ...MINIMAL, computer: { memory: "2048mb", disk: "1tb" } }).computer.disk).toBe("1tb");
});
it("refuses max_open above max_identities", () => {
const issues = issuesOf({ ...MINIMAL, browser: { identities: { max_identities: 2, max_open: 3 } } });
expect(issues).toEqual(["browser.identities.max_open: max_open (3) cannot exceed max_identities (2)"]);
});
it("refuses unknown keys and unknown permissions", () => {
expect(issuesOf({ ...MINIMAL, extra: 1 })[0]).toMatch(/extra/);
expect(issuesOf({ ...MINIMAL, computer: { gpu: 1 } })[0]).toMatch(/gpu/);
@@ -223,11 +208,11 @@ describe("parseDotConfig", () => {
it("lists every issue in the error message", () => {
try {
parseDotConfig({ name: "BAD", goal: "", model: { provider: "x", id: "y" } });
parseDotConfig({ name: "BAD", model: { provider: "x", id: "y" } });
expect.unreachable();
} catch (error) {
expect(error).toBeInstanceOf(DotConfigError);
expect((error as Error).message).toMatch(/name: .*goal: .*model\.provider: /);
expect((error as Error).message).toMatch(/name: .*model\.provider: /);
}
});
});
@@ -249,11 +234,6 @@ describe("CONFIG_BOUNDS", () => {
context_tokens: CONFIG_BOUNDS.contextTokens.default,
max_cost_per_task_usd: CONFIG_BOUNDS.maxCostPerTaskUsd.default,
});
expect(config.browser.identities).toEqual({
managed_by_dot: true,
max_identities: CONFIG_BOUNDS.maxIdentities.default,
max_open: CONFIG_BOUNDS.maxOpen.default,
});
});
it("are the range the schema accepts: both ends in, one step outside refused", () => {
@@ -275,24 +255,14 @@ describe("CONFIG_BOUNDS", () => {
expect(safeParseDotConfig(withLimits({ max_cost_per_task_usd: maxCostPerTaskUsd.max + 1 })).ok).toBe(false);
});
it("are the range of the step, context, identity and open-browser numbers too", () => {
const { maxStepsPerTask, contextTokens, maxIdentities, maxOpen } = CONFIG_BOUNDS;
const withIdentities = (identities: Record<string, unknown>) => ({ ...MINIMAL, browser: { identities } });
it("are the range of the step and context numbers too", () => {
const { maxStepsPerTask, contextTokens } = CONFIG_BOUNDS;
for (const [field, bounds] of [["max_steps_per_task", maxStepsPerTask], ["context_tokens", contextTokens]] as const) {
expect(safeParseDotConfig(withLimits({ [field]: bounds.min })).ok, `${field} min`).toBe(true);
expect(safeParseDotConfig(withLimits({ [field]: bounds.max })).ok, `${field} max`).toBe(true);
expect(safeParseDotConfig(withLimits({ [field]: bounds.min - 1 })).ok, `${field} below`).toBe(false);
expect(safeParseDotConfig(withLimits({ [field]: bounds.max + 1 })).ok, `${field} above`).toBe(false);
}
// max_open may not exceed max_identities, so each end of max_open is tried with room to spare.
expect(safeParseDotConfig(withIdentities({ max_open: maxOpen.min })).ok).toBe(true);
expect(safeParseDotConfig(withIdentities({ max_open: maxOpen.max, max_identities: maxIdentities.max })).ok).toBe(true);
expect(safeParseDotConfig(withIdentities({ max_open: maxOpen.min - 1 })).ok).toBe(false);
expect(safeParseDotConfig(withIdentities({ max_open: maxOpen.max + 1, max_identities: maxIdentities.max })).ok).toBe(false);
expect(safeParseDotConfig(withIdentities({ max_identities: maxIdentities.min, max_open: 1 })).ok).toBe(true);
expect(safeParseDotConfig(withIdentities({ max_identities: maxIdentities.max })).ok).toBe(true);
expect(safeParseDotConfig(withIdentities({ max_identities: maxIdentities.min - 1 })).ok).toBe(false);
expect(safeParseDotConfig(withIdentities({ max_identities: maxIdentities.max + 1 })).ok).toBe(false);
});
});
+3 -3
View File
@@ -269,7 +269,7 @@
},
"error": {
"code": "limit",
"message": "this Dot already has 20 browser identities, the most its configuration allows (max_identities 20); delete one first"
"message": "this Dot already has 20 browser identities, the most it may keep (max_identities 20); delete one first"
},
"existing": 20,
"max": 20
@@ -281,7 +281,7 @@
},
"error": {
"code": "limit",
"message": "this Dot already has 25 browser identities, the most its configuration allows (max_identities 20); delete one first"
"message": "this Dot already has 25 browser identities, the most it may keep (max_identities 20); delete one first"
},
"existing": 25,
"max": 20
@@ -293,7 +293,7 @@
},
"error": {
"code": "limit",
"message": "this Dot already has 0 browser identities, the most its configuration allows (max_identities 0); delete one first"
"message": "this Dot already has 0 browser identities, the most it may keep (max_identities 0); delete one first"
},
"existing": 0,
"max": 0
-5
View File
@@ -441,8 +441,6 @@ export interface DotYamlOptions {
model: string;
/** Permissions written into the config, as `permission: decision`. */
permissions?: Record<string, "allow" | "ask" | "deny">;
/** `browser.identities.max_open`; the product's default when left out. */
maxOpen?: number;
}
/** The Dot's config (architecture section 7) as YAML. */
@@ -450,8 +448,6 @@ export function dotYaml(options: DotYamlOptions): string {
const permissions = Object.entries(options.permissions ?? {});
return [
`name: ${options.name}`,
"goal: >",
" Run the end-to-end acceptance checks of invisible_dots and do exactly what each task says.",
"instructions: >",
" Follow each task literally. When asked to answer with only a value, answer with that value and nothing else.",
"model:",
@@ -461,7 +457,6 @@ export function dotYaml(options: DotYamlOptions): string {
" cpu: 2",
" memory: 4gb",
" idle_timeout: 0",
...(options.maxOpen === undefined ? [] : ["browser:", " identities:", ` max_open: ${options.maxOpen}`]),
...(permissions.length > 0 ? ["permissions:", ...permissions.map(([permission, decision]) => ` ${permission}: ${decision}`)] : []),
"",
].join("\n");
+10 -9
View File
@@ -388,7 +388,7 @@ async function waitReply(dotId: string, messageId: string): Promise<StoredEvent>
}
/** Rewrites the Dot's config (it is pushed to the guest at once): every step names the whole config it needs. */
async function configure(options: Pick<DotYamlOptions, "permissions" | "maxOpen"> = {}): Promise<void> {
async function configure(options: Pick<DotYamlOptions, "permissions"> = {}): Promise<void> {
await api("PATCH", route(ROUTES.dot, { id: state.dotId! }), { config: dotYaml({ name: DOT_NAME, model: MODEL, ...options }) });
}
@@ -865,7 +865,6 @@ async function main(): Promise<void> {
await step("h", "identities: no implicit launch, max_open evicts the least recently used, close and delete from the host", async () => {
const dotId = state.dotId!;
const research = state.identity!;
await configure({ maxOpen: 2 });
const make = async (name: string): Promise<Identity> => {
const made = await api<Identity>("POST", route(ROUTES.identities, { id: dotId }), { name });
assert(made.status === "available" && made.profilePath === `${BROWSERS}/${made.id}/profile`, `a new identity is ${JSON.stringify(made)}`);
@@ -873,6 +872,7 @@ async function main(): Promise<void> {
};
const alpha = await make("alpha");
const beta = await make("beta");
const gamma = await make("gamma");
const opened = async () => new Set((await identities(dotId)).filter((i) => i.status === "open").map((i) => i.id));
assert((await opened()).size === 1 && (await opened()).has(research.id), "only research should be open before this step");
@@ -888,22 +888,22 @@ async function main(): Promise<void> {
assert(identityEvents(afterRefused, "browser.identity.launched", alpha.id, refusedAt).length === 0, "browser_navigate launched a closed identity");
assert((await opened()).size === 1, "an identity was opened by a page tool");
// max_open is 2: the third launch closes the one used least recently, which is research.
// The engine keeps at most 3 open: the fourth launch closes the one used least recently, which is research.
const lruAt = lastEventId(afterRefused);
const lru = await runTask(
`Call browser_identity_launch for the identity ${alpha.id}, then for the identity ${beta.id}, one call at a time. Then call browser_identity_list and answer with only the ids of the identities whose status is open, separated by commas.`,
`Call browser_identity_launch for the identity ${alpha.id}, then for the identity ${beta.id}, then for the identity ${gamma.id}, one call at a time. Then call browser_identity_list and answer with only the ids of the identities whose status is open, separated by commas.`,
);
const afterLru = await events(dotId);
for (const id of [alpha.id, beta.id]) assert(identityEvents(afterLru, "browser.identity.launched", id, lruAt).length === 1, `no single browser.identity.launched for ${id}`);
for (const id of [alpha.id, beta.id, gamma.id]) assert(identityEvents(afterLru, "browser.identity.launched", id, lruAt).length === 1, `no single browser.identity.launched for ${id}`);
assert(identityEvents(afterLru, "browser.identity.closed", research.id, lruAt).length === 1, "research was not closed to make room (browser.identity.closed)");
assert(identityEvents(afterLru, "browser.identity.closed", alpha.id, lruAt).length === 0, "the identity used last was closed instead of the oldest");
const open = await opened();
assert(open.size === 2 && open.has(alpha.id) && open.has(beta.id), `open identities after the third launch: ${JSON.stringify([...open])}`);
assert(open.size === 3 && open.has(alpha.id) && open.has(beta.id) && open.has(gamma.id), `open identities after the fourth launch: ${JSON.stringify([...open])}`);
assert(toolOk(afterLru, lru.id, "browser_identity_list"), "browser_identity_list did not run");
const answer = lru.summary ?? "";
assert(answer.includes(alpha.id) && answer.includes(beta.id) && !answer.includes(research.id), `the model listed the open identities as ${JSON.stringify(answer)}`);
assert(answer.includes(alpha.id) && answer.includes(beta.id) && answer.includes(gamma.id) && !answer.includes(research.id), `the model listed the open identities as ${JSON.stringify(answer)}`);
const servers = await mcpSessions(dotId);
assert([...servers].sort().join() === [alpha.id, beta.id].sort().join(),`browser servers run for ${JSON.stringify(servers)}, the open identities are ${alpha.id} and ${beta.id}`);
assert([...servers].sort().join() === [alpha.id, beta.id, gamma.id].sort().join(), `browser servers run for ${JSON.stringify(servers)}, the open identities are ${alpha.id}, ${beta.id} and ${gamma.id}`);
// The host closes one: the profile stays, the frame says not_open.
const closeAt = lastEventId(afterLru);
@@ -934,9 +934,10 @@ async function main(): Promise<void> {
const gone = await guestExec(dotId, `test ! -e ${BROWSERS}/${alpha.id} && echo gone`);
assert(gone.stdout.trim() === "gone", `${BROWSERS}/${alpha.id} is still in the guest`);
await api("DELETE", route(ROUTES.identity, { id: dotId, identityId: beta.id }));
await api("DELETE", route(ROUTES.identity, { id: dotId, identityId: gamma.id }));
await waitFor("every browser server to be gone", 60_000, async () => ((await mcpSessions(dotId)).length === 0 ? true : undefined), 1000);
assert((await identities(dotId)).map((i) => i.id).join() === research.id, "research should be the only identity left");
return `closed identity not launched by a page tool; max_open 2 closed ${research.id} for the third launch; frame of a closed identity 409 not_open; host close, and host delete of an open identity, leave no browser server and no directory`;
return `closed identity not launched by a page tool; the engine's max_open of 3 closed ${research.id} for the fourth launch; frame of a closed identity 409 not_open; host close, and host delete of an open identity, leave no browser server and no directory`;
});
await step("i", "approvals of identity create and delete: proxy masked, nothing deleted before the approval", async () => {
+1 -3
View File
@@ -177,17 +177,15 @@ describe("the e2e run's contract with the product", () => {
expect(plain.name).toBe("e2e-1006-101500");
expect(plain.model.id).toBe("z-ai/glm-5.3-flash");
expect(plain.computer.idle_timeout).toBe("0");
expect(plain.browser.identities.max_open).toBe(3);
expect("browser" in plain).toBe(false);
const strict = parseDotConfig(
dotYaml({
name: "e2e-x",
model: "m/x",
maxOpen: 2,
permissions: { "files.write": "ask", "computer.exec": "ask", "browser.identity.create": "ask", "browser.identity.delete": "ask" },
}),
);
expect(strict.browser.identities.max_open).toBe(2);
const runtime = toRuntimeConfig(strict);
for (const permission of ["files.write", "computer.exec", "browser.identity.create", "browser.identity.delete"]) {
expect(resolvePermission(runtime, permission), permission).toBe("ask");
-5
View File
@@ -150,11 +150,6 @@ describe("the engine smoke", () => {
const first = /check_offered 1 [^\n]*\\\n[^\n]*\\\n\s+'(\[[^\n]*\])'/.exec(checks);
expect(first, "check_offered 1").not.toBeNull();
expect(JSON.parse(first![1]!)).toEqual(tools);
// Without managed_by_dot the two tools that create and delete identities are the only ones missing.
const unmanaged = /check_offered 5 [^\n]*\\\n[^\n]*\\\n\s+'(\[[^\n]*\])'/.exec(checks);
expect(unmanaged, "check_offered 5").not.toBeNull();
const browserOnly = tools.filter((t) => t.startsWith("browser_") || t === "computer_screenshot");
expect(JSON.parse(unmanaged![1]!)).toEqual(browserOnly.filter((t) => t !== "browser_identity_create" && t !== "browser_identity_delete"));
});
it.each(["smoke.sh", "browser/smoke.sh"])("%s exits non-zero on a failed check and on a skipped one, and prints the summary", (name) => {
+7 -5
View File
@@ -113,17 +113,19 @@ describe("the README's account of the web UI", () => {
});
describe("the numbers the README gives", () => {
const config = parseDotConfig("name: a\ngoal: b\nmodel: { provider: openrouter, id: x/y }\n");
const config = parseDotConfig("name: a\nmodel: { provider: openrouter, id: x/y }\n");
// The text with its line breaks and quote marks folded into spaces, so a reflow of a paragraph breaks nothing.
const flat = readme.replace(/\s*\n(?:> ?)?\s*/g, " ");
it("are the defaults of the configuration", () => {
expect(config.computer.idle_timeout).toBe("15m");
expect(flat).toContain("`idle_timeout`, 15 minutes in the sample");
expect(config.browser.identities.max_open).toBe(3);
expect(flat).toContain("`max_open` (default 3)");
expect(config.browser.identities.max_identities).toBe(20);
expect(flat).toContain("`max_identities` exist (default 20)");
// The browser's limits are the engine's own, not settings.
const engineMain = read("invisible_engine_dots/nanobot/dots/main.py");
expect(engineMain).toContain("DEFAULT_MAX_OPEN = 3\n");
expect(flat).toContain("At most 3 identities are open at once");
expect(engineMain).toContain("DEFAULT_MAX_IDENTITIES = 20\n");
expect(flat).toContain("at most 20 exist.");
expect(config.computer.memory).toBe("4gb");
expect(config.computer.disk).toBe("40gb");
expect(flat).toContain("(40 GB in the sample)");