Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
4 changes: 3 additions & 1 deletion .github/workflows/publish-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: packages/agenty-bootstrap
workspaces: |
packages/agenty-bootstrap
packages/patch-applier

- name: Install dependencies
run: pnpm install --frozen-lockfile
Expand Down
15 changes: 9 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
## Project overview

Agenty is a local-first AI agent application organized as a pnpm + Turborepo monorepo.
The active product path has three workspaces:
The active product path has four workspaces:

- `packages/agenty-core`: Go 1.26 core process and stdio JSON-RPC 2.0 server.
- `packages/agenty-cli`: Bun/TypeScript/React OpenTUI client.
- `packages/patch-applier`: Rust V4A parser and transactional filesystem patch helper.
- `packages/agenty-bootstrap`: Rust self-extracting launcher.

The CLI starts core as a child process and communicates only through NDJSON messages on
Expand Down Expand Up @@ -45,7 +46,9 @@ Core data is local-first:
- Config: `~/.agenty/config.json`
- Sessions: append-only JSONL under `~/.agenty/sessions/`
- Session projection: `~/.agenty/agenty.sqlite`
- Providers/models: `~/.agenty/providers/<provider-code>.json` (models embedded)
- Built-in providers/models: embedded in the core binary; custom providers use
`~/.agenty/providers/<provider-code>.json`, while built-in provider files contain
only the API key.
- Agents: `~/.agenty/agents/`
- Logs: `~/.agenty/logs/<yyyy>/<mm>/<dd>/core.log`

Expand Down Expand Up @@ -79,17 +82,17 @@ and breaks terminal capability handshakes.

The bootstrap artifact layout is:

`[bootstrap stub][xz CLI][xz core][108-byte footer]`
`[bootstrap stub][xz CLI][xz core][xz apply_patch][156-byte footer]`

The footer stores offsets, lengths, and SHA3-256 digests of decompressed payloads.
The footer stores offsets, lengths, and SHA3-256 digests of the three decompressed payloads.
`src/lib.rs` and `scripts/footer.ts` are one wire contract; changing the layout requires
updating both golden tests and incrementing `FORMAT_VERSION`. Compression uses
`@napi-rs/lzma`; Rust decompression uses statically linked vendored liblzma. Code signing
must happen after payload packing.

pnpm owns workspace resolution, Turborepo owns build ordering/caching, Bun builds the
CLI and packs payloads, Go builds core, and Cargo builds the launcher. Do not add an npm
`workspaces` field. The dependency graph builds core, then CLI, then bootstrap.
CLI and packs payloads, Go builds core, and Cargo builds the patch helper and launcher. Do not add an npm
`workspaces` field. The dependency graph builds the patch helper before core, then CLI and bootstrap.

Root `.env` is the single `AGENTY_VERSION` source and stays ignored; only
`.env.example` is committed. Release CI passes target-specific `GOOS`, `GOARCH`, `CC`,
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
[简体中文](./README.zh-CN.md)

Agenty is a local-first AI agent application. The current product path consists of
`agenty-cli`, `agenty-core`, and the self-extracting `agenty-bootstrap` launcher.
`agenty-cli`, `agenty-core`, the Rust `patch-applier` helper, and the self-extracting
`agenty-bootstrap` launcher.
The CLI communicates with core exclusively through line-delimited JSON-RPC 2.0 over
the child process's stdin/stdout; it does not start an HTTP server.

