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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ npm run test:live # opt-in: needs agent-browser installed
|---|---|
| `@tyto/core` | Recipes, lint, verify, brief, find, redaction, ports, fakes (pure) |
| `@tyto/llm` | OpenAI-compatible and Anthropic HTTP model adapters |
| `@tyto/agent-browser`, `@tyto/store`, `@tyto/compiler`, `@tyto/cli` | Coming in slices 3–9 |
| `@tyto/agent-browser` | Runs the agent-browser CLI (argv, batch JSON) |
| `@tyto/store` | Recipes, session locks, replay config under `~/.tyto` |
| `@tyto/cli` | The `tyto` command (`run`, `test`, `recipes`) |
| `@tyto/compiler` | Coming in slice 6 |

A [YOLOVibeCode](https://github.com/YOLOVibeCode) public repo. Product: Noctusoft, Inc. MIT license.
40 changes: 25 additions & 15 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ Separate fakes in `@tyto/core/testing`.
| Package | Role |
|---|---|
| `packages/core` | Recipe schema, lint, render, verify; trace model; brief assembly and rendering; find; redaction; ports; fakes |
| `packages/agent-browser` | `BrowserRunner` and `EventSource` adapters: `execFile` argv, batch via stdin JSON, stream WebSocket, session lock |
| `packages/store` | `~/.tyto/{recipes,traces,locks}`, JSON, `0600`, atomic writes |
| `packages/agent-browser` | `BrowserRunner` and `EventSource` adapters: spawn with an argv array, batch via stdin JSON, stream WebSocket |
| `packages/store` | `~/.tyto/{recipes,traces,locks}`: recipe store, session lock, replay config files; JSON, `0600`, atomic writes |
| `packages/compiler` | `ClaudeCodeCompiler` (default), `OpenAiCompatCompiler` (slice 9) |
| `packages/llm` | OpenAI-compatible and Anthropic HTTP `ModelPort`s (kept); tool calls added in slice 9 |
| `packages/cli` | `tyto` command |
Expand All @@ -40,6 +40,7 @@ Separate fakes in `@tyto/core/testing`.
```json
{ "name": "github-release-info", "version": 1, "status": "draft", "auth": false,
"intent": "…", "examples": ["…"], "origins": ["https://github.com"],
"domains": ["github.githubassets.com", "*.githubusercontent.com"],
"params": { "repo": { "type": "string", "description": "owner/name", "example": "vercel-labs/agent-browser" },
"n": { "type": "int", "description": "position", "example": "1", "default": "1" } },
"steps": [["open", "https://github.com/{{repo|path}}/releases"], ["wait", "--load", "load"],
Expand All @@ -50,17 +51,20 @@ Separate fakes in `@tyto/core/testing`.

Filters: `{{p}}`, `{{p|lower}}`, `{{p|underscore}}`, `{{p|path}}` (percent-encode, keep `/`), `{{p|url}}`
(full URL; origin must be in `origins`). Param types: `string`, `int`, `enum` (with `values`).
`origins` limits what the recipe may open; `domains` adds hosts the page may load resources from (CDNs); both
feed agent-browser's `--allowed-domains` in `tyto-rx`.

**Executor.** lint → render (defaults, types) → prepend `const params = JSON.parse(<double-encoded>);` to eval
code → base64 (`eval -b`) → one `batch --bail --json` → last eval parsed as a JSON object → verify
(non-strings compared JSON-style; params regex-escaped in `match`).
Exit codes: 0 hit · 3 MISS `{miss, step}` · 64 usage · 69 agent-browser missing · 70 internal/agent-browser
error · 75 session busy · 77 auth recipe not approved.
Timeouts: `AGENT_BROWSER_DEFAULT_TIMEOUT=6000`, explicit `--timeout` on waits, 15 s overall deadline. Retry the
batch once only on browser launch errors.

**Sessions.** `tyto-rx`: Tyto-owned agent-browser config (copies `executablePath` from the user's config; no
restore) with `--allowed-domains <origins>`. `tyto-rx-auth`: `--restore main`, only for approved `auth` recipes.
Exit codes: 0 hit · 3 MISS `{miss, step}` · 64 usage/bad params · 65 recipe fails parse or lint · 69
agent-browser missing · 70 internal/agent-browser error · 75 session busy · 77 auth recipe not approved.
Timeouts: `AGENT_BROWSER_DEFAULT_TIMEOUT=6000` in the batch environment (waits read it per call; clicks and fills
read it when the `tyto-rx` daemon starts, which Tyto does), 15 s overall deadline. Retry the batch once only on
browser launch errors.

**Sessions.** `tyto-rx`: Tyto-owned agent-browser config via `AGENT_BROWSER_CONFIG`, which replaces the user's
config entirely (keeps only `executablePath`; no restore), with `--allowed-domains <origin hosts + domains>`. `tyto-rx-auth`: `--restore main`, only for approved `auth` recipes.
Both use an action policy denying download and upload.

## 4. Slices and spec sentences
Expand Down Expand Up @@ -95,18 +99,24 @@ Each slice: tests first, then code, `npm run check` green, one PR.

### Slice 3 — executor, adapter, store, `tyto run`
- `runs all steps as one batch in session tyto-rx with --allowed-domains from origins`
- `a hit prints the result JSON and exits 0`
- `a failing step misses with exit 3 and the step index`
- `the domain allowlist also includes the recipe's extra network domains`
- `a hit returns the verified result`; `tyto run prints the result JSON and exits 0 on a hit`
- `a failing step misses with the step index`; `tyto run prints the miss and exits 3`
- `a verify failure after a successful batch misses at the last step`
- `retries once on a browser launch error and never on element-not-found`
- `sets AGENT_BROWSER_DEFAULT_TIMEOUT and adds --timeout to wait steps`
- `sets AGENT_BROWSER_DEFAULT_TIMEOUT and the Tyto config for the batch`
- `stops at the overall deadline with exit 70`
- `an auth recipe runs in tyto-rx-auth only when approved, otherwise exits 77`
- `a busy session exits 75 after the lock wait`
- `a busy session exits 75`; `the session lock is released after the run`
- `a missing agent-browser binary exits 69`
- `recipes that fail lint are refused before any browser call`
- `recipes that fail lint exit 65 before any browser call`; `bad params exit 64 before any browser call`
- `BrowserRunner.batch sends steps as JSON on stdin and parses results` (stub executable, no browser)
- `run puts --session and session args before the command`; `abort kills the child process`
- `FilesystemRecipeStore writes atomically with mode 0600 and rejects invalid recipes`
- `tyto recipes lists name, intent, and params`
- `FileSessionLock gives the lock to one holder at a time` and `reclaims a lock whose holder process is gone`
- `ensureReplayFiles writes a Tyto agent-browser config with only executablePath and a policy denying download and upload`
- `tyto recipes lists name, intent, and params`; `tyto test runs every regression case and exits 3 if any fails`
- live: `a fixture recipe hits, then misses after the page changes`; `a missing locator fails in under 7 s`

### Slice 4 — brief, find, act
- `brief lists failed Fetch/XHR requests with a redacted response snippet`
Expand Down
43 changes: 42 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
"description": "Learn-once, replay-fast browser recipes and one-call page briefs, on top of agent-browser.",
"workspaces": [
"packages/core",
"packages/llm"
"packages/llm",
"packages/agent-browser",
"packages/store",
"packages/cli"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:live": "TYTO_LIVE=1 vitest run",
"typecheck": "tsc --noEmit -p packages/core && tsc --noEmit -p packages/llm",
"typecheck": "tsc --noEmit -p packages/core && tsc --noEmit -p packages/llm && tsc --noEmit -p packages/agent-browser && tsc --noEmit -p packages/store && tsc --noEmit -p packages/cli",
"secrets:scan": "node scripts/check-secrets.mjs",
"lint:imports": "node scripts/check-core-imports.mjs",
"check": "npm run lint:imports && npm run secrets:scan && npm test && npm run typecheck"
Expand Down
11 changes: 11 additions & 0 deletions packages/agent-browser/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "@tyto/agent-browser",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "BrowserRunner adapter: runs the agent-browser CLI with an argv array.",
"exports": { ".": "./src/index.ts" },
"dependencies": {
"@tyto/core": "*"
}
}
1 change: 1 addition & 0 deletions packages/agent-browser/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AgentBrowserRunner, type AgentBrowserRunnerOptions } from "./runner.ts";
108 changes: 108 additions & 0 deletions packages/agent-browser/src/runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { spawn } from "node:child_process";
import {
AgentBrowserMissingError,
BatchOutputError,
type BatchStepResult,
type BrowserRunner,
type RunOptions,
type RunOutput,
} from "@tyto/core";

