From f863f839603c1282b36ecb005295b267e256563c Mon Sep 17 00:00:00 2001 From: emmettlu Date: Sun, 2 Aug 2026 15:48:09 +0800 Subject: [PATCH] chore: Add Chinese README and refactor NPU backend to use Unix domain sockets - Introduced a new Chinese version of the README (README_CN.md) to provide localized documentation for AgentOS. - Refactored the NPU bridge to utilize Unix domain sockets instead of HTTP loopback, enhancing security and performance. - Updated the NPU backend to include a server socket configuration, ensuring proper communication over Unix sockets. - Modified the Ntex client to support both network and Unix socket transports, improving flexibility in backend communication. - Adjusted validation logic to enforce the use of Unix sockets for local model endpoints, rejecting loopback HTTP addresses. - Enhanced error messages and documentation throughout the codebase to clarify the new socket-based architecture. --- AGENTS.md | 209 +++++++++++ README.md | 351 ++++++++++++++++++ README_CN.md | 330 ++++++++++++++++ .../agentos-cli/src/bin/agentos-npu-bridge.rs | 8 +- crates/agentos-inference/src/lib.rs | 225 ++++++++++- crates/agentos-npu/Cargo.toml | 1 - crates/agentos-npu/src/lib.rs | 83 +++-- crates/agentos-runtime/src/lib.rs | 15 +- 8 files changed, 1157 insertions(+), 65 deletions(-) create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 README_CN.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b4e2541 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,209 @@ +# AgentOS 工程约定 + +本文件面向在本仓库内工作的代码代理。修改前先读本文件、根 +`Cargo.toml`,再读目标 crate 的源码。不要根据旧 Python 版本、Octos 或 +`npu_features/` 中的厂商样例臆测当前行为;`crates/` 才是实现事实来源。 + +## 项目目标 + +AgentOS 是运行在裸 Linux 上的 low-level OS agent,而不是 Linux 发行版、 +容器沙箱或内核 fork。核心原则是尽量复用 Linux 原生能力:进程 UID/GID、 +文件权限、Unix 凭据、pidfd、`no_new_privs`、Btrfs subvolume/snapshot 和设备 +ioctl。 + +当前安全模型是稳定的 `AgentId -> (owner UID, agent UID, agent GID)` 绑定: + +- agent 进程不能以 UID 0 或 GID 0 运行; +- 每次执行前必须核对进程的 effective UID/GID; +- 一个状态数据库中,agent UID 只能绑定给一个 active `AgentId`; +- 不额外构造 namespace、容器或通用 sandbox; +- `no_new_privs` 是补充保护,不能替代 UID/GID 和工具策略; +- 模型不能获得任意 shell,更不能直接获得 root shell。 + +不要把 `owner_uid` 与 `agent_uid` 混为一谈。前者表示拥有者,后者才是内核 +实际执行主体。新增 daemon 或 broker 时,Unix socket 对端必须用内核提供的 +peer credentials 鉴权,不能信任请求体里的 UID。 + +## 当前成熟度边界 + +已经可用: + +- `agentos` one-shot CLI、doctor 和确定性 fake backend; +- OpenAI-compatible Chat Completions / Responses 非流式后端; +- `agentos.chat.v1` 内部协议、严格工具 schema、预算和循环检测; +- 只读 Linux 工具、SQLite 审计/记忆、Btrfs worldline 与 copy fallback; +- CALCULET PCIe ABI、Rust ioctl/DMA 包装、Calbin 解析、张量/命令结构; +- Candle/Qwen3 host pipeline、tool-call 模板和 CALRT 张量适配; +- 旧 llama-server UDS bridge;HTTP 消息格式直接承载在 AF_UNIX 上,不经过 TCP。 + +尚未完成: + +- 纯 Rust CALRT 的 CCU relocation、job launch/completion 和设备 KV reset; +- 可在真实 NPU 上完成推理的 `npu_candle` backend; +- typed mutation broker 和任何变更型系统工具; +- 长驻 AgentOS control daemon、control UDS RPC、服务安装和完整硬件端到端测试。 + +`ConfiguredRuntime::submit()` 当前必须 fail closed 并返回 +`HardwareExecutionUnavailable`。在没有真实板卡证据前,不得把 +`npu_candle` 标记为 ready,不得让 `auto` 选择它,也不得用 mock 测试宣称 +硬件推理已验证。 + +## Workspace 与模块所有权 + +除 `agentos-cli` 外,`crates/` 下均为 library crate。 + +| Crate | 职责 | +| --- | --- | +| `agentos-cli` | `agentos` 与 `agentos-npu-bridge` 两个 binary;只做参数解析和装配入口 | +| `agentos-runtime` | composition root、环境配置、identity/backend/tool 装配、doctor | +| `agentos-agent` | 有预算的 agent loop、工具调度、循环检测、审计事件 | +| `agentos-protocol` | provider-neutral `agentos.chat.v1` 类型与 `ChatBackend` trait | +| `agentos-inference` | ntex HTTP、OpenAI-compatible、subprocess、fake/scripted backend | +| `agentos-tools` | fail-closed registry、schema 校验、审批契约和 Linux 只读工具 | +| `agentos-core` | ID、principal、execution identity、审计和 worldline 公共类型 | +| `agentos-kernel` | Linux/rustix 边界、凭据、pidfd、文件原子写、Btrfs ioctl | +| `agentos-memory` | SQLite WAL、FTS5、principal 绑定、memory 和 audit event | +| `agentos-worldline` | 类 Git 的 filesystem history、branch/commit/diff/rollback/recovery | +| `agentos-npu` | NPU probe、旧 bridge、Rust Candle NPU backend 装配 | +| `agentos-candle` | Qwen3 模板/tokenizer/sampling 与 Calbin prefill/decode host runner | +| `calculet-pcie-abi` | 驱动 0.9.0 / ABI 1.0.0 的 64-bit Linux ioctl 布局 | +| `calculet-pcie` | 安全的设备、BAR、DMA、MSI、reset 和 board/process API | +| `calculet-calrt` | Calbin、allocator、tensor、command stream、DeviceIo 和 runtime 骨架 | + +保持依赖方向从高层向低层: + +```text +agentos-cli -> agentos-runtime -> agentos-agent/tools/inference/npu/worldline/memory +agentos-npu -> agentos-candle -> calculet-calrt -> calculet-pcie -> calculet-pcie-abi +agentos-kernel/core/protocol 是底层公共边界 +``` + +不要让底层 crate 反向依赖 CLI 或 runtime。跨 provider 的消息类型放在 +`agentos-protocol`;Linux syscall/ABI 放在 `agentos-kernel` 或对应的 +`calculet-*` crate;composition 逻辑只放在 `agentos-runtime`。 + +## 不可破坏的设计约束 + +### Linux 与系统调用 + +- 项目只支持 Linux;当前 CALCULET ABI 只验证过 64-bit Linux。 +- 项目自有的低层 syscall/ioctl 优先走 `rustix`,不要直接新增 `libc` 调用。 +- 这不表示最终二进制完全不链接 libc;Rust `std`、OpenSSL 等依赖仍可使用 + 系统 ABI。 +- `unsafe` 仅允许出现在无法避免的 ABI 边界,必须就结构布局、指针生命周期 + 和 opcode 写英文 `SAFETY` 注释,并在调用前完成长度、对齐和范围校验。 +- 不要为了 worldline patch 内核。优先使用现有 Btrfs ioctl;非 Btrfs 环境保留 + copy fallback。 + +### 工具与权限 + +- registry 必须显式 allowlist;未知工具一律拒绝。 +- 所有 tool schema 默认 strict:object、列出全部 required、 + `additionalProperties: false`。 +- 参数和输出必须有字节上限,外部命令必须有 deadline、`kill_on_drop`,并移除 + API key 环境变量。 +- 只有标记为 `ConcurrencyClass::Safe` 的只读工具可以并行;变更工具必须 + exclusive。 +- 现有 `ApprovalLedger` 只是进程内、单次、精确参数绑定的契约。实现 mutation + 前还必须有 typed broker、内核凭据校验、持久审计和失败恢复,不能把审批 ID + 当作任意命令授权。 +- 不得增加通用 `shell`、`exec`、任意路径写入或任意 systemd unit 工具。 + +### 推理后端 + +- 内部统一使用 `agentos.chat.v1`,provider 差异留在 backend adapter。 +- 缺少 NPU 硬件时的 mock 是远程 OpenAI-compatible 模型,不是内置 + `FakeBackend`。远程传输可以使用 HTTP 或 HTTPS;使用 HTTPS 时必须验证 peer。 +- 本机常驻模型服务必须使用 filesystem Unix domain socket,禁止监听或连接 + localhost、loopback 或其他 TCP 地址。纯 Rust Candle in-process 路径不需要 IPC。 +- 本机 llama-server 可以保留 OpenAI-compatible HTTP 消息格式,但必须通过 + ntex 自定义 connector 承载在 AF_UNIX 上,用户配置只能暴露 socket path,不能 + 接受本机 URL。 +- OpenAI-compatible HTTP 和 UDS HTTP client 使用 `ntex`,不要引入 `reqwest`。 +- 禁止自动 redirect;响应大小、超时和 retry 必须有界。 +- 支持 Chat Completions 与 Responses 两种方言,但不要假设所有兼容服务支持 + 完全相同的字段。新增兼容逻辑必须有请求构造和响应解析测试。 +- subprocess backend 只通过 stdin/stdout 传 JSON,stderr 仅用于有界错误信息; + 不要把 secret 传给子进程。它只作为显式测试/bridge adapter,不能代表本机 + 常驻模型传输,也不能进入 `auto`。 +- `auto` 当前顺序是 UDS legacy NPU、远程 OpenAI-compatible。Fake 和 + subprocess 必须显式选择,Rust Candle NPU 在硬件提交完成前不能进入 auto。 + +### NPU + +- `npu_features/` 是忽略提交的厂商源码/部署抓取,仅作逆向参考,不能成为 + 发布包运行时依赖。 +- 原始版本边界是 driver package 0.9.0、driver ABI 1.0.0、CALRT 0.7.6。 +- ioctl struct 使用 `repr(C, packed)`,任何改动都必须同步 size/opcode 测试。 +- DMA 单次上限 8 MiB,已知 H2C/C2H 各 8 个 channel;不要绕过现有验证。 +- Calbin 参数部署会真实写设备。没有用户明确要求和真实硬件测试计划时,不要 + 默认开启 `deploy_parameters`。 +- captured Qwen3 fixture 的 logits 是 151936,tokenizer vocab 是 151669;额外 + 267 个 padded logits 必须在采样前屏蔽。 +- host-side `MockDevice` 测试只能验证解析、地址、buffer 和命令编码,不能证明 + job submission、同步、KV cache 或输出数值正确。远程模型 mock 同样不能证明 + NPU 硬件正确。 +- legacy llama-server 的默认 socket 是 `/run/agentos/npu.sock`;doctor 必须检查 + 它确实是可写的 Unix socket,不能仅检查路径存在。 + +### Worldline 与持久化 + +- Btrfs commit tree 使用 readonly snapshot;branch/current 使用 writable + snapshot。 +- copy fallback 用于开发和无 Btrfs 环境,但当前不会提供内核强制的只读 + commit tree。不要在文档里把它描述成与 Btrfs 等价的不可变性。 +- commit 是内容/元数据哈希标识;rollback 必须创建新 commit,不能改写历史。 +- checkout 要保留 transaction journal、目录 fsync 和可恢复的 replaced tree。 +- 分支提交必须检查 base HEAD,禁止 stale branch 覆盖新 HEAD。 +- SQLite principal 绑定不可静默重绑;schema 变更要考虑已有数据库升级。 + +## Rust 与依赖规则 + +- 使用 workspace 的 Rust edition,不添加 MSRV 或 `rust-toolchain.toml`。 +- 第三方依赖集中写在根 `Cargo.toml` 的 `[workspace.dependencies]`。 +- 版本只写主版本号,例如 `serde = "1"`,不要固定 `1.2.3`。 +- 内部 crate 统一用 `*.workspace = true`。 +- 读取依赖源码时,从 `$CARGO_HOME/registry/src` 找实际锁定版本,不靠记忆猜 API。 +- 优先复用已有 crate 和抽象,不复制协议类型、HTTP client、schema validator、 + runtime probe 或设备 ABI。 +- 代码注释默认英文;用户可见文档分别维护英文与中文。 +- 不要加入 Python、`reqwest` 或直接 `libc` 依赖。 +- 保持开发/测试 profile 的快速编译取向,除非有基准数据,不要随意调高 dev + 优化或减少 codegen units。 + +## 修改流程 + +1. 用 `rg` 定位实现和调用方,先确认改动属于哪个 crate。 +2. 先写清安全边界和失败模式;系统层能力默认 fail closed。 +3. 修改公共协议时,同步所有 backend、tool adapter、CLI 和双语 README。 +4. 新增环境变量时,同步 `RuntimeConfig::from_env`、doctor 和配置表。 +5. 新增 NPU ABI 时,对照 `npu_features/cal-pcie-0.9.0` 或 CALRT 源码,并补布局、 + opcode、边界和 mock 测试。 +6. 不要改写或删除用户的 `npu_features/` 抓取、模型文件和未提交工作。 + +完成前至少执行: + +```bash +cargo fmt --all --check +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +涉及 CLI 时还要实际运行相关命令;涉及 OpenAI-compatible 时至少覆盖两种 API +方言的 serialization/parsing;涉及硬件而本机无板卡时,明确报告未执行的验证。 + +部分 NPU 测试依赖本地、被 `.gitignore` 忽略的 +`npu_features/snapshot_20260801`。fixture 不存在时,应将硬件抓取测试与普通 +workspace 测试分层,而不是把模型数据提交进仓库或伪造通过结果。 + +## 完成标准 + +一次改动只有在以下条件都满足时才算完成: + +- crate 边界和依赖方向没有被破坏; +- 非法输入、权限不足、后端缺失和硬件缺失均 fail closed; +- 无 secret 出现在日志、tool output、子进程或 doctor 报告中; +- 单元/集成测试覆盖正常路径与关键拒绝路径; +- fmt、workspace test、严格 clippy 通过; +- README 与实际成熟度一致,未把 host-side `MockDevice` 或远程模型 mock 写成 + NPU 硬件验证。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4da555d --- /dev/null +++ b/README.md @@ -0,0 +1,351 @@ +# AgentOS + +[简体中文](README_CN.md) + +AgentOS is a low-level, Linux-only operating-system agent written in Rust. It +combines a bounded tool-calling loop with native Linux identities, read-only +system inspection, durable audit state, Btrfs-backed filesystem history, and +multiple inference backends—including an in-progress pure-Rust path for a +CALCULET NPU. + +> **Development status:** the remote OpenAI-compatible mock path and explicit +> subprocess/fake test adapters are implemented. Locally deployed model services +> use Unix domain sockets, never localhost or TCP. The Rust Candle/CALRT host +> pipeline can parse and validate the captured Qwen3 deployment, but CALRT +> hardware job submission is not implemented yet. `npu_candle` therefore fails +> closed and is not production-ready. + +## Design + +AgentOS is not a container sandbox or a kernel fork. Its isolation boundary is +the Linux process identity: + +- every active `AgentId` is stably bound to an owner UID and a non-root agent + UID/GID; +- the runtime verifies the process effective UID/GID before every run; +- UID 0 or GID 0 agents are rejected; +- `no_new_privs` is enabled before model/tool execution; +- the model sees an explicit tool allowlist, never a raw root shell; +- system mutation is intentionally unavailable until a typed, authenticated + broker is implemented. + +The current execution path is: + +```text +agentos CLI + -> runtime + Linux principal + -> budgeted agent loop + -> ChatBackend (remote OpenAI-compatible / UDS NPU / in-process Candle) + -> strict ToolRegistry (read-only Linux tools) + -> SQLite audit log + +filesystem state -> WorldlineStore -> Btrfs snapshots or copy fallback +NPU host path -> Candle/Qwen3 -> CALRT -> PCIe driver ABI +local model IPC -> Unix domain socket -> OpenAI-compatible HTTP payload +``` + +## What works today + +- Provider-neutral `agentos.chat.v1` messages, tools, responses, usage, and + backend interface. +- Remote OpenAI-compatible non-streaming Chat Completions and Responses APIs + over `ntex`, with bounded responses, timeouts, retries, and verified TLS when + HTTPS is selected. +- OpenAI-compatible HTTP payloads over an AF_UNIX transport for the local legacy + llama-server, without a localhost/TCP hop. +- A JSON stdin/stdout subprocess adapter retained for explicit tests and custom + bridges; it is not the transport for a persistent local model service. +- Bounded agent iterations, tool calls, elapsed time, total tokens, parallel + read-only calls, duplicate call-ID checks, and no-progress/cycle detection. +- Strict JSON tool schemas, allowlists, argument/output limits, deadlines, and + hashed audit records. +- Read-only Linux, package, systemd, journal, and CALCULET NPU inspection. +- SQLite WAL state, FTS5 memory primitives, immutable principal bindings, and + append-only agent/tool audit events. +- Btrfs subvolume/snapshot worldlines with branch, diff, commit, log, + non-destructive rollback, stale-branch protection, and checkout recovery. +- Rust representations of CALCULET driver 0.9.0 / ABI 1.0.0, BAR and DMA access, + Calbin 0.7.6 parsing, memory allocation, tensors, and command encoding. +- A Candle-based Qwen3 host runner with chat templates, tool-call parsing, + sampling, prefill/decode contracts, BF16/F32 logits, and vendor tiled-logit + decoding. + +## Workspace + +`agentos-cli` is the only binary crate; it produces two binaries. Every other +workspace member is a library. + +| Crate | Type | Responsibility | +| --- | --- | --- | +| `agentos-cli` | binary | `agentos` CLI and `agentos-npu-bridge` | +| `agentos-runtime` | library | configuration and composition root | +| `agentos-agent` | library | bounded agent loop and audit emission | +| `agentos-protocol` | library | provider-neutral chat/tool protocol | +| `agentos-inference` | library | ntex, remote, subprocess, and test backends | +| `agentos-tools` | library | tool policy, schema validation, approvals, Linux tools | +| `agentos-core` | library | identities, IDs, events, and shared state types | +| `agentos-kernel` | library | rustix-backed Linux and Btrfs boundary | +| `agentos-memory` | library | SQLite memory, audit, and principal state | +| `agentos-worldline` | library | filesystem branch/commit/rollback history | +| `agentos-npu` | library | NPU discovery, legacy bridge, Candle assembly | +| `agentos-candle` | library | Qwen3 host inference and CALRT tensor adapter | +| `calculet-pcie-abi` | library | captured 64-bit Linux ioctl ABI | +| `calculet-pcie` | library | safe PCIe device operations | +| `calculet-calrt` | library | Calbin/runtime/command/tensor implementation | + +## Requirements + +- 64-bit Linux. AgentOS itself is Linux-only, and the captured CALCULET ABI has + only been verified on 64-bit Linux. +- A current Rust toolchain with Rust 2024 edition support. +- A C toolchain, `pkg-config`, and OpenSSL development files. SQLite is built + from the bundled source. +- Btrfs is recommended for kernel-enforced read-only commit snapshots, but is + not required for development. + +On Debian/Ubuntu, the native build dependencies are typically: + +```bash +sudo apt install build-essential pkg-config libssl-dev +``` + +Build both binaries and all libraries: + +```bash +cargo build --workspace +``` + +## Quick start without NPU hardware + +First inspect the host and backend configuration: + +```bash +cargo run -p agentos-cli --bin agentos -- \ + --state-dir target/agentos-state --json doctor +``` + +Run a deterministic smoke test of the model/tool plumbing. This built-in fake +requests the real read-only `system_inspect` tool, but it is not the remote mock +used to substitute for unavailable NPU hardware: + +```bash +cargo run -p agentos-cli --bin agentos -- \ + --state-dir target/agentos-state --fake-model --json \ + agent once "Inspect this Linux system" +``` + +Do not run the agent as root. By default, its principal uses the current real +UID as owner and the current effective UID/GID as the agent identity. + +## Remote OpenAI-compatible mock inference + +Hardware-independent mock testing uses a remote OpenAI-compatible model. +AgentOS supports either `/chat/completions` or `/responses` under a configured +API base URL. Both HTTP and HTTPS remote URLs are accepted; HTTPS should be used +whenever credentials or untrusted networks are involved. Localhost and loopback +model URLs are rejected because local model services must use UDS. + +```bash +export AGENTOS_MODEL_BACKEND=openai_compatible +export AGENTOS_OPENAI_COMPATIBLE_API_KEY='...' +export AGENTOS_OPENAI_COMPATIBLE_BASE_URL='https://api.openai.com/v1' +export AGENTOS_OPENAI_COMPATIBLE_MODEL='your-model' +export AGENTOS_OPENAI_COMPATIBLE_API='responses' # or chat_completions + +cargo run -p agentos-cli --bin agentos -- \ + --state-dir target/agentos-state \ + agent once "Summarize the current system state" +``` + +`OPENAI_API_KEY` is accepted as a fallback. A non-empty credential is currently +required by the remote adapter. Streaming is not implemented. + +## Local model transport + +A persistent model on the same Linux host must expose a filesystem Unix domain +socket. Do not bind it to localhost, a loopback address, or another TCP address. +The socket path and its owner/group/mode are part of the OS security boundary. + +The legacy llama-server already supports this mode when `--host` ends in +`.sock`. AgentOS carries the familiar OpenAI-compatible HTTP request format over +AF_UNIX, so this is HTTP message framing over UDS—not HTTP over localhost. +The pure-Rust Candle backend runs in-process and therefore needs no IPC socket. + +## Read-only tools + +| Tool | Availability | Operation | +| --- | --- | --- | +| `system_inspect` | always | kernel, uptime, load, memory, root filesystem, OS release | +| `npu_inspect` | always | CALCULET device nodes, module/sysfs/proc monitor, CALRT library | +| `package_query` | always | exact package lookup via dpkg, rpm, or pacman | +| `service_inspect` | configured | bounded `systemctl show` for an allowlisted service | +| `journal_read` | configured | bounded journal read for an allowlisted service | + +Enable service tools with an exact comma-separated allowlist: + +```bash +export AGENTOS_SERVICE_INSPECT_ALLOWLIST='agentos.service,agentos-npud.service' +``` + +There are currently no mutation tools. The approval ledger in +`agentos-tools` is a contract primitive, not a complete privilege broker. + +## Filesystem worldlines + +Worldlines provide Git-like filesystem history without patching the kernel. +On Btrfs, current/branch trees are writable snapshots and commit trees are +read-only snapshots. Other filesystems use a copy fallback; that fallback does +not provide kernel-enforced immutability. + +```bash +AGENTOS=(cargo run -q -p agentos-cli --bin agentos -- \ + --state-dir target/agentos-state --json worldline) + +"${AGENTOS[@]}" init +"${AGENTOS[@]}" branch +"${AGENTOS[@]}" status +"${AGENTOS[@]}" log --limit 20 +``` + +`branch` returns a worldline ID and path. Modify that branch tree, then use: + +```bash +"${AGENTOS[@]}" diff +"${AGENTOS[@]}" commit --message "describe the change" +"${AGENTOS[@]}" rollback --message "revert to known state" +"${AGENTOS[@]}" discard +``` + +Rollback creates a new commit and retains history. Worldline operations are +currently explicit CLI operations; agent runs do not automatically create or +commit a branch. + +## NPU paths + +### Legacy llama-server bridge + +The legacy `npu` backend executes `agentos-npu-bridge`, which connects only to a +filesystem Unix domain socket and sends `/v1/chat/completions` over that socket. +It additionally requires a ready CALCULET device, driver sysfs state, and vendor +CALRT shared library. + +```bash +export AGENTOS_MODEL_BACKEND=npu +export AGENTOS_NPU_BRIDGE_COMMAND='agentos-npu-bridge' +export AGENTOS_NPU_SERVER_SOCKET='/run/agentos/npu.sock' +export AGENTOS_NPU_SERVER_MODEL='agentos-npu' +# Optional when CALRT is not in a probed system location: +export AGENTOS_CALRT_LIBRARY='/opt/calculet/lib/libcalrt-linux-x86_64.so' +``` + +Start the captured llama-server with the same socket path, for example +`--host /run/agentos/npu.sock`, and restrict access using the socket directory +and socket ownership/mode. No TCP listener is required. + +### Pure-Rust Candle/CALRT path + +The `npu_candle` path replaces the modified llama.cpp host code with Rust: + +```bash +export AGENTOS_MODEL_BACKEND=npu_candle +export AGENTOS_NPU_CALBIN='/path/to/calbin-directory' +export AGENTOS_NPU_TOKENIZER='/path/to/tokenizer.json' +export AGENTOS_NPU_DEVICE_INDEX=0 +``` + +`doctor` can parse these files and report the model plan without opening the +device. For the captured Qwen3 deployment, the host validates a 40,960-token +context, 151,936 logits, a 151,669-token vocabulary, and masks 267 padded logit +IDs. + +This backend is deliberately not ready: `calculet-calrt` can deploy parameters +and transfer tensors, but `ConfiguredRuntime::submit()` still lacks CCU +relocation, job launch/completion, and device KV-cache reset. No real NPU result +has been validated on the current development machine. + +## Configuration + +| Variable | Default | Meaning | +| --- | --- | --- | +| `AGENTOS_STATE_DIR` | `$XDG_DATA_HOME/agentos` or `~/.local/share/agentos` | SQLite and worldline root | +| `AGENTOS_MODEL_BACKEND` | `auto` | `auto`, `openai_compatible`, `npu`, `npu_candle`, `subprocess`, `fake` | +| `AGENTOS_AGENT_ID` | `default-agent` | stable logical agent ID | +| `AGENTOS_OWNER_UID` | real UID | owning Linux UID | +| `AGENTOS_AGENT_UID` | effective UID | required execution UID | +| `AGENTOS_AGENT_GID` | effective GID | required execution GID | +| `AGENTOS_OPENAI_COMPATIBLE_API_KEY` | `OPENAI_API_KEY` | remote bearer credential | +| `AGENTOS_OPENAI_COMPATIBLE_BASE_URL` | `https://api.openai.com/v1` | remote compatible API root; no localhost/loopback | +| `AGENTOS_OPENAI_COMPATIBLE_MODEL` | empty | provider model name | +| `AGENTOS_OPENAI_COMPATIBLE_API` | `chat_completions` | `chat_completions` or `responses` | +| `AGENTOS_OPENAI_COMPATIBLE_MAX_RETRIES` | `2` | bounded HTTP retries | +| `AGENTOS_MODEL_COMMAND` | empty | explicit subprocess test/bridge command | +| `AGENTOS_MODEL_TIMEOUT_S` | `240` | model/bridge timeout | +| `AGENTOS_NPU_BRIDGE_COMMAND` | empty | legacy NPU bridge command | +| `AGENTOS_NPU_SERVER_SOCKET` | `/run/agentos/npu.sock` | legacy llama-server Unix socket | +| `AGENTOS_NPU_SERVER_MODEL` | `agentos-npu` | legacy llama-server model name | +| `AGENTOS_NPU_SERVER_TIMEOUT_S` | `240` | legacy llama-server UDS HTTP timeout | +| `AGENTOS_NPU_CALBIN` | empty | pure-Rust Calbin directory | +| `AGENTOS_NPU_TOKENIZER` | empty | pure-Rust tokenizer JSON | +| `AGENTOS_NPU_DEVICE_INDEX` | `0` | `/dev/calculetN` index | +| `AGENTOS_CALRT_LIBRARY` | probed | vendor CALRT shared library override | +| `AGENTOS_SERVICE_INSPECT_ALLOWLIST` | empty | enabled systemd service names | +| `AGENTOS_TOOL_OUTPUT_LIMIT_BYTES` | `65536` | per-tool serialized output limit | +| `AGENTOS_TOOL_ARGUMENT_LIMIT_BYTES` | `65536` | per-call argument limit | +| `AGENTOS_AGENT_MAX_ITERATIONS` | `8` | model/tool loop turns | +| `AGENTOS_AGENT_MAX_TOOL_CALLS` | `16` | total tool calls per run | +| `AGENTOS_AGENT_MAX_ELAPSED_S` | `120` | total run deadline | +| `AGENTOS_AGENT_MAX_OUTPUT_TOKENS` | `2048` | model output budget per turn | +| `AGENTOS_AGENT_MAX_TOTAL_TOKENS` | `65536` | cumulative token budget | +| `AGENTOS_AGENT_MAX_SAFE_WORKERS` | `4` | parallel safe-tool workers | + +In `auto` mode, AgentOS selects the UDS-backed legacy NPU and then the remote +OpenAI-compatible backend. Subprocess and fake are explicit test adapters. The +incomplete Candle backend is not selected automatically. + +## State layout + +```text +AGENTOS_STATE_DIR/ + agentos.sqlite3 # principals, audit events, memory, FTS index + worldline/ + current/ # active filesystem tree + branches/ # writable candidate trees + commits/ # commit trees + metadata/ # commit and branch records + refs/ # HEAD references + replaced/ # recoverable previous checkouts +``` + +## Development + +```bash +cargo fmt --all --check +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +Some NPU parser tests use the local, gitignored +`npu_features/snapshot_20260801` capture. Those tests validate host-side parsing +only. They are not hardware tests, and a clean clone may not contain the +fixture. + +Project-owned low-level Linux calls use `rustix`; AgentOS does not directly add +`libc` syscall wrappers. This is not a claim that the final `std`/OpenSSL binary +has no libc ABI dependency. + +## Known gaps + +- No long-running AgentOS control daemon or privilege broker yet; the local + llama-server model transport already uses UDS. +- No mutation tools or complete privilege-approval path. +- No streaming remote inference. +- No completed pure-Rust NPU job submission or physical-board validation. +- The copy worldline backend lacks Btrfs's kernel-enforced read-only commits. +- Memory/recall primitives exist as a library but are not exposed as agent tools + or CLI commands yet. + +## License notes + +AgentOS crates declare Apache-2.0. The captured CALCULET PCIe ABI and device +wrapper crates declare GPL-2.0-only, while `calculet-calrt` retains its vendor +license file. Check the individual crate manifests before redistribution. diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 0000000..bd67f4c --- /dev/null +++ b/README_CN.md @@ -0,0 +1,330 @@ +# AgentOS + +[English](README.md) + +AgentOS 是一个使用 Rust 编写、仅面向 Linux 的 low-level 操作系统 agent。它把 +有界 tool-calling 循环与 Linux 原生身份、只读系统检查、持久审计、Btrfs 文件 +系统历史和多种推理后端组合在一起,其中包括一条仍在开发中的 CALCULET NPU +纯 Rust 路径。 + +> **开发状态:**远程 OpenAI-compatible mock 路径和显式 subprocess/fake 测试 +> adapter 已经实现。本机部署的模型服务只使用 Unix domain socket,绝不使用 +> localhost 或 TCP。Rust Candle/CALRT host pipeline 可以解析并校验抓取到的 +> Qwen3 部署,但 CALRT 硬件 job submission 尚未实现。因此 `npu_candle` 会 +> fail closed,目前不能用于生产。 + +## 设计 + +AgentOS 不是容器 sandbox,也不是内核 fork。它以 Linux 进程身份作为隔离边界: + +- 每个 active `AgentId` 稳定绑定 owner UID 和非 root agent UID/GID; +- runtime 在每次运行前核对进程的 effective UID/GID; +- 拒绝 UID 0 或 GID 0 的 agent; +- 在模型和工具执行前启用 `no_new_privs`; +- 模型只能看到显式工具 allowlist,永远拿不到原始 root shell; +- 在 typed、可鉴权的 broker 完成前,系统变更能力保持不可用。 + +当前执行路径如下: + +```text +agentos CLI + -> runtime + Linux principal + -> 有预算的 agent loop + -> ChatBackend(远程 OpenAI-compatible / UDS NPU / in-process Candle) + -> strict ToolRegistry(Linux 只读工具) + -> SQLite 审计日志 + +文件系统状态 -> WorldlineStore -> Btrfs snapshot 或 copy fallback +NPU host 路径 -> Candle/Qwen3 -> CALRT -> PCIe driver ABI +本机模型 IPC -> Unix domain socket -> OpenAI-compatible HTTP payload +``` + +## 当前已经实现 + +- Provider-neutral 的 `agentos.chat.v1` 消息、工具、响应、usage 和 backend 接口。 +- 基于 `ntex` 的远程 OpenAI-compatible 非流式 Chat Completions 和 Responses + API,包含响应上限、超时、retry,以及选择 HTTPS 时的 TLS peer 校验。 +- 通过 AF_UNIX 向本机 legacy llama-server 发送 OpenAI-compatible HTTP + payload,不经过 localhost/TCP。 +- 保留 JSON stdin/stdout subprocess adapter,用于显式测试和自定义 bridge; + 它不是常驻本机模型服务的传输方式。 +- Agent iteration、tool call、耗时、总 token 预算,并行只读调用,重复 call ID + 检查,以及无进展/周期检测。 +- Strict JSON tool schema、allowlist、参数/输出上限、deadline 和哈希审计记录。 +- Linux、包管理器、systemd、journal 和 CALCULET NPU 的只读检查。 +- SQLite WAL 状态、FTS5 memory 基础能力、不可重绑的 principal 和追加式 + agent/tool 审计事件。 +- 基于 Btrfs subvolume/snapshot 的 worldline,包括 branch、diff、commit、log、 + 非破坏 rollback、陈旧分支保护和 checkout 恢复。 +- CALCULET driver 0.9.0 / ABI 1.0.0 的 Rust 数据布局、BAR/DMA 访问、Calbin + 0.7.6 解析、内存分配、tensor 和 command 编码。 +- 基于 Candle 的 Qwen3 host runner,包括 chat template、tool-call 解析、采样、 + prefill/decode 契约、BF16/F32 logits 和厂商 tiled-logit 解码。 + +## Workspace + +`agentos-cli` 是唯一的 binary crate,并生成两个二进制。其他 workspace member +全部是 library。 + +| Crate | 类型 | 职责 | +| --- | --- | --- | +| `agentos-cli` | binary | `agentos` CLI 和 `agentos-npu-bridge` | +| `agentos-runtime` | library | 配置与 composition root | +| `agentos-agent` | library | 有界 agent loop 与审计事件 | +| `agentos-protocol` | library | provider-neutral chat/tool 协议 | +| `agentos-inference` | library | ntex、远程、subprocess 和测试 backend | +| `agentos-tools` | library | 工具策略、schema 校验、审批和 Linux 工具 | +| `agentos-core` | library | 身份、ID、事件和公共状态类型 | +| `agentos-kernel` | library | 基于 rustix 的 Linux/Btrfs 边界 | +| `agentos-memory` | library | SQLite memory、审计和 principal 状态 | +| `agentos-worldline` | library | 文件系统 branch/commit/rollback 历史 | +| `agentos-npu` | library | NPU 发现、legacy bridge 和 Candle 装配 | +| `agentos-candle` | library | Qwen3 host 推理与 CALRT tensor adapter | +| `calculet-pcie-abi` | library | 抓取到的 64-bit Linux ioctl ABI | +| `calculet-pcie` | library | 安全的 PCIe 设备操作 | +| `calculet-calrt` | library | Calbin/runtime/command/tensor 实现 | + +## 环境要求 + +- 64-bit Linux。AgentOS 本身仅支持 Linux,CALCULET ABI 目前也只在 64-bit + Linux 上核对过。 +- 支持 Rust 2024 edition 的当前 Rust toolchain。 +- C toolchain、`pkg-config` 和 OpenSSL 开发文件。SQLite 使用 bundled 源码构建。 +- 推荐使用 Btrfs,以获得内核强制只读的 commit snapshot;普通开发并不强制。 + +Debian/Ubuntu 通常需要: + +```bash +sudo apt install build-essential pkg-config libssl-dev +``` + +构建两个 binary 和所有 library: + +```bash +cargo build --workspace +``` + +## 无 NPU 硬件快速开始 + +先检查主机和 backend 配置: + +```bash +cargo run -p agentos-cli --bin agentos -- \ + --state-dir target/agentos-state --json doctor +``` + +运行一次确定性的模型/工具 plumbing 冒烟测试。内置 fake 会请求真实的只读 +`system_inspect` 工具,但它不是在 NPU 硬件缺失时使用的远程 mock: + +```bash +cargo run -p agentos-cli --bin agentos -- \ + --state-dir target/agentos-state --fake-model --json \ + agent once "检查当前 Linux 系统" +``` + +不要以 root 运行 agent。默认 principal 使用当前 real UID 作为 owner,使用当前 +effective UID/GID 作为 agent 身份。 + +## 远程 OpenAI-compatible Mock 推理 + +没有 NPU 硬件时,mock 测试使用远程 OpenAI-compatible 模型。AgentOS 支持 +配置 API base URL 下的 `/chat/completions` 或 `/responses`。远程 URL 可以 +使用 HTTP 或 HTTPS;涉及 credential 或不可信网络时应使用 HTTPS。模型 URL +不能是 localhost 或 loopback,因为本机模型服务必须使用 UDS。 + +```bash +export AGENTOS_MODEL_BACKEND=openai_compatible +export AGENTOS_OPENAI_COMPATIBLE_API_KEY='...' +export AGENTOS_OPENAI_COMPATIBLE_BASE_URL='https://api.openai.com/v1' +export AGENTOS_OPENAI_COMPATIBLE_MODEL='your-model' +export AGENTOS_OPENAI_COMPATIBLE_API='responses' # 或 chat_completions + +cargo run -p agentos-cli --bin agentos -- \ + --state-dir target/agentos-state \ + agent once "总结当前系统状态" +``` + +也可以用 `OPENAI_API_KEY` 作为 fallback。远程 adapter 当前要求非空 +credential。暂不支持 streaming。 + +## 本机模型传输 + +同一台 Linux 主机上的常驻模型必须暴露 filesystem Unix domain socket。不要 +监听 localhost、loopback 或其他 TCP 地址。Socket path 及其 owner/group/mode +属于 OS 安全边界的一部分。 + +Legacy llama-server 的 `--host` 参数以 `.sock` 结尾时已经支持这种模式。 +AgentOS 通过 AF_UNIX 承载熟悉的 OpenAI-compatible HTTP 请求格式,所以这是 +UDS 上的 HTTP 消息 framing,而不是 localhost 上的 HTTP。纯 Rust Candle +backend 在进程内运行,不需要 IPC socket。 + +## 只读工具 + +| 工具 | 可用条件 | 操作 | +| --- | --- | --- | +| `system_inspect` | 始终 | kernel、uptime、load、内存、根文件系统、OS release | +| `npu_inspect` | 始终 | CALCULET 设备节点、module/sysfs/proc monitor、CALRT library | +| `package_query` | 始终 | 通过 dpkg、rpm 或 pacman 精确查询一个包 | +| `service_inspect` | 配置后 | 对 allowlist 内的 service 执行有界 `systemctl show` | +| `journal_read` | 配置后 | 对 allowlist 内的 service 执行有界 journal 读取 | + +使用精确、逗号分隔的 allowlist 启用 service 工具: + +```bash +export AGENTOS_SERVICE_INSPECT_ALLOWLIST='agentos.service,agentos-npud.service' +``` + +当前没有任何 mutation 工具。`agentos-tools` 中的 approval ledger 只是契约 +基础设施,不是完整的权限 broker。 + +## 文件系统 Worldline + +Worldline 在不 patch 内核的前提下提供类似 Git 的文件系统历史。在 Btrfs 上, +current/branch tree 是 writable snapshot,commit tree 是 readonly snapshot。 +其他文件系统使用 copy fallback,但 fallback 不具备内核强制不可变性。 + +```bash +AGENTOS=(cargo run -q -p agentos-cli --bin agentos -- \ + --state-dir target/agentos-state --json worldline) + +"${AGENTOS[@]}" init +"${AGENTOS[@]}" branch +"${AGENTOS[@]}" status +"${AGENTOS[@]}" log --limit 20 +``` + +`branch` 会返回 worldline ID 和路径。修改 branch tree 后执行: + +```bash +"${AGENTOS[@]}" diff +"${AGENTOS[@]}" commit --message "描述变更" +"${AGENTOS[@]}" rollback --message "回退到已知状态" +"${AGENTOS[@]}" discard +``` + +Rollback 会创建新 commit,不会改写历史。Worldline 当前仍由 CLI 显式管理; +agent run 不会自动创建或提交 branch。 + +## NPU 路径 + +### Legacy llama-server bridge + +Legacy `npu` backend 会执行 `agentos-npu-bridge`,bridge 只连接 filesystem +Unix domain socket,并通过该 socket 发送 `/v1/chat/completions`。此外还要求 +CALCULET 设备、driver sysfs 状态和厂商 CALRT shared library 全部 ready。 + +```bash +export AGENTOS_MODEL_BACKEND=npu +export AGENTOS_NPU_BRIDGE_COMMAND='agentos-npu-bridge' +export AGENTOS_NPU_SERVER_SOCKET='/run/agentos/npu.sock' +export AGENTOS_NPU_SERVER_MODEL='agentos-npu' +# CALRT 不在自动检查路径时可覆盖: +export AGENTOS_CALRT_LIBRARY='/opt/calculet/lib/libcalrt-linux-x86_64.so' +``` + +抓取到的 llama-server 可使用 `--host /run/agentos/npu.sock` 启动,并通过 socket +目录及 socket 的 owner/group/mode 限制访问。不需要 TCP listener。 + +### 纯 Rust Candle/CALRT 路径 + +`npu_candle` 用 Rust 替代魔改 llama.cpp 的 host 代码: + +```bash +export AGENTOS_MODEL_BACKEND=npu_candle +export AGENTOS_NPU_CALBIN='/path/to/calbin-directory' +export AGENTOS_NPU_TOKENIZER='/path/to/tokenizer.json' +export AGENTOS_NPU_DEVICE_INDEX=0 +``` + +`doctor` 可以在不打开设备的情况下解析这些文件并报告 model plan。对抓取到的 +Qwen3 部署,host 已校验 40,960 token context、151,936 logits 和 151,669 +token vocabulary,并会屏蔽额外的 267 个 padded logit ID。 + +这个 backend 目前会刻意保持 not ready:`calculet-calrt` 已能部署参数和传输 +tensor,但 `ConfiguredRuntime::submit()` 仍缺少 CCU relocation、job +launch/completion 和设备 KV-cache reset。当前开发机没有验证过任何真实 NPU +推理结果。 + +## 配置 + +| 环境变量 | 默认值 | 含义 | +| --- | --- | --- | +| `AGENTOS_STATE_DIR` | `$XDG_DATA_HOME/agentos` 或 `~/.local/share/agentos` | SQLite 与 worldline 根目录 | +| `AGENTOS_MODEL_BACKEND` | `auto` | `auto`、`openai_compatible`、`npu`、`npu_candle`、`subprocess`、`fake` | +| `AGENTOS_AGENT_ID` | `default-agent` | 稳定的逻辑 agent ID | +| `AGENTOS_OWNER_UID` | real UID | owner Linux UID | +| `AGENTOS_AGENT_UID` | effective UID | 要求的执行 UID | +| `AGENTOS_AGENT_GID` | effective GID | 要求的执行 GID | +| `AGENTOS_OPENAI_COMPATIBLE_API_KEY` | `OPENAI_API_KEY` | 远程 bearer credential | +| `AGENTOS_OPENAI_COMPATIBLE_BASE_URL` | `https://api.openai.com/v1` | 远程 compatible API root;禁止 localhost/loopback | +| `AGENTOS_OPENAI_COMPATIBLE_MODEL` | 空 | provider model 名称 | +| `AGENTOS_OPENAI_COMPATIBLE_API` | `chat_completions` | `chat_completions` 或 `responses` | +| `AGENTOS_OPENAI_COMPATIBLE_MAX_RETRIES` | `2` | 有界 HTTP retry 次数 | +| `AGENTOS_MODEL_COMMAND` | 空 | 显式 subprocess 测试/bridge 命令 | +| `AGENTOS_MODEL_TIMEOUT_S` | `240` | model/bridge 超时 | +| `AGENTOS_NPU_BRIDGE_COMMAND` | 空 | legacy NPU bridge 命令 | +| `AGENTOS_NPU_SERVER_SOCKET` | `/run/agentos/npu.sock` | legacy llama-server Unix socket | +| `AGENTOS_NPU_SERVER_MODEL` | `agentos-npu` | legacy llama-server model 名称 | +| `AGENTOS_NPU_SERVER_TIMEOUT_S` | `240` | legacy llama-server UDS HTTP 超时 | +| `AGENTOS_NPU_CALBIN` | 空 | 纯 Rust Calbin 目录 | +| `AGENTOS_NPU_TOKENIZER` | 空 | 纯 Rust tokenizer JSON | +| `AGENTOS_NPU_DEVICE_INDEX` | `0` | `/dev/calculetN` 序号 | +| `AGENTOS_CALRT_LIBRARY` | 自动检查 | 厂商 CALRT shared library 覆盖路径 | +| `AGENTOS_SERVICE_INSPECT_ALLOWLIST` | 空 | 启用的 systemd service 名称 | +| `AGENTOS_TOOL_OUTPUT_LIMIT_BYTES` | `65536` | 单个工具序列化输出上限 | +| `AGENTOS_TOOL_ARGUMENT_LIMIT_BYTES` | `65536` | 单次调用参数上限 | +| `AGENTOS_AGENT_MAX_ITERATIONS` | `8` | 模型/工具循环轮数 | +| `AGENTOS_AGENT_MAX_TOOL_CALLS` | `16` | 单次 run 的 tool call 总数 | +| `AGENTOS_AGENT_MAX_ELAPSED_S` | `120` | 单次 run 总 deadline | +| `AGENTOS_AGENT_MAX_OUTPUT_TOKENS` | `2048` | 每轮模型输出预算 | +| `AGENTOS_AGENT_MAX_TOTAL_TOKENS` | `65536` | 累计 token 预算 | +| `AGENTOS_AGENT_MAX_SAFE_WORKERS` | `4` | 并行 safe-tool worker 数量 | + +`auto` 模式依次选择 UDS legacy NPU 和远程 OpenAI-compatible。Subprocess 与 +fake 都是显式测试 adapter,未完成的 Candle backend 不会被自动选中。 + +## 状态目录 + +```text +AGENTOS_STATE_DIR/ + agentos.sqlite3 # principal、审计事件、memory、FTS index + worldline/ + current/ # 当前文件系统 tree + branches/ # writable candidate tree + commits/ # commit tree + metadata/ # commit 和 branch 记录 + refs/ # HEAD reference + replaced/ # 可恢复的旧 checkout +``` + +## 开发 + +```bash +cargo fmt --all --check +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +部分 NPU parser 测试使用本地、被 git 忽略的 +`npu_features/snapshot_20260801` 抓取。这些测试只能验证 host 解析,不是硬件 +测试;干净 clone 也可能不包含该 fixture。 + +项目自有的 low-level Linux 调用使用 `rustix`,AgentOS 不直接添加 `libc` +syscall wrapper。这不代表使用 `std`/OpenSSL 的最终 binary 完全没有 libc ABI +依赖。 + +## 已知缺口 + +- 尚无常驻 AgentOS 控制 daemon 或权限 broker;本机 llama-server 模型传输已经 + 使用 UDS。 +- 尚无 mutation 工具或完整的权限审批路径。 +- 远程推理尚不支持 streaming。 +- 纯 Rust NPU job submission 和真实板卡验证尚未完成。 +- Copy worldline backend 不具备 Btrfs 的内核强制只读 commit。 +- Memory/recall 基础能力已存在于 library,但尚未暴露为 agent 工具或 CLI。 + +## License 说明 + +AgentOS crates 声明为 Apache-2.0。抓取的 CALCULET PCIe ABI 与设备 wrapper +crates 声明为 GPL-2.0-only,`calculet-calrt` 保留厂商 license 文件。重新分发前 +请分别检查各 crate manifest。 diff --git a/crates/agentos-cli/src/bin/agentos-npu-bridge.rs b/crates/agentos-cli/src/bin/agentos-npu-bridge.rs index f7e51ed..dbf18ea 100644 --- a/crates/agentos-cli/src/bin/agentos-npu-bridge.rs +++ b/crates/agentos-cli/src/bin/agentos-npu-bridge.rs @@ -1,4 +1,4 @@ -use agentos_npu::{LoopbackServerConfig, bridge_chat}; +use agentos_npu::{DEFAULT_NPU_SERVER_SOCKET, UnixSocketServerConfig, bridge_chat}; use agentos_protocol::ChatRequest; use anyhow::{Context, Result, bail}; use clap::Parser; @@ -30,9 +30,9 @@ async fn main() -> Result<()> { serde_json::from_slice(&input).context("parse agentos.chat.v1 request")?; request.validate()?; request.parallel_tool_calls = false; - let config = LoopbackServerConfig { - endpoint: std::env::var("AGENTOS_NPU_SERVER_URL") - .unwrap_or_else(|_| "http://127.0.0.1:8031/v1/chat/completions".into()), + let config = UnixSocketServerConfig { + socket_path: std::env::var_os("AGENTOS_NPU_SERVER_SOCKET") + .map_or_else(|| DEFAULT_NPU_SERVER_SOCKET.into(), Into::into), model: std::env::var("AGENTOS_NPU_SERVER_MODEL").unwrap_or_else(|_| "agentos-npu".into()), timeout: Duration::from_secs( std::env::var("AGENTOS_NPU_SERVER_TIMEOUT_S") diff --git a/crates/agentos-inference/src/lib.rs b/crates/agentos-inference/src/lib.rs index b720eee..bc7f09c 100644 --- a/crates/agentos-inference/src/lib.rs +++ b/crates/agentos-inference/src/lib.rs @@ -1,4 +1,4 @@ -//! OpenAI-compatible remote, subprocess/NPU bridge, and deterministic mock backends. +//! Remote OpenAI-compatible, Unix-socket, subprocess, and deterministic fake backends. use agentos_protocol::{ BackendError, BackendStatus, ChatBackend, ChatMessage, ChatRequest, ChatResponse, MessageRole, @@ -14,7 +14,8 @@ use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use std::collections::VecDeque; -use std::path::PathBuf; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::Duration; use tokio::io::AsyncWriteExt; @@ -155,6 +156,17 @@ pub struct NtexJsonClient { sender: tokio::sync::mpsc::UnboundedSender, } +#[derive(Clone, Debug)] +pub struct NtexUdsJsonClient { + inner: NtexJsonClient, +} + +#[derive(Clone, Debug)] +enum NtexTransport { + Network, + Unix(PathBuf), +} + struct NtexRequest { endpoint: String, payload: Value, @@ -180,6 +192,22 @@ impl NtexJsonClient { max_retries: usize, user_agent: &'static str, bearer_token: Option, + ) -> Result { + Self::with_transport( + timeout, + max_retries, + user_agent, + bearer_token, + NtexTransport::Network, + ) + } + + fn with_transport( + timeout: Duration, + max_retries: usize, + user_agent: &'static str, + bearer_token: Option, + transport: NtexTransport, ) -> Result { let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); let (startup_sender, startup_receiver) = std::sync::mpsc::sync_channel(1); @@ -190,7 +218,7 @@ impl NtexJsonClient { .name("agentos-ntex-client") .build(rt::DefaultRuntime) .block_on(async move { - match build_ntex_client(timeout, user_agent).await { + match build_ntex_client(timeout, user_agent, transport).await { Ok(client) => { let _ = startup_sender.send(Ok(())); run_ntex_worker( @@ -234,10 +262,47 @@ impl NtexJsonClient { } } -async fn build_ntex_client(timeout: Duration, user_agent: &str) -> Result { - let tls = verified_tls_connector()?; +impl NtexUdsJsonClient { + pub fn new( + socket_path: impl Into, + timeout: Duration, + max_retries: usize, + user_agent: &'static str, + ) -> Result { + let socket_path = socket_path.into(); + validate_uds_path(&socket_path)?; + Ok(Self { + inner: NtexJsonClient::with_transport( + timeout, + max_retries, + user_agent, + None, + NtexTransport::Unix(socket_path), + )?, + }) + } + + pub async fn post( + &self, + endpoint_path: &str, + payload: &Value, + ) -> Result { + let endpoint = uds_http_endpoint(endpoint_path)?; + self.inner.post(&endpoint, payload).await + } +} + +async fn build_ntex_client( + timeout: Duration, + user_agent: &str, + transport: NtexTransport, +) -> Result { + let connector = match transport { + NtexTransport::Network => Connector::default().openssl(verified_tls_connector()?), + NtexTransport::Unix(socket_path) => unix_http_connector(socket_path), + }; Client::builder() - .connector::<&str>(Connector::default().openssl(tls)) + .connector::<&str>(connector) .response_timeout(timeout) .response_payload_limit(MAX_RESPONSE_BYTES) .response_payload_timeout(Millis::from(timeout)) @@ -248,6 +313,50 @@ async fn build_ntex_client(timeout: Duration, user_agent: &str) -> Result Connector { + let factory = ntex::service::fn_factory_with_config(move |config: SharedCfg| { + let socket_path = socket_path.clone(); + async move { + Ok::<_, std::io::Error>(ntex::service::fn_service( + move |_request: ntex::connect::Connect| { + let config = config.clone(); + let socket_path = socket_path.clone(); + async move { + rt::unix_connect(socket_path, config) + .await + .map_err(ntex::connect::ConnectError::from) + } + }, + )) + } + }); + Connector::new().connector(factory) +} + +fn validate_uds_path(path: &Path) -> Result<(), BackendError> { + const LINUX_SUN_PATH_BYTES: usize = 108; + let bytes = path.as_os_str().as_bytes(); + if !path.is_absolute() + || bytes.is_empty() + || bytes.len() >= LINUX_SUN_PATH_BYTES + || bytes.contains(&0) + { + return Err(BackendError::NotReady( + "Unix model socket must be an absolute filesystem path shorter than 108 bytes".into(), + )); + } + Ok(()) +} + +fn uds_http_endpoint(path: &str) -> Result { + if !path.starts_with('/') || path.contains(['\r', '\n', '?', '#']) { + return Err(BackendError::NotReady( + "Unix model endpoint must be an absolute HTTP path without query or fragment".into(), + )); + } + Ok(format!("http://agentos.local{path}")) +} + async fn run_ntex_worker( mut receiver: tokio::sync::mpsc::UnboundedReceiver, client: Client, @@ -482,7 +591,7 @@ impl ChatBackend for FakeBackend { BackendStatus { name: self.name().into(), ready: true, - reason: "deterministic local mock".into(), + reason: "deterministic unit-test fake".into(), details: json!({"hardware_required": false}), } } @@ -495,7 +604,10 @@ impl ChatBackend for FakeBackend { .find(|message| message.role == MessageRole::Tool) { return Ok(ChatResponse { - content: format!("Mock 已完成只读检查:{}", tool.content), + content: format!( + "Fake backend completed a read-only inspection: {}", + tool.content + ), usage: TokenUsage { input_tokens: 12, output_tokens: 8, @@ -525,7 +637,7 @@ impl ChatBackend for FakeBackend { }); } Ok(ChatResponse { - content: "Mock backend is ready.".into(), + content: "Fake backend is ready.".into(), ..ChatResponse::default() }) } @@ -811,19 +923,32 @@ fn required_string(value: &Value, key: &str) -> Result { fn validated_base_url(value: &str) -> Result { let url = Url::parse(value).map_err(|error| BackendError::NotReady(error.to_string()))?; - let loopback = url - .host_str() - .is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "::1")); + let local = match url.host() { + Some(url::Host::Domain(host)) => { + host.eq_ignore_ascii_case("localhost") + || host.to_ascii_lowercase().ends_with(".localhost") + } + Some(url::Host::Ipv4(address)) => address.is_loopback() || address.is_unspecified(), + Some(url::Host::Ipv6(address)) => { + address.is_loopback() + || address.is_unspecified() + || address + .to_ipv4_mapped() + .is_some_and(|mapped| mapped.is_loopback() || mapped.is_unspecified()) + } + None => true, + }; if url.cannot_be_a_base() - || url.host_str().is_none() + || local || url.username() != "" || url.password().is_some() || url.query().is_some() || url.fragment().is_some() - || !(url.scheme() == "https" || (url.scheme() == "http" && loopback)) + || !matches!(url.scheme(), "http" | "https") { return Err(BackendError::NotReady( - "invalid OpenAI-compatible base URL".into(), + "remote OpenAI-compatible base URL must use HTTP(S), not a local or loopback host" + .into(), )); } Ok(url) @@ -950,10 +1075,12 @@ mod tests { } #[test] - fn only_https_or_loopback_http_is_accepted() { + fn remote_http_is_accepted_but_loopback_model_urls_are_rejected() { assert!(validated_base_url("https://api.openai.com/v1").is_ok()); - assert!(validated_base_url("http://127.0.0.1:8080/v1").is_ok()); - assert!(validated_base_url("http://example.com/v1").is_err()); + assert!(validated_base_url("http://model.example/v1").is_ok()); + assert!(validated_base_url("http://127.0.0.1:8080/v1").is_err()); + assert!(validated_base_url("http://localhost:8080/v1").is_err()); + assert!(validated_base_url("http://[::ffff:127.0.0.1]:8080/v1").is_err()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1033,4 +1160,66 @@ mod tests { } server.await.unwrap(); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn ntex_client_carries_local_http_over_a_unix_socket() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + async fn read_http_request(stream: &mut tokio::net::UnixStream) -> String { + let mut request = Vec::new(); + loop { + let mut chunk = [0_u8; 4096]; + let received = stream.read(&mut chunk).await.unwrap(); + assert_ne!(received, 0); + request.extend_from_slice(&chunk[..received]); + let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") + else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + let request_end = header_end + 4 + content_length; + if request.len() >= request_end { + return String::from_utf8(request[..request_end].to_vec()).unwrap(); + } + } + } + + let root = + std::env::temp_dir().join(format!("agentos-inference-uds-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let socket_path = root.join("model.sock"); + let listener = tokio::net::UnixListener::bind(&socket_path).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1")); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}", + ) + .await + .unwrap(); + }); + + let client = + NtexUdsJsonClient::new(&socket_path, Duration::from_secs(2), 0, "agentos-uds-test") + .unwrap(); + let response = client + .post("/v1/chat/completions", &json!({"ping": true})) + .await + .unwrap(); + assert!(response.is_success()); + assert_eq!(response.body, json!({"ok": true})); + server.await.unwrap(); + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/agentos-npu/Cargo.toml b/crates/agentos-npu/Cargo.toml index 59328f9..392ba54 100644 --- a/crates/agentos-npu/Cargo.toml +++ b/crates/agentos-npu/Cargo.toml @@ -16,7 +16,6 @@ rustix.workspace = true serde.workspace = true serde_json.workspace = true tokio.workspace = true -url.workspace = true [lints] workspace = true diff --git a/crates/agentos-npu/src/lib.rs b/crates/agentos-npu/src/lib.rs index 0183b49..48daa5f 100644 --- a/crates/agentos-npu/src/lib.rs +++ b/crates/agentos-npu/src/lib.rs @@ -5,16 +5,16 @@ use agentos_candle::{ HuggingFaceTokenizer, Result as CandleResult, TokenCodec, }; use agentos_inference::{ - NtexJsonClient, SubprocessBackend, chat_completion_messages, parse_chat_completions, + NtexUdsJsonClient, SubprocessBackend, chat_completion_messages, parse_chat_completions, }; use agentos_protocol::{BackendError, BackendStatus, ChatBackend, ChatRequest, ChatResponse}; use async_trait::async_trait; use rustix::fs::Access; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use std::os::unix::fs::FileTypeExt; use std::path::{Path, PathBuf}; use std::time::Duration; -use url::Url; use calculet_calrt::{Calbin, ConfiguredRuntime, PcieRuntimeDevice}; @@ -26,6 +26,7 @@ pub const CALRT_VERSION: &str = calculet_calrt::CALRT_COMPATIBLE_VERSION; pub const H2C_DMA_CHANNELS: u8 = calculet_pcie_abi::WRITE_CHANNELS; pub const C2H_DMA_CHANNELS: u8 = calculet_pcie_abi::READ_CHANNELS; pub const MAX_DMA_TRANSFER_BYTES: usize = calculet_pcie_abi::MAX_DMA_TRANSFER_BYTES; +pub const DEFAULT_NPU_SERVER_SOCKET: &str = "/run/agentos/npu.sock"; pub type RustCandleNpuBackend = CandleChatBackend>; @@ -229,10 +230,16 @@ impl NpuProbe { pub struct NpuBackend { bridge: SubprocessBackend, probe: NpuProbe, + server_socket: PathBuf, } impl NpuBackend { - pub fn new(command: Vec, timeout: Duration, probe: NpuProbe) -> Self { + pub fn new( + command: Vec, + timeout: Duration, + probe: NpuProbe, + server_socket: PathBuf, + ) -> Self { Self { bridge: SubprocessBackend { name: "npu".into(), @@ -241,6 +248,7 @@ impl NpuBackend { serialized: true, }, probe, + server_socket, } } } @@ -258,15 +266,24 @@ impl ChatBackend for NpuBackend { fn status(&self) -> BackendStatus { let bridge = self.bridge.status(); let npu = self.probe.probe(); + let socket_ready = is_unix_socket(&self.server_socket); BackendStatus { name: self.name().into(), - ready: bridge.ready && npu.hardware_ready, - reason: if bridge.ready && npu.hardware_ready { - "NPU inference chain is ready".into() + ready: bridge.ready && npu.hardware_ready && socket_ready, + reason: if bridge.ready && npu.hardware_ready && socket_ready { + "NPU inference chain is ready over a Unix domain socket".into() } else { - "NPU bridge, driver, device, or calrt is unavailable".into() + "NPU bridge, Unix model socket, driver, device, or calrt is unavailable".into() }, - details: json!({"bridge": bridge, "npu": npu}), + details: json!({ + "bridge": bridge, + "model_socket": { + "path": self.server_socket, + "ready": socket_ready, + "transport": "unix", + }, + "npu": npu, + }), } } @@ -304,22 +321,21 @@ impl ChatBackend for NpuBackend { } #[derive(Clone, Debug)] -pub struct LoopbackServerConfig { - pub endpoint: String, +pub struct UnixSocketServerConfig { + pub socket_path: PathBuf, pub model: String, pub timeout: Duration, } pub async fn bridge_chat( - config: &LoopbackServerConfig, + config: &UnixSocketServerConfig, request: &ChatRequest, ) -> Result { - let endpoint = validate_loopback_endpoint(&config.endpoint)?; - let client = NtexJsonClient::new( + let client = NtexUdsJsonClient::new( + &config.socket_path, config.timeout, 0, concat!("agentos-npu-bridge/", env!("CARGO_PKG_VERSION")), - None, )?; let payload = json!({ "model": config.model, @@ -339,10 +355,10 @@ pub async fn bridge_chat( "temperature": 0, "stream": false, }); - let response = client.post(endpoint.as_str(), &payload).await?; + let response = client.post("/v1/chat/completions", &payload).await?; if !response.is_success() { return Err(BackendError::Transport(format!( - "llama-server HTTP {}: {}", + "llama-server UDS HTTP {}: {}", response.status, response .body @@ -354,24 +370,10 @@ pub async fn bridge_chat( parse_chat_completions(&response.body) } -fn validate_loopback_endpoint(value: &str) -> Result { - let url = Url::parse(value).map_err(|error| BackendError::NotReady(error.to_string()))?; - let loopback = url - .host_str() - .is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "::1")); - if url.scheme() != "http" - || !loopback - || url.username() != "" - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() - || !url.path().ends_with("/chat/completions") - { - return Err(BackendError::NotReady( - "NPU server URL must be a loopback HTTP chat endpoint".into(), - )); - } - Ok(url) +fn is_unix_socket(path: &Path) -> bool { + path.is_absolute() + && std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_socket()) + && rustix::fs::access(path, Access::WRITE_OK).is_ok() } fn read_small(path: PathBuf) -> Option { @@ -398,9 +400,16 @@ mod tests { use super::*; #[test] - fn bridge_never_accepts_a_remote_plaintext_endpoint() { - assert!(validate_loopback_endpoint("http://127.0.0.1:8031/v1/chat/completions").is_ok()); - assert!(validate_loopback_endpoint("http://npu.example/v1/chat/completions").is_err()); + fn local_model_endpoint_must_be_a_unix_socket() { + let root = std::env::temp_dir().join(format!("agentos-npu-uds-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let socket_path = root.join("model.sock"); + let listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap(); + assert!(is_unix_socket(&socket_path)); + assert!(!is_unix_socket(Path::new("relative.sock"))); + drop(listener); + std::fs::remove_dir_all(root).unwrap(); } #[test] diff --git a/crates/agentos-runtime/src/lib.rs b/crates/agentos-runtime/src/lib.rs index b2d436f..9b6aad6 100644 --- a/crates/agentos-runtime/src/lib.rs +++ b/crates/agentos-runtime/src/lib.rs @@ -7,7 +7,10 @@ use agentos_inference::{ }; use agentos_kernel::{ProcessCredentials, enable_no_new_privileges, kernel_info}; use agentos_memory::MemoryStore; -use agentos_npu::{CandleNpuConfig, NpuBackend, NpuProbe, inspect_candle_npu, open_candle_npu}; +use agentos_npu::{ + CandleNpuConfig, DEFAULT_NPU_SERVER_SOCKET, NpuBackend, NpuProbe, inspect_candle_npu, + open_candle_npu, +}; use agentos_protocol::{BackendStatus, ChatBackend}; use agentos_tools::ToolRegistry; use agentos_tools::linux::{LinuxToolConfig, build_linux_registry}; @@ -61,6 +64,7 @@ pub struct RuntimeConfig { pub openai_compatible_api: OpenAiCompatibleApi, pub openai_compatible_max_retries: usize, pub npu_bridge_command: Vec, + pub npu_server_socket: PathBuf, pub npu_candle_calbin: Option, pub npu_candle_tokenizer: Option, pub npu_candle_device_index: u8, @@ -112,6 +116,8 @@ impl RuntimeConfig { openai_compatible_api, openai_compatible_max_retries: env_usize("AGENTOS_OPENAI_COMPATIBLE_MAX_RETRIES", 2)?, npu_bridge_command: command_env("AGENTOS_NPU_BRIDGE_COMMAND"), + npu_server_socket: std::env::var_os("AGENTOS_NPU_SERVER_SOCKET") + .map_or_else(|| PathBuf::from(DEFAULT_NPU_SERVER_SOCKET), PathBuf::from), npu_candle_calbin: std::env::var_os("AGENTOS_NPU_CALBIN").map(PathBuf::from), npu_candle_tokenizer: std::env::var_os("AGENTOS_NPU_TOKENIZER").map(PathBuf::from), npu_candle_device_index: env_u8("AGENTOS_NPU_DEVICE_INDEX", 0)?, @@ -266,6 +272,7 @@ pub fn doctor(config: &RuntimeConfig) -> Result { config.npu_bridge_command.clone(), config.model_timeout, probe.clone(), + config.npu_server_socket.clone(), ); backends.push(npu_backend.status()); backends.push(candle_npu_status(config)); @@ -303,6 +310,7 @@ fn build_backend(config: &RuntimeConfig) -> Result, Runtime config.npu_bridge_command.clone(), config.model_timeout, npu_probe(config), + config.npu_server_socket.clone(), )); let subprocess: Arc = Arc::new(SubprocessBackend { name: "subprocess".into(), @@ -329,11 +337,8 @@ fn build_backend(config: &RuntimeConfig) -> Result, Runtime { return Ok(Arc::new(openai_compatible)); } - if subprocess.status().ready { - return Ok(subprocess); - } Err(RuntimeError::BackendUnavailable( - "no NPU, OpenAI-compatible, or subprocess backend is ready; use --fake-model only for tests".into(), + "no NPU or remote OpenAI-compatible backend is ready; local deployed models must use a Unix domain socket, and subprocess/fake backends are explicit test adapters".into(), )) } }