Expand All @@ -24,21 +25,24 @@ sudo install -m 755 agenty /usr/local/bin/agenty
agenty
```

On first run, the launcher verifies and extracts the bundled CLI and core into
`~/.agenty/bin/{cli,core}`. The CLI starts core as a child process and opens a setup
On first run, the launcher verifies and extracts the bundled CLI, core, and patch helper into
`~/.agenty/bin/{cli,core,apply_patch}`. The CLI starts core as a child process and opens a setup
wizard. The wizard creates one provider, one chat model, and one default agent through
the existing `provider.*` and `agent.*` IPC methods, then calls `initialize.complete`.

## Runtime model

The launcher contains two XZ-compressed payloads and their decompressed SHA3-256
The launcher contains three XZ-compressed payloads and their decompressed SHA3-256
digests. Matching extracted files are reused; missing or mismatched files are verified
and atomically replaced. The CLI resolves core in this order:

1. `AGENTY_CORE_BIN`
2. `packages/agenty-core/bin/agenty-core` during repository development
3. `~/.agenty/bin/core` from the launcher

Before starting core, the CLI prepends core's directory to `PATH`, making the bundled
`apply_patch` command available to core and shell tool calls.

Core reads one compact JSON-RPC message per stdin line and writes responses and
notifications to stdout. After `session.start`, core sends ordered `session.event`
notifications for round lifecycle, persisted messages, model stream deltas, tool calls,
Expand All @@ -59,7 +63,7 @@ Core stores data under `~/.agenty` by default. Pass `--data-dir <path>` to the C
| Configuration | `~/.agenty/config.json` |
| Session transcripts | `~/.agenty/sessions/<yyyy>/<mm>/<dd>/<session-id>.jsonl` |
| Session index | `~/.agenty/agenty.sqlite` |
| Providers and models | `~/.agenty/providers/<provider-code>.json` (models embedded) |
| Providers and models | Built-in catalog is embedded in the core binary; custom providers use `~/.agenty/providers/<provider-code>.json`, built-in provider files store only API keys; core automatically discovers empty configured catalogs when listing providers/models and caches them under `~/.agenty/providers/.models/` for 8 hours |
Comment thread
masteryyh marked this conversation as resolved.
Outdated
| Agents | `~/.agenty/agents/` |
| Logs | `~/.agenty/logs/<yyyy>/<mm>/<dd>/core.log` |

Expand Down
12 changes: 8 additions & 4 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
[English](./README.md)

Agenty 是一个本地优先的 AI agent 应用。当前产品链路由 `agenty-cli`、
`agenty-core` 和自解压 launcher `agenty-bootstrap` 组成。CLI 仅通过子进程
`agenty-core`、Rust `patch-applier` helper 和自解压 launcher `agenty-bootstrap` 组成。
CLI 仅通过子进程
stdin/stdout 上的逐行 JSON-RPC 2.0 与 core 通信,不再启动 HTTP server。

core 当前支持 provider/model/agent 管理、持久化会话、模型流式输出、agent 工具循环
Expand All @@ -22,19 +23,22 @@ agenty
```

首次运行时,launcher 会校验并释放内置的 CLI 和 core 到
`~/.agenty/bin/{cli,core}`。CLI 启动 core 子进程并打开初始化向导;向导通过
`~/.agenty/bin/{cli,core,apply_patch}`。CLI 启动 core 子进程并打开初始化向导;向导通过
已有的 `provider.*` 和 `agent.*` IPC methods 创建一个 provider、一个聊天 model 和一个默认 agent,
最后调用 `initialize.complete` 标记初始化完成。

## 运行模型

launcher 内含两个 XZ 压缩 payload 及其解压内容的 SHA3-256 摘要。已释放文件摘要一致时
launcher 内含三个 XZ 压缩 payload 及其解压内容的 SHA3-256 摘要。已释放文件摘要一致时
直接复用;缺失或不一致时会重新解压、校验并原子替换。CLI 按以下顺序查找 core:

1. `AGENTY_CORE_BIN`
2. 仓库开发环境中的 `packages/agenty-core/bin/agenty-core`
3. launcher 释放的 `~/.agenty/bin/core`

CLI 启动 core 前会把 core 所在目录放到 `PATH` 首位,使 core 和 shell 工具调用可以找到
同目录中的 `apply_patch`。