export type AgentBrowserRunnerOptions = {
/** Path or name of the agent-browser binary. Default: `agent-browser` on PATH. */
bin?: string;
baseEnv?: NodeJS.ProcessEnv;
maxOutputBytes?: number;
};

const DEFAULT_MAX_OUTPUT = 64 * 1024 * 1024;

function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}

function withoutLifecycle(result: unknown): unknown {
if (!isRecord(result) || !("lifecycle" in result)) return result;
const { lifecycle: _lifecycle, ...rest } = result;
return rest;
}

function toStepResult(raw: unknown): BatchStepResult {
if (!isRecord(raw) || !Array.isArray(raw.command)) throw new BatchOutputError("batch output entry has no command");
const error = raw.error;
return {
command: raw.command.map(String),
success: raw.success === true,
result: withoutLifecycle(raw.result),
error: error === null || error === undefined ? null : typeof error === "string" ? error : JSON.stringify(error),
};
}

/** Runs the agent-browser CLI with an argv array (never a shell string). */
export class AgentBrowserRunner implements BrowserRunner {
readonly #bin: string;
readonly #baseEnv: NodeJS.ProcessEnv;
readonly #maxOutput: number;

constructor(opts: AgentBrowserRunnerOptions = {}) {
this.#bin = opts.bin ?? "agent-browser";
this.#baseEnv = opts.baseEnv ?? process.env;
this.#maxOutput = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT;
}

run(argv: readonly string[], opts: RunOptions): Promise<RunOutput> {
return this.#exec([...this.#prefix(opts), ...argv], opts, undefined);
}

async batch(steps: readonly (readonly string[])[], opts: RunOptions): Promise<BatchStepResult[]> {
const out = await this.#exec([...this.#prefix(opts), "batch", "--bail", "--json"], opts, JSON.stringify(steps));
let parsed: unknown;
try {
parsed = JSON.parse(out.stdout);
} catch {
throw new BatchOutputError(`agent-browser batch did not print JSON: ${out.stderr.trim().slice(0, 300)}`);
}
if (!Array.isArray(parsed)) throw new BatchOutputError("agent-browser batch output is not an array");
return parsed.map(toStepResult);
}

#prefix(opts: RunOptions): string[] {
return ["--session", opts.session, ...(opts.args ?? [])];
}

