mirror of
https://github.com/feder-cr/Jobs_Applier_AI_Agent.git
synced 2026-11-07 10:36:05 -10:00
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:
1 parent
23c130b10a
commit
bb37a3a105
90 files changed
+334
-987
No files matched your search
@@ -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": "" },
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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" })]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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) })) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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?");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)}`}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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?" }));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)");
|
||||
|
||||
Reference in new issue
Block a user