core 从 stdin 逐行读取紧凑 JSON-RPC message,并把 response 和 notification 写到 stdout。
调用 `session.start` 后,core 会持续发送有序的 `session.event` 通知,覆盖 round 生命周期、
已持久化消息、模型流式增量、工具调用和 round 终态。通知可能早于 `session.start` response
Expand All @@ -53,7 +57,7 @@ core 默认把数据保存在 `~/.agenty`。可向 CLI 传入 `--data-dir <path>
| 配置 | `~/.agenty/config.json` |
| 会话 transcript | `~/.agenty/sessions/<yyyy>/<mm>/<dd>/<session-id>.jsonl` |
| 会话索引 | `~/.agenty/agenty.sqlite` |
| Providers 和 models | `~/.agenty/providers/<provider-code>.json`(模型内嵌) |
| Providers 和 models | 内置 catalog 固化在 core 二进制中;自定义 provider 使用 `~/.agenty/providers/<provider-code>.json`,内置 provider 文件仅保存 API key;core 在获取 provider/model 列表时自动发现已配置的空模型 catalog,结果缓存于 `~/.agenty/providers/.models/`,有效期 8 小时 |
| Agents | `~/.agenty/agents/` |
| 日志 | `~/.agenty/logs/<yyyy>/<mm>/<dd>/core.log` |

Expand Down
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"clean": "rm -rf dist && turbo run clean",
"dev": "turbo run build --filter=agenty-bootstrap && pnpm --filter agenty-bootstrap exec bun -e \"const { join } = await import('node:path'); const { resolveArch, resolveOS } = await import('./scripts/target.ts'); const os = resolveOS(); const executable = join('bin', 'agenty-' + os + '-' + resolveArch() + (os === 'windows' ? '.exe' : '')); const child = Bun.spawn([executable, ...process.argv.slice(1)], { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', env: process.env }); process.exit(await child.exited);\"",
"deepclean": "pnpm clean && rm -rf node_modules packages/*/node_modules",
"core:build": "turbo run build --filter=agenty-core",
"core:test": "turbo run test --filter=agenty-core",
Expand All @@ -18,6 +19,10 @@
"core:test:repeat": "pnpm --filter agenty-core test:repeat",
"core:tidyup": "cd packages/agenty-core && go fmt ./... && go vet ./... && go mod tidy",
"core:clean": "pnpm --filter agenty-core clean",
"patch-applier:build": "turbo run build --filter=patch-applier",
"patch-applier:test": "turbo run test --filter=patch-applier",
"patch-applier:lint": "pnpm --filter patch-applier lint",
"patch-applier:clean": "pnpm --filter patch-applier clean",
"cli:build": "turbo run build --filter=agenty-cli",
"cli:dev": "turbo run build --filter=agenty-core && pnpm --filter agenty-cli dev",
"cli:typecheck": "pnpm --filter agenty-cli typecheck",
Expand All @@ -33,7 +38,7 @@
"eslint-plugin-import-newlines": "^2.0.0",
"eslint-plugin-jsonc": "^3.4.1",
"eslint-plugin-simple-import-sort": "^14.0.0",
"turbo": "^2.10.10",
"turbo": "^2.10.11",
"typescript-eslint": "^8.67.0"
}
}
}
3 changes: 2 additions & 1 deletion packages/agenty-bootstrap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"devDependencies": {
"agenty-cli": "workspace:*",
"agenty-core": "workspace:*"
"agenty-core": "workspace:*",
"patch-applier": "workspace:*"
}
}
10 changes: 8 additions & 2 deletions packages/agenty-bootstrap/scripts/footer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { encodeFooter, FOOTER_SIZE } from "./footer";
const GOLDEN_FOOTER_HEX =
"88776655443322110807060504030201000102030405060708090a0b0c0d0e0f" +
"101112131415161718191a1b1c1d1e1f1122334455667788010203040506070820212223242526" +
"2728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f01000000cafebabe10136666";
"2728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f08090a0b0c0d0e0f1011121314151617" +
"404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f02000000cafebabe10136666";

function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
Expand All @@ -29,6 +30,11 @@ describe("encodeFooter", () => {
len: 0x0807060504030201n,
sha3_256: Uint8Array.from({ length: 32 }, (_, i) => 0x20 + i),
},
{
offset: 0x0f0e0d0c0b0a0908n,
len: 0x1716151413121110n,
sha3_256: Uint8Array.from({ length: 32 }, (_, i) => 0x40 + i),
},
);

expect(footer.length).toBe(FOOTER_SIZE);
Expand All @@ -37,6 +43,6 @@ describe("encodeFooter", () => {

test("rejects non-32-byte digests", () => {
const spec = { offset: 0n, len: 0n, sha3_256: new Uint8Array(31) };
expect(() => encodeFooter(spec, spec)).toThrow("32 bytes");
expect(() => encodeFooter(spec, spec, spec)).toThrow("32 bytes");
});
});
15 changes: 9 additions & 6 deletions packages/agenty-bootstrap/scripts/footer.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
export const MAGIC = [0xca, 0xfe, 0xba, 0xbe, 0x10, 0x13, 0x66, 0x66] as const;
export const FORMAT_VERSION = 1;
export const FOOTER_SIZE = 108;
export const FORMAT_VERSION = 2;
export const FOOTER_SIZE = 156;

export interface PayloadSpec {
offset: bigint;
len: bigint;
sha3_256: Uint8Array;
}

export function encodeFooter(cli: PayloadSpec, core: PayloadSpec): Uint8Array {
if (cli.sha3_256.length !== 32 || core.sha3_256.length !== 32) {
export function encodeFooter(cli: PayloadSpec, core: PayloadSpec, patchApplier: PayloadSpec): Uint8Array {
if (cli.sha3_256.length !== 32 || core.sha3_256.length !== 32 || patchApplier.sha3_256.length !== 32) {
throw new Error("payload SHA3-256 digests must be 32 bytes");
}

Expand All @@ -21,7 +21,10 @@ export function encodeFooter(cli: PayloadSpec, core: PayloadSpec): Uint8Array {
view.setBigUint64(48, core.offset, true);
view.setBigUint64(56, core.len, true);
out.set(core.sha3_256, 64);
view.setUint32(96, FORMAT_VERSION, true);
out.set(MAGIC, 100);
view.setBigUint64(96, patchApplier.offset, true);
view.setBigUint64(104, patchApplier.len, true);
out.set(patchApplier.sha3_256, 112);
view.setUint32(144, FORMAT_VERSION, true);
out.set(MAGIC, 148);
return out;
}
24 changes: 19 additions & 5 deletions packages/agenty-bootstrap/scripts/pack.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Packs the final self-extracting `agenty-<os>-<arch>` binary:
*
* [ agenty-bootstrap stub ][ compressed CLI ][ compressed core ][ footer ]
* [ bootstrap stub ][ compressed CLI ][ compressed core ][ compressed apply_patch ][ footer ]
*
* Both payloads are compressed in memory with xz and appended directly, so no
* intermediate archives are written to disk. The footer records each payload's
Expand All @@ -28,6 +28,7 @@ const PKG = resolve(import.meta.dir, "..");
const REPO = resolve(PKG, "../..");
const CORE_BIN_DIR = join(REPO, "packages/agenty-core/bin");
const CLI_DIST_DIR = join(REPO, "packages/agenty-cli/bin");
const PATCH_APPLIER_TARGET_DIR = join(REPO, "packages/patch-applier/target/release");
const DIST = join(PKG, "bin");

function findAgentyBinary(dir: string): string | null {
Expand Down Expand Up @@ -96,18 +97,25 @@ if (!existsSync(stubPath)) {
}
const cliPath = resolveCliBinary(os, arch, ext);
const corePath = resolveCoreBinary(os, arch);
const patchApplierPath = join(PATCH_APPLIER_TARGET_DIR, `apply_patch${ext}`);
if (!existsSync(patchApplierPath)) {
throw new Error(`patch-applier binary not found at ${patchApplierPath}; run its build first`);
}

const stub = readFileSync(stubPath);
const cli = readFileSync(cliPath);
const core = readFileSync(corePath);
if (stub.length === 0 || cli.length === 0 || core.length === 0) {
throw new Error("stub, CLI or core binary is empty");
const patchApplier = readFileSync(patchApplierPath);
if (stub.length === 0 || cli.length === 0 || core.length === 0 || patchApplier.length === 0) {
throw new Error("stub, CLI, core or patch-applier binary is empty");
}

const cliSha3 = sha3_256(cli);
const coreSha3 = sha3_256(core);
const patchApplierSha3 = sha3_256(patchApplier);
const cliPayload = await compress(cli);
const corePayload = await compress(core);
const patchApplierPayload = await compress(patchApplier);

const footer = encodeFooter(
{
Expand All @@ -120,11 +128,16 @@ const footer = encodeFooter(
len: BigInt(corePayload.length),
sha3_256: coreSha3,
},
{
offset: BigInt(stub.length + cliPayload.length + corePayload.length),
len: BigInt(patchApplierPayload.length),
sha3_256: patchApplierSha3,
},
);

mkdirSync(DIST, { recursive: true });
const out = join(DIST, `agenty-${os}-${arch}${ext}`);
writeFileSync(out, Buffer.concat([stub, cliPayload, corePayload, footer]));
writeFileSync(out, Buffer.concat([stub, cliPayload, corePayload, patchApplierPayload, footer]));
if (os !== "windows") {
chmodSync(out, 0o755);
}
Expand All @@ -134,5 +147,6 @@ console.log(
` stub ${stub.length} bytes (${stubPath})\n` +
` cli ${cli.length} -> ${cliPayload.length} bytes (${cliPath})\n` +
` core ${core.length} -> ${corePayload.length} bytes (${corePath})\n` +
` total ${stub.length + cliPayload.length + corePayload.length + footer.length} bytes`,
` patch ${patchApplier.length} -> ${patchApplierPayload.length} bytes (${patchApplierPath})\n` +
` total ${stub.length + cliPayload.length + corePayload.length + patchApplierPayload.length + footer.length} bytes`,
);
Loading
Loading