#exec(args: readonly string[], opts: RunOptions, stdin: string | undefined): Promise<RunOutput> {
return new Promise((resolve, reject) => {
const child = spawn(this.#bin, args, {
env: { ...this.#baseEnv, ...(opts.env ?? {}) },
stdio: ["pipe", "pipe", "pipe"],
...(opts.signal ? { signal: opts.signal } : {}),
});
let stdout = "";
let stderr = "";
let size = 0;
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
size += chunk.length;
if (size > this.#maxOutput) child.kill("SIGKILL");
else stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
if (stderr.length < 65_536) stderr += chunk;
});
child.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT" || err.code === "EACCES") reject(new AgentBrowserMissingError(`agent-browser not found at ${this.#bin}`));
else reject(err);
});
child.on("close", (code) => {
if (opts.signal?.aborted) reject(opts.signal.reason instanceof Error ? opts.signal.reason : new Error("aborted"));
else if (size > this.#maxOutput) reject(new BatchOutputError("agent-browser output exceeded the size limit"));
else resolve({ exitCode: code ?? 1, stdout, stderr });
});
child.stdin.on("error", () => {
// The child may exit before reading stdin (e.g. missing binary); `error`/`close` report the outcome.
});
child.stdin.end(stdin ?? "");
});
}
}
33 changes: 33 additions & 0 deletions packages/agent-browser/test/fixtures/fake-agent-browser.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env node
// Stand-in for the agent-browser binary in offline tests. Behavior comes from FAKE_AB_MODE.
import { appendFileSync } from "node:fs";

const argv = process.argv.slice(2);
if (process.env.FAKE_AB_LOG) appendFileSync(process.env.FAKE_AB_LOG, JSON.stringify(argv) + "\n");
const mode = process.env.FAKE_AB_MODE ?? "ok";

if (mode === "hang") {
setInterval(() => {}, 1000);
} else if (argv.includes("batch")) {
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (c) => (input += c));
process.stdin.on("end", () => {
if (mode === "garbage") {
process.stdout.write("not json\n");
process.stderr.write("daemon exploded\n");
process.exit(1);
}
const steps = JSON.parse(input);
const out = steps.map((command, i) => ({
command,
error: mode === "fail-last" && i === steps.length - 1 ? "Element not found: #x" : null,
result: { lifecycle: { launched: i === 0 }, result: `ok:${command[0]}`, config: process.env.AGENT_BROWSER_CONFIG ?? null },
success: !(mode === "fail-last" && i === steps.length - 1),
}));
process.stdout.write(JSON.stringify(out) + "\n");
process.exit(mode === "fail-last" ? 1 : 0);
});
} else {
process.stdout.write(`ran ${argv.join(" ")}\n`);
}
Loading
Loading